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
@@ -129,7 +129,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 70,
width: 120,
sticky: "end",
align: "right",
render: (campaign) => <TableActionGroup actions={[{
@@ -172,7 +172,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
<Card
title="i18n:govoplan-campaign.campaign_identity.a00ca574"
collapsible
actions={<Link className="btn btn-secondary" to="wizard/create">i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Link>}>
actions={<Link className="btn btn-secondary" to="wizard">i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Link>}>
<div className="form-grid campaign-identity-grid">
<FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e">
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
@@ -308,8 +308,9 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 150,
width: 180,
sticky: "end",
align: "right",
render: (version) => {
const isCurrent = version.id === currentVersionId;
const temporarilyLocked = isCurrent && isTemporaryUserLockedVersion(version);
@@ -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;
@@ -18,6 +18,7 @@ const ReviewSendPage = lazy(() => import("./ReviewSendPage"));
const CreateWizard = lazy(() => import("./wizard/CreateWizard"));
const ReviewWizard = lazy(() => import("./wizard/ReviewWizard"));
const SendWizard = lazy(() => import("./wizard/SendWizard"));
const WizardDirectoryPage = lazy(() => import("./wizard/WizardDirectoryPage"));
const CampaignJsonView = lazy(() => import("./CampaignJsonView"));
const CampaignReportPage = lazy(() => import("./CampaignReportPage"));
const CampaignAuditPage = lazy(() => import("./CampaignAuditPage"));
@@ -109,9 +110,10 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
<Route path="reports" element={<Navigate to="../report" replace />} />
<Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
<Route path="wizard" element={<WizardDirectoryPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="wizard/create" element={<CreateWizard settings={settings} campaignId={campaignId || ""} />} />
<Route path="wizard/review" element={<ReviewWizard />} />
<Route path="wizard/send" element={<SendWizard />} />
<Route path="wizard/review" element={<ReviewWizard settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="wizard/send" element={<SendWizard settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="create" element={<Navigate to="../wizard/create" replace />} />
<Route path="campaign" element={<Navigate to="../data" replace />} />
<Route path="mail" element={<Navigate to="../mail-settings" replace />} />
@@ -111,7 +111,17 @@ type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "sen
const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"];
export default function ReviewSendPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
export default function ReviewSendPage({
settings,
auth,
campaignId,
initialStageId
}: {
settings: ApiSettings;
auth: AuthInfo;
campaignId: string;
initialStageId?: string;
}) {
const navigate = useGuardedNavigate();
const devMailboxCapability = usePlatformUiCapability<MailDevMailboxUiCapability>("mail.devMailbox");
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
@@ -188,6 +198,7 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
const [selectedDeliveryJobDetail, setSelectedDeliveryJobDetail] = useState<Record<string, unknown> | null>(null);
const persistedReview = storedMessageReviewState(version);
const persistedReviewKey = `${persistedReview.buildToken}|${persistedReview.inspectionComplete ? "1" : "0"}|${persistedReview.reviewedMessageKeys.join(",")}|${JSON.stringify(persistedReview.issueDecisions)}`;
const initialStageScrollKey = useRef("");
useEffect(() => {
setBuiltReviewRows([]);
@@ -218,6 +229,20 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
resetDeltaWatermark();
}, [version?.id, resetDeltaWatermark]);
useEffect(() => {
if (!initialStageId || loading || !version?.id) return;
const key = `${version.id}:${initialStageId}`;
if (initialStageScrollKey.current === key) return;
const frame = window.requestAnimationFrame(() => {
const target = document.getElementById(initialStageId);
if (!target) return;
initialStageScrollKey.current = key;
target.scrollIntoView({ behavior: "smooth", block: "start" });
target.focus({ preventScroll: true });
});
return () => window.cancelAnimationFrame(frame);
}, [initialStageId, loading, version?.id]);
useEffect(() => {
setMessageReviewComplete(persistedReview.inspectionComplete);
setReviewedMessageKeys(new Set(persistedReview.reviewedMessageKeys));
@@ -120,7 +120,7 @@ export function WorkflowStage({
} as CSSProperties;
return (
<section id={stage.id} className="review-flow-stage" data-state={stage.state} style={style}>
<section id={stage.id} className="review-flow-stage" data-state={stage.state} style={style} tabIndex={-1}>
<div className="review-flow-stage-marker" aria-hidden="true">
<div className="review-flow-stage-node">
<Icon size={20} strokeWidth={1.8} />
@@ -1,22 +1,21 @@
import { Card } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
export default function ReviewWizard() {
return (
<div className="content-pad workspace-data-page">
<div className="page-heading workspace-heading">
<h1>i18n:govoplan-campaign.review_wizard.3d8bf0aa</h1>
</div>
<div className="metric-grid">
<MetricCard label="i18n:govoplan-campaign.needs_review.33a506cf" value="—" tone="warning" />
<MetricCard label="i18n:govoplan-campaign.missing_attachments.729ad125" value="—" tone="warning" />
<MetricCard label="i18n:govoplan-campaign.ambiguous_matches.dc658a9c" value="—" tone="info" />
<MetricCard label="i18n:govoplan-campaign.blocked.99613c74" value="—" tone="danger" />
</div>
<Card title="i18n:govoplan-campaign.resolution_workflow.708d6c0b">
<p className="muted">i18n:govoplan-campaign.this_wizard_will_guide_users_through_issues_one_.5cb212c3</p>
<Button variant="primary">i18n:govoplan-campaign.start_review.d0bc5cdf</Button>
</Card>
</div>);
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import ReviewSendPage from "../ReviewSendPage";
export default function ReviewWizard({
settings,
auth,
campaignId
}: {
settings: ApiSettings;
auth: AuthInfo;
campaignId: string;
}) {
return (
<ReviewSendPage
settings={settings}
auth={auth}
campaignId={campaignId}
initialStageId="workflow-build-review"
/>
);
}
@@ -1,21 +1,21 @@
import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
export default function SendWizard() {
return (
<div className="content-pad workspace-data-page">
<div className="page-heading workspace-heading">
<h1>i18n:govoplan-campaign.send_wizard.3c137422</h1>
</div>
<div className="dashboard-grid">
<Card title="i18n:govoplan-campaign.test_send.03dfff38">
<p className="muted">i18n:govoplan-campaign.send_one_generated_message_to_a_test_address.bc0a4e47</p>
<Button>i18n:govoplan-campaign.open_test_send_dialog.661db713</Button>
</Card>
<Card title="i18n:govoplan-campaign.queue_estimate.5480288a">
<p className="muted">i18n:govoplan-campaign.estimated_duration_will_be_based_on_ready_jobs_a.15b63283</p>
<Button variant="primary">i18n:govoplan-campaign.queue_dry_run.800e1606</Button>
</Card>
</div>
</div>);
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import ReviewSendPage from "../ReviewSendPage";
export default function SendWizard({
settings,
auth,
campaignId
}: {
settings: ApiSettings;
auth: AuthInfo;
campaignId: string;
}) {
return (
<ReviewSendPage
settings={settings}
auth={auth}
campaignId={campaignId}
initialStageId="workflow-send"
/>
);
}
@@ -0,0 +1,55 @@
import { Link } from "react-router";
import {
PageTitle,
WizardDirectory,
usePlatformUiCapabilities,
wizardEntriesForContext,
type ApiSettings,
type AuthInfo,
type WizardDirectoriesUiCapability
} from "@govoplan/core-webui";
import { campaignWizardDirectories } from "./directory";
export default function WizardDirectoryPage({
settings,
auth,
campaignId
}: {
settings: ApiSettings;
auth: AuthInfo;
campaignId: string;
}) {
const contributed = usePlatformUiCapabilities<WizardDirectoriesUiCapability>(
"wizard.directories"
);
const capabilities = contributed.some((capability) =>
capability.directories.some(
(directory) => directory.id === "campaigns.resource"
)
)
? contributed
: [...contributed, campaignWizardDirectories];
const entries = wizardEntriesForContext(capabilities, {
settings,
auth,
contextId: "campaign.resource",
resourceId: campaignId
});
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<PageTitle>i18n:govoplan-campaign.guided_workflows</PageTitle>
<Link className="btn btn-secondary" to="../">
i18n:govoplan-campaign.back_to_overview.ec986cba
</Link>
</div>
<WizardDirectory
entries={entries}
auth={auth}
emptyText="i18n:govoplan-campaign.no_guided_workflows"
/>
</div>
);
}
@@ -0,0 +1,57 @@
import type {
WizardDirectoriesUiCapability,
WizardDirectoryEntry
} from "@govoplan/core-webui";
const campaignRead = ["campaigns:campaign:read"];
export function campaignWizardEntries(
campaignId: string
): WizardDirectoryEntry[] {
const basePath = `/campaigns/${encodeURIComponent(campaignId)}/wizard`;
return [
{
id: "configure",
moduleId: "campaigns",
label: "i18n:govoplan-campaign.create_campaign.59812bbc",
description: "i18n:govoplan-campaign.name_and_scenario.f2bc5241",
to: `${basePath}/create`,
actionLabel: "i18n:govoplan-campaign.open.cf9b7706",
anyOf: campaignRead,
order: 10
},
{
id: "review",
moduleId: "campaigns",
label: "i18n:govoplan-campaign.review_wizard.3d8bf0aa",
description: "i18n:govoplan-campaign.lock_and_validate_the_campaign_then_inspect_bloc.e22b9266",
to: `${basePath}/review`,
actionLabel: "i18n:govoplan-campaign.open.cf9b7706",
anyOf: campaignRead,
order: 20
},
{
id: "send",
moduleId: "campaigns",
label: "i18n:govoplan-campaign.send_wizard.3c137422",
description: "i18n:govoplan-campaign.review_the_final_execution_summary_optionally_ru.6b32b00a",
to: `${basePath}/send`,
actionLabel: "i18n:govoplan-campaign.open.cf9b7706",
anyOf: campaignRead,
order: 30
}
];
}
export const campaignWizardDirectories: WizardDirectoriesUiCapability = {
directories: [
{
id: "campaigns.resource",
moduleId: "campaigns",
contextId: "campaign.resource",
order: 20,
entries: ({ resourceId }) =>
resourceId ? campaignWizardEntries(resourceId) : []
}
]
};