feat(webui): add campaign metric drill-downs

This commit is contained in:
2026-08-19 18:47:45 +02:00
parent 039ce35e78
commit 8e2f9d743d
17 changed files with 124 additions and 73 deletions
+2 -2
View File
@@ -882,7 +882,7 @@ manifest = ModuleManifest(
id="campaigns.workflow.prepare-validate-and-build",
title="Prepare, validate, and build a campaign",
summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.",
body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Recipient data can activate or deactivate every currently opposite-state row as one explicitly confirmed draft change; saving it creates the normal Campaign version evidence and invalidates stale validation, build, and review state. New campaign credentials and password-valued fields offer the shared secure generator; its candidate remains separate until Use password is confirmed. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build. When the Templates module is installed, its single Templates navigation entry owns the reusable library while campaign-specific composition remains in the campaign workspace.",
body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Recipient data can activate or deactivate every currently opposite-state row as one explicitly confirmed draft change; saving it creates the normal Campaign version evidence and invalidates stale validation, build, and review state. New campaign credentials and password-valued fields offer the shared secure generator; its candidate remains separate until Use password is confirmed. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build. Summary metrics expose a named drill-down only when an authorized source collection, filtered review table, attachment preview, or report helps the user inspect and act on the count. Privacy-suppressed aggregate reports remain non-interactive because an unsuppressed subgroup would violate their disclosure boundary. When the Templates module is installed, its single Templates navigation entry owns the reusable library while campaign-specific composition remains in the campaign workspace.",
layer="configured",
documentation_types=("user",),
audience=("campaign_manager", "campaign_author"),
@@ -956,7 +956,7 @@ manifest = ModuleManifest(
id="campaigns.workflow.complete-review",
title="Inspect built messages and complete review",
summary="Resolve critical blockers, record individual message decisions, and acknowledge non-critical review items for one exact build.",
body="Review completion remains bound to the current build token, inspected message keys, recorded issue decisions, and message evidence. Changing recipients, content, attachments, owner context, or non-secret transport identity requires validation, building, and review again.",
body="Review completion remains bound to the current build token, inspected message keys, recorded issue decisions, and message evidence. Use the explicit actions on actionable recipient, attachment, validation, and review metrics to reveal the corresponding source page, evidence preview, or filtered built-message table. Informational and privacy-suppressed measures do not become hidden click targets. Changing recipients, content, attachments, owner context, or non-secret transport identity requires validation, building, and review again.",
layer="configured",
documentation_types=("user",),
audience=("campaign_reviewer",),
@@ -246,6 +246,21 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
patchZipCollection({ ...zipConfig, archives: moveArrayItem(zipConfig.archives, index, targetIndex) });
}
function revealAttachmentSection(id: string) {
const section = document.getElementById(id);
if (!section) return;
section.querySelector<HTMLButtonElement>('.card-collapse-toggle[aria-expanded="false"]')?.click();
window.requestAnimationFrame(() => {
section.scrollIntoView({ behavior: "smooth", block: "start" });
section.focus({ preventScroll: true });
});
}
function openRecipients() {
const query = version?.id ? `?version=${encodeURIComponent(version.id)}` : "";
navigate(`/campaigns/${campaignId}/recipients${query}`);
}
return (
<PageLayout
@@ -257,8 +272,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
error={error}
actions={<PageActionBar
variant="editor"
dirty={dirty}
saving={loading}
state={loading ? "saving" : dirty ? "dirty" : "clean"}
contextActions={filesModuleInstalled ? <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button> : undefined}
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current editor blocker before saving." : undefined }}
@@ -272,13 +286,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
<MetricGrid>
<MetricCard label="i18n:govoplan-campaign.base_paths.edf35a97" value={basePaths.length} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.global_attachments.438263a1" value={`${globalSummary.direct} / ${globalSummary.rules}`} tone="info" detail="direct / rules" />
<MetricCard label="i18n:govoplan-campaign.per_recipient_patterns.53da30da" value={individualRulesCount} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.base_paths.edf35a97" value={basePaths.length} tone="neutral" drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: "Attachment sources" }), onActivate: () => revealAttachmentSection("campaign-attachment-sources") }} />
<MetricCard label="i18n:govoplan-campaign.global_attachments.438263a1" value={`${globalSummary.direct} / ${globalSummary.rules}`} tone="info" detail="direct / rules" drilldown={{ label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealAttachmentSection("campaign-global-attachments") }} />
<MetricCard label="i18n:govoplan-campaign.per_recipient_patterns.53da30da" value={individualRulesCount} tone="neutral" drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: "Recipients" }), onActivate: openRecipients }} />
<MetricCard label="i18n:govoplan-campaign.upload_support.1c54931c" value={managedFilesAvailable ? "Files" : "Manual"} tone={managedFilesAvailable ? "good" : filesModuleInstalled ? "warning" : "neutral"} />
</MetricGrid>
<Card title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
<Card id="campaign-attachment-sources" tabIndex={-1} title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
<div className="admin-table-surface attachment-sources-table-surface">
<DataGrid
id={`campaign-${campaignId}-attachment-sources`}
@@ -373,7 +387,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
}
</Card>
<Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
<Card id="campaign-global-attachments" tabIndex={-1} title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
<AttachmentRulesDataGrid
id={`campaign-${campaignId}-global-attachments`}
rules={globalRules}
@@ -21,7 +21,7 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
actions={<PageActionBar
variant="detail"
refreshable
reloadAction={<Button onClick={() => void reload({ force: true })} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>}
reloadAction={{ onReload: () => void reload({ force: true }), loading }}
/>}
>
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
@@ -159,8 +159,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: {settings:
error={error}
actions={<PageActionBar
variant="editor"
dirty={dirty}
saving={loading}
state={loading ? "saving" : dirty ? "dirty" : "clean"}
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: saveFields, disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current field-definition blocker before saving." : undefined }}
/>}
@@ -26,7 +26,7 @@ export default function CampaignJsonView({ settings, campaignId }: {settings: Ap
actions={<PageActionBar
variant="detail"
refreshable
reloadAction={<Button onClick={() => void reload({ force: true })} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>}
reloadAction={{ onReload: () => void reload({ force: true }), loading }}
primaryActions={<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>i18n:govoplan-campaign.download_json.d296a30a</Button>}
/>}
notices={
@@ -153,7 +153,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
actions={<PageActionBar
variant="collection"
refreshable
reloadAction={<Button onClick={() => void load(null)} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>}
reloadAction={{ onReload: () => void load(null), loading }}
createAction={<Button variant="primary" onClick={create} disabled={creating}>
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
</Button>}
@@ -114,6 +114,19 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy");
const canSchedule = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:schedule") && hasScope(auth, "campaigns:campaign:copy");
function openSection(section: string, fragment = "") {
const params = new URLSearchParams();
if (data.currentVersion?.id) params.set("version", data.currentVersion.id);
const query = params.toString();
navigate(`/campaigns/${campaignId}/${section}${query ? `?${query}` : ""}${fragment}`);
}
function openQueue() {
const params = new URLSearchParams({ campaign: campaignId });
if (data.currentVersion?.id) params.set("version", data.currentVersion.id);
navigate(`/campaigns/queue?${params.toString()}`);
}
useUnsavedDraftGuard({
dirty: identityDirty,
onSave: saveIdentity,
@@ -343,8 +356,7 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
success={message}
actions={<PageActionBar
variant="editor"
dirty={identityDirty}
saving={savingIdentity}
state={savingIdentity ? "saving" : identityDirty ? "dirty" : "clean"}
primaryActions={<>
{canCopy && data.currentVersion && <Button
onClick={() => void prepareLifecycleAction("copy_campaign", data.currentVersion ?? undefined)}
@@ -386,10 +398,10 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaign_overview.ffa1adf0">
<MetricGrid>
<MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" />
<MetricCard label="i18n:govoplan-campaign.needs_attention.a126722e" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="i18n:govoplan-campaign.review_first.741ac781" />
<MetricCard label="i18n:govoplan-campaign.sent.35f49dcf" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="i18n:govoplan-campaign.smtp_success.3591a856" />
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" />
<MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" drilldown={{ label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => openSection("review", "#workflow-send") }} />
<MetricCard label="i18n:govoplan-campaign.needs_attention.a126722e" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="i18n:govoplan-campaign.review_first.741ac781" drilldown={{ label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => openSection("review", "#workflow-build-review") }} />
<MetricCard label="i18n:govoplan-campaign.sent.35f49dcf" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="i18n:govoplan-campaign.smtp_success.3591a856" drilldown={{ label: "i18n:govoplan-campaign.open_report.44f83158", onActivate: () => openSection("report") }} />
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" drilldown={{ label: "i18n:govoplan-campaign.open_report.44f83158", onActivate: () => openSection("report") }} />
</MetricGrid>
<Card
@@ -460,15 +472,15 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
</div>}>
<MetricGrid spacing="inset">
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" />
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: "Fields" }), onActivate: () => openSection("fields") }} />
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: "Recipients" }), onActivate: () => openSection("recipients") }} />
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} drilldown={{ label: "i18n:govoplan-campaign.open_template_editor.1739b545", onActivate: () => openSection("template") }} />
</MetricGrid>
<MetricGrid spacing="inset">
<MetricCard label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<MetricCard label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<MetricCard label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
<MetricCard label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} drilldown={{ label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => openSection("review", "#workflow-validate-review") }} />
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} drilldown={{ label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => openSection("review", "#workflow-validate-review") }} />
<MetricCard label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} drilldown={{ label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => openSection("review", "#workflow-build-review") }} />
<MetricCard label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: campaign?.name ?? campaignId }), onActivate: openQueue }} />
</MetricGrid>
<div className="admin-table-surface version-history-table-surface">
<DataGrid
@@ -457,7 +457,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
actions={<PageActionBar
variant="detail"
refreshable
reloadAction={<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.reload.cce71553</Button>}
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>
@@ -187,8 +187,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
error={error}
actions={<PageActionBar
variant="editor"
dirty={dirty}
saving={loading}
state={loading ? "saving" : dirty ? "dirty" : "clean"}
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
/>}
@@ -339,13 +339,12 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
<PageActionBar
variant="detail"
refreshable
reloadAction={<Button onClick={() => void reload({ force: true })} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>}
reloadAction={{ onReload: () => void reload({ force: true }), loading }}
/>
) : (
<PageActionBar
variant="editor"
dirty={dirty}
saving={loading}
state={loading ? "saving" : dirty ? "dirty" : "clean"}
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => void saveDraft("manual"), disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current mail-settings blocker before saving." : undefined }}
/>
+48 -26
View File
@@ -1287,7 +1287,27 @@ export default function ReviewSendPage({
}
function scrollToStage(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
revealReviewElement(id);
}
function revealReviewElement(id: string) {
const target = document.getElementById(id);
if (!target) return;
const stage = target.classList.contains("review-flow-stage")
? target
: target.closest<HTMLElement>(".review-flow-stage");
stage?.querySelector<HTMLButtonElement>('.card-collapse-toggle[aria-expanded="false"]')?.click();
window.requestAnimationFrame(() => {
target.scrollIntoView({ behavior: "smooth", block: "start" });
target.focus({ preventScroll: true });
});
}
function showBuiltDetails(filters: Record<string, string> = {}, includeAll = false) {
setShowAllReviewJobs(includeAll);
setReviewQuery({ sort: null, filters });
setReviewPage(1);
window.requestAnimationFrame(() => revealReviewElement("campaign-built-message-details"));
}
const matchedAttachments = numberFrom(attachmentSummary, ["total_matched_files"]);
@@ -1375,7 +1395,7 @@ export default function ReviewSendPage({
actions={<PageActionBar
variant="workspace"
refreshable
reloadAction={<Button onClick={() => void reload({ force: true })} disabled={loading || Boolean(busy)}>i18n:govoplan-campaign.reload.cce71553</Button>}
reloadAction={{ onReload: () => void reload({ force: true }), loading: loading || Boolean(busy) }}
primaryActions={<Button onClick={() => void refreshQueueStatus(false)} disabled={!version || queueStatusLoading || Boolean(busy)}>
{queueStatusLoading ? "i18n:govoplan-campaign.refreshing_status.d8965739" : "i18n:govoplan-campaign.refresh_status.ade15a52"}
</Button>}
@@ -1400,9 +1420,9 @@ export default function ReviewSendPage({
<WorkflowStage stage={stages[0]} nextState={stages[1].state} nextConnectorState={stageConnectorState(stages[1])}>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.status.bae7d5be" value={validationPresent ? validationOk ? "i18n:govoplan-campaign.passed.271d60f4" : "i18n:govoplan-campaign.needs_attention.a126722e" : "i18n:govoplan-campaign.not_run.9e019cd5"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.blocking.d785c0d4" value={validationPresent ? validationErrors : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.blocking.d785c0d4" value={validationPresent ? validationErrors : "—"} drilldown={validationErrors > 0 && visibleValidationIssues.length > 0 ? { label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => revealReviewElement("campaign-validation-details") } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} drilldown={validationWarnings > 0 && visibleValidationIssues.length > 0 ? { label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => revealReviewElement("campaign-validation-details") } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} drilldown={hasBuild && Number(cards?.needs_attention ?? 0) > 0 ? { label: "i18n:govoplan-campaign.review_candidates.438b8b57", onActivate: () => showBuiltDetails() } : undefined} />
</MetricGrid>
<ValidationWorkflowGuidance
errors={validationErrors}
@@ -1412,17 +1432,19 @@ export default function ReviewSendPage({
{validationPresent && validationErrors === 0 &&
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.no_blocking_validation_exceptions_remain.73186d0d</p>
}
<AttachmentLinkingPreview
preview={attachmentPreview}
loading={attachmentPreviewLoading}
error={attachmentPreviewError}
linking={attachmentLinking}
disabled={!version || readOnlyVersion || readyForDelivery || Boolean(busy)}
onRefresh={() => void reloadAttachmentPreview(false)}
onLink={() => void linkMatchedAttachmentFiles()} />
<div id="campaign-attachment-preview" tabIndex={-1}>
<AttachmentLinkingPreview
preview={attachmentPreview}
loading={attachmentPreviewLoading}
error={attachmentPreviewError}
linking={attachmentLinking}
disabled={!version || readOnlyVersion || readyForDelivery || Boolean(busy)}
onRefresh={() => void reloadAttachmentPreview(false)}
onLink={() => void linkMatchedAttachmentFiles()} />
</div>
{visibleValidationIssues.length > 0 &&
<div className="review-flow-data-section">
<div id="campaign-validation-details" className="review-flow-data-section" tabIndex={-1}>
<h3>i18n:govoplan-campaign.validation_details.aa503267</h3>
<DescriptionList variant="inline">
{visibleValidationIssues.map((issue, index) =>
@@ -1457,13 +1479,13 @@ export default function ReviewSendPage({
<WorkflowStage stage={stages[1]} nextState={stages[2].state} nextConnectorState={stageConnectorState(stages[2])}>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? buildReviewProgress.reviewed : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.remaining.cc632b5e" value={hasBuild ? buildReviewProgress.remaining : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} drilldown={hasBuild && builtCount > 0 ? { label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => showBuiltDetails({}, true) } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} drilldown={buildBlocked > 0 ? { label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => showBuiltDetails({ validation: 'list:["blocked"]' }) } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} drilldown={buildNeedsReview > 0 ? { label: "i18n:govoplan-campaign.review_candidates.438b8b57", onActivate: () => showBuiltDetails({ validation: 'list:["warning","needs_review"]' }) } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? buildReviewProgress.reviewed : "—"} drilldown={buildReviewProgress.reviewed > 0 ? { label: "i18n:govoplan-campaign.reviewed.31ef8593", onActivate: () => showBuiltDetails({ reviewed: 'list:["yes"]' }, true) } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.remaining.cc632b5e" value={hasBuild ? buildReviewProgress.remaining : "—"} drilldown={buildReviewProgress.remaining > 0 ? { label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => showBuiltDetails({ reviewed: 'list:["no"]' }) } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} drilldown={reviewJobs.total > 0 ? { label: "i18n:govoplan-campaign.review_candidates.438b8b57", onActivate: () => showBuiltDetails() } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} drilldown={missingAttachments + ambiguousAttachments > 0 ? { label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealReviewElement("campaign-attachment-preview") } : undefined} />
</MetricGrid>
{hasBuild && <BuiltMessageReviewProgress progress={buildReviewProgress} />}
{hasBuild && <BuiltMessageWorkflowGuidance progress={buildReviewProgress} buildWarnings={buildWarnings} />}
@@ -1518,7 +1540,7 @@ export default function ReviewSendPage({
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.all_built_messages_are_ready_no_manual_review_ac.c5549791</p>
}
{hasBuild &&
<div className="review-flow-data-section">
<div id="campaign-built-message-details" className="review-flow-data-section" tabIndex={-1}>
<div className="page-heading split">
<div className="button-row compact-actions">
<Button onClick={() => {setShowAllReviewJobs((value) => !value);setReviewPage(1);setJobsLoadedKey("");}} disabled={busy === "inspect"}>
@@ -1617,10 +1639,10 @@ export default function ReviewSendPage({
<WorkflowStage stage={stages[3]} nextState={stages[4].state} nextConnectorState={stageConnectorState(stages[4])}>
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.recipients.78cbf8eb" value={jobsTotal || "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.messages_to_send.f7763bf9" value={builtCount || jobsTotal || "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.matched_attachments.ead1eeb1" value={matchedAttachments || "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.missing_ambiguous.fffdd3f5" value={`${missingAttachments} / ${ambiguousAttachments}`} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.recipients.78cbf8eb" value={jobsTotal || "—"} drilldown={hasBuild && jobsTotal > 0 ? { label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => showBuiltDetails({}, true) } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.messages_to_send.f7763bf9" value={builtCount || jobsTotal || "—"} drilldown={hasBuild && builtCount > 0 ? { label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => showBuiltDetails({}, true) } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.matched_attachments.ead1eeb1" value={matchedAttachments || "—"} drilldown={matchedAttachments > 0 ? { label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealReviewElement("campaign-attachment-preview") } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.missing_ambiguous.fffdd3f5" value={`${missingAttachments} / ${ambiguousAttachments}`} drilldown={missingAttachments + ambiguousAttachments > 0 ? { label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealReviewElement("campaign-attachment-preview") } : undefined} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.rate_limit.d08e55f5" value={messagesPerMinute > 0 ? i18nMessage("i18n:govoplan-campaign.value_min.c9d89eae", { value0: messagesPerMinute }) : "i18n:govoplan-campaign.not_set.93039e60"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.minimum_duration.91a71a6d" value={estimatedMinutes ? i18nMessage("i18n:govoplan-campaign.about_value_min.7c2e77fc", { value0: estimatedMinutes }) : "—"} />
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_append.8c0d9e96" value={Boolean(imapAppend.enabled) ? "i18n:govoplan-campaign.enabled.df174a3f" : "i18n:govoplan-campaign.disabled.f4f4473d"} />
@@ -421,8 +421,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
success={contentLibraryNotice}
actions={<PageActionBar
variant="editor"
dirty={dirty}
saving={loading}
state={loading ? "saving" : dirty ? "dirty" : "clean"}
contextActions={<Button onClick={() => window.location.assign("/templates")}>i18n:govoplan-campaign.manage_templates.23688071</Button>}
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
@@ -24,6 +24,7 @@ type CampaignDraftPageScaffoldProps = {
children: ReactNode;
};
// semantic-editor-guard: delegated to each caller's useCampaignDraftEditor lifecycle.
export default function CampaignDraftPageScaffold({
settings,
campaignId,
@@ -53,8 +54,7 @@ export default function CampaignDraftPageScaffold({
error={error || ""}
actions={<PageActionBar
variant="editor"
dirty={dirty}
saving={loading}
state={loading ? "saving" : dirty ? "dirty" : "clean"}
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: onReload }}
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: onSave, disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
/>}
@@ -771,7 +771,7 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
actions={<PageActionBar
variant="collection"
refreshable
reloadAction={<Button onClick={() => void refreshAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.refresh.56e3badc</Button>}
reloadAction={{ onReload: () => void refreshAll(), loading: loading || jobsLoading }}
/>}
notices={jobsError ? <DismissibleAlert tone="danger" resetKey={jobsError} floating>{jobsError}</DismissibleAlert> : undefined}
>
@@ -1,9 +1,8 @@
import { DescriptionList } from "@govoplan/core-webui";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Eye, RefreshCw } from "lucide-react";
import { Eye } from "lucide-react";
import { useSearchParams } from "react-router";
import { ContentGrid,
Button,
Card,
DataGrid,
LoadingFrame,
@@ -177,12 +176,10 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
actions={<PageActionBar
variant="overview"
refreshable
reloadAction={<Button
onClick={() => void Promise.all([loadCampaigns(), selectedFromUrl ? loadReport(selectedFromUrl) : Promise.resolve()])}
disabled={listLoading || reportLoading}
>
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-campaign.refresh.56e3badc
</Button>}
reloadAction={{
onReload: () => void Promise.all([loadCampaigns(), selectedFromUrl ? loadReport(selectedFromUrl) : Promise.resolve()]),
loading: listLoading || reportLoading
}}
/>}
>
@@ -11,6 +11,8 @@ const reviewPresentation = readFileSync(
"utf8"
);
const operatorPage = readFileSync("src/features/operator/OperatorQueuePage.tsx", "utf8");
const overviewPage = readFileSync("src/features/campaigns/CampaignOverviewPage.tsx", "utf8");
const attachmentsPage = readFileSync("src/features/campaigns/AttachmentsDataPage.tsx", "utf8");
const deliveryMode = readFileSync("src/features/campaigns/utils/deliveryMode.ts", "utf8");
const translationCatalog = readFileSync("src/i18n/generatedTranslations.ts", "utf8");
@@ -49,6 +51,13 @@ const outcomeCardsStart = page.indexOf("Aggregate outcome cards are deliberately
const outcomeCardsEnd = page.indexOf('<Card title="i18n:govoplan-campaign.population_and_exclusions', outcomeCardsStart);
assert(outcomeCardsStart >= 0 && outcomeCardsEnd > outcomeCardsStart, "the aggregate privacy boundary is documented beside the outcome cards");
assert(!page.slice(outcomeCardsStart, outcomeCardsEnd).includes("onClick"), "aggregate outcome cards cannot create unsuppressed filtered subgroups");
assert(!page.slice(outcomeCardsStart, outcomeCardsEnd).includes("drilldown="), "privacy-suppressed aggregates remain informational metrics");
assert(overviewPage.includes("drilldown={{"), "campaign overview metrics expose explicit destinations");
assert(overviewPage.includes("openSection(\"recipients\")"), "the recipient count opens its owning collection");
assert(attachmentsPage.includes('revealAttachmentSection("campaign-global-attachments")'), "attachment counts reveal their underlying editor section");
assert(attachmentsPage.includes("openRecipients"), "per-recipient attachment counts open recipient details");
assert(reviewPage.includes("showBuiltDetails({ validation:"), "review counts apply a relevant table filter");
assert(reviewPage.includes('revealReviewElement("campaign-attachment-preview")'), "attachment-result counts reveal the evidence preview");
assert(!page.includes("{report.population.denominator_definition}"), "the known denominator contract uses a localized UI explanation");
assert(!page.includes("{report.privacy.rule}"), "the known privacy contract uses a localized UI explanation");
assert(page.includes("all_persisted_recipient_delivery_jobs_for_the_se.208e90cc"), "the denominator explanation is bilingual");
@@ -17,6 +17,7 @@ assert.match(guidance, /documentation=\{\{ topicId: documentationTopicId \}\}/);
assert.match(guidance, /campaigns\.workflow\.prepare-validate-and-build/);
assert.match(guidance, /campaigns\.workflow\.complete-review/);
assert.match(page, /calculateBuildReviewProgress/);
assert.match(page, /<WorkflowFact label="i18n:govoplan-campaign\.remaining\.cc632b5e"/);
assert.match(page, /<MetricCard[^>]+label="i18n:govoplan-campaign\.remaining\.cc632b5e"/);
assert.match(page, /showBuiltDetails\(\{ reviewed: 'list:\["no"\]' \}\)/);
assert.doesNotMatch(page, /blocked_or_failed_message_s_must_be_resolved_bef/);
assert.doesNotMatch(page, /resolve_the_blocking_entries_then_validate_again/);