feature: version one notification feature implemented.

This commit is contained in:
2026-07-18 01:09:54 +03:30
parent 0740493384
commit 9f6ec193d2
40 changed files with 1679 additions and 224 deletions

View File

@@ -9,7 +9,9 @@ import { storeAuthRedirectFromPath } from '@/lib/auth/postAuthRedirect';
import Sidebar from '@/components/ui/shared/Sidebar';
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
import { NotificationBell } from '@/components/ui/notifications/NotificationBell';
import { ToastProvider } from '@/components/ui/shared/ToastProvider';
import { RealtimeProvider } from '@/lib/realtime/RealtimeProvider';
import {
canAccessDashboardRoute,
firstAccessibleDashboardPath,
@@ -87,6 +89,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return (
<ToastProvider>
<RealtimeProvider>
<div className="app-dashboard-shell flex h-[100dvh] app-web-bg text-text-primary">
{sidebarOpen ? (
<button
@@ -112,6 +115,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</main>
</div>
</div>
</RealtimeProvider>
</ToastProvider>
);
}
@@ -141,6 +145,7 @@ const DashboardHeader = memo(function DashboardHeader({
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
<TopBarControls />
<NotificationBell />
<DashboardAccountMenu />
</div>
</header>

View File

@@ -0,0 +1,7 @@
'use client';
import { NotificationsPage } from '@/components/ui/notifications/NotificationsPage';
export default function NotificationsRoutePage() {
return <NotificationsPage />;
}

View File

@@ -10,6 +10,7 @@ export default function TreatmentPage() {
const { user, currentOrganization, isAuthReady } = useAuth();
const searchParams = useSearchParams();
const initialAppointmentId = searchParams.get('appointmentId');
const initialLabCaseId = searchParams.get('labCaseId');
if (!isAuthReady || !user) {
return (
@@ -22,6 +23,7 @@ export default function TreatmentPage() {
userId={user.id}
currentOrganization={currentOrganization}
initialAppointmentId={initialAppointmentId}
initialLabCaseId={initialLabCaseId}
/>
);
}

View File

@@ -173,6 +173,32 @@ export function TasksPage() {
setPage(1);
}, [searchParams]);
useEffect(() => {
const taskId = searchParams.get('taskId')?.trim();
if (!taskId || !canView) return;
let cancelled = false;
void (async () => {
try {
const response = await tasksApi.locatePage(buildDefaultLocateParams(taskId, PAGE_SIZE));
if (cancelled || !response.data.found) return;
setStatusFilter('');
setImportantOnly(false);
setOverdueOnly(false);
setUnassignedOnly(false);
setProsthesisTypeCode('');
setSortBy('date');
setSortDir('desc');
setPage(response.data.page);
setHighlightTaskId(taskId);
} catch {
/* ignore deep-link locate failures */
}
})();
return () => {
cancelled = true;
};
}, [searchParams, canView]);
const loadTasks = useCallback(async () => {
setLoading(true);
setError('');

View File

@@ -0,0 +1,149 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Bell } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Link, useRouter } from '@/i18n/navigation';
import { notificationsApi } from '@/lib/api/notifications';
import { useRealtime } from '@/lib/realtime/RealtimeProvider';
import { NavBadgePill } from '@/components/ui/shared/NavBadgePill';
import { NotificationCard } from '@/components/ui/notifications/NotificationCard';
import type { UserNotificationItem } from '@/types/notifications';
const DROPDOWN_LIMIT = 10;
export function NotificationBell() {
const t = useTranslations('notifications');
const router = useRouter();
const { lastNotification, unreadCount: liveUnread, setUnreadCount } = useRealtime();
const [open, setOpen] = useState(false);
const [items, setItems] = useState<UserNotificationItem[]>([]);
const [unreadCount, setLocalUnread] = useState(0);
const [loading, setLoading] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const refreshUnread = useCallback(async () => {
try {
const res = await notificationsApi.inboxUnreadCount();
const count = res.data.count;
setLocalUnread(count);
setUnreadCount(count);
} catch {
/* ignore */
}
}, [setUnreadCount]);
const loadRecent = useCallback(async () => {
setLoading(true);
try {
const res = await notificationsApi.listInbox({ limit: DROPDOWN_LIMIT });
setItems(res.data.items);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshUnread();
}, [refreshUnread]);
useEffect(() => {
if (typeof liveUnread === 'number') {
setLocalUnread(liveUnread);
}
}, [liveUnread]);
useEffect(() => {
if (!lastNotification) return;
setItems((prev) => {
if (prev.some((item) => item.id === lastNotification.id)) return prev;
return [lastNotification, ...prev].slice(0, DROPDOWN_LIMIT);
});
}, [lastNotification]);
useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
useEffect(() => {
if (open) {
void loadRecent();
}
}, [open, loadRecent]);
const handleSelect = async (item: UserNotificationItem) => {
setOpen(false);
try {
if (!item.readAt) {
await notificationsApi.markInboxRead(item.id);
setItems((prev) =>
prev.map((row) =>
row.id === item.id ? { ...row, readAt: new Date().toISOString() } : row,
),
);
await refreshUnread();
}
} catch {
/* still navigate */
}
router.push(item.href);
};
return (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="relative inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/80 text-text-primary hover:border-border-strong hover:bg-background-card/80 transition-colors"
aria-label={t('bellAria')}
aria-expanded={open}
aria-haspopup="dialog"
>
<Bell className="h-[18px] w-[18px] icon-flat" />
{unreadCount > 0 ? (
<span className="absolute -top-1 -end-1">
<NavBadgePill count={unreadCount} ariaLabel={t('unreadCount', { count: unreadCount })} />
</span>
) : null}
</button>
{open ? (
<div
role="dialog"
aria-label={t('dropdownTitle')}
className="absolute end-0 z-[200] mt-2 w-[min(22rem,calc(100vw-1.5rem))] rounded-[var(--radius-md)] border border-border bg-background-secondary/95 shadow-lg backdrop-blur-sm"
>
<div className="flex items-center justify-between gap-2 border-b border-border/70 px-3 py-2">
<p className="text-sm font-medium text-text-primary">{t('dropdownTitle')}</p>
<Link
href="/notifications"
className="text-xs text-primary hover:underline"
onClick={() => setOpen(false)}
>
{t('viewAll')}
</Link>
</div>
<div className="max-h-[min(24rem,60vh)] overflow-y-auto p-2 space-y-1.5">
{loading && items.length === 0 ? (
<p className="text-xs text-text-muted px-2 py-3">{t('loading')}</p>
) : items.length === 0 ? (
<p className="text-xs text-text-muted px-2 py-3">{t('empty')}</p>
) : (
items.map((item) => (
<NotificationCard key={item.id} item={item} onSelect={handleSelect} />
))
)}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { formatAppDateTime } from '@/lib/i18n/format';
import type { UserNotificationItem, UserNotificationType } from '@/types/notifications';
const TYPE_I18N: Record<UserNotificationType, string> = {
CASE_SENT: 'typeCaseSent',
CLINIC_COMMENT: 'typeClinicComment',
LAB_COMMENT: 'typeLabComment',
LAB_COMMENT_CLINIC: 'typeLabCommentClinic',
CASE_IMPORTANT: 'typeCaseImportant',
TASK_COMPLETED: 'typeTaskCompleted',
TASK_ASSIGNED: 'typeTaskAssigned',
CONNECTION_REQUEST: 'typeConnectionRequest',
STAFF_INVITE: 'typeStaffInvite',
};
export function NotificationCard({
item,
onSelect,
}: {
item: UserNotificationItem;
onSelect: (item: UserNotificationItem) => void;
}) {
const t = useTranslations('notifications');
const locale = useLocale();
const unread = !item.readAt;
const titleKey = TYPE_I18N[item.type] ?? 'typeUnknown';
return (
<button
type="button"
onClick={() => onSelect(item)}
className={`w-full text-start rounded-[var(--radius-md)] border px-3 py-2.5 transition-colors ${
unread
? 'border-primary/40 bg-primary/5 hover:border-primary/60'
: 'border-border/70 bg-background-secondary/40 hover:border-border'
}`}
>
<div className="flex items-start gap-2">
{unread ? (
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-badge-warning-fg" aria-hidden />
) : (
<span className="mt-1.5 h-2 w-2 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-text-primary">{t(titleKey)}</p>
<p className="text-[11px] text-text-muted mt-0.5">
{formatAppDateTime(item.createdAt, locale)}
</p>
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { notificationsApi } from '@/lib/api/notifications';
import { useRealtime } from '@/lib/realtime/RealtimeProvider';
import { Button } from '@/components/ui/shared/Button';
import { NotificationCard } from '@/components/ui/notifications/NotificationCard';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
import type { UserNotificationItem } from '@/types/notifications';
export function NotificationsPage() {
const t = useTranslations('notifications');
const tErrors = useTranslations('errors');
const router = useRouter();
const toast = useToast();
const { lastNotification, setUnreadCount } = useRealtime();
const [items, setItems] = useState<UserNotificationItem[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const loadPage = useCallback(async (cursor?: string | null, append = false) => {
if (append) setLoadingMore(true);
else setLoading(true);
try {
const res = await notificationsApi.listInbox({
limit: 20,
cursor: cursor ?? undefined,
});
setItems((prev) => (append ? [...prev, ...res.data.items] : res.data.items));
setNextCursor(res.data.nextCursor);
} catch (error: unknown) {
toast.showError(getUserFacingError(error, tErrors, t('errorLoad')));
} finally {
setLoading(false);
setLoadingMore(false);
}
}, [t, tErrors, toast]);
useEffect(() => {
void loadPage();
}, [loadPage]);
useEffect(() => {
if (!lastNotification) return;
setItems((prev) => {
if (prev.some((item) => item.id === lastNotification.id)) return prev;
return [lastNotification, ...prev];
});
}, [lastNotification]);
const handleSelect = async (item: UserNotificationItem) => {
try {
if (!item.readAt) {
await notificationsApi.markInboxRead(item.id);
setItems((prev) =>
prev.map((row) =>
row.id === item.id ? { ...row, readAt: new Date().toISOString() } : row,
),
);
const countRes = await notificationsApi.inboxUnreadCount();
setUnreadCount(countRes.data.count);
}
} catch {
/* still navigate */
}
router.push(item.href);
};
const handleMarkAll = async () => {
try {
await notificationsApi.markInboxReadAll();
setItems((prev) =>
prev.map((row) => ({ ...row, readAt: row.readAt ?? new Date().toISOString() })),
);
setUnreadCount(0);
} catch (error: unknown) {
toast.showError(getUserFacingError(error, tErrors, t('errorMarkRead')));
}
};
return (
<div className="space-y-4 max-w-2xl">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('pageTitle')}</h1>
<p className="text-sm text-text-muted mt-1">{t('pageSubtitle')}</p>
</div>
<Button variant="outline" size="sm" onClick={() => void handleMarkAll()}>
{t('markAllRead')}
</Button>
</div>
<div className="space-y-2">
{loading ? (
<p className="text-sm text-text-muted">{t('loading')}</p>
) : items.length === 0 ? (
<p className="text-sm text-text-muted surface-card p-4">{t('empty')}</p>
) : (
items.map((item) => (
<NotificationCard key={item.id} item={item} onSelect={(row) => void handleSelect(row)} />
))
)}
</div>
{nextCursor ? (
<Button
variant="outline"
size="sm"
isLoading={loadingMore}
onClick={() => void loadPage(nextCursor, true)}
>
{t('loadMore')}
</Button>
) : null}
</div>
);
}

View File

@@ -318,12 +318,14 @@ interface TreatmentWorkspaceProps {
userId: string;
currentOrganization: Organization | null;
initialAppointmentId?: string | null;
initialLabCaseId?: string | null;
}
export function TreatmentWorkspace({
userId,
currentOrganization,
initialAppointmentId = null,
initialLabCaseId = null,
}: TreatmentWorkspaceProps) {
const locale = useLocale();
const t = useTranslations('treatment');
@@ -404,6 +406,7 @@ export function TreatmentWorkspace({
labCaseDraftsRef.current = labCaseDrafts;
const skipNextGetDraftRef = useRef(false);
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
const labPanelRef = useRef<HTMLDivElement>(null);
const historyRequestRef = useRef(0);
/** When set, activeDetailId effect opens this wizard step instead of resetting to teeth. */
@@ -417,6 +420,10 @@ export function TreatmentWorkspace({
}
}, [initialAppointmentId]);
useEffect(() => {
pendingLabCaseIdRef.current = initialLabCaseId;
}, [initialLabCaseId]);
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
const [organizationSearch, setOrganizationSearch] = useState('');
@@ -1351,6 +1358,17 @@ export function TreatmentWorkspace({
],
);
useEffect(() => {
const labCaseId = pendingLabCaseIdRef.current;
if (!labCaseId) return;
const match =
unreadLabCases.find((item) => item.labCaseId === labCaseId) ??
patientLabCases.find((item) => item.labCaseId === labCaseId);
if (!match) return;
pendingLabCaseIdRef.current = null;
void handleSelectPatientLabCase(match);
}, [unreadLabCases, patientLabCases, handleSelectPatientLabCase]);
useEffect(() => {
const match = patientLabCases.find((item) => item.detailClientId === activeDetailId);
if (match) {

View File

@@ -1,4 +1,5 @@
import { apiClient } from '@/lib/api/client';
import type { InboxPage, UserNotificationItem } from '@/types/notifications';
import type { LabCaseActivityItem } from '@/types/lab-case-activity';
import type { LabCaseTabReadTarget, TabBadgeCounts } from '@/lib/tabBadgeUtils';
@@ -27,4 +28,29 @@ export const notificationsApi = {
const response = await apiClient.post('/notifications/mark-case-read', { labCaseId });
return response.data;
},
listInbox: async (params?: {
limit?: number;
cursor?: string;
}): Promise<{ success: boolean; data: InboxPage }> => {
const response = await apiClient.get('/notifications/inbox', { params });
return response.data;
},
inboxUnreadCount: async (): Promise<{ success: boolean; data: { count: number } }> => {
const response = await apiClient.get('/notifications/inbox/unread-count');
return response.data;
},
markInboxRead: async (
id: string,
): Promise<{ success: boolean; data: UserNotificationItem | null }> => {
const response = await apiClient.post(`/notifications/inbox/${id}/read`);
return response.data;
},
markInboxReadAll: async (): Promise<{ success: boolean }> => {
const response = await apiClient.post('/notifications/inbox/read-all');
return response.data;
},
};

View File

@@ -0,0 +1,100 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { io, type Socket } from 'socket.io-client';
import { useAuth } from '@/lib/hooks/useAuth';
import type { UserNotificationItem } from '@/types/notifications';
type RealtimeContextValue = {
connected: boolean;
lastNotification: UserNotificationItem | null;
unreadCount: number | null;
setUnreadCount: (count: number) => void;
};
const RealtimeContext = createContext<RealtimeContextValue>({
connected: false,
lastNotification: null,
unreadCount: null,
setUnreadCount: () => undefined,
});
function apiOrigin(): string {
const base = process.env.NEXT_PUBLIC_API_URL ?? '';
try {
return new URL(base).origin;
} catch {
return typeof window !== 'undefined' ? window.location.origin : '';
}
}
export function RealtimeProvider({ children }: { children: ReactNode }) {
const { user, currentOrganization, isAuthReady } = useAuth();
const [connected, setConnected] = useState(false);
const [lastNotification, setLastNotification] = useState<UserNotificationItem | null>(null);
const [unreadCount, setUnreadCount] = useState<number | null>(null);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
if (!isAuthReady || !user || !currentOrganization?.id) {
socketRef.current?.disconnect();
socketRef.current = null;
setConnected(false);
return;
}
const socket = io(`${apiOrigin()}/realtime`, {
withCredentials: true,
transports: ['websocket', 'polling'],
});
socketRef.current = socket;
socket.on('connect', () => setConnected(true));
socket.on('disconnect', () => setConnected(false));
socket.on('notification.created', (payload: { notification?: UserNotificationItem }) => {
if (payload?.notification) {
setLastNotification(payload.notification);
}
});
socket.on('notification.unreadCount', (payload: { count?: number }) => {
if (typeof payload?.count === 'number') {
setUnreadCount(payload.count);
}
});
return () => {
socket.disconnect();
socketRef.current = null;
setConnected(false);
};
}, [isAuthReady, user, currentOrganization?.id]);
const setUnreadCountStable = useCallback((count: number) => {
setUnreadCount(count);
}, []);
const value = useMemo(
() => ({
connected,
lastNotification,
unreadCount,
setUnreadCount: setUnreadCountStable,
}),
[connected, lastNotification, unreadCount, setUnreadCountStable],
);
return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>;
}
export function useRealtime() {
return useContext(RealtimeContext);
}

View File

@@ -0,0 +1,25 @@
export type UserNotificationType =
| 'CASE_SENT'
| 'CLINIC_COMMENT'
| 'LAB_COMMENT'
| 'LAB_COMMENT_CLINIC'
| 'CASE_IMPORTANT'
| 'TASK_COMPLETED'
| 'TASK_ASSIGNED'
| 'CONNECTION_REQUEST'
| 'STAFF_INVITE';
export type UserNotificationItem = {
id: string;
type: UserNotificationType;
href: string;
payload: Record<string, unknown> | null;
readAt: string | null;
createdAt: string;
actorUserId: string | null;
};
export type InboxPage = {
items: UserNotificationItem[];
nextCursor: string | null;
};