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

@@ -968,6 +968,30 @@
"monthNovember": "November",
"monthDecember": "December"
},
"notifications": {
"bellAria": "Notifications",
"unreadCount": "{count} unread notifications",
"dropdownTitle": "Notifications",
"viewAll": "View all",
"pageTitle": "Notifications",
"pageSubtitle": "Updates from cases, tasks, organizations, and staff.",
"loading": "Loading notifications…",
"empty": "No notifications yet.",
"loadMore": "Load more",
"markAllRead": "Mark all as read",
"errorLoad": "Could not load notifications.",
"errorMarkRead": "Could not update notification.",
"typeCaseSent": "New lab case received",
"typeClinicComment": "New clinic comment on a case",
"typeLabComment": "New lab comment on a case",
"typeLabCommentClinic": "New lab comment on your case",
"typeCaseImportant": "Case marked as important",
"typeTaskCompleted": "Lab task completed",
"typeTaskAssigned": "A task was assigned to you",
"typeConnectionRequest": "New organization connection request",
"typeStaffInvite": "Staff invitation created",
"typeUnknown": "Notification"
},
"errors": {
"GENERIC": "Something went wrong. Please try again.",
"NETWORK_ERROR": "Could not reach the server. Check your connection and try again.",

View File

@@ -969,6 +969,30 @@
"monthNovember": "نوامبر",
"monthDecember": "دسامبر"
},
"notifications": {
"bellAria": "اعلان‌ها",
"unreadCount": "{count} اعلان خوانده‌نشده",
"dropdownTitle": "اعلان‌ها",
"viewAll": "مشاهده همه",
"pageTitle": "اعلان‌ها",
"pageSubtitle": "به‌روزرسانی‌های پرونده‌ها، وظایف، سازمان‌ها و کارکنان.",
"loading": "در حال بارگذاری اعلان‌ها…",
"empty": "هنوز اعلانی نیست.",
"loadMore": "بیشتر",
"markAllRead": "علامت‌گذاری همه به‌عنوان خوانده‌شده",
"errorLoad": "بارگذاری اعلان‌ها ممکن نشد.",
"errorMarkRead": "به‌روزرسانی اعلان ممکن نشد.",
"typeCaseSent": "پرونده جدید در لابراتوار دریافت شد",
"typeClinicComment": "نظر جدید کلینیک روی پرونده",
"typeLabComment": "نظر جدید لابراتوار روی پرونده",
"typeLabCommentClinic": "نظر جدید لابراتوار روی پرونده شما",
"typeCaseImportant": "پرونده به‌عنوان مهم علامت خورد",
"typeTaskCompleted": "وظیفه لابراتوار تکمیل شد",
"typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد",
"typeConnectionRequest": "درخواست اتصال سازمان جدید",
"typeStaffInvite": "دعوتنامه کارکنان ایجاد شد",
"typeUnknown": "اعلان"
},
"errors": {
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
"NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.",

View File

@@ -968,6 +968,30 @@
"monthNovember": "November",
"monthDecember": "December"
},
"notifications": {
"bellAria": "Meldingen",
"unreadCount": "{count} ongelezen meldingen",
"dropdownTitle": "Meldingen",
"viewAll": "Alles bekijken",
"pageTitle": "Meldingen",
"pageSubtitle": "Updates van cases, taken, organisaties en personeel.",
"loading": "Meldingen laden…",
"empty": "Nog geen meldingen.",
"loadMore": "Meer laden",
"markAllRead": "Alles als gelezen markeren",
"errorLoad": "Meldingen konden niet worden geladen.",
"errorMarkRead": "Melding kon niet worden bijgewerkt.",
"typeCaseSent": "Nieuwe labcase ontvangen",
"typeClinicComment": "Nieuwe kliniekreactie op een case",
"typeLabComment": "Nieuwe labreactie op een case",
"typeLabCommentClinic": "Nieuwe labreactie op uw case",
"typeCaseImportant": "Case gemarkeerd als belangrijk",
"typeTaskCompleted": "Labtaak voltooid",
"typeTaskAssigned": "Er is een taak aan u toegewezen",
"typeConnectionRequest": "Nieuw organisatieverzoek",
"typeStaffInvite": "Personeelsuitnodiging aangemaakt",
"typeUnknown": "Melding"
},
"errors": {
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",
"NETWORK_ERROR": "Kan de server niet bereiken. Controleer uw verbinding.",

View File

@@ -20,6 +20,7 @@
"react-hook-form": "^7.71.2",
"react-qr-code": "^2.0.15",
"recharts": "^3.9.2",
"socket.io-client": "^4.8.3",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -1627,6 +1628,12 @@
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
"license": "MIT"
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -3614,7 +3621,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3736,6 +3742,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/engine.io-client": {
"version": "6.6.6",
"resolved": "https://registry.npmmirror.com/engine.io-client/-/engine.io-client-6.6.6.tgz",
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.21.0",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.20.0",
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
@@ -5962,7 +5990,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -7096,6 +7123,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmmirror.com/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.7",
"resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -7844,6 +7899,35 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",

View File

@@ -21,6 +21,7 @@
"react-hook-form": "^7.71.2",
"react-qr-code": "^2.0.15",
"recharts": "^3.9.2",
"socket.io-client": "^4.8.3",
"zod": "^4.3.6"
},
"devDependencies": {

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;
};