feat(campaign): expose governed archive action

This commit is contained in:
2026-08-03 20:39:06 +02:00
parent d9195a2d2b
commit 3f3545f080
7 changed files with 79 additions and 8 deletions
+2 -1
View File
@@ -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"
+9
View File
@@ -1002,6 +1002,15 @@ payload: CampaignUpdatePayload)
});
}
export async function archiveCampaign(
settings: ApiSettings,
campaignId: string)
: Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/archive`, {
method: "POST"
});
}
export async function createNewCampaign(
settings: ApiSettings,
overrides: CampaignCreateMinimalPayload = {})
@@ -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<PendingLockAction>(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 (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
@@ -153,6 +174,14 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
</div>
<div className="button-row compact-actions">
{canArchive && <Button
variant="danger"
onClick={() => setArchiveDialogOpen(true)}
disabled={loading || savingIdentity || lockBusy || identityDirty}
disabledReason={identityDirty ? "i18n:govoplan-campaign.save_or_discard_overview_changes_before_archiving.413ff9e0" : undefined}>
<Archive size={16} aria-hidden="true" />
i18n:govoplan-campaign.archive_campaign.26dcfb8a
</Button>}
<Button onClick={() => void discardOverview()} disabled={loading || savingIdentity || lockBusy}>Discard</Button>
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save.efc007a3"}</Button>
</div>
@@ -226,6 +255,16 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
</Card>
</LoadingFrame>
<ConfirmDialog
open={archiveDialogOpen}
title="i18n:govoplan-campaign.archive_campaign.26dcfb8a"
message="i18n:govoplan-campaign.archive_campaign_confirmation.c0cc62e1"
confirmLabel="i18n:govoplan-campaign.archive_campaign.26dcfb8a"
tone="danger"
busy={archiving}
onCancel={() => setArchiveDialogOpen(false)}
onConfirm={() => void applyArchive()} />
<ConfirmDialog
open={Boolean(pendingLockAction)}
title={lockDialogTitle(pendingLockAction)}
@@ -89,7 +89,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
<SectionSidebar active={active} onSelect={select} />
<section className="workspace-content">
<Routes>
<Route index element={<CampaignOverviewPage settings={settings} campaignId={campaignId || ""} />} />
<Route index element={<CampaignOverviewPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="data" element={<Navigate to="../recipients" replace />} />
<Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
+8
View File
@@ -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",
@@ -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, /<CampaignOverviewPage settings=\{settings\} auth=\{auth\}/);
assert.match(overview, /hasScope\(auth, "campaigns:campaign:archive"\)/);
assert.match(overview, /archiveDialogOpen/);
assert.match(overview, /archive_campaign_confirmation/);
assert.match(overview, /await archiveCampaign\(settings, campaign\.id\)/);
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/);
console.log("Campaign lifecycle UI structural contract passed.");