Complete governed campaign lifecycle actions

This commit is contained in:
2026-08-04 01:04:39 +02:00
parent df5a93d6a3
commit 8f5231147d
13 changed files with 1324 additions and 79 deletions
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { Archive, ExternalLink, LockKeyhole, LockOpen } from "lucide-react";
import { Archive, Copy, ExternalLink, LockKeyhole, LockOpen, Trash2 } from "lucide-react";
import { Link } from "react-router";
import type { ApiSettings, AuthInfo } from "../../types";
import { Button } from "@govoplan/core-webui";
@@ -10,14 +10,20 @@ 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 { ToggleSwitch } 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,
archiveCampaignVersion,
copyCampaign,
deleteCampaign,
getCampaignLifecyclePolicy,
lockCampaignVersionPermanently,
lockCampaignVersionTemporarily,
unlockCampaignVersionUserLock,
updateCampaignMetadata,
type CampaignLifecyclePolicy,
type CampaignVersionDetail,
type CampaignVersionListItem } from
"../../api/campaigns";
@@ -39,22 +45,32 @@ import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddre
const campaignModeOptions = ["draft", "test", "send"];
type LockAction = "temporary" | "unlock" | "permanent";
type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
type LifecycleAction = "archive_campaign" | "delete_campaign" | "copy_campaign" | "archive_version";
type PendingLifecycleAction = {
action: LifecycleAction;
policy: CampaignLifecyclePolicy;
version?: CampaignVersionListItem;
} | null;
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;
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
const [showArchivedVersions, setShowArchivedVersions] = useState(false);
const archivedVersionCount = useMemo(() => data.versions.filter((version) => Boolean(version.archived_at)).length, [data.versions]);
const versions = useMemo(() => data.versions.filter((version) => showArchivedVersions || !version.archived_at).sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions, showArchivedVersions]);
const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" });
const [identityDirty, setIdentityDirty] = useState(false);
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 [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
const [lifecycleBusy, setLifecycleBusy] = 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");
const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete");
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy") && hasScope(auth, "campaigns:recipient:read");
useUnsavedDraftGuard({
dirty: identityDirty,
@@ -149,20 +165,56 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
await reload({ force: true });
}
async function applyArchive() {
if (!campaign || archiving) return;
setArchiving(true);
async function prepareLifecycleAction(action: LifecycleAction, version?: CampaignVersionListItem) {
if (!campaign || lifecycleBusy || identityDirty) return;
setLifecycleBusy(true);
setError("");
setMessage("");
try {
await archiveCampaign(settings, campaign.id);
setArchiveDialogOpen(false);
setMessage("i18n:govoplan-campaign.campaign_archived.3f0ca2b7");
const policy = await getCampaignLifecyclePolicy(settings, campaign.id, version?.id);
const decision = policy.actions[action];
if (!decision?.allowed) {
setError(decision?.reason || "This lifecycle action is not available for the current campaign state.");
return;
}
setPendingLifecycleAction({ action, policy, version });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLifecycleBusy(false);
}
}
async function applyLifecycleAction() {
if (!campaign || !pendingLifecycleAction || lifecycleBusy) return;
const pending = pendingLifecycleAction;
setLifecycleBusy(true);
setError("");
setMessage("");
try {
if (pending.action === "archive_campaign") {
await archiveCampaign(settings, campaign.id, pending.policy.state_token);
setMessage("i18n:govoplan-campaign.campaign_archived.3f0ca2b7");
} else if (pending.action === "delete_campaign") {
await deleteCampaign(settings, campaign.id, pending.policy.state_token);
setPendingLifecycleAction(null);
navigate("/campaigns");
return;
} else if (pending.action === "copy_campaign" && pending.version) {
const created = await copyCampaign(settings, campaign.id, pending.version.id, pending.policy.state_token);
setPendingLifecycleAction(null);
navigate(`/campaigns/${created.campaign.id}`);
return;
} else if (pending.action === "archive_version" && pending.version) {
await archiveCampaignVersion(settings, campaign.id, pending.version.id, pending.policy.state_token);
setMessage(`Version #${pending.version.version_number} archived.`);
}
setPendingLifecycleAction(null);
await reload({ force: true });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setArchiving(false);
setLifecycleBusy(false);
}
}
@@ -174,10 +226,25 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
</div>
<div className="button-row compact-actions">
{canCopy && data.currentVersion && <Button
onClick={() => void prepareLifecycleAction("copy_campaign", data.currentVersion ?? undefined)}
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
disabledReason={identityDirty ? "Save or discard overview changes before copying." : undefined}>
<Copy size={16} aria-hidden="true" />
Copy campaign
</Button>}
{canDelete && <Button
variant="danger"
onClick={() => void prepareLifecycleAction("delete_campaign")}
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
disabledReason={identityDirty ? "Save or discard overview changes before deleting." : undefined}>
<Trash2 size={16} aria-hidden="true" />
Delete draft
</Button>}
{canArchive && <Button
variant="danger"
onClick={() => setArchiveDialogOpen(true)}
disabled={loading || savingIdentity || lockBusy || identityDirty}
onClick={() => void prepareLifecycleAction("archive_campaign")}
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || 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
@@ -220,14 +287,19 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
</div>
</Card>
<Card title="Versions" collapsible actions={<Link
to={`send?version=${campaign?.current_version_id}`}
className={`btn btn-primary`}
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
i18n:govoplan-campaign.open.cf9b7706
</Link>}>
<Card title="Versions" collapsible actions={<div className="button-row compact-actions">
{archivedVersionCount > 0 && <ToggleSwitch
label={`Show archived (${archivedVersionCount})`}
checked={showArchivedVersions}
onChange={setShowArchivedVersions} />}
<Link
to={`send?version=${campaign?.current_version_id}`}
className={`btn btn-primary`}
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
i18n:govoplan-campaign.open.cf9b7706
</Link>
</div>}>
<div className="metric-grid inside campaign-versions-metrics">
<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" />
@@ -244,26 +316,33 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
<DataGrid
id={`campaign-${campaignId}-versions`}
rows={versions}
columns={versionColumns(setPendingLockAction, navigate, campaign?.current_version_id)}
columns={versionColumns(
setPendingLockAction,
navigate,
campaign?.current_version_id,
canCopy,
hasScope(auth, "campaigns:campaign:archive"),
(action, version) => void prepareLifecycleAction(action, version)
)}
getRowKey={(version) => version.id}
initialSort={{ columnId: "version", direction: "desc" }}
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
className="version-history-table"
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined} />
rowClassName={(version) => version.archived_at ? "archived-version-row" : version.id === data.currentVersion?.id ? "current-version-row" : undefined} />
</div>
</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"
open={Boolean(pendingLifecycleAction)}
title={lifecycleDialogTitle(pendingLifecycleAction)}
message={lifecycleDialogMessage(pendingLifecycleAction)}
confirmLabel={lifecycleDialogLabel(pendingLifecycleAction)}
tone="danger"
busy={archiving}
onCancel={() => setArchiveDialogOpen(false)}
onConfirm={() => void applyArchive()} />
busy={lifecycleBusy}
onCancel={() => setPendingLifecycleAction(null)}
onConfirm={() => void applyLifecycleAction()} />
<ConfirmDialog
open={Boolean(pendingLockAction)}
@@ -336,7 +415,14 @@ function textValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, navigate: (to: string) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
function versionColumns(
setPendingLockAction: (action: PendingLockAction) => void,
navigate: (to: string) => void,
currentVersionId: string | null | undefined,
canCopy: boolean,
canArchive: boolean,
onLifecycleAction: (action: LifecycleAction, version: CampaignVersionListItem) => void
): DataGridColumn<CampaignVersionListItem>[] {
return [
{ id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
{ id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
@@ -356,6 +442,8 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
const canTemporarilyLock = isCurrent && !temporarilyLocked && !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at;
return <TableActionGroup actions={[
{ id: "open", label: i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number }), icon: <ExternalLink aria-hidden="true" />, variant: isCurrent ? "primary" : "secondary", onClick: () => navigate(`send?version=${version.id}`) },
{ id: "copy-campaign", label: "Copy as new campaign", icon: <Copy aria-hidden="true" />, applicable: canCopy, onClick: () => onLifecycleAction("copy_campaign", version) },
{ id: "archive-version", label: "Archive historical version", icon: <Archive aria-hidden="true" />, variant: "danger", applicable: canArchive && !isCurrent && !version.archived_at, onClick: () => onLifecycleAction("archive_version", version) },
{ id: "unlock", label: "i18n:govoplan-campaign.unlock.1526a17e", icon: <LockOpen aria-hidden="true" />, applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "unlock" }) },
{ id: "permanent-lock", label: "i18n:govoplan-campaign.lock_permanently.cc0ce9e7", icon: <LockKeyhole aria-hidden="true" />, variant: "danger", applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "permanent" }) },
{ id: "temporary-lock", label: i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number }), icon: <LockKeyhole aria-hidden="true" />, applicable: canTemporarilyLock, onClick: () => setPendingLockAction({ version, action: "temporary" }) }
@@ -367,6 +455,7 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
}
function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string {
if (version.archived_at) return "Archived from default history";
if (currentVersionId && version.id !== currentVersionId) return "i18n:govoplan-campaign.historical_review_only.5afffe82";
if (isTemporaryUserLockedVersion(version)) return "i18n:govoplan-campaign.temporary_user_lock.c2bda6a9";
if (isPermanentUserLockedVersion(version)) return "i18n:govoplan-campaign.permanent_user_lock.9d5d8959";
@@ -415,3 +504,27 @@ function lockDialogLabel(pending: PendingLockAction): string {
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
return "i18n:govoplan-campaign.confirm.04a21221";
}
function lifecycleDialogTitle(pending: PendingLifecycleAction): string {
if (pending?.action === "archive_campaign") return "i18n:govoplan-campaign.archive_campaign.26dcfb8a";
if (pending?.action === "delete_campaign") return "Delete untouched draft";
if (pending?.action === "copy_campaign") return "Copy campaign";
if (pending?.action === "archive_version") return "Archive historical version";
return "Confirm lifecycle action";
}
function lifecycleDialogMessage(pending: PendingLifecycleAction): string {
if (pending?.action === "archive_campaign") return "i18n:govoplan-campaign.archive_campaign_confirmation.c0cc62e1";
if (pending?.action === "delete_campaign") return "This removes the untouched draft from active work. Drafts with build, delivery, sharing, lock, publication, or snapshot evidence cannot be deleted.";
if (pending?.action === "copy_campaign") return `Create a fresh campaign draft from version #${pending.version?.version_number ?? "?"}? Delivery jobs, outcomes, shares, locks, and audit evidence are not copied.`;
if (pending?.action === "archive_version") return `Hide historical version #${pending.version?.version_number ?? "?"} from the default history? Its configuration, reports, delivery results, and audit evidence remain available.`;
return "Review the lifecycle consequence before continuing.";
}
function lifecycleDialogLabel(pending: PendingLifecycleAction): string {
if (pending?.action === "archive_campaign") return "i18n:govoplan-campaign.archive_campaign.26dcfb8a";
if (pending?.action === "delete_campaign") return "Delete draft";
if (pending?.action === "copy_campaign") return "Create copy";
if (pending?.action === "archive_version") return "Archive version";
return "Confirm";
}