import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ApiSettings } from "../../types"; import { downloadCampaignJobsCsv, emailCampaignReport, getCampaignJobDetail, getCampaignJobsDelta, 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, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { asRecord, formatDateTime, humanize } from "./utils/campaignView"; import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas"; 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) })); const IMAP_STATUS_OPTIONS: DataGridListOption[] = [ "not_requested", "pending", "appended", "failed", "skipped"]. 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 { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); 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(() => emptyCampaignJobsResponse()); const jobsRef = useRef(emptyCampaignJobsResponse()); const jobPageCursorsRef = useRef>({ 1: null }); const [jobsLoading, setJobsLoading] = useState(false); const [page, setPage] = useState(1); const [sendStatus, setSendStatus] = useState(""); const [imapStatus, setImapStatus] = 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]); const jobsQueryKey = useMemo( () => JSON.stringify({ campaignId, versionId: version?.id ?? null, page, pageSize: 50, sendStatus, imapStatus, appliedQuery, apiBaseUrl: settings.apiBaseUrl, apiKey: settings.apiKey, accessToken: settings.accessToken }), [campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken] ); useEffect(() => { jobPageCursorsRef.current = { 1: null }; }, [campaignId, version?.id, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]); const loadJobs = useCallback(async () => { if (!campaignId) return; setJobsLoading(true); setActionError(""); try { let nextWatermark = getDeltaWatermark(jobsQueryKey); let merged = jobsRef.current; let hasMore = false; const pageCursor = page === 1 ? null : jobPageCursorsRef.current[page]; do { const response = await getCampaignJobsDelta(settings, campaignId, { versionId: version?.id, page, pageSize: 50, cursor: pageCursor, sendStatus: sendStatus ? [sendStatus] : undefined, imapStatus: imapStatus ? [imapStatus] : undefined, query: appliedQuery || undefined, since: nextWatermark }); merged = mergeCampaignJobsDelta(merged, response); if (response.cursor !== undefined) jobPageCursorsRef.current[page] = response.cursor ?? null; if (response.next_cursor !== undefined) { if (response.next_cursor) jobPageCursorsRef.current[page + 1] = response.next_cursor; else delete jobPageCursorsRef.current[page + 1]; } nextWatermark = response.watermark ?? null; hasMore = response.has_more; } while (hasMore); setDeltaWatermark(jobsQueryKey, nextWatermark); jobsRef.current = merged; setJobs(merged); if (merged.pages > 0 && page > merged.pages) setPage(merged.pages); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); } finally { setJobsLoading(false); } }, [settings, campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, jobsQueryKey, getDeltaWatermark, setDeltaWatermark]); useEffect(() => { resetDeltaWatermark(jobsQueryKey); jobsRef.current = emptyCampaignJobsResponse(); setJobs(emptyCampaignJobsResponse()); }, [jobsQueryKey, resetDeltaWatermark]); useEffect(() => { void loadJobs(); }, [loadJobs]); 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" ? "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 }); 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: "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)", 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, render: (row) => , value: (row) => String(row.validation_status ?? "unknown") }, { id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, render: (row) => , value: (row) => String(row.queue_status ?? "unknown") }, { id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", 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: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, 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) }, { 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)", resizable: true, filterable: 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: 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 (
i18n:govoplan-campaign.report.ee45c303
{(error || actionError) && {error || actionError}} {actionMessage && {actionMessage}}
i18n:govoplan-campaign.generated.8eefdd52
{formatDateTime(data.summary?.generated_at)}
i18n:govoplan-campaign.jobs_total.98da65bc
{cards?.jobs_total ?? "—"}
i18n:govoplan-campaign.smtp_accepted.e3aa7603
{cards?.smtp_accepted ?? cards?.sent ?? 0}
i18n:govoplan-campaign.failed.09fef5d8
{cards?.failed ?? 0}
i18n:govoplan-campaign.outcome_unknown.6e929fca
{cards?.outcome_unknown ?? 0}
i18n:govoplan-campaign.not_attempted.e1be3c69
{cards?.not_attempted ?? 0}
i18n:govoplan-campaign.cancelled.a1bf92ef
{cards?.cancelled ?? 0}
i18n:govoplan-campaign.imap_appended.56017ea3
{cards?.imap_appended ?? 0}
i18n:govoplan-campaign.imap_failed.50dbca55
{cards?.imap_failed ?? 0}
i18n:govoplan-campaign.append_policy.f195cb05
{imapPolicy.enabled === true ? i18nMessage("i18n:govoplan-campaign.enabled_value.e395e48f", { value0: String(imapPolicy.folder ?? "i18n:govoplan-campaign.auto.0d612c12") }) : "i18n:govoplan-campaign.disabled.f4f4473d"}
i18n:govoplan-campaign.rate_limit.d08e55f5
{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}
i18n:govoplan-campaign.minimum_remaining_duration.639b792c
{String(delivery.estimated_remaining_send_human ?? "—")}
i18n:govoplan-campaign.execution_snapshot.5a67f098
{delivery.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: String(delivery.execution_snapshot_hash).slice(0, 12) }) : "i18n:govoplan-campaign.missing.92185dc5"}

i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80

setQuery(event.target.value)} placeholder="i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5" />
{jobs.total} i18n:govoplan-campaign.matching_of.66a3778e {jobs.total_unfiltered} i18n:govoplan-campaign.total_job_s.c94b7d20
> id={`campaign-report-jobs-${campaignId}`} rows={jobs.jobs} columns={columns} getRowKey={(row: Record) => String(row.id ?? "")} emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5" />
i18n:govoplan-campaign.page.fb06270f {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}
setEmailOpen(false)} closeDisabled={busyAction === "email"} footer={
}>