Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-09-08 01:32:26 +02:00
parent 1b32427813
commit c51fc180fb
111 changed files with 6905 additions and 980 deletions
@@ -1,7 +1,7 @@
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 type { ApiSettings, AuthInfo } from "../../types";
import {
downloadCampaignJobsCsv,
emailCampaignReport,
@@ -9,7 +9,6 @@ import {
getCampaignJobs,
resolveCampaignJobOutcome,
retryCampaignJobs,
sendCampaignJob,
sendUnattemptedCampaignJobs,
type CampaignJobDetailResponse,
type CampaignJobsResponse,
@@ -17,15 +16,19 @@ import {
"../../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 { 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";
@@ -38,24 +41,6 @@ import {
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",
@@ -77,16 +62,6 @@ const POSTBOX_STATUS_OPTIONS: DataGridListOption[] = [
"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",
@@ -106,17 +81,25 @@ 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;}) {
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<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
const jobsRequestRef = useRef(0);
@@ -140,7 +123,18 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
const [emailRecipients, setEmailRecipients] = useState("");
const [attachCsv, setAttachCsv] = useState(true);
const [attachJson, setAttachJson] = useState(false);
const [reconcile, setReconcile] = useState<ReconcileRequest>(null);
const [reconcile, setReconcile] = useState<ReportReconciliation | null>(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(() => {
@@ -172,6 +166,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
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" },
@@ -181,13 +177,16 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
{ 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) return;
if (!campaignId || !version?.id) return;
const requestId = ++jobsRequestRef.current;
setJobsLoading(true);
setActionError("");
@@ -200,16 +199,16 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
sortBy: campaignJobSortColumn(appliedJobGridQuery.sort?.columnId),
sortDirection: appliedJobGridQuery.sort?.direction ?? "asc",
filters: appliedJobGridQuery.filters
});
if (requestId !== jobsRequestRef.current) return;
}, { 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) setActionError(err instanceof Error ? err.message : String(err));
if (requestId === jobsRequestRef.current && reportContext === reportContextRef.current) setActionError(err instanceof Error ? err.message : String(err));
} finally {
if (requestId === jobsRequestRef.current) setJobsLoading(false);
if (requestId === jobsRequestRef.current && reportContext === reportContextRef.current) setJobsLoading(false);
}
}, [settings, campaignId, version?.id, page, pageSize, appliedQuery, appliedJobGridQuery]);
}, [settings, campaignId, version?.id, page, pageSize, appliedQuery, appliedJobGridQuery, reportContext]);
useEffect(() => {
void loadJobs();
@@ -220,7 +219,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
}
async function runExplicitAction(action: "retry" | "unattempted") {
if (!version || busyAction) return;
if (!version || busyAction || pendingAction.current || !workersAvailable || (action === "retry" ? !canRetry : !canQueue)) return;
pendingAction.current = true;
setBusyAction(action);
setActionError("");
setActionMessage("");
@@ -228,94 +228,74 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
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) {
setActionError(err instanceof Error ? err.message : String(err));
if (reportContext === reportContextRef.current) setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyAction("");
if (reportContext === reportContextRef.current) { pendingAction.current = false; setBusyAction(""); }
}
}
const failedRowsOnPage = useMemo(
() => jobs.jobs.filter((row) => retryableFailedStatus(String(row.send_status ?? "")) && String(row.id ?? "")),
() => 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 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");
async function runInlineRecovery(action: CampaignInlineRecoveryAction, rows: Record<string, unknown>[]) {
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("");
let attempted = 0;
let accepted = 0;
let skipped = 0;
const failures: string[] = [];
const versionId = version.id;
setProgress({ versionId, requestState: "running" });
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();
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 {
setBusyAction("");
if (reportContext === reportContextRef.current) { pendingAction.current = false; setBusyAction(""); }
}
}
async function reconcileOutcome() {
if (!reconcile || busyAction) return;
async function reconcileOutcome(note: string) {
if (!reconcile || busyAction || pendingAction.current || !canReconcile || !note.trim()) return;
pendingAction.current = true;
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");
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);
await reloadAll();
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
try { await reloadAll(); }
catch { if (reportContext === reportContextRef.current) setActionError("i18n:govoplan-campaign.report_acknowledged_refresh_failed"); }
} finally {
setBusyAction("");
if (reportContext === reportContextRef.current) { pendingAction.current = false; setBusyAction(""); }
}
}
@@ -323,11 +303,12 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
setBusyAction("detail");
setActionError("");
try {
setDetail(await getCampaignJobDetail(settings, campaignId, jobId));
const response = await getCampaignJobDetail(settings, campaignId, jobId);
if (reportContext === reportContextRef.current) setDetail(response);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
if (reportContext === reportContextRef.current) setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyAction("");
if (reportContext === reportContextRef.current) setBusyAction("");
}
}
@@ -376,13 +357,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
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 ?? "—")
render: (row) => <ReportRecipients row={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) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
@@ -422,7 +398,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
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>,
render: (row) => <div className="recipient-outcome-cell"><span className={row.last_error ? "recipient-outcome-error" : "muted"} title={String(row.last_error ?? "")}>{String(row.last_error ?? "—")}</span>
{reportRecoveryHints(row).map(hint => <span key={hint}>{hint}</span>)}</div>,
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 ?? "")) },
@@ -434,16 +411,23 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
render: (row) => {
const id = String(row.id ?? "");
const status = String(row.send_status ?? "");
const smtpClaim = reportClaimRecovery(row, "smtp");
const imapClaim = reportClaimRecovery(row, "imap");
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" }) }
{ id: "retry", label: "i18n:govoplan-campaign.report_retry_now", icon: <RotateCcw aria-hidden="true" />, applicable: reportRetryableFailure(row), disabled: !id || !canRetry || !canSend || Boolean(busyAction), onClick: () => void runInlineRecovery("retry", [row]) },
{ id: "unattempted", label: "i18n:govoplan-campaign.report_send_unattempted_now", icon: <RotateCcw aria-hidden="true" />, applicable: reportUnattempted(row), disabled: !id || !canQueue || !canSend || Boolean(busyAction), onClick: () => void runInlineRecovery("send-unattempted", [row]) },
{ id: "accepted", label: "i18n:govoplan-campaign.accepted.61a0572c", icon: <Check aria-hidden="true" />, applicable: status === "outcome_unknown", disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "outcome", 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", disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "outcome", jobId: id, decision: "not_sent" }) },
{ id: "recover-smtp", label: "i18n:govoplan-campaign.report_recover_smtp_claim", icon: <RotateCcw aria-hidden="true" />, applicable: smtpClaim.eligible, disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "claim", jobId: id, channel: "smtp", revision: smtpClaim.revision }) },
{ id: "recover-imap", label: "i18n:govoplan-campaign.report_recover_imap_claim", icon: <RotateCcw aria-hidden="true" />, applicable: imapClaim.eligible, disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "claim", jobId: id, channel: "imap", revision: imapClaim.revision }) },
{ id: "imap-appended", label: "i18n:govoplan-campaign.report_record_imap_appended", icon: <Check aria-hidden="true" />, applicable: row.imap_status === "outcome_unknown", disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "outcome", jobId: id, decision: "imap_appended" }) },
{ id: "imap-not-appended", label: "i18n:govoplan-campaign.report_record_imap_not_appended", icon: <X aria-hidden="true" />, variant: "danger", applicable: row.imap_status === "outcome_unknown", disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "outcome", jobId: id, decision: "imap_not_appended" }) }
]} />;
}
}],
[busyAction, retryFailedSynchronously]);
[busyAction, canRetry, canSend, canQueue, canReconcile, runInlineRecovery]);
return (
<PageLayout
@@ -457,7 +441,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
actions={<PageActionBar
variant="detail"
refreshable
reloadAction={{ onReload: () => void reloadAll(), loading: loading || jobsLoading }}
reloadAction={{ onReload: () => void reloadAll(), loading: loading || jobsLoading, disabled: Boolean(busyAction) }}
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>
@@ -533,12 +517,17 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<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 onClick={() => void runExplicitAction("retry")} disabled={!version || !canRetry || !workersAvailable || Boolean(busyAction)}>i18n:govoplan-campaign.report_queue_retry_workers</Button>
<Button onClick={() => void runInlineRecovery("retry", failedRowsOnPage)} disabled={!version || !canRetry || !canSend || Boolean(busyAction) || failedRowsOnPage.length === 0}>
{i18nMessage("i18n:govoplan-campaign.report_retry_page_now", { value0: failedRowsOnPage.length })}
</Button>
<Button helpContextId="campaign.report" helpModuleId="campaigns" onClick={() => void runExplicitAction("unattempted")} disabled={!version || !canQueue || !workersAvailable || Boolean(busyAction)}>i18n:govoplan-campaign.report_queue_unattempted_workers</Button>
<Button onClick={() => void runInlineRecovery("send-unattempted", unattemptedRowsOnPage)} disabled={!version || !canQueue || !canSend || Boolean(busyAction) || unattemptedRowsOnPage.length === 0}>
{i18nMessage("i18n:govoplan-campaign.report_send_page_now", { value0: unattemptedRowsOnPage.length })}
</Button>
<Button helpContextId="campaign.report" helpModuleId="campaigns" onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
</div>
{!workersAvailable && <p className="muted">i18n:govoplan-campaign.report_workers_unavailable</p>}
<p className="muted">i18n:govoplan-campaign.report_inline_scope_help</p>
</Card>
</ContentGrid>
@@ -612,7 +601,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
{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.recipients.78cbf8eb</dt><dd><ReportRecipients row={detail.job} /></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>
@@ -640,17 +629,11 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
}
</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)} />
{reconcile && <ReportRecoveryDialog key={`${reconcile.jobId}-${reconcile.kind}-${reconcile.kind === "claim" ? reconcile.channel : reconcile.decision}`}
request={reconcile} onConfirm={reconcileOutcome} onClose={() => setReconcile(null)} />}
{progress && <CampaignDeliveryProgressDialog settings={settings} campaignId={campaignId} versionId={progress.versionId}
kind="smtp" requestState={progress.requestState} requestError={progress.requestError}
onClose={() => { if (!pendingAction.current) setProgress(null); }} />}
</PageLayout>);
@@ -1049,10 +1032,6 @@ function initialReportGridFilters(): Record<string, string | string[]> {
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 || "—");
@@ -1086,11 +1065,3 @@ function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
}
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;
}