import { useEffect, useMemo, useState } from "react"; import type { ApiSettings } from "../../types"; import { downloadCampaignJobsCsv, emailCampaignReport, getCampaignJobDetail, getCampaignJobs, resolveCampaignJobOutcome, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignJobDetailResponse, type CampaignJobsResponse, } from "../../api/campaigns"; import { Card } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid"; import { Dialog } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui"; import VersionLine from "./components/VersionLine"; import { LoadingFrame } from "@govoplan/core-webui"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { asRecord, formatDateTime, humanize, stringifyPreview } from "./utils/campaignView"; const SEND_STATUS_OPTIONS: DataGridListOption[] = [ "not_queued", "queued", "claimed", "sending", "smtp_accepted", "sent", "outcome_unknown", "failed_temporary", "failed_permanent", "cancelled", ].map((value) => ({ value, label: humanize(value) })); 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 rateLimit = asRecord(delivery.rate_limit); const imapPolicy = asRecord(delivery.imap_append_sent); const [jobs, setJobs] = useState({ jobs: [], page: 1, page_size: 50, total: 0, total_unfiltered: 0, pages: 0, counts: {}, filtered_counts: {}, review: {}, }); const [jobsLoading, setJobsLoading] = useState(false); const [page, setPage] = useState(1); const [sendStatus, setSendStatus] = useState(""); const [query, setQuery] = useState(""); const [appliedQuery, setAppliedQuery] = useState(""); 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()); setPage(1); }, 350); return () => window.clearTimeout(handle); }, [query]); useEffect(() => { void loadJobs(); }, [campaignId, version?.id, page, sendStatus, appliedQuery]); async function loadJobs() { if (!campaignId) return; setJobsLoading(true); setActionError(""); try { const response = await getCampaignJobs(settings, campaignId, { versionId: version?.id, page, pageSize: 50, sendStatus: sendStatus ? [sendStatus] : undefined, query: appliedQuery || undefined, }); setJobs(response); if (response.pages > 0 && page > response.pages) setPage(response.pages); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); } finally { setJobsLoading(false); } } async function reloadAll() { await Promise.all([reload(), 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(""); } } async function reconcileOutcome() { if (!reconcile || busyAction) return; setBusyAction("reconcile"); setActionError(""); try { await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision); setActionMessage(reconcile.decision === "smtp_accepted" ? "The job was recorded as SMTP accepted and is protected from retry." : "The job was recorded as not sent. It is now an explicit retry candidate."); 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("Campaign job CSV downloaded."); } 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, }); setActionMessage(`Report sent to ${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: "Recipient", width: 240, resizable: true, sortable: true, filterable: true, value: (row) => String(row.recipient_email ?? "—") }, { id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") }, { id: "validation", header: "Validation", width: 145, sortable: true, filterable: true, render: (row) => , value: (row) => String(row.validation_status ?? "unknown") }, { id: "send", header: "SMTP", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, value: (row) => String(row.send_status ?? "unknown") }, { id: "imap", header: "IMAP", width: 130, sortable: true, filterable: true, render: (row) => , value: (row) => String(row.imap_status ?? "unknown") }, { id: "attempts", header: "Attempts", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) }, { id: "error", header: "Last result", width: "minmax(220px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "—") }, { id: "actions", header: "Actions", width: 250, sticky: "end", render: (row) => { const id = String(row.id ?? ""); const status = String(row.send_status ?? ""); return (
{status === "outcome_unknown" && } {status === "outcome_unknown" && }
); }, }, ], [busyAction]); return (
Report
{(error || actionError) && {error || actionError}} {actionMessage && {actionMessage}}
Generated
{formatDateTime(data.summary?.generated_at)}
Jobs total
{cards?.jobs_total ?? "—"}
SMTP accepted
{cards?.smtp_accepted ?? cards?.sent ?? 0}
Failed
{cards?.failed ?? 0}
Outcome unknown
{cards?.outcome_unknown ?? 0}
Not attempted
{cards?.not_attempted ?? 0}
Cancelled
{cards?.cancelled ?? 0}
IMAP appended
{cards?.imap_appended ?? 0}
IMAP failed
{cards?.imap_failed ?? 0}
Append policy
{imapPolicy.enabled === true ? `Enabled (${String(imapPolicy.folder ?? "auto")})` : "Disabled"}
Rate limit
{rateLimit.messages_per_minute ? `${String(rateLimit.messages_per_minute)} / minute` : "—"}
Minimum remaining duration
{String(delivery.estimated_remaining_send_human ?? "—")}
Execution snapshot
{delivery.execution_snapshot_hash ? `${String(delivery.execution_snapshot_hash).slice(0, 12)}…` : "Missing"}

These actions never include SMTP-accepted or unresolved jobs. Uncertain outcomes must be reconciled first.

setQuery(event.target.value)} placeholder="Search recipient, subject or entry ID" />
{jobs.total} matching of {jobs.total_unfiltered} total job(s)
> id={`campaign-report-jobs-${campaignId}`} rows={jobs.jobs} columns={columns} getRowKey={(row: Record) => String(row.id ?? "")} emptyText="No jobs match the current filters." />
Page {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}
setEmailOpen(false)} closeDisabled={busyAction === "email"} footer={(
)} >