feature: version one notification feature implemented.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { NotificationsPage } from '@/components/ui/notifications/NotificationsPage';
|
||||
|
||||
export default function NotificationsRoutePage() {
|
||||
return <NotificationsPage />;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
149
frontend/src/components/ui/notifications/NotificationBell.tsx
Normal file
149
frontend/src/components/ui/notifications/NotificationBell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
121
frontend/src/components/ui/notifications/NotificationsPage.tsx
Normal file
121
frontend/src/components/ui/notifications/NotificationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
100
frontend/src/lib/realtime/RealtimeProvider.tsx
Normal file
100
frontend/src/lib/realtime/RealtimeProvider.tsx
Normal 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);
|
||||
}
|
||||
25
frontend/src/types/notifications.ts
Normal file
25
frontend/src/types/notifications.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user