1097 lines
53 KiB
TypeScript
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;
|
|
}
|