improvement: notify counter badge added to treatment and tasks tabs for upadted and edited cases.
This commit is contained in:
@@ -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}",
|
||||
|
||||
@@ -428,6 +428,7 @@
|
||||
"statusInProgress": "در حال انجام",
|
||||
"statusCompleted": "انجام شده",
|
||||
"importantLabel": "مهم",
|
||||
"unreadCase": "بهروزرسانیهای خواندهنشده",
|
||||
"markCaseImportant": "علامتگذاری پرونده بهعنوان مهم",
|
||||
"markImportant": "علامتگذاری به عنوان مهم",
|
||||
"lastUpdatedBy": "بهروزرسانی توسط {name}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
17
frontend/src/components/ui/shared/NavBadgePill.tsx
Normal file
17
frontend/src/components/ui/shared/NavBadgePill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
19
frontend/src/lib/api/notifications.ts
Normal file
19
frontend/src/lib/api/notifications.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
60
frontend/src/lib/hooks/useTabBadgeCounts.ts
Normal file
60
frontend/src/lib/hooks/useTabBadgeCounts.ts
Normal 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]);
|
||||
}
|
||||
31
frontend/src/lib/tabBadgeUtils.ts
Normal file
31
frontend/src/lib/tabBadgeUtils.ts
Normal 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;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface LabCaseListItem {
|
||||
dueDate: string | null;
|
||||
isOverdue: boolean;
|
||||
isImportant: boolean;
|
||||
hasUnread: boolean;
|
||||
clinic: { id: string; name: string };
|
||||
patient: {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user