Files
govoplan-campaign/webui/src/features/campaigns/CampaignReportPage.tsx
T

1097 lines
53 KiB
TypeScript

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<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
const jobsRequestRef = useRef(0);
const [jobsLoading, setJobsLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [initialGridFilters] = useState<Record<string, string | string[]>>(() => initialReportGridFilters());
const initialGridQuery = useMemo<DataGridQueryState>(() => ({
sort: DEFAULT_REPORT_GRID_SORT,
filters: serializeInitialGridFilters(initialGridFilters)
}), [initialGridFilters]);
const [jobGridQuery, setJobGridQuery] = useState<DataGridQueryState>(initialGridQuery);
const [appliedJobGridQuery, setAppliedJobGridQuery] = useState<DataGridQueryState>(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<CampaignJobDetailResponse | null>(null);
const [emailOpen, setEmailOpen] = useState(false);
const [emailRecipients, setEmailRecipients] = useState("");
const [attachCsv, setAttachCsv] = useState(true);
const [attachJson, setAttachJson] = useState(false);
const [reconcile, setReconcile] = useState<ReconcileRequest>(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<string, unknown>[]) {
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<DataGridColumn<Record<string, unknown>>[]>(() => [
{ 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) =>
<div className="recipient-outcome-cell">
<strong>{String(row.recipient_email ?? "—")}</strong>
<span>{String(row.entry_id ?? i18nMessage("i18n:govoplan-campaign.entry_value.b7706ee4", { value0: Number(row.entry_index ?? 0) || 1 }))}</span>
</div>,
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) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, 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) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, 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) => <StatusBadge status={String(row.send_status ?? "unknown")} label={deliveryStatusLabel(String(row.send_status ?? "unknown"))} />, 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) => <StatusBadge status={String(row.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(row.postbox_status ?? "unknown"))} />, 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 === "—" ? <span className="muted"></span> : <StatusBadge status={status.toLowerCase()} label={humanize(status)} />;
}
},
{ id: "print", header: "Print", width: 135, sortable: true, filterable: true, columnType: "from-list", list: { options: PRINT_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.print_status ?? "unknown")} label={deliveryStatusLabel(String(row.print_status ?? "unknown"))} />, 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) => <StatusBadge status={String(row.imap_status ?? "unknown")} label={deliveryStatusLabel(String(row.imap_status ?? "unknown"))} />, 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) =>
<div className="recipient-outcome-cell">
<span title={String(row.message_id_header ?? "")}>{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"}</span>
<span>{String(row.attachment_count ?? 0)} i18n:govoplan-campaign.attachment_rule_s.0e5ee66a {String(row.matched_file_count ?? 0)} i18n:govoplan-campaign.file_s.4bc4cc05</span>
</div>,
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) => <span className={row.last_error ? "recipient-outcome-error" : "muted"} title={String(row.last_error ?? "")}>{String(row.last_error ?? "—")}</span>,
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 <TableActionGroup actions={[
{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, disabled: !id || busyAction === "detail", onClick: () => void openJob(id) },
{ id: "retry", label: busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now", icon: <RotateCcw aria-hidden="true" />, applicable: retryableFailedStatus(status), disabled: !id || Boolean(busyAction), onClick: () => void retryFailedSynchronously([row]) },
{ id: "accepted", label: "i18n:govoplan-campaign.accepted.61a0572c", icon: <Check aria-hidden="true" />, applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "smtp_accepted" }) },
{ id: "not-sent", label: "i18n:govoplan-campaign.not_sent.587c501e", icon: <X aria-hidden="true" />, variant: "danger", applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "not_sent" }) }
]} />;
}
}],
[busyAction, retryFailedSynchronously]);
return (
<PageLayout
archetype="detail"
mode="workspace"
title="i18n:govoplan-campaign.report.ee45c303"
description={<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />}
headerLoading={loading}
error={error || actionError}
success={actionMessage}
actions={<PageActionBar
variant="detail"
refreshable
reloadAction={{ onReload: () => void reloadAll(), loading: loading || jobsLoading }}
primaryActions={<>
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
<Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
</>}
/>}
>
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_report_data.0908ade5">
<ContentGrid columns={2} collapseAt="workspace" className="">
<Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
<DescriptionList variant="inline">
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
{deliveryOutcomeShortcuts.map(({ label, value, shortcutId }) => {
const active = activeJobGridShortcut === shortcutId;
return (
<div key={shortcutId}>
<dt>{label}</dt>
<dd>
<Button type="button" variant={active ? "primary" : "ghost"} aria-pressed={active} onClick={() => applyJobGridShortcut(shortcutId)}>
{value}
</Button>
</dd>
</div>
);
})}
</DescriptionList>
</Card>
<Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
<DescriptionList variant="inline">
{imapOutcomeShortcuts.map(({ label, value, shortcutId }) => {
const active = activeJobGridShortcut === shortcutId;
return (
<div key={shortcutId}>
<dt>{label}</dt>
<dd>
<Button type="button" variant={active ? "primary" : "ghost"} aria-pressed={active} onClick={() => applyJobGridShortcut(shortcutId)}>
{value}
</Button>
</dd>
</div>
);
})}
<div><dt>i18n:govoplan-campaign.append_policy.f195cb05</dt><dd>{imapPolicy.enabled === true ? i18nMessage("i18n:govoplan-campaign.enabled_value.e395e48f", { value0: String(imapPolicy.folder ?? "i18n:govoplan-campaign.auto.0d612c12") }) : "i18n:govoplan-campaign.disabled.f4f4473d"}</dd></div>
<div><dt>i18n:govoplan-campaign.rate_limit.d08e55f5</dt><dd>{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}</dd></div>
<div><dt>i18n:govoplan-campaign.minimum_remaining_duration.639b792c</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.execution_snapshot.5a67f098</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: String(delivery.execution_snapshot_hash).slice(0, 12) }) : "i18n:govoplan-campaign.missing.92185dc5"}</dd></div>
</DescriptionList>
</Card>
<Card title="Postbox receipt evidence">
<DescriptionList variant="inline">
<div>
<dt>Evidence state</dt>
<dd>
<StatusBadge
status={
postboxReceipts.status === "available"
? "success"
: postboxReceipts.status === "unavailable"
? "warning"
: "info"
}
label={humanize(String(postboxReceipts.status ?? "not applicable"))}
/>
</dd>
</div>
<div><dt>Accepted deliveries</dt><dd>{String(postboxReceipts.delivery_count ?? 0)}</dd></div>
<div><dt>Currently readable</dt><dd>{String(postboxReceipts.currently_readable_delivery_count ?? "—")}</dd></div>
<div><dt>Read</dt><dd>{String(postboxReceipts.read_delivery_count ?? "—")}</dd></div>
<div><dt>Acknowledged</dt><dd>{String(postboxReceipts.acknowledged_delivery_count ?? "—")}</dd></div>
<div><dt>Withdrawn / expired copies</dt><dd>{String(Number(postboxReceipts.withdrawn_message_count ?? 0) + Number(postboxReceipts.expired_message_count ?? 0))}</dd></div>
</DescriptionList>
</Card>
<RetentionPrivacyCard retention={retention} />
<Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4">
<p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
<div className="button-row compact-actions">
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.retry_temporary_failures.e65cfd13</Button>
<Button onClick={() => void retryFailedSynchronously(failedRowsOnPage)} disabled={!version || Boolean(busyAction) || failedRowsOnPage.length === 0}>
{busyAction === "retry-sync-page" ? "Sending failed jobs..." : `Retry failed on this page now (${failedRowsOnPage.length})`}
</Button>
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
</div>
</Card>
</ContentGrid>
<Card title="i18n:govoplan-campaign.recipient_delivery_jobs.52492608">
<p className="muted small-note">
i18n:govoplan-campaign.excluded_rows_are_intentionally_omitted_from_del.421a1f00
</p>
<div className="page-heading split">
<div className="button-row compact-actions">
<FormField label="i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5">
<input value={query} onChange={(event) => setQuery(event.target.value)} />
</FormField>
</div>
<span className="muted">{jobs.total} i18n:govoplan-campaign.matching_of.66a3778e {jobs.total_unfiltered} i18n:govoplan-campaign.total_job_s.c94b7d20</span>
</div>
<LoadingFrame loading={jobsLoading} label="i18n:govoplan-campaign.loading_delivery_jobs.20ecc37e">
<DataGrid<Record<string, unknown>>
id={`campaign-report-jobs-v2-${campaignId}`}
rows={jobs.jobs}
columns={columns}
getRowKey={(row: Record<string, unknown>) => String(row.id ?? "")}
emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5"
initialFilters={initialGridFilters}
initialSort={DEFAULT_REPORT_GRID_SORT}
query={jobGridQuery}
pagination={{
mode: "server",
page,
pageSize,
totalRows: jobs.total,
pageSizeOptions: [25, 50, 100, 200],
disabled: jobsLoading,
onPageChange: setPage,
onPageSizeChange: (nextPageSize) => {
setPageSize(nextPageSize);
setPage(1);
}
}}
onQueryChange={handleJobGridQuery} />
</LoadingFrame>
</Card>
</LoadingFrame>
<Dialog
open={emailOpen}
title="i18n:govoplan-campaign.email_campaign_report.61a2989d"
onClose={() => setEmailOpen(false)}
closeDisabled={busyAction === "email"}
footer={
<div className="button-row">
<Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>i18n:govoplan-campaign.send_report.a5b32af9</Button>
</div>
}>
<label className="field-stack">
<span>i18n:govoplan-campaign.recipients.78cbf8eb</span>
<textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} />
</label>
<ToggleSwitch label="i18n:govoplan-campaign.attach_job_csv.adb76197" checked={attachCsv} onChange={setAttachCsv} />
<ToggleSwitch label="i18n:govoplan-campaign.attach_json_report.d70883b5" checked={attachJson} onChange={setAttachJson} />
</Dialog>
<Dialog
open={Boolean(detail)}
title="i18n:govoplan-campaign.campaign_job_detail.81dc68e1"
onClose={() => setDetail(null)}
className="dialog-panel-wide">
{detail &&
<div className="stacked-sections">
<DescriptionList variant="inline">
<div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.send_status ?? "unknown"))} /></dd></div>
<div><dt>Postbox state</dt><dd><StatusBadge status={String(detail.job.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.postbox_status ?? "unknown"))} /></dd></div>
<div><dt>Postbox targets</dt><dd>{String(detail.job.postbox_target_count ?? 0)}</dd></div>
<div><dt>Calendar RSVP</dt><dd>{calendarRsvpStatus(detail.job)}</dd></div>
<div><dt>Print state</dt><dd><StatusBadge status={String(detail.job.print_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.print_status ?? "unknown"))} /></dd></div>
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.imap_status ?? "unknown"))} /></dd></div>
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
<div><dt>Message SHA-256</dt><dd><code>{String(detail.job.eml_sha256 ?? "—")}</code></dd></div>
</DescriptionList>
<PostboxTargetEvidenceSection
targets={
Array.isArray(detail.job.resolved_postbox_targets)
? detail.job.resolved_postbox_targets
: []
}
/>
<AttachmentEvidenceSection attachments={Array.isArray(detail.job.attachments) ? detail.job.attachments : []} />
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
<AttemptHistoryTable kind="postbox" rows={detail.attempts.postbox ?? []} />
<AttemptHistoryTable kind="print" rows={detail.attempts.print ?? []} />
<AttemptHistoryTable kind="imap" rows={detail.attempts.imap ?? []} />
</div>
}
</Dialog>
<ConfirmDialog
open={Boolean(reconcile)}
title={reconcile?.decision === "smtp_accepted" ? "i18n:govoplan-campaign.record_smtp_acceptance.c40f8c9d" : "i18n:govoplan-campaign.record_message_as_not_sent.42e4faf8"}
message={reconcile?.decision === "smtp_accepted" ?
"i18n:govoplan-campaign.use_this_only_after_checking_the_smtp_server_or_.6f4396e1" :
"i18n:govoplan-campaign.use_this_only_when_you_have_evidence_that_smtp_d.aa48f4ad"}
confirmLabel={reconcile?.decision === "smtp_accepted" ? "i18n:govoplan-campaign.record_accepted.023d6747" : "i18n:govoplan-campaign.record_not_sent.b376b4ed"}
tone={reconcile?.decision === "smtp_accepted" ? "default" : "danger"}
busy={busyAction === "reconcile"}
onConfirm={() => void reconcileOutcome()}
onCancel={() => setReconcile(null)} />
</PageLayout>);
}
function RetentionPrivacyCard({ retention }: { retention?: CampaignRetentionReport }) {
if (!retention) {
return (
<Card title="i18n:govoplan-campaign.retention_and_privacy.24418676">
<p className="muted">i18n:govoplan-campaign.retention_information_is_unavailable.f68cc4d1</p>
</Card>
);
}
const policy = retention.effective_policy;
const evidenceRows = [
["i18n:govoplan-campaign.raw_campaign_json.53d8522d", retention.evidence.raw_campaign_json],
["i18n:govoplan-campaign.stored_report_detail.13a437d7", retention.evidence.stored_report_detail],
["i18n:govoplan-campaign.generated_message_files.2d86ef64", retention.evidence.generated_eml],
["i18n:govoplan-campaign.postbox_copies.080b404f", retention.evidence.postbox_copies]
] as const;
return (
<Card title="i18n:govoplan-campaign.retention_and_privacy.24418676">
<p>{retention.privacy_impact.summary}</p>
{retention.policy_reason && <p className="muted">{retention.policy_reason}</p>}
<DescriptionList variant="inline">
<div>
<dt>i18n:govoplan-campaign.policy_state.7e955a0d</dt>
<dd>
<StatusBadge
status={retention.policy_status === "configured" ? "success" : retention.policy_status === "defaults" ? "warning" : "error"}
label={humanize(retention.policy_status)}
/>
</dd>
</div>
<div>
<dt>i18n:govoplan-campaign.policy_sources.a592802f</dt>
<dd>{retention.sources.map((source) => source.label || source.path).filter(Boolean).join(" → ") || "—"}</dd>
</div>
<div>
<dt>i18n:govoplan-campaign.raw_json_retention.19ea35f7</dt>
<dd>{policy.store_raw_campaign_json === false ? "i18n:govoplan-campaign.do_not_retain.9fd25c4d" : retentionDuration(policy.raw_campaign_json_retention_days)}</dd>
</div>
<div>
<dt>i18n:govoplan-campaign.generated_message_retention.ce6366ca</dt>
<dd>{retentionDuration(policy.generated_eml_retention_days)}</dd>
</div>
<div>
<dt>i18n:govoplan-campaign.report_detail_retention.68e30587</dt>
<dd>{retentionDuration(policy.stored_report_detail_retention_days)}</dd>
</div>
<div>
<dt>i18n:govoplan-campaign.audit_detail.2a85f905</dt>
<dd>{humanize(policy.audit_detail_level ?? "full")} · {retentionDuration(policy.audit_detail_retention_days)}</dd>
</div>
{evidenceRows.map(([label, evidence]) =>
<div key={label}>
<dt>{label}</dt>
<dd>
<StatusBadge
status={retentionEvidenceTone(evidence?.state)}
label={humanize(evidence?.state ?? "unavailable")}
/>
<span className="muted"> · {retentionEvidenceDetail(evidence)}</span>
</dd>
</div>
)}
</DescriptionList>
</Card>
);
}
function retentionDuration(days?: number | null): string {
if (days === null || days === undefined) return "i18n:govoplan-campaign.no_automatic_expiry.52cb62f8";
if (days === 0) return "i18n:govoplan-campaign.remove_when_eligible.385ff0eb";
return i18nMessage("i18n:govoplan-campaign.value_days.2bf9b447", { value0: String(days) });
}
function retentionEvidenceTone(state?: string): string {
if (state === "retained") return "success";
if (state === "redacted" || state === "expired") return "inactive";
if (state === "partially_redacted" || state === "partially_expired") return "warning";
if (state === "unavailable") return "error";
return "info";
}
function retentionEvidenceDetail(
evidence?: CampaignRetentionReport["evidence"][string]
): string {
if (!evidence) return "—";
const counts = [
evidence.retained_count !== undefined ? `${evidence.retained_count} retained` : "",
evidence.expired_count !== undefined ? `${evidence.expired_count} expired` : "",
evidence.redacted_summary_count !== undefined
? `${evidence.redacted_summary_count}/${evidence.summary_count ?? 0} redacted`
: "",
evidence.currently_readable_count !== undefined ? `${evidence.currently_readable_count} readable` : "",
evidence.withdrawn_count !== undefined ? `${evidence.withdrawn_count} withdrawn` : ""
].filter(Boolean);
return counts.join(", ") || (evidence.redacted_at ? formatDateTime(evidence.redacted_at) : "—");
}
function PostboxTargetEvidenceSection({ targets }: { targets: unknown[] }) {
const rows = targets.map(asRecord);
if (rows.length === 0) return null;
const columns: DataGridColumn<Record<string, unknown>>[] = [
{
id: "target",
header: "Frozen Postbox target",
width: "minmax(240px, 1fr)",
minWidth: 220,
maxWidth: 640,
resizable: true,
filterable: true,
value: (row) => String(row.address ?? row.name ?? row.postbox_id ?? "—"),
render: (row) => (
<span className="campaign-evidence-identifiers">
<strong>{String(row.name ?? row.address ?? row.postbox_id ?? "—")}</strong>
<small>{String(row.address ?? row.address_key ?? "")}</small>
</span>
)
},
{
id: "organization",
header: "Organization / function",
width: 260,
resizable: true,
filterable: true,
value: (row) =>
`${String(row.organization_unit_name ?? row.organization_unit_id ?? "")} ${String(row.function_name ?? row.function_id ?? "")}`,
render: (row) => (
<span className="campaign-evidence-identifiers">
<span>{String(row.organization_unit_name ?? row.organization_unit_id ?? "—")}</span>
<small>{String(row.function_name ?? row.function_id ?? "—")}</small>
</span>
)
},
{
id: "resolution",
header: "Frozen resolution",
width: 220,
resizable: true,
value: (row) =>
`${String(row.mode ?? "direct")} ${String(row.context_key ?? "")} ${String(row.template_revision_id ?? "")}`,
render: (row) => (
<span className="campaign-evidence-identifiers">
<span>{humanize(String(row.mode ?? "direct"))}</span>
<small>
{row.template_revision_id
? `Template ${shortEvidenceId(String(row.template_revision_id))}`
: "Exact Postbox"}
{row.context_key ? ` · Context ${String(row.context_key)}` : ""}
</small>
</span>
)
},
{
id: "holders",
header: "Build-time holders",
width: 150,
align: "right",
sortable: true,
value: (row) => Number(row.holder_count ?? 0),
render: (row) =>
row.vacant === true ? (
<StatusBadge status="warning" label="Vacant" />
) : (
String(row.holder_count ?? 0)
)
}
];
return (
<section className="attempt-history-section">
<h3>Frozen Postbox targets</h3>
<p className="muted small-note">
These addresses, organization/function references, and template revisions
were resolved during build and are the delivery evidence for this job.
</p>
<DataGrid
id="campaign-postbox-target-evidence"
rows={rows}
columns={columns}
getRowKey={(row, index) =>
String(row.target_id ?? row.postbox_id ?? `postbox-target-${index}`)
}
/>
</section>
);
}
type AttachmentEvidenceRow = {
id: string;
rule: string;
status: string;
delivery: string;
file: string;
assetId: string;
versionId: string;
sourceRevision: string;
checksum: string;
sizeBytes: number | null;
};
function AttachmentEvidenceSection({ attachments }: {attachments: unknown[];}) {
const rows = attachmentEvidenceRows(attachments);
const columns: DataGridColumn<AttachmentEvidenceRow>[] = [
{ id: "rule", header: "Rule", width: 180, resizable: true, sortable: true, filterable: true, value: (row) => row.rule },
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.status} />, value: (row) => row.status },
{ id: "delivery", header: "Attachment output", width: 220, resizable: true, filterable: true, value: (row) => row.delivery },
{ id: "file", header: "Frozen file", width: "minmax(240px, 1fr)", minWidth: 220, maxWidth: 640, resizable: true, filterable: true, value: (row) => row.file },
{
id: "version",
header: "Managed version",
width: 230,
resizable: true,
filterable: true,
value: (row) => `${row.versionId} ${row.assetId} ${row.sourceRevision}`,
render: (row) =>
<span className="campaign-evidence-identifiers">
<code title={row.versionId}>{row.versionId || "Legacy source"}</code>
{row.assetId && <small title={row.assetId}>Asset {shortEvidenceId(row.assetId)}</small>}
{row.sourceRevision && <small title={row.sourceRevision}>Source {shortEvidenceId(row.sourceRevision)}</small>}
</span>
},
{
id: "checksum",
header: "SHA-256",
width: 170,
filterable: true,
value: (row) => row.checksum,
render: (row) => <code title={row.checksum}>{row.checksum ? shortEvidenceId(row.checksum) : "—"}</code>
},
{ id: "size", header: "Size", width: 110, align: "right", sortable: true, value: (row) => row.sizeBytes ?? -1, render: (row) => row.sizeBytes === null ? "—" : `${row.sizeBytes.toLocaleString()} B` }
];
return (
<section className="attempt-history-section campaign-attachment-evidence">
<h3>Frozen attachment evidence</h3>
{rows.length === 0 ?
<p className="muted small-note">No attachment rule or frozen file is recorded for this delivery job.</p> :
<>
<p className="muted small-note">Exact managed versions and checksums shown here are the immutable files used when this message was built.</p>
<DataGrid
id="campaign-job-attachment-evidence"
rows={rows}
columns={columns}
getRowKey={(row) => row.id}
resizeBehavior="free" />
</>
}
</section>
);
}
function attachmentEvidenceRows(attachments: unknown[]): AttachmentEvidenceRow[] {
const rows: AttachmentEvidenceRow[] = [];
attachments.forEach((value, ruleIndex) => {
const attachment = asRecord(value);
const rule = String(attachment.label ?? attachment.attachment_id ?? `Rule ${ruleIndex + 1}`);
const status = String(attachment.status ?? "unknown");
const zipEnabled = attachment.zip_enabled === true;
const delivery = zipEnabled
? `ZIP ${String(attachment.zip_filename ?? attachment.zip_archive_id ?? "")}`.trim() + ` (${String(attachment.zip_mode ?? "inherit")})`
: "Direct attachment";
const managedMatches = Array.isArray(attachment.managed_matches) ? attachment.managed_matches.map(asRecord) : [];
const legacyMatches = Array.isArray(attachment.matches) ? attachment.matches : [];
const matches: Array<Record<string, unknown> | string | null> = managedMatches.length > 0
? managedMatches
: legacyMatches.length > 0
? legacyMatches.map((match) => typeof match === "string" ? match : asRecord(match))
: [null];
matches.forEach((match, matchIndex) => {
const managed = typeof match === "string" || match === null ? {} : match;
const filename = typeof match === "string"
? match
: String(managed.display_path ?? managed.relative_path ?? managed.filename ?? "No matched file");
rows.push({
id: `${ruleIndex}:${matchIndex}:${String(managed.version_id ?? filename)}`,
rule,
status,
delivery,
file: filename,
assetId: String(managed.asset_id ?? ""),
versionId: String(managed.version_id ?? ""),
sourceRevision: String(managed.source_revision ?? ""),
checksum: String(managed.checksum_sha256 ?? ""),
sizeBytes: Number.isFinite(Number(managed.size_bytes)) ? Number(managed.size_bytes) : null
});
});
});
return rows;
}
function shortEvidenceId(value: string): string {
return value.length > 16 ? `${value.slice(0, 16)}...` : value;
}
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox" | "print";rows: Record<string, unknown>[];}) {
const title = kind === "smtp"
? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6"
: kind === "postbox"
? "Postbox delivery attempts"
: kind === "print"
? "Printable output attempts"
: "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
if (rows.length === 0) {
return (
<section className="attempt-history-section">
<h3>{title}</h3>
<p className="muted small-note">No {kind} attempt has been recorded for this job.</p>
</section>);
}
const columns: DataGridColumn<Record<string, unknown>>[] = [
{ id: "attempt", header: "#", width: 72, sortable: true, value: (row, index) => Number(row.attempt_number ?? index + 1), render: (row, index) => String(row.attempt_number ?? index + 1) },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (row) => String(row.status ?? "unknown"), render: (row) => <StatusBadge status={String(row.status ?? "unknown")} /> },
kind === "imap" ?
{ id: "folder", header: "i18n:govoplan-campaign.folder.30baa249", width: 180, sortable: true, filterable: true, value: (row) => String(row.folder ?? "—"), render: (row) => String(row.folder ?? "—") } :
kind === "print" ?
{ id: "render", header: "Render", width: 220, sortable: true, filterable: true, value: (row) => String(row.render_id ?? "—"), render: (row) => String(row.render_id ?? "—") } :
kind === "postbox" ?
{ id: "target", header: "Postbox", width: 220, sortable: true, filterable: true, value: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—"), render: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—") } :
{ id: "code", header: "i18n:govoplan-campaign.code.adac6937", width: 110, sortable: true, value: (row) => String(row.smtp_status_code ?? "—"), render: (row) => String(row.smtp_status_code ?? "—") },
...(kind === "postbox" ? [
{
id: "readability",
header: "Current access",
width: 155,
value: (row: Record<string, unknown>) => {
const summary = asRecord(row.receipt_summary);
return String(
row.receipt_summary_status === "available"
? summary.currently_readable === true
? "readable"
: "unavailable"
: row.receipt_summary_status ?? "unavailable"
);
},
render: (row: Record<string, unknown>) => {
const summary = asRecord(row.receipt_summary);
const available = row.receipt_summary_status === "available";
const readable = available && summary.currently_readable === true;
return (
<StatusBadge
status={readable ? "success" : available ? "warning" : "info"}
label={
readable
? "Readable"
: available
? "Not currently readable"
: "Evidence unavailable"
}
/>
);
}
},
{
id: "receipts",
header: "Read / acknowledged",
width: 170,
align: "right" as const,
value: (row: Record<string, unknown>) => {
const summary = asRecord(row.receipt_summary);
return `${String(summary.read_receipt_count ?? 0)} / ${String(summary.acknowledged_receipt_count ?? 0)}`;
}
}
] : []),
{ id: "started", header: "i18n:govoplan-campaign.started.faa9e7e7", width: 180, sortable: true, value: (row) => String(row.started_at ?? row.created_at ?? ""), render: (row) => formatDateTime(String(row.started_at ?? row.created_at ?? "")) },
{ id: "finished", header: "i18n:govoplan-campaign.finished.355bcc57", width: 180, sortable: true, value: (row) => String(row.finished_at ?? row.updated_at ?? ""), render: (row) => formatDateTime(String(row.finished_at ?? row.updated_at ?? "")) },
{ id: "result", header: "i18n:govoplan-campaign.result.5faa59d4", width: "minmax(240px, 1fr)", minWidth: 200, maxWidth: 720, resizable: true, filterable: true, value: (row) => String(row.smtp_response ?? row.error_message ?? row.error_code ?? "—"), render: (row) => <span title={String(row.smtp_response ?? row.error_message ?? row.error_code ?? "")}>{String(row.smtp_response ?? row.error_message ?? row.error_code ?? "—")}</span> }
];
return (
<section className="attempt-history-section">
<h3>{title}</h3>
<DataGrid id={`campaign-${kind}-attempt-history`} rows={rows} columns={columns} getRowKey={(row, index) => String(row.id ?? `${kind}-${index}`)} />
</section>);
}
function initialReportGridFilters(): Record<string, string | string[]> {
if (typeof window === "undefined") return {};
const params = new URLSearchParams(window.location.search);
const result: Record<string, string | string[]> = {};
const send = statusParameters(params, "send_status", SEND_STATUS_OPTIONS);
const imap = statusParameters(params, "imap_status", IMAP_STATUS_OPTIONS);
const postbox = statusParameters(params, "postbox_status", POSTBOX_STATUS_OPTIONS);
const print = statusParameters(params, "print_status", PRINT_STATUS_OPTIONS);
const validation = statusParameters(params, "validation_status", VALIDATION_STATUS_OPTIONS);
if (send.length > 0) result.send = send;
if (imap.length > 0) result.imap = imap;
if (postbox.length > 0) result.postbox = postbox;
if (print.length > 0) result.print = print;
if (validation.length > 0) result.validation = validation;
return result;
}
function deliveryStatusLabel(status: string): string | undefined {
return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7" : undefined;
}
function calendarRsvpStatus(row: Record<string, unknown>): string {
const invitation = asRecord(row.calendar_invitation);
return String(invitation.rsvp_status || "—");
}
function initialReportQuery(): string {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get("q")?.trim() ?? "";
}
function statusParameters(params: URLSearchParams, name: string, options: DataGridListOption[]): string[] {
const allowed = new Set(options.map((option) => option.value));
return [...new Set(
params.getAll(name).
flatMap((value) => value.split(",")).
map((value) => value.trim()).
filter((value) => allowed.has(value))
)];
}
function serializeInitialGridFilters(filters: Record<string, string | string[]>): Record<string, string> {
return Object.fromEntries(Object.entries(filters).map(([columnId, value]) => [
columnId,
Array.isArray(value) ? `list:${JSON.stringify([...new Set(value)])}` : value
]));
}
function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "postbox" || value === "print" || value === "imap" || value === "attempts" || value === "updated") {
return value;
}
return "number";
}
function retryableFailedStatus(status: string): boolean {
return status === "failed_temporary" || status === "failed_permanent" || status === "partially_accepted";
}
function shortJobId(jobId: string): string {
return jobId.length > 12 ? `${jobId.slice(0, 12)}...` : jobId;
}