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 } from "../../types"; import { downloadCampaignJobsCsv, emailCampaignReport, getCampaignJobDetail, getCampaignJobs, resolveCampaignJobOutcome, retryCampaignJobs, sendCampaignJob, sendUnattemptedCampaignJobs, type CampaignJobDetailResponse, type CampaignJobsResponse, type CampaignRetentionReport } from "../../api/campaigns"; import { ContentGrid, Card } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; import { DismissibleAlert } 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, i18nMessage } from "@govoplan/core-webui"; 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 SEND_STATUS_OPTIONS: DataGridListOption[] = [ "not_queued", "skipped", "queued", "claimed", "sending", "smtp_accepted", "postbox_accepted", "print_accepted", "delivered", "partially_accepted", "sent", "outcome_unknown", "failed_temporary", "failed_permanent", "cancelled"]. map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) })); 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 IMAP_STATUS_OPTIONS: DataGridListOption[] = [ "not_requested", "pending", "appending", "appended", "outcome_unknown", "failed", "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; type ReconcileRequest = {jobId: string;decision: "smtp_accepted" | "not_sent";} | null; export default function CampaignReportPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); const version = data.currentVersion; const cards = data.summary?.cards; 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 [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); 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: "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.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) 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 }); if (requestId !== jobsRequestRef.current) return; setJobs(response); if (response.pages > 0 && page > response.pages) setPage(response.pages); } catch (err) { if (requestId === jobsRequestRef.current) setActionError(err instanceof Error ? err.message : String(err)); } finally { if (requestId === jobsRequestRef.current) setJobsLoading(false); } }, [settings, campaignId, version?.id, page, pageSize, appliedQuery, appliedJobGridQuery]); useEffect(() => { void loadJobs(); }, [loadJobs]); async function reloadAll() { await Promise.all([reload({ force: true }), loadJobs()]); } async function runExplicitAction(action: "retry" | "unattempted") { if (!version || busyAction) return; 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 }); 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) { setActionError(err instanceof Error ? err.message : String(err)); } finally { setBusyAction(""); } } const failedRowsOnPage = useMemo( () => jobs.jobs.filter((row) => retryableFailedStatus(String(row.send_status ?? "")) && String(row.id ?? "")), [jobs.jobs] ); async function retryFailedSynchronously(rows: Record[]) { if (!version || busyAction || rows.length === 0) return; setBusyAction(rows.length === 1 ? `retry-sync:${String(rows[0].id ?? "")}` : "retry-sync-page"); setActionError(""); setActionMessage(""); let attempted = 0; let accepted = 0; let skipped = 0; const failures: string[] = []; try { for (const row of rows) { const jobId = String(row.id ?? ""); if (!jobId) { skipped += 1; continue; } const sendStatus = String(row.send_status ?? ""); const queueResponse = await retryCampaignJobs(settings, campaignId, { version_id: version.id, job_ids: [jobId], include_permanent: sendStatus === "failed_permanent", enqueue_celery: false }); const queueResult = asRecord(queueResponse.result ?? queueResponse); if (Number(queueResult.selected_count ?? 0) < 1) { skipped += 1; const skippedRows = Array.isArray(queueResult.skipped) ? queueResult.skipped.map(asRecord) : []; const reason = String(skippedRows[0]?.reason ?? "not selected for retry"); failures.push(`${shortJobId(jobId)}: ${reason}`); continue; } attempted += 1; try { const sendResponse = await sendCampaignJob(settings, campaignId, jobId, { kind: "single_resend", idempotency_key: crypto.randomUUID(), reason: "Operator requested synchronous resend from the campaign report.", include_warnings: true, use_rate_limit: true, enqueue_imap_task: false }); const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result); const status = String(sendResult.status ?? "submitted"); if (["smtp_accepted", "postbox_accepted", "print_accepted", "delivered", "partially_accepted", "already_accepted"].includes(status)) accepted += 1; else failures.push(`${shortJobId(jobId)}: ${humanize(status)}`); } catch (err) { failures.push(`${shortJobId(jobId)}: ${err instanceof Error ? err.message : String(err)}`); } } const failed = failures.length; setActionMessage(`Synchronous retry finished: ${attempted} attempted, ${accepted} accepted, ${failed} failed, ${skipped} skipped.`); if (failures.length > 0) setActionError(failures.slice(0, 5).join("\n")); await reloadAll(); } finally { setBusyAction(""); } } async function reconcileOutcome() { if (!reconcile || busyAction) return; setBusyAction("reconcile"); setActionError(""); try { await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision); setActionMessage(reconcile.decision === "smtp_accepted" ? "i18n:govoplan-campaign.the_job_was_recorded_as_smtp_accepted_and_is_pro.12ee72b6" : "i18n:govoplan-campaign.the_job_was_recorded_as_not_sent_it_is_now_an_ex.2cea8409"); setReconcile(null); await reloadAll(); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); } finally { setBusyAction(""); } } async function openJob(jobId: string) { setBusyAction("detail"); setActionError(""); try { setDetail(await getCampaignJobDetail(settings, campaignId, jobId)); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); } finally { 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) =>
{String(row.recipient_email ?? "—")} {String(row.entry_id ?? i18nMessage("i18n:govoplan-campaign.entry_value.b7706ee4", { value0: Number(row.entry_index ?? 0) || 1 }))}
, value: (row) => String(row.recipient_email ?? "—") }, { 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 ?? "—")}, 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 ?? ""); return