feat(CC-069): add Notifications Inbox page (filter tabs, ack, dismiss, snooze, mark-read)

This commit is contained in:
admin-valentin 2026-08-02 12:21:33 +00:00
parent 9d50b7089d
commit 9047bcffaf

View file

@ -0,0 +1,219 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Notification {
id: string; channel: string; priority: string; title: string;
body: string | null; source: string | null; entityType: string | null;
entityId: string | null; readAt: string | null; acknowledgedAt: string | null;
snoozedUntil: string | null; dismissedAt: string | null;
createdAt: string;
}
const FILTERS = [
{ key: 'all', label: 'Toate' },
{ key: 'unread', label: 'Necitite' },
{ key: 'action_required', label: 'Acțiune' },
{ key: 'critical', label: 'Critice' },
{ key: 'intelligence', label: 'Intelligence' },
{ key: 'system', label: 'Sistem' },
] as const;
const PRIORITY_META: Record<string, { cls: string; dot: string }> = {
critical: { cls: 'border-l-signal-danger', dot: 'bg-signal-danger' },
high: { cls: 'border-l-orange-500', dot: 'bg-orange-500' },
medium: { cls: 'border-l-signal-warn', dot: 'bg-signal-warn' },
low: { cls: 'border-l-sky-400', dot: 'bg-sky-400' },
info: { cls: 'border-l-muted', dot: 'bg-muted' },
};
const CHANNEL_ICON: Record<string, string> = {
in_app: '🔔', email: '📧', sms: '📱', push: '📲',
};
function relativeTime(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60_000);
if (m < 1) return 'acum';
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
return `${Math.floor(h / 24)}z`;
}
export default function NotificationsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [filter, setFilter] = useState<string>('all');
const [snoozingId, setSnoozingId] = useState<string | null>(null);
const { data: notifications = [], isLoading } = useQuery({
queryKey: ['notifications', tenantId, filter],
queryFn: () => apiFetch<Notification[]>(`/v1/notifications?filter=${filter}`, { tenantId }),
enabled: Boolean(tenantId), staleTime: 15_000, refetchInterval: 30_000,
});
const { data: allNotifs = [] } = useQuery({
queryKey: ['notifications', tenantId, 'all'],
queryFn: () => apiFetch<Notification[]>('/v1/notifications?filter=all', { tenantId }),
enabled: Boolean(tenantId), staleTime: 15_000,
});
const unreadCount = allNotifs.filter((n) => !n.readAt && !n.dismissedAt).length;
const criticalCount = allNotifs.filter((n) => n.priority === 'critical' && !n.acknowledgedAt).length;
const markReadMut = useMutation({
mutationFn: (ids: string[]) => apiFetch('/v1/notifications/mark-read', { tenantId, method: 'POST', body: { ids } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications', tenantId] }),
});
const ackMut = useMutation({
mutationFn: (id: string) => apiFetch(`/v1/notifications/${id}/acknowledge`, { tenantId, method: 'POST', body: {} }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications', tenantId] }),
});
const dismissMut = useMutation({
mutationFn: (id: string) => apiFetch(`/v1/notifications/${id}/dismiss`, { tenantId, method: 'POST', body: {} }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications', tenantId] }),
});
const snoozeMut = useMutation({
mutationFn: ({ id, minutes }: { id: string; minutes: number }) =>
apiFetch(`/v1/notifications/${id}/snooze`, { tenantId, method: 'POST', body: { minutes } }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['notifications', tenantId] }); setSnoozingId(null); },
});
const markAllRead = () => {
const unreadIds = notifications.filter((n) => !n.readAt).map((n) => n.id);
if (unreadIds.length) markReadMut.mutate(unreadIds);
};
const visible = notifications.filter((n) => !n.dismissedAt);
return (
<div className="max-w-3xl space-y-5 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold text-ink flex items-center gap-2">
Notificări
{unreadCount > 0 && (
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-signal-danger px-1.5 text-[10px] font-bold text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</h1>
<p className="text-sm text-ink-faint mt-1">
{criticalCount > 0 && <span className="text-signal-danger font-medium">{criticalCount} critice · </span>}
{unreadCount} necitite din {allNotifs.length} totale
</p>
</div>
<button onClick={markAllRead} disabled={unreadCount === 0 || markReadMut.isPending}
className="text-xs text-bronze-deep hover:underline disabled:opacity-40 disabled:no-underline">
Marchează toate citite
</button>
</div>
{/* Filter tabs */}
<div className="flex gap-1 border-b border-border/50 overflow-x-auto">
{FILTERS.map(({ key, label }) => {
const count = key === 'all' ? allNotifs.filter((n) => !n.dismissedAt).length
: key === 'unread' ? allNotifs.filter((n) => !n.readAt && !n.dismissedAt).length
: key === 'critical' ? allNotifs.filter((n) => n.priority === 'critical' && !n.dismissedAt).length
: key === 'action_required' ? allNotifs.filter((n) => (n.priority === 'critical' || n.priority === 'high') && !n.acknowledgedAt && !n.dismissedAt).length
: 0;
return (
<button key={key} onClick={() => setFilter(key)}
className={`shrink-0 pb-2 px-3 text-sm font-medium border-b-2 transition-colors ${
filter === key ? 'border-primary text-ink' : 'border-transparent text-ink-faint hover:text-ink'
}`}>
{label}
{count > 0 && (
<span className={`ml-1.5 rounded-full px-1.5 py-0.5 text-[10px] font-bold ${
key === 'critical' ? 'bg-signal-danger/10 text-signal-danger'
: key === 'action_required' ? 'bg-signal-warn/10 text-signal-warn'
: 'bg-muted text-ink-faint'
}`}>{count}</span>
)}
</button>
);
})}
</div>
{/* Notification list */}
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-12">Se încarcă</div>
) : visible.length === 0 ? (
<div className="text-center space-y-2 py-12">
<p className="text-3xl"></p>
<p className="text-sm text-ink-faint">Nicio notificare în această categorie.</p>
</div>
) : (
<div className="space-y-2">
{visible.map((n) => {
const pm = PRIORITY_META[n.priority] ?? PRIORITY_META.info;
const isUnread = !n.readAt;
const needsAck = (n.priority === 'critical' || n.priority === 'high') && !n.acknowledgedAt;
const isSnoozed = n.snoozedUntil && new Date(n.snoozedUntil) > new Date();
return (
<div key={n.id}
className={`card border-l-4 ${pm.cls} p-4 space-y-2 ${isUnread ? 'bg-primary/2' : ''} transition-colors`}
onClick={() => { if (isUnread) markReadMut.mutate([n.id]); }}>
<div className="flex items-start gap-3">
<div className={`mt-1.5 h-2 w-2 rounded-full shrink-0 ${isUnread ? pm.dot : 'bg-muted'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className={`text-sm ${isUnread ? 'font-semibold text-ink' : 'font-medium text-ink-faint'}`}>
{n.title}
{isSnoozed && <span className="ml-2 text-[10px] text-signal-warn">💤 snoozed</span>}
</p>
<span className="text-[10px] text-ink-faint shrink-0">{relativeTime(n.createdAt)}</span>
</div>
{n.body && <p className="text-xs text-ink-faint mt-0.5 line-clamp-2">{n.body}</p>}
<div className="flex items-center gap-2 mt-1 flex-wrap">
{n.source && <span className="text-[10px] text-ink-faint/60">{n.source}</span>}
<span className="text-[10px]">{CHANNEL_ICON[n.channel] ?? '🔔'} {n.channel}</span>
</div>
</div>
</div>
<div className="flex gap-2 pl-5 flex-wrap">
{needsAck && (
<button onClick={(e) => { e.stopPropagation(); ackMut.mutate(n.id); }}
className="rounded border px-2 py-1 text-[10px] text-signal-ok hover:bg-signal-ok/5 transition-colors">
Acknowled
</button>
)}
{snoozingId === n.id ? (
<div className="flex gap-1">
{[15, 60, 480].map((m) => (
<button key={m} onClick={(e) => { e.stopPropagation(); snoozeMut.mutate({ id: n.id, minutes: m }); }}
className="rounded border px-2 py-1 text-[10px] text-signal-warn hover:bg-signal-warn/5">
{m < 60 ? `${m}m` : `${m/60}h`}
</button>
))}
<button onClick={(e) => { e.stopPropagation(); setSnoozingId(null); }}
className="text-[10px] text-ink-faint hover:text-ink px-1"></button>
</div>
) : (
<button onClick={(e) => { e.stopPropagation(); setSnoozingId(n.id); }}
className="rounded border px-2 py-1 text-[10px] text-ink-faint hover:bg-muted transition-colors">
💤 Snooze
</button>
)}
<button onClick={(e) => { e.stopPropagation(); dismissMut.mutate(n.id); }}
className="rounded border px-2 py-1 text-[10px] text-ink-faint hover:bg-muted transition-colors">
Ignoră
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}