improvement: notification feature polished. feature tabs following the same notification socket emmited data to be updated.

This commit is contained in:
2026-07-18 16:57:13 +03:30
parent 9f6ec193d2
commit 9941dca849
23 changed files with 506 additions and 63 deletions

View File

@@ -990,7 +990,13 @@
"typeTaskAssigned": "A task was assigned to you",
"typeConnectionRequest": "New organization connection request",
"typeStaffInvite": "Staff invitation created",
"typeUnknown": "Notification"
"typeUnknown": "Notification",
"destCases": "Open Cases",
"destTasks": "Open Tasks",
"destTreatment": "Open Treatment",
"destOrganizations": "Open Organizations",
"destStaff": "Open Staff",
"destOpen": "Open"
},
"errors": {
"GENERIC": "Something went wrong. Please try again.",

View File

@@ -991,7 +991,13 @@
"typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد",
"typeConnectionRequest": "درخواست اتصال سازمان جدید",
"typeStaffInvite": "دعوتنامه کارکنان ایجاد شد",
"typeUnknown": "اعلان"
"typeUnknown": "اعلان",
"destCases": "باز کردن پرونده‌ها",
"destTasks": "باز کردن وظایف",
"destTreatment": "باز کردن درمان",
"destOrganizations": "باز کردن سازمان‌ها",
"destStaff": "باز کردن کارکنان",
"destOpen": "باز کردن"
},
"errors": {
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",

View File

@@ -990,7 +990,13 @@
"typeTaskAssigned": "Er is een taak aan u toegewezen",
"typeConnectionRequest": "Nieuw organisatieverzoek",
"typeStaffInvite": "Personeelsuitnodiging aangemaakt",
"typeUnknown": "Melding"
"typeUnknown": "Melding",
"destCases": "Cases openen",
"destTasks": "Taken openen",
"destTreatment": "Behandeling openen",
"destOrganizations": "Organisaties openen",
"destStaff": "Personeel openen",
"destOpen": "Openen"
},
"errors": {
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",

View File

@@ -16,7 +16,7 @@ import {
} from '@/components/lab/caseDetailUtils';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import { notificationsApi } from '@/lib/api/notifications';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import { notifyTabBadgesChanged, tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
import { casesApi } from '@/lib/api/cases';
import { tasksApi } from '@/lib/api/tasks';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
@@ -106,16 +106,22 @@ export function CasesPage() {
search.trim() || clinicId || prosthesisTypeCode || sentFrom || sentTo,
);
const loadCases = async (params: {
q: string;
clinicOrganizationId: string;
prosthesisTypeCode: string;
sentFrom: string;
sentTo: string;
page: number;
}) => {
setLoadingList(true);
toast.setError('');
const loadCases = async (
params: {
q: string;
clinicOrganizationId: string;
prosthesisTypeCode: string;
sentFrom: string;
sentTo: string;
page: number;
},
options?: { silent?: boolean },
) => {
const silent = options?.silent ?? false;
if (!silent) {
setLoadingList(true);
toast.setError('');
}
try {
const response = await casesApi.list({
q: params.q.trim() || undefined,
@@ -129,9 +135,11 @@ export function CasesPage() {
setCases(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
toast.showError(getUserFacingError(error, tErrors, t('errorLoadList')));
if (!silent) {
toast.showError(getUserFacingError(error, tErrors, t('errorLoadList')));
}
} finally {
setLoadingList(false);
if (!silent) setLoadingList(false);
}
};
@@ -213,6 +221,28 @@ export function CasesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload
}, [search, clinicId, prosthesisTypeCode, sentFrom, sentTo, page]);
useEffect(() => {
const onBadgesChanged = () => {
void loadCases(
{
q: search,
clinicOrganizationId: clinicId,
prosthesisTypeCode,
sentFrom,
sentTo,
page,
},
{ silent: true },
);
if (selectedCaseId) {
void loadDetail(selectedCaseId, { silent: true });
}
};
window.addEventListener(tabBadgesChangedEventName(), onBadgesChanged);
return () => window.removeEventListener(tabBadgesChangedEventName(), onBadgesChanged);
// eslint-disable-next-line react-hooks/exhaustive-deps -- soft refresh from live inbox socket
}, [search, clinicId, prosthesisTypeCode, sentFrom, sentTo, page, selectedCaseId]);
useEffect(() => {
if (!selectedCaseId) {
setMobileDetailOpen(false);

View File

@@ -20,7 +20,7 @@ import {
} from '@/components/lab/tasksViewDefaults';
import { parseTasksSearchParams } from '@/components/lab/parseTasksSearchParams';
import { useMarkTabReadOnVisit } from '@/lib/hooks/useTabBadgeCounts';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import { notifyTabBadgesChanged, tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
@@ -199,17 +199,22 @@ export function TasksPage() {
};
}, [searchParams, canView]);
const loadTasks = useCallback(async () => {
setLoading(true);
setError('');
const loadTasks = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) {
setLoading(true);
setError('');
}
try {
const response = await tasksApi.list(listParams);
setTasks(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList')));
if (!silent) {
showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList')));
}
} finally {
setLoading(false);
if (!silent) setLoading(false);
}
}, [listParams, showError, setError, tErrors]);
@@ -221,6 +226,15 @@ export function TasksPage() {
return () => clearTimeout(timeout);
}, [canView, loadTasks, search]);
useEffect(() => {
if (!canView) return;
const onBadgesChanged = () => {
void loadTasks({ silent: true });
};
window.addEventListener(tabBadgesChangedEventName(), onBadgesChanged);
return () => window.removeEventListener(tabBadgesChangedEventName(), onBadgesChanged);
}, [canView, loadTasks]);
useEffect(() => {
if (!canView) return;
void (async () => {

View File

@@ -119,26 +119,31 @@ export function NotificationBell() {
<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"
className="fixed inset-x-3 top-[4.75rem] z-[200] max-h-[min(70dvh,28rem)] overflow-hidden rounded-[var(--radius-md)] border border-border bg-background-secondary/95 shadow-lg backdrop-blur-sm sm:absolute sm:inset-x-auto sm:end-0 sm:top-auto sm:mt-2 sm:w-[min(22rem,calc(100vw-1.5rem))]"
>
<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"
className="text-xs text-primary hover:underline shrink-0"
onClick={() => setOpen(false)}
>
{t('viewAll')}
</Link>
</div>
<div className="max-h-[min(24rem,60vh)] overflow-y-auto p-2 space-y-1.5">
<div className="max-h-[min(calc(70dvh-2.75rem),24rem)] overflow-y-auto overscroll-contain 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} />
<NotificationCard
key={item.id}
item={item}
compact
onSelect={handleSelect}
/>
))
)}
</div>

View File

@@ -1,6 +1,7 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { ChevronRight } from 'lucide-react';
import { formatAppDateTime } from '@/lib/i18n/format';
import type { UserNotificationItem, UserNotificationType } from '@/types/notifications';
@@ -16,40 +17,165 @@ const TYPE_I18N: Record<UserNotificationType, string> = {
STAFF_INVITE: 'typeStaffInvite',
};
function destKeyFromHref(href: string): string {
if (href.startsWith('/cases')) return 'destCases';
if (href.startsWith('/tasks')) return 'destTasks';
if (href.startsWith('/treatment')) return 'destTreatment';
if (href.startsWith('/organizations')) return 'destOrganizations';
if (href.startsWith('/staff')) return 'destStaff';
return 'destOpen';
}
function asString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function formatProsthesisCode(code: string): string {
return code.replace(/_/g, ' ');
}
/** Build a single truncated context string from denormalized inbox payload. */
export function notificationContextLine(
item: UserNotificationItem,
): string | null {
const payload = item.payload ?? {};
const parts: string[] = [];
const patientName = asString(payload.patientName);
const clinicName = asString(payload.clinicName);
const labName = asString(payload.labName);
const taskName = asString(payload.taskName);
const fromOrganizationName = asString(payload.fromOrganizationName);
const inviteeName = asString(payload.inviteeName);
const email = asString(payload.email);
const prosthesisTypeCode = asString(payload.prosthesisTypeCode);
const prosthesisCodes = Array.isArray(payload.prosthesisTypeCodes)
? payload.prosthesisTypeCodes
.filter((code): code is string => typeof code === 'string' && code.trim().length > 0)
.map((code) => formatProsthesisCode(code.trim()))
: [];
const prosthesisLabel =
prosthesisTypeCode != null
? formatProsthesisCode(prosthesisTypeCode)
: prosthesisCodes.length > 0
? prosthesisCodes.slice(0, 2).join(', ')
: null;
switch (item.type) {
case 'CASE_SENT':
case 'CLINIC_COMMENT':
case 'LAB_COMMENT':
case 'CASE_IMPORTANT':
if (patientName) parts.push(patientName);
if (clinicName) parts.push(clinicName);
if (prosthesisLabel) parts.push(prosthesisLabel);
break;
case 'LAB_COMMENT_CLINIC':
if (patientName) parts.push(patientName);
if (labName) parts.push(labName);
if (prosthesisLabel) parts.push(prosthesisLabel);
break;
case 'TASK_COMPLETED':
case 'TASK_ASSIGNED':
if (patientName) parts.push(patientName);
if (clinicName) parts.push(clinicName);
else if (labName) parts.push(labName);
if (taskName) parts.push(taskName);
if (prosthesisLabel) parts.push(prosthesisLabel);
break;
case 'CONNECTION_REQUEST':
if (fromOrganizationName) parts.push(fromOrganizationName);
break;
case 'STAFF_INVITE':
if (inviteeName) parts.push(inviteeName);
if (email) parts.push(email);
break;
default:
// Fallback: show any denormalized fields even if type is unexpected.
if (patientName) parts.push(patientName);
if (clinicName) parts.push(clinicName);
if (labName) parts.push(labName);
if (taskName) parts.push(taskName);
if (prosthesisLabel) parts.push(prosthesisLabel);
break;
}
if (parts.length === 0) return null;
return parts.join(' · ');
}
export function NotificationCard({
item,
onSelect,
compact = false,
}: {
item: UserNotificationItem;
onSelect: (item: UserNotificationItem) => void;
compact?: boolean;
}) {
const t = useTranslations('notifications');
const locale = useLocale();
const unread = !item.readAt;
const titleKey = TYPE_I18N[item.type] ?? 'typeUnknown';
const destKey = destKeyFromHref(item.href);
const context = notificationContextLine(item);
return (
<button
type="button"
onClick={() => onSelect(item)}
className={`w-full text-start rounded-[var(--radius-md)] border px-3 py-2.5 transition-colors ${
className={`w-full min-h-11 text-start rounded-[var(--radius-md)] border px-3 py-2.5 sm:py-3 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">
<div className="flex items-start gap-2.5 sm:gap-3">
{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
className={`font-medium text-text-primary ${
compact ? 'text-sm' : 'text-sm sm:text-base'
}`}
>
{t(titleKey)}
</p>
<div
className={`mt-0.5 flex min-w-0 items-center gap-x-2 text-text-muted ${
compact ? 'text-[11px]' : 'text-xs sm:text-sm'
}`}
>
{context ? (
<>
<span className="min-w-0 flex-1 truncate" title={context}>
{context}
</span>
<span className="text-border shrink-0" aria-hidden>
·
</span>
</>
) : null}
<span className="shrink-0 whitespace-nowrap">
{formatAppDateTime(item.createdAt, locale)}
</span>
<span className="text-border shrink-0" aria-hidden>
·
</span>
<span className="shrink-0 whitespace-nowrap">{t(destKey)}</span>
</div>
</div>
<ChevronRight
className="mt-0.5 h-4 w-4 shrink-0 text-text-muted rtl:rotate-180"
aria-hidden
/>
</div>
</button>
);

View File

@@ -83,18 +83,23 @@ export function NotificationsPage() {
};
return (
<div className="space-y-4 max-w-2xl">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="space-y-4 w-full">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<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()}>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto shrink-0"
onClick={() => void handleMarkAll()}
>
{t('markAllRead')}
</Button>
</div>
<div className="space-y-2">
<div className="space-y-2 w-full">
{loading ? (
<p className="text-sm text-text-muted">{t('loading')}</p>
) : items.length === 0 ? (
@@ -110,6 +115,7 @@ export function NotificationsPage() {
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
isLoading={loadingMore}
onClick={() => void loadPage(nextCursor, true)}
>

View File

@@ -107,21 +107,36 @@ export function OrganizationsPage() {
const existingRows = items;
async function loadList() {
setLoading(true);
toast.setError('');
async function loadList(options?: { silent?: boolean }) {
const silent = options?.silent ?? false;
if (!silent) {
setLoading(true);
toast.setError('');
}
try {
const res = await organizationApi.list();
setItems(res.data.items);
} catch (e) {
toast.showError(formatApiMessage(e));
if (!silent) {
toast.showError(formatApiMessage(e));
}
} finally {
setLoading(false);
if (!silent) setLoading(false);
}
}
useEffect(() => {
void loadList();
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
}, []);
useEffect(() => {
const onChanged = () => {
void loadList({ silent: true });
};
window.addEventListener('pending-connections-changed', onChanged);
return () => window.removeEventListener('pending-connections-changed', onChanged);
// eslint-disable-next-line react-hooks/exhaustive-deps -- soft refresh from live inbox
}, []);
useEffect(() => {

View File

@@ -1,6 +1,7 @@
// src/lib/api/client.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import type { ApiError } from '@/types/api';
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
@@ -79,6 +80,7 @@ apiClient.interceptors.response.use(
}
}
notifyAccessTokenRefreshed();
return apiClient(originalRequest);
} catch (refreshError) {
if (typeof window !== 'undefined') {

View File

@@ -0,0 +1,11 @@
/** Dispatched after a successful access-token refresh so realtime can reconnect with the new cookie. */
export const ACCESS_TOKEN_REFRESHED_EVENT = 'dyolink:access-token-refreshed';
export function notifyAccessTokenRefreshed() {
if (typeof window === 'undefined') return;
window.dispatchEvent(new Event(ACCESS_TOKEN_REFRESHED_EVENT));
}
export function accessTokenRefreshedEventName() {
return ACCESS_TOKEN_REFRESHED_EVENT;
}

View File

@@ -1,4 +1,5 @@
import { authApi } from '@/lib/api/auth';
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
const STORAGE_KEY = 'dyolink.accessTokenExpiresAt';
/** Refresh this long before the access JWT expires. */
@@ -91,6 +92,7 @@ async function refreshAccessTokenWithOrg(): Promise<string | undefined> {
}
lastRefreshAt = Date.now();
notifyAccessTokenRefreshed();
return expiresAt;
} finally {
refreshInFlight = null;

View File

@@ -16,6 +16,7 @@ import {
rememberAccessTokenExpiresAt,
startProactiveSessionRefresh,
} from '@/lib/auth/proactiveRefresh';
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
import { asApiError, legacyStatusCode, type ApiError } from '@/types/api';
import { consumeAuthRedirect } from '@/lib/auth/postAuthRedirect';
@@ -162,6 +163,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (orgId) {
await authApi.selectOrganization(orgId);
}
notifyAccessTokenRefreshed();
const retry = await authApi.getProfile();
if (retry.success) {
const { user: userData, organizations: orgs } = normalizeProfilePayload(retry.data);

View File

@@ -12,6 +12,9 @@ import {
} from 'react';
import { io, type Socket } from 'socket.io-client';
import { useAuth } from '@/lib/hooks/useAuth';
import { accessTokenRefreshedEventName } from '@/lib/auth/accessTokenEvents';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount';
import type { UserNotificationItem } from '@/types/notifications';
type RealtimeContextValue = {
@@ -37,13 +40,33 @@ function apiOrigin(): string {
}
}
function invalidateSidebarBadges(notification?: UserNotificationItem | null) {
// Sidebar Cases/Tasks/Treatment badges + open tab soft-refetch (via window event).
notifyTabBadgesChanged();
// Orgs pending badge/list — only for connection-related inbox events.
if (
notification?.type === 'CONNECTION_REQUEST' ||
notification?.href?.startsWith('/organizations')
) {
notifyPendingConnectionsChanged();
}
}
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);
/** Bumped after access-token refresh so the socket reconnects with the new cookie. */
const [socketEpoch, setSocketEpoch] = useState(0);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
const onRefreshed = () => setSocketEpoch((n) => n + 1);
window.addEventListener(accessTokenRefreshedEventName(), onRefreshed);
return () => window.removeEventListener(accessTokenRefreshedEventName(), onRefreshed);
}, []);
useEffect(() => {
if (!isAuthReady || !user || !currentOrganization?.id) {
socketRef.current?.disconnect();
@@ -63,6 +86,9 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
socket.on('notification.created', (payload: { notification?: UserNotificationItem }) => {
if (payload?.notification) {
setLastNotification(payload.notification);
invalidateSidebarBadges(payload.notification);
} else {
invalidateSidebarBadges(null);
}
});
socket.on('notification.unreadCount', (payload: { count?: number }) => {
@@ -76,7 +102,7 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
socketRef.current = null;
setConnected(false);
};
}, [isAuthReady, user, currentOrganization?.id]);
}, [isAuthReady, user, currentOrganization?.id, socketEpoch]);
const setUnreadCountStable = useCallback((count: number) => {
setUnreadCount(count);