import { useEffect, useMemo, useState } from "react"; import { Bell, Check, ExternalLink, RefreshCw, Send, XCircle } from "lucide-react"; import { ActionBlockerHint, AdminIconButton, Button, ConfirmDialog, DismissibleAlert, DocumentationHelpLink, SegmentedControl, SelectionList, SelectionListItem, StatusBadge, 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 = | "all" | "pending" | "queued" | "sending" | "accepted" | "paused" | "sent" | "failed" | "skipped" | "cancelled"; const statusFilters: StatusFilter[] = [ "all", "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 [notifications, setNotifications] = useState([]); const [selectedId, setSelectedId] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); 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 : !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 : !canDispatch ? NOTIFICATIONS_I18N.dispatchPermissionRequired : undefined; useEffect(() => { if (!canRead) { setLoading(false); return; } void load(); }, [canRead, settings.apiBaseUrl, settings.apiKey, settings.accessToken, statusFilter]); async function load() { setLoading(true); setError(""); try { const response = await listNotifications(settings, { status: statusFilter === "all" ? undefined : statusFilter, limit: 200 }); setNotifications(response.notifications); setSelectedId((current) => current && response.notifications.some((item) => item.id === current) ? current : response.notifications[0]?.id ?? ""); } catch (err) { setError(errorMessage(err)); } finally { setLoading(false); } } async function markSelected(status: "read" | "acknowledged" | "cancelled"): Promise { if (!selected || !canWrite) return false; setBusy(true); setError(""); try { const next = await updateNotification(settings, selected.id, { status }); setNotifications((items) => items.map((item) => item.id === next.id ? next : item)); notifyNotificationsChanged(); return true; } catch (err) { setError(errorMessage(err)); return false; } finally { setBusy(false); } } async function runDelivery(): Promise { if (!canDispatch) return false; setBusy(true); setError(""); try { await deliverPendingNotifications(settings, 50); await load(); notifyNotificationsChanged(); return true; } catch (err) { setError(errorMessage(err)); return false; } finally { 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 (
{selected?.subject || selected?.event_kind || "i18n:govoplan-notifications.surface.center"}
{error ? {error} : null} {selected && !canWrite ? ( ) : null} {selected ? : (

i18n:govoplan-notifications.notifications

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")); }