From 3f3545f080858eccec56f7ecf441417e1d0ab185 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 20:39:06 +0200 Subject: [PATCH] feat(campaign): expose governed archive action --- .../backend/documentation.py | 3 +- webui/package.json | 3 +- webui/src/api/campaigns.ts | 9 ++++ .../campaigns/CampaignOverviewPage.tsx | 47 +++++++++++++++++-- .../features/campaigns/CampaignWorkspace.tsx | 2 +- webui/src/i18n/generatedTranslations.ts | 8 ++++ .../campaign-lifecycle-ui-structure.test.mjs | 15 ++++++ 7 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 webui/tests/campaign-lifecycle-ui-structure.test.mjs diff --git a/src/govoplan_campaign/backend/documentation.py b/src/govoplan_campaign/backend/documentation.py index dec4ce2..f56376f 100644 --- a/src/govoplan_campaign/backend/documentation.py +++ b/src/govoplan_campaign/backend/documentation.py @@ -569,14 +569,13 @@ CAMPAIGN_USER_DOCUMENTATION = ( steps=( "Open Report and resolve every active or outcome-unknown delivery state.", "Confirm that the campaign should leave active work while its evidence remains retained.", - "Invoke the authorized Archive action from a supporting client.", + "Open the campaign overview, choose Archive campaign, and confirm the retained-evidence consequence.", "Reopen or query the campaign and confirm its state is Archived.", ), outcome="An archived campaign whose versions, reports, and audit evidence remain preserved.", verification="The campaign state is Archived and its report remains available. Ask an authorized audit reader to verify the platform audit event.", related_topic_ids=("campaigns.workflow.view-delivery-report", "campaigns.workflow.delete-untouched-draft"), limitations=( - "The current Campaign Web UI does not yet expose the archive action; use an authorized supporting client or API.", "The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.", ), ), diff --git a/webui/package.json b/webui/package.json index 3e31776..f18981e 100644 --- a/webui/package.json +++ b/webui/package.json @@ -34,7 +34,8 @@ "test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs", "test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs", "test:wizards": "node tests/wizard-directory-ui-structure.test.mjs", - "test:accessibility-contract": "node tests/accessibility-contract.test.mjs" + "test:accessibility-contract": "node tests/accessibility-contract.test.mjs", + "test:campaign-lifecycle": "node tests/campaign-lifecycle-ui-structure.test.mjs" }, "devDependencies": { "typescript": "^5.7.2" diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 9186647..f5e4743 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -1002,6 +1002,15 @@ payload: CampaignUpdatePayload) }); } +export async function archiveCampaign( +settings: ApiSettings, +campaignId: string) +: Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/archive`, { + method: "POST" + }); +} + export async function createNewCampaign( settings: ApiSettings, overrides: CampaignCreateMinimalPayload = {}) diff --git a/webui/src/features/campaigns/CampaignOverviewPage.tsx b/webui/src/features/campaigns/CampaignOverviewPage.tsx index cea53d1..1d00bfd 100644 --- a/webui/src/features/campaigns/CampaignOverviewPage.tsx +++ b/webui/src/features/campaigns/CampaignOverviewPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; -import { ExternalLink, LockKeyhole, LockOpen } from "lucide-react"; +import { Archive, ExternalLink, LockKeyhole, LockOpen } from "lucide-react"; import { Link } from "react-router"; -import type { ApiSettings } from "../../types"; +import type { ApiSettings, AuthInfo } from "../../types"; import { Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; @@ -10,9 +10,10 @@ import { LoadingFrame } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui"; -import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui"; +import { DismissibleAlert, TableActionGroup, hasScope, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { + archiveCampaign, lockCampaignVersionPermanently, lockCampaignVersionTemporarily, unlockCampaignVersionUserLock, @@ -39,7 +40,7 @@ const campaignModeOptions = ["draft", "test", "send"]; type LockAction = "temporary" | "unlock" | "permanent"; type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null; -export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { +export default function CampaignOverviewPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) { const navigate = useGuardedNavigate(); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); const campaign = data.campaign; @@ -49,8 +50,11 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings const [savingIdentity, setSavingIdentity] = useState(false); const [pendingLockAction, setPendingLockAction] = useState(null); const [lockBusy, setLockBusy] = useState(false); + const [archiveDialogOpen, setArchiveDialogOpen] = useState(false); + const [archiving, setArchiving] = useState(false); const [message, setMessage] = useState(""); const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]); + const canArchive = Boolean(campaign) && campaign?.status !== "archived" && hasScope(auth, "campaigns:campaign:archive"); useUnsavedDraftGuard({ dirty: identityDirty, @@ -145,6 +149,23 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings await reload({ force: true }); } + async function applyArchive() { + if (!campaign || archiving) return; + setArchiving(true); + setError(""); + setMessage(""); + try { + await archiveCampaign(settings, campaign.id); + setArchiveDialogOpen(false); + setMessage("i18n:govoplan-campaign.campaign_archived.3f0ca2b7"); + await reload({ force: true }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setArchiving(false); + } + } + return (
@@ -153,6 +174,14 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings

i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113

+ {canArchive && }
@@ -226,6 +255,16 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings + setArchiveDialogOpen(false)} + onConfirm={() => void applyArchive()} /> +
- } /> + } /> } /> } /> } /> diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index bf7cad0..a30eba8 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -251,9 +251,12 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.archive_filename.de958cbd": "archive filename", "i18n:govoplan-campaign.archive_filenames_must_be_present_and_unique.aa2fb639": "Archive filenames must be present and unique.", "i18n:govoplan-campaign.archive_filenames_must_not_be_empty.42e91427": "Archive filenames must not be empty", + "i18n:govoplan-campaign.archive_campaign.26dcfb8a": "Archive campaign", + "i18n:govoplan-campaign.archive_campaign_confirmation.c0cc62e1": "This removes the campaign from active work while preserving versions, delivery results, and audit evidence. Active or outcome-unknown delivery must be resolved first.", "i18n:govoplan-campaign.archive_name.6310f9e1": "Archive name", "i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1": "Archive names support recipient and campaign fields. Use the pencil action to edit the filename and insert placeholders. Password fields are intentionally not offered for filenames. The campaign standard is used by attachment rows set to Campaign standard.", "i18n:govoplan-campaign.archived.eddc813f": "Archived", + "i18n:govoplan-campaign.campaign_archived.3f0ca2b7": "Campaign archived.", "i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.", "i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV", "i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report", @@ -1042,6 +1045,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.save.efc007a3": "Save", "i18n:govoplan-campaign.saved.c0ae8f6e": "Saved", "i18n:govoplan-campaign.saving.56a2285c": "Saving…", + "i18n:govoplan-campaign.save_or_discard_overview_changes_before_archiving.413ff9e0": "Save or discard overview changes before archiving.", "i18n:govoplan-campaign.scope_field_value": "{value0} field \"{value1}\"", "i18n:govoplan-campaign.scope.4651a34e": "Scope", "i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5": "Search recipient, subject or entry ID", @@ -1580,9 +1584,12 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.archive_filename.de958cbd": "archive filename", "i18n:govoplan-campaign.archive_filenames_must_be_present_and_unique.aa2fb639": "Archive filenames must be present and unique.", "i18n:govoplan-campaign.archive_filenames_must_not_be_empty.42e91427": "Archive filenames must not be empty", + "i18n:govoplan-campaign.archive_campaign.26dcfb8a": "Kampagne archivieren", + "i18n:govoplan-campaign.archive_campaign_confirmation.c0cc62e1": "Dadurch wird die Kampagne aus der aktiven Arbeit entfernt. Versionen, Versandergebnisse und Pruefnachweise bleiben erhalten. Aktive oder ungeklaerte Versandvorgaenge muessen zuerst abgeschlossen werden.", "i18n:govoplan-campaign.archive_name.6310f9e1": "Archive name", "i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1": "Archive names support recipient and campaign fields. Use the pencil action to edit the filename and insert placeholders. Password fields are intentionally not offered for filenames. The campaign standard is used by attachment rows set to Campaign standard.", "i18n:govoplan-campaign.archived.eddc813f": "Archived", + "i18n:govoplan-campaign.campaign_archived.3f0ca2b7": "Kampagne archiviert.", "i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.", "i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV", "i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report", @@ -2371,6 +2378,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.save.efc007a3": "Speichern", "i18n:govoplan-campaign.saved.c0ae8f6e": "Gespeichert", "i18n:govoplan-campaign.saving.56a2285c": "Saving…", + "i18n:govoplan-campaign.save_or_discard_overview_changes_before_archiving.413ff9e0": "Speichern oder verwerfen Sie die Aenderungen an der Uebersicht vor dem Archivieren.", "i18n:govoplan-campaign.scope_field_value": "{value0}-Feld \"{value1}\"", "i18n:govoplan-campaign.scope.4651a34e": "Geltungsbereich", "i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5": "Search recipient, subject or entry ID", diff --git a/webui/tests/campaign-lifecycle-ui-structure.test.mjs b/webui/tests/campaign-lifecycle-ui-structure.test.mjs new file mode 100644 index 0000000..b788ca6 --- /dev/null +++ b/webui/tests/campaign-lifecycle-ui-structure.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const workspace = readFileSync("src/features/campaigns/CampaignWorkspace.tsx", "utf8"); +const overview = readFileSync("src/features/campaigns/CampaignOverviewPage.tsx", "utf8"); +const api = readFileSync("src/api/campaigns.ts", "utf8"); + +assert.match(workspace, /