import { useEffect, useMemo, useRef, useState } from "react"; import { Bell, Check, ExternalLink, Send, XCircle } from "lucide-react"; import { ActionBlockerHint, ActionToolbar, Button, ConfirmDialog, CountBadge, DismissibleAlert, DocumentationHelpLink, MultiSelectFilter, SelectionList, SelectionListItem, StatePanel, StatusBadge, WorkspaceActionBar, WorkspaceFrame, WorkspaceLayout, hasScope, i18nMessage, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; import { deliverPendingNotifications, listNotifications, updateNotification, type NotificationMessage } from "../../api/notifications"; import { safeNotificationActionUrl } from "../../security/actionUrl"; import { NOTIFICATIONS_BLOCKER_LABELS, NOTIFICATIONS_DELIVERY_DOCUMENTATION, NOTIFICATIONS_DOCUMENTATION, NOTIFICATIONS_I18N } from "./interfacePatterns"; type StatusFilter = | "pending" | "queued" | "sending" | "accepted" | "paused" | "sent" | "failed" | "skipped" | "cancelled"; const statusFilters: StatusFilter[] = [ "pending", "queued", "sending", "accepted", "paused", "sent", "failed", "skipped", "cancelled" ]; const cancellableStatuses = new Set(["pending", "queued", "paused", "failed"]); export default function NotificationCenterPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { const scopeKey = JSON.stringify([settings.apiBaseUrl, settings.apiKey, settings.accessToken, auth.tenant.id, auth.user.id, auth.scopes]); const currentScope = useRef(scopeKey); const scopeGeneration = useRef(0); if (currentScope.current !== scopeKey) { currentScope.current = scopeKey; scopeGeneration.current += 1; } const generation = scopeGeneration.current; const isCurrentScope = () => currentScope.current === scopeKey && scopeGeneration.current === generation; const operationScope = useRef(null); const [loadedScope, setLoadedScope] = useState(scopeKey); const [loadedNotifications, setNotifications] = useState([]); const notifications = loadedScope === scopeKey ? loadedNotifications : []; const [selectedId, setSelectedId] = useState(""); const [statusFilter, setStatusFilter] = useState(null); const loadRequest = useRef(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [confirmingAction, setConfirmingAction] = useState<"cancel" | "dispatch" | null>(null); const canRead = hasScope(auth, "notifications:notification:read"); const canWrite = hasScope(auth, "notifications:notification:write"); const canDispatch = hasScope(auth, "notifications:delivery:dispatch"); const selected = useMemo(() => notifications.find((item) => item.id === selectedId) || notifications[0] || null, [notifications, selectedId]); const unreadCount = notifications.filter((item) => !item.read_at && !["cancelled", "skipped"].includes(item.status)).length; const commonSelectionReason = busy ? NOTIFICATIONS_I18N.actionActive : loading ? "i18n:govoplan-notifications.loading_notifications" : !selected ? NOTIFICATIONS_I18N.selectionRequired : !canWrite ? NOTIFICATIONS_I18N.writePermissionRequired : undefined; const markReadDisabledReason = commonSelectionReason ?? (selected?.read_at ? NOTIFICATIONS_I18N.alreadyRead : undefined); const acknowledgeDisabledReason = commonSelectionReason ?? (selected?.acknowledged_at ? NOTIFICATIONS_I18N.alreadyAcknowledged : undefined); const cancelDisabledReason = commonSelectionReason ?? (selected && !cancellableStatuses.has(selected.status) ? NOTIFICATIONS_I18N.notCancellable : undefined); const dispatchDisabledReason = busy ? NOTIFICATIONS_I18N.actionActive : loading ? "i18n:govoplan-notifications.loading_notifications" : !canDispatch ? NOTIFICATIONS_I18N.dispatchPermissionRequired : undefined; useEffect(() => { operationScope.current = null; setBusy(false); setConfirmingAction(null); }, [scopeKey]); useEffect(() => { if (!canRead) { loadRequest.current?.abort(); setNotifications([]); setSelectedId(""); setLoading(false); return; } void load(); return () => loadRequest.current?.abort(); }, [canRead, scopeKey, statusFilter]); async function load() { if (!isCurrentScope() || !canRead) return; loadRequest.current?.abort(); const request = new AbortController(); loadRequest.current = request; setLoading(true); setError(""); try { const response = await listNotifications(settings, { status: statusFilter ?? undefined, limit: 200 }, request.signal); if (request.signal.aborted || !isCurrentScope()) return; setLoadedScope(scopeKey); setNotifications(response.notifications); setSelectedId((current) => current && response.notifications.some((item) => item.id === current) ? current : response.notifications[0]?.id ?? ""); } catch (err) { if (!request.signal.aborted && isCurrentScope()) setError(errorMessage(err)); } finally { if (!request.signal.aborted && isCurrentScope()) setLoading(false); } } async function markSelected(status: "read" | "acknowledged" | "cancelled"): Promise { if (!selected || !canWrite || loading || operationScope.current === scopeKey) return false; operationScope.current = scopeKey; loadRequest.current?.abort(); setBusy(true); setError(""); try { const next = await updateNotification(settings, selected.id, { status }); if (!isCurrentScope()) return false; setNotifications((items) => items.map((item) => item.id === next.id ? next : item) .filter((item) => statusFilter === null || statusFilter.includes(item.status))); notifyNotificationsChanged(); return true; } catch (err) { if (isCurrentScope()) setError(errorMessage(err)); return false; } finally { if (isCurrentScope()) { operationScope.current = null; setBusy(false); } } } async function runDelivery(): Promise { if (!canDispatch || loading || operationScope.current === scopeKey) return false; operationScope.current = scopeKey; loadRequest.current?.abort(); setBusy(true); setError(""); try { await deliverPendingNotifications(settings, 50); if (!isCurrentScope()) return false; await load(); if (!isCurrentScope()) return false; notifyNotificationsChanged(); return true; } catch (err) { if (isCurrentScope()) setError(errorMessage(err)); return false; } finally { if (isCurrentScope()) { operationScope.current = null; setBusy(false); } } } async function confirmAction() { const succeeded = confirmingAction === "cancel" ? await markSelected("cancelled") : confirmingAction === "dispatch" ? await runDelivery() : false; if (succeeded) setConfirmingAction(null); } if (!canRead) { return (
); } return ( void load(), loading: loading || busy, label: "i18n:govoplan-notifications.refresh" }} className="notifications-sidebar-bar" contextActions={
i18n:govoplan-notifications.notifications {unreadCount > 0 ? {unreadCount} : null}
} /> ({ value: status, label: statusLabel(status) }))} value={statusFilter} onChange={setStatusFilter} label="i18n:govoplan-notifications.notification_status" disabled={busy} />
{loading ?
i18n:govoplan-notifications.loading_notifications
: null} {!loading && notifications.length === 0 ?
i18n:govoplan-notifications.no_notifications
: null} {notifications.length > 0 ? ( {notifications.map((notification) => ( setSelectedId(notification.id)} > {notification.subject || notification.event_kind} {formatStatus(notification.status)} {notification.source_module} {formatDate(notification.created_at)} ))} ) : null}
} > {selected?.subject || selected?.event_kind || "i18n:govoplan-notifications.surface.center"} } helpAction={} primaryActions={
} destructiveActions={} /> {error ? {error} : null} {selected && !canWrite ? ( ) : null} {selected ? : ( } title="i18n:govoplan-notifications.notifications" description="i18n:govoplan-notifications.select_notification_help" /> )}
setConfirmingAction(null)} onConfirm={() => void confirmAction()} /> setConfirmingAction(null)} onConfirm={() => void confirmAction()} />
); } function NotificationDetails({ notification }: { notification: NotificationMessage }) { const actionUrl = safeNotificationActionUrl(notification.action_url); return (
{notification.channel} {formatDate(notification.created_at)}

{notification.subject || notification.event_kind}

{notification.body_text ?

{notification.body_text}

:

i18n:govoplan-notifications.no_message_body

} {actionUrl ? ( i18n:govoplan-notifications.open_related_item ) : null}

i18n:govoplan-notifications.source_and_delivery

i18n:govoplan-notifications.source
{notification.source_module} / {notification.source_resource_type}
i18n:govoplan-notifications.resource
{notification.source_resource_id || "i18n:govoplan-notifications.none"}
i18n:govoplan-notifications.recipient
{notification.recipient_label || notification.recipient || notification.recipient_id || "i18n:govoplan-notifications.none"}
i18n:govoplan-notifications.priority
{notification.priority}
i18n:govoplan-notifications.queued
{formatDate(notification.queued_at)}
i18n:govoplan-notifications.sent
{formatDate(notification.sent_at)}
i18n:govoplan-notifications.read
{formatDate(notification.read_at)}
i18n:govoplan-notifications.attempts
{notification.attempt_count}
{notification.last_error ?

{notification.last_error}

: null}

i18n:govoplan-notifications.delivery_attempts

{notification.attempts.length === 0 ?

i18n:govoplan-notifications.no_delivery_attempt

: null} {notification.attempts.map((attempt) => (
{attempt.provider || attempt.channel} {formatStatus(attempt.status)} {formatDate(attempt.started_at)} - {formatDate(attempt.finished_at)} {attempt.error ?

{attempt.error}

: null}
))}
); } function formatStatus(value: string): string { return statusLabel(value); } function statusLabel(value: string): string { return `i18n:govoplan-notifications.status.${value.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}`; } function formatDate(value?: string | null): string { if (!value) return "i18n:govoplan-notifications.not_set"; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "i18n:govoplan-notifications.request_failed"; } function notifyNotificationsChanged(): void { window.dispatchEvent(new CustomEvent("govoplan:notifications-changed")); }