feat(CC-069): add Notifications Inbox page (filter tabs, ack, dismiss, snooze, mark-read)
This commit is contained in:
parent
9d50b7089d
commit
9047bcffaf
1 changed files with 219 additions and 0 deletions
219
src/app/dashboard/notifications/page.tsx
Normal file
219
src/app/dashboard/notifications/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue