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

@@ -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)}
>