diff --git a/src/app/dashboard/notifications/page.tsx b/src/app/dashboard/notifications/page.tsx new file mode 100644 index 0000000..6b09a60 --- /dev/null +++ b/src/app/dashboard/notifications/page.tsx @@ -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 = { + 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 = { + 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('all'); + const [snoozingId, setSnoozingId] = useState(null); + + const { data: notifications = [], isLoading } = useQuery({ + queryKey: ['notifications', tenantId, filter], + queryFn: () => apiFetch(`/v1/notifications?filter=${filter}`, { tenantId }), + enabled: Boolean(tenantId), staleTime: 15_000, refetchInterval: 30_000, + }); + + const { data: allNotifs = [] } = useQuery({ + queryKey: ['notifications', tenantId, 'all'], + queryFn: () => apiFetch('/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 ( +
+
+
+

+ Notificări + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} +

+

+ {criticalCount > 0 && {criticalCount} critice · } + {unreadCount} necitite din {allNotifs.length} totale +

+
+ +
+ + {/* Filter tabs */} +
+ {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 ( + + ); + })} +
+ + {/* Notification list */} + {isLoading ? ( +
Se încarcă…
+ ) : visible.length === 0 ? ( +
+

+

Nicio notificare în această categorie.

+
+ ) : ( +
+ {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 ( +
{ if (isUnread) markReadMut.mutate([n.id]); }}> +
+
+
+
+

+ {n.title} + {isSnoozed && 💤 snoozed} +

+ {relativeTime(n.createdAt)} +
+ {n.body &&

{n.body}

} +
+ {n.source && {n.source}} + {CHANNEL_ICON[n.channel] ?? '🔔'} {n.channel} +
+
+
+
+ {needsAck && ( + + )} + {snoozingId === n.id ? ( +
+ {[15, 60, 480].map((m) => ( + + ))} + +
+ ) : ( + + )} + +
+
+ ); + })} +
+ )} +
+ ); +}