feat: complete campaign wizards and retention reporting

This commit is contained in:
2026-07-31 04:21:34 +02:00
parent e689fdf495
commit fa4eb39e0b
18 changed files with 785 additions and 55 deletions
@@ -11,7 +11,8 @@ import {
sendCampaignJob,
sendUnattemptedCampaignJobs,
type CampaignJobDetailResponse,
type CampaignJobsResponse } from
type CampaignJobsResponse,
type CampaignRetentionReport } from
"../../api/campaigns";
import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
@@ -103,6 +104,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
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);
@@ -500,6 +502,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<div><dt>Withdrawn / expired copies</dt><dd>{String(Number(postboxReceipts.withdrawn_message_count ?? 0) + Number(postboxReceipts.expired_message_count ?? 0))}</dd></div>
</dl>
</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">
@@ -623,6 +626,102 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
}
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>}
<dl className="detail-list">
<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>
)}
</dl>
</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;