feat(notifications): add Notifications inbox page — filter, mark read, dismiss
This commit is contained in:
parent
bfc9700990
commit
d28d3086cf
1 changed files with 172 additions and 0 deletions
172
src/app/dashboard/settings/notifications/page.tsx
Normal file
172
src/app/dashboard/settings/notifications/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue