495 lines
26 KiB
TypeScript
495 lines
26 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { ApiSettings } from "../../types";
|
|
import {
|
|
downloadCampaignJobsCsv,
|
|
emailCampaignReport,
|
|
getCampaignJobDetail,
|
|
getCampaignJobsDelta,
|
|
resolveCampaignJobOutcome,
|
|
retryCampaignJobs,
|
|
sendUnattemptedCampaignJobs,
|
|
type CampaignJobDetailResponse,
|
|
type CampaignJobsResponse } from
|
|
"../../api/campaigns";
|
|
import { Card } from "@govoplan/core-webui";
|
|
import { Button } from "@govoplan/core-webui";
|
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
|
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
|
|
import { Dialog } from "@govoplan/core-webui";
|
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
|
import { PageTitle } from "@govoplan/core-webui";
|
|
import { StatusBadge } from "@govoplan/core-webui";
|
|
import VersionLine from "./components/VersionLine";
|
|
import { LoadingFrame, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui";
|
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
|
import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
|
|
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
|
|
|
|
const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
|
"not_queued",
|
|
"queued",
|
|
"claimed",
|
|
"sending",
|
|
"smtp_accepted",
|
|
"sent",
|
|
"outcome_unknown",
|
|
"failed_temporary",
|
|
"failed_permanent",
|
|
"cancelled"].
|
|
map((value) => ({ value, label: humanize(value) }));
|
|
|
|
const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
|
|
"not_requested",
|
|
"pending",
|
|
"appended",
|
|
"failed",
|
|
"skipped"].
|
|
map((value) => ({ value, label: humanize(value) }));
|
|
|
|
type ReconcileRequest = {jobId: string;decision: "smtp_accepted" | "not_sent";} | null;
|
|
|
|
export default function CampaignReportPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
|
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
|
const version = data.currentVersion;
|
|
const cards = data.summary?.cards;
|
|
const delivery = asRecord(data.summary?.delivery);
|
|
const rateLimit = asRecord(delivery.rate_limit);
|
|
const imapPolicy = asRecord(delivery.imap_append_sent);
|
|
|
|
const [jobs, setJobs] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
|
|
const jobsRef = useRef<CampaignJobsResponse>(emptyCampaignJobsResponse());
|
|
const jobPageCursorsRef = useRef<Record<number, string | null>>({ 1: null });
|
|
const [jobsLoading, setJobsLoading] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
const [sendStatus, setSendStatus] = useState("");
|
|
const [imapStatus, setImapStatus] = useState("");
|
|
const [query, setQuery] = useState("");
|
|
const [appliedQuery, setAppliedQuery] = useState("");
|
|
const [actionMessage, setActionMessage] = useState("");
|
|
const [actionError, setActionError] = useState("");
|
|
const [busyAction, setBusyAction] = useState("");
|
|
const [detail, setDetail] = useState<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());
|
|
setPage(1);
|
|
}, 350);
|
|
return () => window.clearTimeout(handle);
|
|
}, [query]);
|
|
|
|
const jobsQueryKey = useMemo(
|
|
() => JSON.stringify({
|
|
campaignId,
|
|
versionId: version?.id ?? null,
|
|
page,
|
|
pageSize: 50,
|
|
sendStatus,
|
|
imapStatus,
|
|
appliedQuery,
|
|
apiBaseUrl: settings.apiBaseUrl,
|
|
apiKey: settings.apiKey,
|
|
accessToken: settings.accessToken
|
|
}),
|
|
[campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]
|
|
);
|
|
|
|
useEffect(() => {
|
|
jobPageCursorsRef.current = { 1: null };
|
|
}, [campaignId, version?.id, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
|
|
|
const loadJobs = useCallback(async () => {
|
|
if (!campaignId) return;
|
|
setJobsLoading(true);
|
|
setActionError("");
|
|
try {
|
|
let nextWatermark = getDeltaWatermark(jobsQueryKey);
|
|
let merged = jobsRef.current;
|
|
let hasMore = false;
|
|
const pageCursor = page === 1 ? null : jobPageCursorsRef.current[page];
|
|
do {
|
|
const response = await getCampaignJobsDelta(settings, campaignId, {
|
|
versionId: version?.id,
|
|
page,
|
|
pageSize: 50,
|
|
cursor: pageCursor,
|
|
sendStatus: sendStatus ? [sendStatus] : undefined,
|
|
imapStatus: imapStatus ? [imapStatus] : undefined,
|
|
query: appliedQuery || undefined,
|
|
since: nextWatermark
|
|
});
|
|
merged = mergeCampaignJobsDelta(merged, response);
|
|
if (response.cursor !== undefined) jobPageCursorsRef.current[page] = response.cursor ?? null;
|
|
if (response.next_cursor !== undefined) {
|
|
if (response.next_cursor) jobPageCursorsRef.current[page + 1] = response.next_cursor;
|
|
else delete jobPageCursorsRef.current[page + 1];
|
|
}
|
|
nextWatermark = response.watermark ?? null;
|
|
hasMore = response.has_more;
|
|
} while (hasMore);
|
|
setDeltaWatermark(jobsQueryKey, nextWatermark);
|
|
jobsRef.current = merged;
|
|
setJobs(merged);
|
|
if (merged.pages > 0 && page > merged.pages) setPage(merged.pages);
|
|
} catch (err) {
|
|
setActionError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setJobsLoading(false);
|
|
}
|
|
}, [settings, campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, jobsQueryKey, getDeltaWatermark, setDeltaWatermark]);
|
|
|
|
useEffect(() => {
|
|
resetDeltaWatermark(jobsQueryKey);
|
|
jobsRef.current = emptyCampaignJobsResponse();
|
|
setJobs(emptyCampaignJobsResponse());
|
|
}, [jobsQueryKey, resetDeltaWatermark]);
|
|
|
|
useEffect(() => {
|
|
void loadJobs();
|
|
}, [loadJobs]);
|
|
|
|
async function reloadAll() {
|
|
await Promise.all([reload(), loadJobs()]);
|
|
}
|
|
|
|
async function runExplicitAction(action: "retry" | "unattempted") {
|
|
if (!version || busyAction) return;
|
|
setBusyAction(action);
|
|
setActionError("");
|
|
setActionMessage("");
|
|
try {
|
|
const response = action === "retry" ?
|
|
await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true }) :
|
|
await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true });
|
|
const result = asRecord(response.result ?? response);
|
|
setActionMessage(`${humanize(String(result.action ?? action))}: ${String(result.selected_count ?? 0)} job(s) selected, ${String(result.enqueued_count ?? 0)} enqueued.`);
|
|
await reloadAll();
|
|
} catch (err) {
|
|
setActionError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusyAction("");
|
|
}
|
|
}
|
|
|
|
async function reconcileOutcome() {
|
|
if (!reconcile || busyAction) return;
|
|
setBusyAction("reconcile");
|
|
setActionError("");
|
|
try {
|
|
await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision);
|
|
setActionMessage(reconcile.decision === "smtp_accepted" ?
|
|
"i18n:govoplan-campaign.the_job_was_recorded_as_smtp_accepted_and_is_pro.12ee72b6" :
|
|
"i18n:govoplan-campaign.the_job_was_recorded_as_not_sent_it_is_now_an_ex.2cea8409");
|
|
setReconcile(null);
|
|
await reloadAll();
|
|
} catch (err) {
|
|
setActionError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusyAction("");
|
|
}
|
|
}
|
|
|
|
async function openJob(jobId: string) {
|
|
setBusyAction("detail");
|
|
setActionError("");
|
|
try {
|
|
setDetail(await getCampaignJobDetail(settings, campaignId, jobId));
|
|
} catch (err) {
|
|
setActionError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusyAction("");
|
|
}
|
|
}
|
|
|
|
async function exportCsv() {
|
|
setBusyAction("csv");
|
|
setActionError("");
|
|
try {
|
|
await downloadCampaignJobsCsv(settings, campaignId, version?.id);
|
|
setActionMessage("i18n:govoplan-campaign.campaign_job_csv_downloaded.08af6930");
|
|
} catch (err) {
|
|
setActionError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusyAction("");
|
|
}
|
|
}
|
|
|
|
async function sendReportEmail() {
|
|
const recipients = emailRecipients.split(/[;,\n]+/).map((value) => value.trim()).filter(Boolean);
|
|
if (recipients.length === 0 || busyAction) return;
|
|
setBusyAction("email");
|
|
setActionError("");
|
|
try {
|
|
await emailCampaignReport(settings, campaignId, {
|
|
to: recipients,
|
|
version_id: version?.id,
|
|
include_jobs: false,
|
|
attach_jobs_csv: attachCsv,
|
|
attach_report_json: attachJson
|
|
});
|
|
setActionMessage(`Report sent to ${recipients.join(", ")}.`);
|
|
setEmailOpen(false);
|
|
} catch (err) {
|
|
setActionError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusyAction("");
|
|
}
|
|
}
|
|
|
|
const columns = useMemo<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)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
|
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, render: (row) => <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, render: (row) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, value: (row) => String(row.queue_status ?? "unknown") },
|
|
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} />, value: (row) => String(row.send_status ?? "unknown") },
|
|
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={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) },
|
|
{
|
|
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)",
|
|
resizable: true,
|
|
filterable: 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: 250,
|
|
sticky: "end",
|
|
render: (row) => {
|
|
const id = String(row.id ?? "");
|
|
const status = String(row.send_status ?? "");
|
|
return (
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
|
|
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>}
|
|
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
|
|
</div>);
|
|
|
|
}
|
|
}],
|
|
[busyAction]);
|
|
|
|
return (
|
|
<div className="content-pad workspace-data-page">
|
|
<div className="page-heading split workspace-heading">
|
|
<div>
|
|
<PageTitle loading={loading}>i18n:govoplan-campaign.report.ee45c303</PageTitle>
|
|
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />
|
|
</div>
|
|
<div className="button-row compact-actions">
|
|
<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>
|
|
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
|
</div>
|
|
</div>
|
|
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
|
|
{actionMessage && <DismissibleAlert tone="success" resetKey={actionMessage} floating>{actionMessage}</DismissibleAlert>}
|
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_report_data.0908ade5">
|
|
<div className="dashboard-grid">
|
|
<Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
|
|
<dl className="detail-list">
|
|
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.jobs_total.98da65bc</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.smtp_accepted.e3aa7603</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.failed.09fef5d8</dt><dd>{cards?.failed ?? 0}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.outcome_unknown.6e929fca</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.not_attempted.e1be3c69</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.cancelled.a1bf92ef</dt><dd>{cards?.cancelled ?? 0}</dd></div>
|
|
</dl>
|
|
</Card>
|
|
<Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
|
|
<dl className="detail-list">
|
|
<div><dt>i18n:govoplan-campaign.imap_appended.56017ea3</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.imap_failed.50dbca55</dt><dd>{cards?.imap_failed ?? 0}</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>
|
|
</dl>
|
|
</Card>
|
|
<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 runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card title="i18n:govoplan-campaign.recipient_delivery_jobs.52492608">
|
|
<div className="page-heading split">
|
|
<div className="button-row compact-actions">
|
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5" />
|
|
<select value={sendStatus} onChange={(event) => {setSendStatus(event.target.value);setPage(1);}}>
|
|
<option value="">i18n:govoplan-campaign.all_smtp_states.739597b1</option>
|
|
{SEND_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
</select>
|
|
<select value={imapStatus} onChange={(event) => {setImapStatus(event.target.value);setPage(1);}}>
|
|
<option value="">i18n:govoplan-campaign.all_imap_states.8546b84c</option>
|
|
{IMAP_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
</select>
|
|
</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-${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" />
|
|
|
|
</LoadingFrame>
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>i18n:govoplan-campaign.previous.50f94286</Button>
|
|
<span>i18n:govoplan-campaign.page.fb06270f {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}</span>
|
|
<Button onClick={() => setPage((value) => Math.min(jobs.pages || 1, value + 1))} disabled={page >= jobs.pages || jobsLoading}>i18n:govoplan-campaign.next.bc981983</Button>
|
|
</div>
|
|
</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>
|
|
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> i18n:govoplan-campaign.attach_job_csv.adb76197</label>
|
|
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> i18n:govoplan-campaign.attach_json_report.d70883b5</label>
|
|
</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">
|
|
<dl className="detail-list">
|
|
<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")} /></dd></div>
|
|
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={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>
|
|
</dl>
|
|
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
|
|
<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)} />
|
|
|
|
</div>);
|
|
|
|
}
|
|
|
|
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record<string, unknown>[];}) {
|
|
const title = kind === "smtp" ? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" : "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">i18n:govoplan-campaign.no.816c52fd {kind === "smtp" ? "i18n:govoplan-campaign.smtp.efff9cca" : "i18n:govoplan-campaign.imap.271f9ef2"} i18n:govoplan-campaign.attempt_has_been_recorded_for_this_job.e4050f01</p>
|
|
</section>);
|
|
|
|
}
|
|
|
|
return (
|
|
<section className="attempt-history-section">
|
|
<h3>{title}</h3>
|
|
<div className="attempt-history-wrap">
|
|
<table className="attempt-history-table">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>i18n:govoplan-campaign.status.bae7d5be</th>
|
|
{kind === "imap" && <th>i18n:govoplan-campaign.folder.30baa249</th>}
|
|
{kind === "smtp" && <th>i18n:govoplan-campaign.code.adac6937</th>}
|
|
<th>i18n:govoplan-campaign.started.faa9e7e7</th>
|
|
<th>i18n:govoplan-campaign.finished.355bcc57</th>
|
|
<th>i18n:govoplan-campaign.result.5faa59d4</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row, index) =>
|
|
<tr key={String(row.id ?? `${kind}-${index}`)}>
|
|
<td>{String(row.attempt_number ?? index + 1)}</td>
|
|
<td><StatusBadge status={String(row.status ?? "unknown")} /></td>
|
|
{kind === "imap" && <td>{String(row.folder ?? "—")}</td>}
|
|
{kind === "smtp" && <td>{String(row.smtp_status_code ?? "—")}</td>}
|
|
<td>{formatDateTime(String(row.started_at ?? row.created_at ?? ""))}</td>
|
|
<td>{formatDateTime(String(row.finished_at ?? row.updated_at ?? ""))}</td>
|
|
<td title={String(row.smtp_response ?? row.error_message ?? "")}>
|
|
{String(row.smtp_response ?? row.error_message ?? "—")}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>);
|
|
|
|
}
|