From 91373007801bc2ac14c0673b22517d369d54c487 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 4 Aug 2026 10:51:44 +0200 Subject: [PATCH] Add bulk recipient activation controls --- docs/CAMPAIGN_HANDBOOK.md | 9 +++ src/govoplan_campaign/backend/manifest.py | 4 +- .../features/campaigns/RecipientDataPage.tsx | 57 ++++++++++++++++++- webui/src/i18n/generatedTranslations.ts | 16 ++++++ .../campaign-lifecycle-ui-structure.test.mjs | 6 ++ 5 files changed, 89 insertions(+), 3 deletions(-) diff --git a/docs/CAMPAIGN_HANDBOOK.md b/docs/CAMPAIGN_HANDBOOK.md index fa72500..1051415 100644 --- a/docs/CAMPAIGN_HANDBOOK.md +++ b/docs/CAMPAIGN_HANDBOOK.md @@ -454,6 +454,15 @@ Breaking payload or ownership changes require an interface-version bump and a release-composition alignment gate. Optional absence must be tested physically, not only hidden in navigation. +### Bulk recipient activation + +Recipient data can activate all currently inactive rows or deactivate all +currently active rows. The action displays the exact affected count and +requires confirmation. It changes only the local Campaign draft until the +operator saves. Saving follows the ordinary versioned Campaign update path, so +the resulting recipient state is retained in version and protocol evidence and +any stale validation, build, or review evidence is invalidated. + ### Reusable Distribution Lists When Distribution Lists is available, Recipient data offers a separate import diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 1321274..150cf3a 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -844,7 +844,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. 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. 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.", layer="configured", documentation_types=("user",), audience=("campaign_manager", "campaign_author"), @@ -900,7 +900,7 @@ manifest = ModuleManifest( ], "steps": [ "Set campaign-wide fields and purpose, then define the recipient fields and templates.", - "Import or select recipients and inspect provenance, exclusions, and review-required rows.", + "Import or select recipients, optionally confirm a counted activate-all or deactivate-all draft action, and inspect provenance, exclusions, and review-required rows.", "Select managed attachment versions and an authorized Mail profile when those capabilities are used.", "Validate the relevant sections and resolve every blocker without hiding warnings.", "Build the selected version and inspect representative and exceptional rendered messages.", diff --git a/webui/src/features/campaigns/RecipientDataPage.tsx b/webui/src/features/campaigns/RecipientDataPage.tsx index 4185218..ae68661 100644 --- a/webui/src/features/campaigns/RecipientDataPage.tsx +++ b/webui/src/features/campaigns/RecipientDataPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { CircleCheck, CircleX } from "lucide-react"; import type { ApiSettings } from "../../types"; import { getCampaignPostboxCatalog, @@ -13,6 +14,7 @@ import { "../../api/campaigns"; import { Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; +import { ConfirmDialog } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui"; import { usePlatformModuleInstalled } from "@govoplan/core-webui"; import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold"; @@ -39,7 +41,7 @@ import { createAddressSourceImportProvenance } from "./utils/addressSourceImport"; import { addressesFromValue, type MailboxAddress } from "@govoplan/core-webui"; -import { insertAfter, moveArrayItem, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui"; +import { i18nMessage, insertAfter, moveArrayItem, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui"; import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog"; import DistributionListImportDialog from "./recipients/DistributionListImportDialog"; import { @@ -69,6 +71,12 @@ import { } from "./recipients/RecipientAddressEditor"; import { RecipientImportDialog } from "./recipients/RecipientImportDialog"; import { recipientProfileColumns } from "./recipients/recipientProfileColumns"; + +type RecipientBulkActivation = { + active: boolean; + count: number; +}; + export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { const { translateText } = usePlatformLanguage(); const filesModuleInstalled = usePlatformModuleInstalled("files"); @@ -91,6 +99,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A const [recipientProfilesQuery, setRecipientProfilesQuery] = useState({ sort: null, filters: {} }); const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState(null); const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState(null); + const [bulkActivation, setBulkActivation] = useState(null); const [postboxCatalog, setPostboxCatalog] = useState({ available: false, postboxes: [], @@ -346,6 +355,19 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A }); } + function requestBulkActivation(active: boolean) { + if (locked) return; + const count = inlineEntries.filter((entry) => (entry.active !== false) !== active).length; + if (count === 0) return; + setBulkActivation({ active, count }); + } + + function confirmBulkActivation() { + if (!bulkActivation || locked) return; + replaceInlineEntries(inlineEntries.map((entry) => ({ ...entry, active: bulkActivation.active }))); + setBulkActivation(null); + } + function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) { if (locked || !draft) return; setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode, provenance })); @@ -492,6 +514,22 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A } + + }> {inlineEntries.length === 0 && Boolean(source.type) && @@ -626,6 +664,23 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A onClose={() => setRecipientAddressEditorIndex(null)} /> } + setBulkActivation(null)} /> {postboxTargetEditorIndex !== null && inlineEntries[postboxTargetEditorIndex] &&