Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user