import { DescriptionList } from "@govoplan/core-webui"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Check, RotateCcw, Search, X } from "lucide-react"; import type { ApiSettings, AuthInfo } from "../../types"; import { downloadCampaignJobsCsv, emailCampaignReport, getCampaignJobDetail, getCampaignJobs, resolveCampaignJobOutcome, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignJobDetailResponse, type CampaignJobsResponse, type CampaignRetentionReport } from "../../api/campaigns"; import { ContentGrid, Card } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui"; import { PageActionBar, PageLayout } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui"; import VersionLine from "./components/VersionLine"; import { LoadingFrame, TableActionGroup, ToggleSwitch, hasScope, i18nMessage } from "@govoplan/core-webui"; import { recoverCampaignJobClaim, runCampaignInlineRecovery, type CampaignInlineRecoveryAction } from "../../api/deliveryRecovery"; import ReportRecipients, { reportRecipientSearchText } from "./reporting/ReportRecipients"; import ReportRecoveryDialog from "./reporting/ReportRecoveryDialog"; import { reportClaimRecovery, reportRecoveryHints, reportRetryableFailure, reportUnattempted, type ReportReconciliation } from "./reporting/reportRecovery"; import CampaignDeliveryProgressDialog from "./review/CampaignDeliveryProgressDialog"; import { SEND_STATUS_OPTIONS, IMAP_STATUS_OPTIONS, deliveryStatusLabel } from "./utils/deliveryStatusOptions"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { asRecord, formatDateTime, humanize } from "./utils/campaignView"; import { emptyCampaignJobsResponse } from "./utils/jobDeltas"; import type { CampaignJobSortColumn } from "./utils/jobListQuery"; import { DEFAULT_REPORT_GRID_SORT, activeReportGridShortcut, reportGridQueriesEqual, toggleReportGridShortcut, type ReportGridShortcutId } from "./utils/reportGridShortcuts"; const PRINT_STATUS_OPTIONS: DataGridListOption[] = [ "not_requested", "ready", "accepted", "failed", "skipped"]. map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) })); const POSTBOX_STATUS_OPTIONS: DataGridListOption[] = [ "not_requested", "pending", "delivering", "accepted", "accepted_vacant", "partially_accepted", "rejected_temporary", "rejected_permanent", "outcome_unknown", "skipped"]. map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) })); const VALIDATION_STATUS_OPTIONS: DataGridListOption[] = [ "ready", "warning", "needs_review", "blocked", "excluded", "inactive"]. map((value) => ({ value, label: humanize(value) })); const QUEUE_STATUS_OPTIONS: DataGridListOption[] = [ "draft", "queued", "sending", "paused", "cancelled"]. map((value) => ({ value, label: humanize(value) })); const JOB_GRID_QUERY_DELAY_MS = 300; export default function CampaignReportPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) { const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); const version = data.currentVersion; const cards = data.summary?.cards; const sendCounts = data.summary?.status_counts?.send ?? {}; const imapCounts = data.summary?.status_counts?.imap ?? {}; const delivery = asRecord(data.summary?.delivery); const postboxReceipts = asRecord(data.summary?.postbox_receipts); const retention = data.summary?.retention; const rateLimit = asRecord(delivery.rate_limit); const imapPolicy = asRecord(delivery.imap_append_sent); const canRetry = hasScope(auth, "campaigns:campaign:retry"); const canSend = hasScope(auth, "campaigns:campaign:send"); const canQueue = hasScope(auth, "campaigns:campaign:queue"); const canReconcile = hasScope(auth, "campaigns:campaign:reconcile"); const workersAvailable = delivery.background_workers_enabled === true || delivery.celery_enabled === true; const reportContext = JSON.stringify([campaignId, version?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, auth.tenant.id, auth.user.id]); const reportContextRef = useRef(reportContext); reportContextRef.current = reportContext; const [jobs, setJobs] = useState(() => emptyCampaignJobsResponse()); const jobsRequestRef = useRef(0); const [jobsLoading, setJobsLoading] = useState(false); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); const [initialGridFilters] = useState>(() => initialReportGridFilters()); const initialGridQuery = useMemo(() => ({ sort: DEFAULT_REPORT_GRID_SORT, filters: serializeInitialGridFilters(initialGridFilters) }), [initialGridFilters]); const [jobGridQuery, setJobGridQuery] = useState(initialGridQuery); const [appliedJobGridQuery, setAppliedJobGridQuery] = useState(initialGridQuery); const [query, setQuery] = useState(() => initialReportQuery()); const [appliedQuery, setAppliedQuery] = useState(query.trim()); const [actionMessage, setActionMessage] = useState(""); const [actionError, setActionError] = useState(""); const [busyAction, setBusyAction] = useState(""); const [detail, setDetail] = useState(null); const [emailOpen, setEmailOpen] = useState(false); const [emailRecipients, setEmailRecipients] = useState(""); const [attachCsv, setAttachCsv] = useState(true); const [attachJson, setAttachJson] = useState(false); const [reconcile, setReconcile] = useState(null); const pendingAction = useRef(false); const [progress, setProgress] = useState<{ versionId: string; requestState: "running" | "finished" | "interrupted"; requestError?: string } | null>(null); useEffect(() => { ++jobsRequestRef.current; setJobs(emptyCampaignJobsResponse()); setDetail(null); setReconcile(null); setProgress(null); setActionError(""); setActionMessage(""); setBusyAction(""); pendingAction.current = false; setPage(1); }, [reportContext]); useEffect(() => { const handle = window.setTimeout(() => { setAppliedQuery(query.trim()); setAppliedJobGridQuery((current) => reportGridQueriesEqual(current, jobGridQuery) ? current : jobGridQuery); setPage(1); }, JOB_GRID_QUERY_DELAY_MS); return () => window.clearTimeout(handle); }, [query, jobGridQuery]); const handleJobGridQuery = useCallback((next: DataGridQueryState) => { setJobGridQuery((current) => reportGridQueriesEqual(current, next) ? current : next); }, []); const activeJobGridShortcut = useMemo( () => query.trim() ? null : activeReportGridShortcut(jobGridQuery), [jobGridQuery, query] ); const applyJobGridShortcut = useCallback((shortcutId: ReportGridShortcutId) => { const next = toggleReportGridShortcut(jobGridQuery, shortcutId); setQuery(""); setAppliedQuery(""); setJobGridQuery(next); setAppliedJobGridQuery(next); setPage(1); }, [jobGridQuery]); const deliveryOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [ { label: "i18n:govoplan-campaign.jobs_total.98da65bc", value: cards?.jobs_total ?? "—", shortcutId: "all" }, { label: "i18n:govoplan-campaign.smtp_accepted.e3aa7603", value: cards?.smtp_accepted ?? cards?.sent ?? 0, shortcutId: "smtp_accepted" }, { label: "i18n:govoplan-campaign.delivery_status_sending", value: (sendCounts.claimed ?? 0) + (sendCounts.sending ?? 0), shortcutId: "smtp_active" }, { label: "i18n:govoplan-campaign.delivery_status_queued", value: sendCounts.queued ?? 0, shortcutId: "smtp_queued" }, { label: "Postbox accepted", value: cards?.postbox_accepted ?? 0, shortcutId: "postbox_accepted" }, { label: "Print accepted", value: cards?.print_accepted ?? 0, shortcutId: "print_accepted" }, { label: "i18n:govoplan-campaign.failed.09fef5d8", value: cards?.failed ?? 0, shortcutId: "failed" }, { label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: cards?.outcome_unknown ?? 0, shortcutId: "outcome_unknown" }, { label: "i18n:govoplan-campaign.not_attempted.e1be3c69", value: cards?.not_attempted ?? 0, shortcutId: "not_attempted" }, { label: "i18n:govoplan-campaign.smtp_skipped_excluded_.df6eca19", value: cards?.skipped ?? jobs.counts.send?.skipped ?? 0, shortcutId: "smtp_skipped" }, { label: "i18n:govoplan-campaign.cancelled.a1bf92ef", value: cards?.cancelled ?? 0, shortcutId: "cancelled" } ]; const imapOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [ { label: "i18n:govoplan-campaign.delivery_status_pending", value: imapCounts.pending ?? 0, shortcutId: "imap_pending" }, { label: "i18n:govoplan-campaign.delivery_status_appending", value: imapCounts.appending ?? 0, shortcutId: "imap_active" }, { label: "i18n:govoplan-campaign.delivery_status_outcome_unknown", value: imapCounts.outcome_unknown ?? 0, shortcutId: "imap_unknown" }, { label: "i18n:govoplan-campaign.imap_appended.56017ea3", value: cards?.imap_appended ?? 0, shortcutId: "imap_appended" }, { label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: cards?.imap_failed ?? 0, shortcutId: "imap_failed" }, { label: "i18n:govoplan-campaign.imap_skipped.5a97b542", value: cards?.imap_skipped ?? jobs.counts.imap?.skipped ?? 0, shortcutId: "imap_skipped" } ]; const loadJobs = useCallback(async () => { if (!campaignId || !version?.id) return; const requestId = ++jobsRequestRef.current; setJobsLoading(true); setActionError(""); try { const response = await getCampaignJobs(settings, campaignId, { versionId: version?.id, page, pageSize, query: appliedQuery || undefined, sortBy: campaignJobSortColumn(appliedJobGridQuery.sort?.columnId), sortDirection: appliedJobGridQuery.sort?.direction ?? "asc", filters: appliedJobGridQuery.filters }, { cache: "no-store" }); if (requestId !== jobsRequestRef.current || reportContext !== reportContextRef.current) return; setJobs(response); if (response.pages > 0 && page > response.pages) setPage(response.pages); } catch (err) { if (requestId === jobsRequestRef.current && reportContext === reportContextRef.current) setActionError(err instanceof Error ? err.message : String(err)); } finally { if (requestId === jobsRequestRef.current && reportContext === reportContextRef.current) setJobsLoading(false); } }, [settings, campaignId, version?.id, page, pageSize, appliedQuery, appliedJobGridQuery, reportContext]); useEffect(() => { void loadJobs(); }, [loadJobs]); async function reloadAll() { await Promise.all([reload({ force: true }), loadJobs()]); } async function runExplicitAction(action: "retry" | "unattempted") { if (!version || busyAction || pendingAction.current || !workersAvailable || (action === "retry" ? !canRetry : !canQueue)) return; pendingAction.current = true; setBusyAction(action); setActionError(""); setActionMessage(""); try { const response = action === "retry" ? await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true }) : await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true }); if (reportContext !== reportContextRef.current) return; const result = asRecord(response.result ?? response); setActionMessage(`${humanize(String(result.action ?? action))}: ${String(result.selected_count ?? 0)} job(s) selected, ${String(result.enqueued_count ?? 0)} enqueued.`); await reloadAll(); } catch (err) { if (reportContext === reportContextRef.current) setActionError(err instanceof Error ? err.message : String(err)); } finally { if (reportContext === reportContextRef.current) { pendingAction.current = false; setBusyAction(""); } } } const failedRowsOnPage = useMemo( () => jobs.jobs.filter((row) => reportRetryableFailure(row) && String(row.id ?? "")), [jobs.jobs] ); const unattemptedRowsOnPage = useMemo(() => jobs.jobs.filter(row => reportUnattempted(row) && String(row.id ?? "")), [jobs.jobs]); async function runInlineRecovery(action: CampaignInlineRecoveryAction, rows: Record[]) { if (!version || busyAction || pendingAction.current || !canSend || (action === "retry" ? !canRetry : !canQueue) || rows.length === 0) return; const jobIds = [...new Set(rows.map(row => String(row.id ?? "")).filter(Boolean))]; if (!jobIds.length) return; pendingAction.current = true; setBusyAction(`inline:${action}`); setActionError(""); setActionMessage(""); const versionId = version.id; setProgress({ versionId, requestState: "running" }); try { const result = await runCampaignInlineRecovery(settings, campaignId, action, { version_id: versionId, job_ids: jobIds, ...(action === "retry" ? { include_permanent: rows.some(row => row.send_status === "failed_permanent") } : {}) }); if (reportContext !== reportContextRef.current) return; setProgress({ versionId, requestState: "finished" }); setActionMessage(i18nMessage("i18n:govoplan-campaign.report_inline_result", { value0: result.attempted_count ?? 0, value1: result.sent_count ?? 0, value2: result.failed_count ?? 0, value3: result.outcome_unknown_count ?? 0, value4: result.remaining_count ?? 0 })); try { await reloadAll(); } catch { if (reportContext === reportContextRef.current) setActionError("i18n:govoplan-campaign.report_acknowledged_refresh_failed"); } } catch (cause) { if (reportContext !== reportContextRef.current) return; const message = cause instanceof Error ? cause.message : String(cause); setActionError(message); setProgress({ versionId, requestState: "interrupted", requestError: message }); } finally { if (reportContext === reportContextRef.current) { pendingAction.current = false; setBusyAction(""); } } } async function reconcileOutcome(note: string) { if (!reconcile || busyAction || pendingAction.current || !canReconcile || !note.trim()) return; pendingAction.current = true; setBusyAction("reconcile"); setActionError(""); try { if (reconcile.kind === "claim") { await recoverCampaignJobClaim(settings, campaignId, reconcile.jobId, { channel: reconcile.channel, expected_revision: reconcile.revision, note }); } else { await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision, note); } if (reportContext !== reportContextRef.current) return; setActionMessage(reconcile.kind === "claim" ? "i18n:govoplan-campaign.report_claim_recovered" : "i18n:govoplan-campaign.report_evidence_recorded"); setReconcile(null); try { await reloadAll(); } catch { if (reportContext === reportContextRef.current) setActionError("i18n:govoplan-campaign.report_acknowledged_refresh_failed"); } } finally { if (reportContext === reportContextRef.current) { pendingAction.current = false; setBusyAction(""); } } } async function openJob(jobId: string) { setBusyAction("detail"); setActionError(""); try { const response = await getCampaignJobDetail(settings, campaignId, jobId); if (reportContext === reportContextRef.current) setDetail(response); } catch (err) { if (reportContext === reportContextRef.current) setActionError(err instanceof Error ? err.message : String(err)); } finally { if (reportContext === reportContextRef.current) setBusyAction(""); } } async function exportCsv() { setBusyAction("csv"); setActionError(""); try { await downloadCampaignJobsCsv(settings, campaignId, version?.id); setActionMessage("i18n:govoplan-campaign.campaign_job_csv_downloaded.08af6930"); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); } finally { setBusyAction(""); } } async function sendReportEmail() { const recipients = emailRecipients.split(/[;,\n]+/).map((value) => value.trim()).filter(Boolean); if (recipients.length === 0 || busyAction) return; setBusyAction("email"); setActionError(""); try { await emailCampaignReport(settings, campaignId, { to: recipients, version_id: version?.id, include_jobs: false, attach_jobs_csv: attachCsv, attach_report_json: attachJson, idempotency_key: crypto.randomUUID() }); setActionMessage(`Report queued for ${recipients.join(", ")}.`); setEmailOpen(false); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); } finally { setBusyAction(""); } } const columns = useMemo>[]>(() => [ { id: "number", header: "#", width: 70, sticky: "start", sortable: true, value: (row) => Number(row.entry_index ?? 0) || 1 }, { id: "recipient", header: "i18n:govoplan-campaign.recipient.90343260", width: 260, resizable: true, sortable: true, filterable: true, render: (row) => , value: reportRecipientSearchText }, { id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") }, { id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, columnType: "from-list", list: { options: VALIDATION_STATUS_OPTIONS, display: "pill" }, render: (row) => , value: (row) => String(row.validation_status ?? "unknown") }, { id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: QUEUE_STATUS_OPTIONS, display: "pill" }, render: (row) => , value: (row) => String(row.queue_status ?? "unknown") }, { id: "send", header: "Delivery", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => , value: (row) => String(row.send_status ?? "unknown") }, { id: "postbox", header: "Postbox", width: 155, sortable: true, filterable: true, columnType: "from-list", list: { options: POSTBOX_STATUS_OPTIONS, display: "pill" }, render: (row) => , value: (row) => String(row.postbox_status ?? "unknown") }, { id: "rsvp", header: "RSVP", width: 145, value: (row) => calendarRsvpStatus(row), render: (row) => { const status = calendarRsvpStatus(row); return status === "—" ? : ; } }, { id: "print", header: "Print", width: 135, sortable: true, filterable: true, columnType: "from-list", list: { options: PRINT_STATUS_OPTIONS, display: "pill" }, render: (row) => , value: (row) => String(row.print_status ?? "unknown") }, { id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: IMAP_STATUS_OPTIONS, display: "pill" }, render: (row) => , value: (row) => String(row.imap_status ?? "unknown") }, { id: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0), render: (row) => String(Number(row.attempt_count ?? 0) + Number(row.postbox_attempt_count ?? 0)) }, { id: "evidence", header: "i18n:govoplan-campaign.evidence.7ea014de", width: 240, resizable: true, filterable: true, render: (row) =>
{row.message_id_header ? i18nMessage("i18n:govoplan-campaign.message_id_value_value.24027e70", { value0: String(row.message_id_header).slice(0, 28), value1: String(row.message_id_header).length > 28 ? "..." : "" }) : "i18n:govoplan-campaign.no_message_id.43390ef7"} {String(row.attachment_count ?? 0)} i18n:govoplan-campaign.attachment_rule_s.0e5ee66a {String(row.matched_file_count ?? 0)} i18n:govoplan-campaign.file_s.4bc4cc05
, value: (row) => `${String(row.message_id_header ?? "")} ${String(row.eml_sha256 ?? "")}` }, { id: "error", header: "i18n:govoplan-campaign.last_result.110b888b", width: "minmax(220px, 1fr)", maxWidth: 720, resizable: true, render: (row) =>
{String(row.last_error ?? "—")} {reportRecoveryHints(row).map(hint => {hint})}
, value: (row) => String(row.last_error ?? "—") }, { id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 165, sortable: true, value: (row) => formatDateTime(String(row.updated_at ?? row.sent_at ?? row.queued_at ?? "")) }, { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 190, sticky: "end", render: (row) => { const id = String(row.id ?? ""); const status = String(row.send_status ?? ""); const smtpClaim = reportClaimRecovery(row, "smtp"); const imapClaim = reportClaimRecovery(row, "imap"); return