feat(notifications): add Notifications inbox page — filter, mark read, dismiss

This commit is contained in:
admin-valentin 2026-07-31 17:12:06 +00:00
parent bfc9700990
commit d28d3086cf

View file

@ -0,0 +1,172 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch, type Notification, type NotificationSeverity } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
type InboxFilter = 'all' | 'unread' | 'action_required' | 'critical' | 'intelligence' | 'system';
const FILTERS: { key: InboxFilter; label: string }[] = [
{ key: 'all', label: 'Toate' },
{ key: 'unread', label: 'Necitite' },
{ key: 'action_required', label: 'Necesită acțiune' },
{ key: 'critical', label: 'Critice' },
{ key: 'intelligence', label: 'Intelligence' },
{ key: 'system', label: 'Sistem' },
];
const SEVERITY_COLORS: Record<NotificationSeverity, string> = {
CRITICAL: 'bg-signal-danger text-paper',
ACTION_REQUIRED: 'bg-signal-warn text-paper',
WARNING: 'bg-signal-warn/20 text-signal-warn',
INFORMATION: 'bg-paper-sunken text-ink-soft',
INTELLIGENCE: 'bg-bronze-wash text-bronze-deep',
SYSTEM: 'bg-paper-sunken text-ink-faint',
};
const SEVERITY_LABELS: Record<NotificationSeverity, string> = {
CRITICAL: 'Critic',
ACTION_REQUIRED: 'Acțiune',
WARNING: 'Avertisment',
INFORMATION: 'Informare',
INTELLIGENCE: 'Intelligence',
SYSTEM: 'Sistem',
};
export default function NotificationsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const queryClient = useQueryClient();
const [filter, setFilter] = useState<InboxFilter>('all');
const { data: notifications = [], isLoading } = useQuery({
queryKey: ['notifications', tenantId, filter],
queryFn: () =>
apiFetch<Notification[]>(`/v1/notifications?filter=${filter}`, { tenantId }),
enabled: Boolean(tenantId),
staleTime: 15_000,
});
const markRead = useMutation({
mutationFn: (ids: string[]) =>
apiFetch('/v1/notifications/mark-read', { method: 'POST', tenantId, body: { ids } }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['notifications', tenantId] });
await queryClient.invalidateQueries({ queryKey: ['notifications-unread', tenantId] });
},
});
const dismiss = useMutation({
mutationFn: (id: string) =>
apiFetch(`/v1/notifications/${id}/dismiss`, { method: 'POST', tenantId }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['notifications', tenantId] });
await queryClient.invalidateQueries({ queryKey: ['notifications-unread', tenantId] });
},
});
const unreadIds = notifications.filter((n) => !n.readAt).map((n) => n.id);
return (
<div className="max-w-3xl">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Notificări</h1>
<p className="text-sm text-ink-faint">
{unreadIds.length > 0
? `${unreadIds.length} necitite în workspace.`
: 'Nicio notificare necitită.'}
</p>
</div>
{unreadIds.length > 0 && (
<button
type="button"
className="btn-ghost text-xs"
disabled={markRead.isPending}
onClick={() => markRead.mutate(unreadIds)}
>
Marchează toate citite
</button>
)}
</div>
<div className="mb-4 flex flex-wrap gap-2">
{FILTERS.map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => setFilter(key)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
filter === key
? 'bg-ink text-paper'
: 'bg-paper-sunken text-ink-soft hover:text-ink'
}`}
>
{label}
</button>
))}
</div>
{isLoading && <p className="text-sm text-ink-faint">Se încarcă</p>}
{!isLoading && notifications.length === 0 && (
<div className="card p-8 text-center">
<p className="text-sm font-medium text-ink">Nicio notificare</p>
<p className="mt-0.5 text-xs text-ink-faint">
Notificările apar automat când se produc evenimente în workspace.
</p>
</div>
)}
<ul className="space-y-2">
{notifications.map((notif) => (
<li
key={notif.id}
className={`card flex items-start gap-3 p-4 ${!notif.readAt ? 'border-l-2 border-l-bronze' : ''}`}
>
<span
className={`mt-0.5 shrink-0 rounded px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide ${
SEVERITY_COLORS[notif.severity]
}`}
>
{SEVERITY_LABELS[notif.severity]}
</span>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${notif.readAt ? 'text-ink-soft' : 'text-ink'}`}>
{notif.title}
</p>
{notif.body && (
<p className="mt-0.5 text-xs text-ink-faint line-clamp-2">{notif.body}</p>
)}
<p className="mt-1 text-[11px] text-ink-line">
{new Date(notif.createdAt).toLocaleString('ro-RO', {
day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit',
})}
{notif.aggregatedCount > 1 && ` · ${notif.aggregatedCount} evenimente similare`}
</p>
</div>
<div className="flex shrink-0 flex-col gap-1">
{!notif.readAt && (
<button
type="button"
onClick={() => markRead.mutate([notif.id])}
className="text-[11px] text-ink-faint hover:text-ink"
>
Citit
</button>
)}
<button
type="button"
onClick={() => dismiss.mutate(notif.id)}
className="text-[11px] text-ink-faint hover:text-signal-danger"
>
Închide
</button>
</div>
</li>
))}
</ul>
</div>
);
}