From 8e2f9d743d3927058b14af1119fdb51d929c7aef Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 19 Aug 2026 18:47:45 +0200 Subject: [PATCH] feat(webui): add campaign metric drill-downs --- src/govoplan_campaign/backend/manifest.py | 4 +- .../campaigns/AttachmentsDataPage.tsx | 28 +++++-- .../features/campaigns/CampaignAuditPage.tsx | 2 +- .../features/campaigns/CampaignFieldsPage.tsx | 3 +- .../features/campaigns/CampaignJsonView.tsx | 2 +- .../features/campaigns/CampaignListPage.tsx | 2 +- .../campaigns/CampaignOverviewPage.tsx | 38 ++++++---- .../features/campaigns/CampaignReportPage.tsx | 2 +- .../features/campaigns/GlobalSettingsPage.tsx | 3 +- .../features/campaigns/MailSettingsPage.tsx | 5 +- .../src/features/campaigns/ReviewSendPage.tsx | 74 ++++++++++++------- .../features/campaigns/TemplateDataPage.tsx | 3 +- .../components/CampaignDraftPageScaffold.tsx | 4 +- .../features/operator/OperatorQueuePage.tsx | 2 +- .../features/reports/AggregateReportsPage.tsx | 13 ++-- .../aggregate-report-ui-structure.test.mjs | 9 +++ ...ew-workflow-guidance-ui-structure.test.mjs | 3 +- 17 files changed, 124 insertions(+), 73 deletions(-) diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 6a23ad5..0c1062d 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -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",), diff --git a/webui/src/features/campaigns/AttachmentsDataPage.tsx b/webui/src/features/campaigns/AttachmentsDataPage.tsx index 74a5bd5..3379179 100644 --- a/webui/src/features/campaigns/AttachmentsDataPage.tsx +++ b/webui/src/features/campaigns/AttachmentsDataPage.tsx @@ -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('.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 ( navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7 : 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: <> - - - + revealAttachmentSection("campaign-attachment-sources") }} /> + revealAttachmentSection("campaign-global-attachments") }} /> + - +
- + void reload({ force: true })} disabled={loading}>i18n:govoplan-campaign.reload.cce71553} + reloadAction={{ onReload: () => void reload({ force: true }), loading }} />} > diff --git a/webui/src/features/campaigns/CampaignFieldsPage.tsx b/webui/src/features/campaigns/CampaignFieldsPage.tsx index bb8a07a..d6650ff 100644 --- a/webui/src/features/campaigns/CampaignFieldsPage.tsx +++ b/webui/src/features/campaigns/CampaignFieldsPage.tsx @@ -159,8 +159,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: {settings: error={error} actions={ 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 }} />} diff --git a/webui/src/features/campaigns/CampaignJsonView.tsx b/webui/src/features/campaigns/CampaignJsonView.tsx index c889bf4..a66bed1 100644 --- a/webui/src/features/campaigns/CampaignJsonView.tsx +++ b/webui/src/features/campaigns/CampaignJsonView.tsx @@ -26,7 +26,7 @@ export default function CampaignJsonView({ settings, campaignId }: {settings: Ap actions={ void reload({ force: true })} disabled={loading}>i18n:govoplan-campaign.reload.cce71553} + reloadAction={{ onReload: () => void reload({ force: true }), loading }} primaryActions={} />} notices={ diff --git a/webui/src/features/campaigns/CampaignListPage.tsx b/webui/src/features/campaigns/CampaignListPage.tsx index fc861f3..379ac8f 100644 --- a/webui/src/features/campaigns/CampaignListPage.tsx +++ b/webui/src/features/campaigns/CampaignListPage.tsx @@ -153,7 +153,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;}) actions={ void load(null)} disabled={loading}>i18n:govoplan-campaign.reload.cce71553} + reloadAction={{ onReload: () => void load(null), loading }} createAction={} diff --git a/webui/src/features/campaigns/CampaignOverviewPage.tsx b/webui/src/features/campaigns/CampaignOverviewPage.tsx index 25ca1a9..3485aac 100644 --- a/webui/src/features/campaigns/CampaignOverviewPage.tsx +++ b/webui/src/features/campaigns/CampaignOverviewPage.tsx @@ -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={ {canCopy && data.currentVersion && } + reloadAction={{ onReload: () => void reloadAll(), loading: loading || jobsLoading }} primaryActions={<> diff --git a/webui/src/features/campaigns/GlobalSettingsPage.tsx b/webui/src/features/campaigns/GlobalSettingsPage.tsx index 9a0cef9..5a552be 100644 --- a/webui/src/features/campaigns/GlobalSettingsPage.tsx +++ b/webui/src/features/campaigns/GlobalSettingsPage.tsx @@ -187,8 +187,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view = error={error} actions={ 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 }} />} diff --git a/webui/src/features/campaigns/MailSettingsPage.tsx b/webui/src/features/campaigns/MailSettingsPage.tsx index b8d60b0..66c657b 100644 --- a/webui/src/features/campaigns/MailSettingsPage.tsx +++ b/webui/src/features/campaigns/MailSettingsPage.tsx @@ -339,13 +339,12 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting void reload({ force: true })} disabled={loading}>i18n:govoplan-campaign.reload.cce71553} + reloadAction={{ onReload: () => void reload({ force: true }), loading }} /> ) : ( 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 }} /> diff --git a/webui/src/features/campaigns/ReviewSendPage.tsx b/webui/src/features/campaigns/ReviewSendPage.tsx index 0330e09..c711f00 100644 --- a/webui/src/features/campaigns/ReviewSendPage.tsx +++ b/webui/src/features/campaigns/ReviewSendPage.tsx @@ -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(".review-flow-stage"); + stage?.querySelector('.card-collapse-toggle[aria-expanded="false"]')?.click(); + window.requestAnimationFrame(() => { + target.scrollIntoView({ behavior: "smooth", block: "start" }); + target.focus({ preventScroll: true }); + }); + } + + function showBuiltDetails(filters: Record = {}, 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={ void reload({ force: true })} disabled={loading || Boolean(busy)}>i18n:govoplan-campaign.reload.cce71553} + reloadAction={{ onReload: () => void reload({ force: true }), loading: loading || Boolean(busy) }} primaryActions={} @@ -1400,9 +1420,9 @@ export default function ReviewSendPage({ - - - + 0 && visibleValidationIssues.length > 0 ? { label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => revealReviewElement("campaign-validation-details") } : undefined} /> + 0 && visibleValidationIssues.length > 0 ? { label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => revealReviewElement("campaign-validation-details") } : undefined} /> + 0 ? { label: "i18n:govoplan-campaign.review_candidates.438b8b57", onActivate: () => showBuiltDetails() } : undefined} />