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