improvement: notify counter badge added to treatment and tasks tabs for upadted and edited cases.

This commit is contained in:
2026-07-13 17:44:44 +03:30
parent 547457d637
commit 2ad572f4c8
33 changed files with 908 additions and 31 deletions

View File

@@ -428,6 +428,7 @@
"statusInProgress": "In progress",
"statusCompleted": "Completed",
"importantLabel": "Important",
"unreadCase": "Unread updates",
"markCaseImportant": "Mark case as important",
"markImportant": "Mark as important",
"lastUpdatedBy": "Updated by {name}",

View File

@@ -428,6 +428,7 @@
"statusInProgress": "در حال انجام",
"statusCompleted": "انجام شده",
"importantLabel": "مهم",
"unreadCase": "به‌روزرسانی‌های خوانده‌نشده",
"markCaseImportant": "علامت‌گذاری پرونده به‌عنوان مهم",
"markImportant": "علامت‌گذاری به عنوان مهم",
"lastUpdatedBy": "به‌روزرسانی توسط {name}",

View File

@@ -428,6 +428,7 @@
"statusInProgress": "Bezig",
"statusCompleted": "Voltooid",
"importantLabel": "Belangrijk",
"unreadCase": "Ongelezen updates",
"markCaseImportant": "Zaak als belangrijk markeren",
"markImportant": "Markeren als belangrijk",
"lastUpdatedBy": "Bijgewerkt door {name}",

View File

@@ -14,6 +14,8 @@ import {
formatPatientName,
} from '@/components/lab/caseDetailUtils';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import { notificationsApi } from '@/lib/api/notifications';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import { casesApi } from '@/lib/api/cases';
import { tasksApi } from '@/lib/api/tasks';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
@@ -184,6 +186,14 @@ export function CasesPage() {
useEffect(() => {
if (selectedCaseId) {
void loadDetail(selectedCaseId);
void notificationsApi.markCaseRead(selectedCaseId).then(() => {
notifyTabBadgesChanged();
setCases((prev) =>
prev.map((item) =>
item.id === selectedCaseId ? { ...item, hasUnread: false } : item,
),
);
});
void tasksApi
.listComments(selectedCaseId)
.then((r) => setCommentCount(r.data.length))
@@ -244,6 +254,7 @@ export function CasesPage() {
item.id === selectedCaseId ? { ...item, isImportant: response.data.isImportant } : item,
),
);
notifyTabBadgesChanged();
} catch (error: unknown) {
setSelectedCase(previousCase);
toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
@@ -373,19 +384,27 @@ export function CasesPage() {
: 'border-border hover:border-primary/40'
}`}
>
<div className="flex flex-wrap items-center gap-1.5">
<div className="font-medium text-text-primary">
{formatPatientName(item.patient)}
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<div className="font-medium text-text-primary">
{formatPatientName(item.patient)}
</div>
<LabCaseDueDateBadge
dueDate={item.dueDate}
locale={locale}
className="text-[10px]"
/>
{item.isImportant ? (
<Badge variant="warning" fixedWidth={false}>
{t('importantLabel')}
</Badge>
) : null}
</div>
<LabCaseDueDateBadge
dueDate={item.dueDate}
locale={locale}
className="text-[10px]"
/>
{item.isImportant ? (
<Badge variant="warning" fixedWidth={false}>
{t('importantLabel')}
</Badge>
{item.hasUnread ? (
<span
className="h-2 w-2 shrink-0 rounded-full bg-badge-warning-fg"
aria-label={t('unreadCase')}
/>
) : null}
</div>
<div className="text-xs text-text-muted mt-0.5">
@@ -495,6 +514,7 @@ export function CasesPage() {
visibleToClinic,
});
setCommentCount((n) => n + 1);
notifyTabBadgesChanged();
return r.data;
}}
onToggleVisibility={async (commentId, visible) => {

View File

@@ -12,6 +12,7 @@ import {
labTaskStatusVariant,
} from '@/components/lab/labTaskStatusDisplay';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import {
formatToothList,
prosthesisTypeBadgeStyleFromCatalog,
@@ -228,6 +229,7 @@ export function TaskRow({
body,
visibleToClinic,
});
notifyTabBadgesChanged();
return r.data;
}}
onToggleVisibility={async (commentId, visible) => {

View File

@@ -16,6 +16,8 @@ import {
isDefaultTasksView,
TASK_COMPLETE_EXIT_MS,
} from '@/components/lab/tasksViewDefaults';
import { useMarkTabReadOnVisit } from '@/lib/hooks/useTabBadgeCounts';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
@@ -78,6 +80,7 @@ export function TasksPage() {
const canView = canViewTasks(currentOrganization);
const canEdit = canEditTasks(currentOrganization);
const locale = user?.language ?? 'en';
useMarkTabReadOnVisit();
const tRef = useRef(t);
tRef.current = t;
@@ -273,6 +276,7 @@ export function TasksPage() {
} else {
await loadTasks();
}
notifyTabBadgesChanged();
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
} finally {

View File

@@ -0,0 +1,17 @@
interface NavBadgePillProps {
count: number;
ariaLabel: string;
}
export function NavBadgePill({ count, ariaLabel }: NavBadgePillProps) {
if (count <= 0) return null;
return (
<span
className="min-w-[1.25rem] rounded-full bg-badge-warning-bg px-1.5 py-0.5 text-center text-xs font-medium tabular-nums text-badge-warning-fg border border-badge-warning-border"
aria-label={ariaLabel}
>
{count > 99 ? '99+' : count}
</span>
);
}

View File

@@ -18,6 +18,9 @@ import {
import type { OrgTypeName } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCount';
import { useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
import { badgeCountForPath } from '@/lib/tabBadgeUtils';
import { NavBadgePill } from '@/components/ui/shared/NavBadgePill';
import {
canViewAppointmentsTab,
canViewCases,
@@ -48,6 +51,7 @@ function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
const pathname = usePathname();
const { currentOrganization } = useAuth();
const pendingConnectionsCount = usePendingConnectionsCount();
const tabBadgeCounts = useTabBadgeCounts();
const orgType = currentOrganization?.type;
const menu = useMemo((): MenuItem[] => {
@@ -119,6 +123,7 @@ function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
const isActive = pathname === item.path;
const showPendingBadge =
item.path === '/organizations' && pendingConnectionsCount > 0;
const tabBadgeCount = badgeCountForPath(tabBadgeCounts, item.path);
return (
<Link
@@ -134,14 +139,13 @@ function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
>
<Icon className="w-[18px] h-[18px] icon-flat" />
<span className="text-sm flex-1">{item.name}</span>
{showPendingBadge && (
<span
className="min-w-[1.25rem] rounded-full bg-badge-warning-bg px-1.5 py-0.5 text-center text-xs font-medium tabular-nums text-badge-warning-fg border border-badge-warning-border"
aria-label={`${pendingConnectionsCount} pending connection requests`}
>
{pendingConnectionsCount}
</span>
)}
{showPendingBadge ? (
<NavBadgePill
count={pendingConnectionsCount}
ariaLabel={`${pendingConnectionsCount} pending connection requests`}
/>
) : null}
<NavBadgePill count={tabBadgeCount} ariaLabel={`${tabBadgeCount} unread updates`} />
</Link>
);
})}

View File

@@ -35,6 +35,9 @@ import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatc
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { useMarkTabReadOnVisit } from '@/lib/hooks/useTabBadgeCounts';
import { notificationsApi } from '@/lib/api/notifications';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
import type { Organization } from '@/types/organization';
@@ -275,6 +278,7 @@ export function TreatmentWorkspace({
const { showError, showSuccess, messages: toastMessages } = useToast();
const canView = canViewTreatment(currentOrganization);
const canEdit = canEditTreatment(currentOrganization);
useMarkTabReadOnVisit();
const [stripHidden, setStripHidden] = useState(false);
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
@@ -346,6 +350,18 @@ export function TreatmentWorkspace({
[labCaseDrafts],
);
const activeSentLabCaseId = useMemo(() => {
const match = labCaseDrafts.find(
(lc) => lc.detailClientId === activeDetailId && lc.sentAt && lc.id,
);
return match?.id ?? null;
}, [labCaseDrafts, activeDetailId]);
useEffect(() => {
if (!activeSentLabCaseId) return;
void notificationsApi.markCaseRead(activeSentLabCaseId).then(() => notifyTabBadgesChanged());
}, [activeSentLabCaseId]);
const isDirty = useMemo(
() => isDetailsDirty(details, savedSnapshot),
[details, savedSnapshot],
@@ -1205,6 +1221,7 @@ export function TreatmentWorkspace({
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
});
showSuccess(t('successCaseSent'));
notifyTabBadgesChanged();
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSendCase')));
} finally {

View File

@@ -0,0 +1,19 @@
import { apiClient } from '@/lib/api/client';
import type { LabCaseTabReadTarget, TabBadgeCounts } from '@/lib/tabBadgeUtils';
export const notificationsApi = {
tabCounts: async (): Promise<{ success: boolean; data: TabBadgeCounts }> => {
const response = await apiClient.get('/notifications/tab-counts');
return response.data;
},
markTabRead: async (tab: LabCaseTabReadTarget): Promise<{ success: boolean }> => {
const response = await apiClient.post('/notifications/mark-tab-read', { tab });
return response.data;
},
markCaseRead: async (labCaseId: string): Promise<{ success: boolean }> => {
const response = await apiClient.post('/notifications/mark-case-read', { labCaseId });
return response.data;
},
};

View File

@@ -0,0 +1,60 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { usePathname } from '@/i18n/navigation';
import { notificationsApi } from '@/lib/api/notifications';
import {
tabBadgesChangedEventName,
tabFromPathname,
type TabBadgeCounts,
} from '@/lib/tabBadgeUtils';
import { useAuth } from '@/lib/hooks/useAuth';
const EMPTY_COUNTS: TabBadgeCounts = {};
export function useTabBadgeCounts(): TabBadgeCounts {
const pathname = usePathname();
const { currentOrganization } = useAuth();
const [counts, setCounts] = useState<TabBadgeCounts>(EMPTY_COUNTS);
const fetchCounts = useCallback(async () => {
if (!currentOrganization?.id) {
setCounts(EMPTY_COUNTS);
return;
}
try {
const res = await notificationsApi.tabCounts();
setCounts(res.data ?? EMPTY_COUNTS);
} catch {
setCounts(EMPTY_COUNTS);
}
}, [currentOrganization?.id]);
useEffect(() => {
void fetchCounts();
}, [fetchCounts, pathname]);
useEffect(() => {
const onChanged = () => void fetchCounts();
window.addEventListener(tabBadgesChangedEventName(), onChanged);
return () => window.removeEventListener(tabBadgesChangedEventName(), onChanged);
}, [fetchCounts]);
return counts;
}
export function useMarkTabReadOnVisit() {
const pathname = usePathname();
const { currentOrganization } = useAuth();
useEffect(() => {
const tab = tabFromPathname(pathname);
// Cases tab badge clears per opened case (mark-case-read), not on tab visit.
if (!tab || tab === 'CASES' || !currentOrganization?.id) return;
void notificationsApi.markTabRead(tab).then(() => {
window.dispatchEvent(new Event(tabBadgesChangedEventName()));
});
}, [pathname, currentOrganization?.id]);
}

View File

@@ -0,0 +1,31 @@
export type TabBadgeCounts = {
cases?: number;
tasks?: number;
treatment?: number;
};
export type LabCaseTabReadTarget = 'CASES' | 'TASKS' | 'TREATMENT';
const TAB_BADGES_CHANGED_EVENT = 'tab-badges-changed';
export function notifyTabBadgesChanged() {
window.dispatchEvent(new Event(TAB_BADGES_CHANGED_EVENT));
}
export function tabBadgesChangedEventName() {
return TAB_BADGES_CHANGED_EVENT;
}
export function tabFromPathname(pathname: string): LabCaseTabReadTarget | null {
if (pathname === '/cases' || pathname.startsWith('/cases/')) return 'CASES';
if (pathname === '/tasks' || pathname.startsWith('/tasks/')) return 'TASKS';
if (pathname === '/treatment' || pathname.startsWith('/treatment/')) return 'TREATMENT';
return null;
}
export function badgeCountForPath(counts: TabBadgeCounts, pathname: string): number {
if (pathname === '/cases' || pathname.startsWith('/cases/')) return counts.cases ?? 0;
if (pathname === '/tasks' || pathname.startsWith('/tasks/')) return counts.tasks ?? 0;
if (pathname === '/treatment' || pathname.startsWith('/treatment/')) return counts.treatment ?? 0;
return 0;
}

View File

@@ -6,6 +6,7 @@ export interface LabCaseListItem {
dueDate: string | null;
isOverdue: boolean;
isImportant: boolean;
hasUnread: boolean;
clinic: { id: string; name: string };
patient: {
id: string;