947 lines
54 KiB
TypeScript
947 lines
54 KiB
TypeScript
import { MetricGrid } from "@govoplan/core-webui";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { Archive, CalendarClock, Copy, Download, ExternalLink, LockKeyhole, LockOpen, Pause, Play, Trash2 } from "lucide-react";
|
|
import { Link } from "react-router";
|
|
import type { ApiSettings, AuthInfo } from "../../types";
|
|
import { FormGrid, Button } from "@govoplan/core-webui";
|
|
import { Card } from "@govoplan/core-webui";
|
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
|
import { Dialog } from "@govoplan/core-webui";
|
|
import { FormField } from "@govoplan/core-webui";
|
|
import { LoadingFrame } from "@govoplan/core-webui";
|
|
import { MetricCard } from "@govoplan/core-webui";
|
|
import { PageActionBar, PageLayout } 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,
|
|
createCampaignSchedule,
|
|
deleteCampaign,
|
|
exportCampaignPackage,
|
|
getCampaignLifecyclePolicy,
|
|
lockCampaignVersionPermanently,
|
|
lockCampaignVersionTemporarily,
|
|
unlockCampaignVersionUserLock,
|
|
listCampaignSchedules,
|
|
setCampaignScheduleState,
|
|
updateCampaignMetadata,
|
|
type CampaignSchedule,
|
|
type CampaignScheduleCreate,
|
|
type CampaignLifecyclePolicy,
|
|
type CampaignCopyOptions,
|
|
type CampaignTransferScope,
|
|
type CampaignVersionDetail,
|
|
type CampaignVersionListItem } from
|
|
"../../api/campaigns";
|
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
|
import {
|
|
asArray,
|
|
asRecord,
|
|
canUnlockValidationVersion,
|
|
formatDateTime,
|
|
getCampaignJson,
|
|
isFinalLockedVersion,
|
|
isPermanentUserLockedVersion,
|
|
isTemporaryUserLockedVersion,
|
|
isVersionReadyForDelivery,
|
|
summaryValue } from
|
|
"./utils/campaignView";
|
|
import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
|
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
|
|
|
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;
|
|
const defaultCopyOptions: CampaignCopyOptions = {
|
|
name: "",
|
|
external_id: "",
|
|
include_recipients: true,
|
|
include_files: true,
|
|
include_shares: false,
|
|
include_policies: true,
|
|
include_mail_profile: true
|
|
};
|
|
const defaultExportScopes: CampaignTransferScope[] = ["metadata", "template_config"];
|
|
|
|
function defaultScheduleDraft(): CampaignScheduleCreate {
|
|
const start = new Date(Date.now() + 60 * 60 * 1000);
|
|
start.setSeconds(0, 0);
|
|
return {
|
|
source_version_id: "",
|
|
name: "",
|
|
delivery_mode: "manual",
|
|
recurrence_kind: "once",
|
|
interval_count: 1,
|
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
starts_at: localDateTimeValue(start),
|
|
max_occurrences: 1,
|
|
include_recipients: true,
|
|
include_files: true,
|
|
include_shares: false,
|
|
include_policies: true,
|
|
include_mail_profile: true
|
|
};
|
|
}
|
|
|
|
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 [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 [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
|
|
const [copyOptions, setCopyOptions] = useState<CampaignCopyOptions>(defaultCopyOptions);
|
|
const [lifecycleBusy, setLifecycleBusy] = useState(false);
|
|
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
|
const [exportScopes, setExportScopes] = useState<CampaignTransferScope[]>(defaultExportScopes);
|
|
const [exportBusy, setExportBusy] = useState(false);
|
|
const [message, setMessage] = useState("");
|
|
const [schedules, setSchedules] = useState<CampaignSchedule[]>([]);
|
|
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
|
|
const [scheduleDraft, setScheduleDraft] = useState<CampaignScheduleCreate>(defaultScheduleDraft);
|
|
const [scheduleBusy, setScheduleBusy] = useState(false);
|
|
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");
|
|
const canExport = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:export");
|
|
const canSchedule = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:schedule") && hasScope(auth, "campaigns:campaign:copy");
|
|
const canAutonomousSchedule = canSchedule
|
|
&& hasScope(auth, "campaigns:campaign:queue")
|
|
&& hasScope(auth, "campaigns:campaign:send")
|
|
&& hasScope(auth, "mail:profile:use");
|
|
const canExportRecipients = hasScope(auth, "campaigns:recipient:read") && hasScope(auth, "campaigns:recipient:export");
|
|
const canExportReview = hasScope(auth, "campaigns:report:read");
|
|
const canExportDelivery = canExportRecipients && hasScope(auth, "campaigns:report:export");
|
|
|
|
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,
|
|
onDiscard: () => {
|
|
if (!campaign) return;
|
|
setIdentity({
|
|
external_id: campaign.external_id ?? "",
|
|
name: campaign.name ?? "",
|
|
status: campaign.status ?? "",
|
|
description: campaign.description ?? ""
|
|
});
|
|
setIdentityDirty(false);
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!campaign || identityDirty) return;
|
|
setIdentity({
|
|
external_id: campaign.external_id ?? "",
|
|
name: campaign.name ?? "",
|
|
status: campaign.status ?? "",
|
|
description: campaign.description ?? ""
|
|
});
|
|
}, [campaign, identityDirty]);
|
|
|
|
useEffect(() => {
|
|
if (!campaign?.id) {
|
|
setSchedules([]);
|
|
return;
|
|
}
|
|
let active = true;
|
|
void listCampaignSchedules(settings, campaign.id).then((items) => {
|
|
if (active) setSchedules(items);
|
|
}).catch((err) => {
|
|
if (active) setError(err instanceof Error ? err.message : String(err));
|
|
});
|
|
return () => { active = false; };
|
|
}, [campaign?.id, settings, setError]);
|
|
|
|
function patchIdentity(key: keyof typeof identity, value: string) {
|
|
setIdentity((current) => ({ ...current, [key]: value }));
|
|
setIdentityDirty(true);
|
|
setMessage("");
|
|
}
|
|
|
|
async function saveIdentity(): Promise<boolean> {
|
|
if (!campaign || savingIdentity || !identityDirty) return false;
|
|
setSavingIdentity(true);
|
|
setError("");
|
|
setMessage("");
|
|
try {
|
|
await updateCampaignMetadata(settings, campaign.id, {
|
|
external_id: identity.external_id,
|
|
name: identity.name,
|
|
status: identity.status,
|
|
description: identity.description
|
|
});
|
|
setIdentityDirty(false);
|
|
await reload();
|
|
return true;
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
return false;
|
|
} finally {
|
|
setSavingIdentity(false);
|
|
}
|
|
}
|
|
|
|
async function applyLockAction() {
|
|
const pending = pendingLockAction;
|
|
if (!pending || lockBusy) return;
|
|
setLockBusy(true);
|
|
setError("");
|
|
setMessage("");
|
|
try {
|
|
if (pending.action === "temporary") {
|
|
await lockCampaignVersionTemporarily(settings, campaignId, pending.version.id);
|
|
setMessage(i18nMessage("i18n:govoplan-campaign.version_value_temporarily_locked.4ed4ffa7", { value0: pending.version.version_number }));
|
|
} else if (pending.action === "unlock") {
|
|
await unlockCampaignVersionUserLock(settings, campaignId, pending.version.id);
|
|
setMessage(i18nMessage("i18n:govoplan-campaign.temporary_lock_removed_from_version_value.9a05bcfb", { value0: pending.version.version_number }));
|
|
} else {
|
|
await lockCampaignVersionPermanently(settings, campaignId, pending.version.id);
|
|
setMessage(i18nMessage("i18n:govoplan-campaign.version_value_permanently_locked.60595f45", { value0: pending.version.version_number }));
|
|
}
|
|
setPendingLockAction(null);
|
|
await reload();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setLockBusy(false);
|
|
}
|
|
}
|
|
|
|
async function discardOverview() {
|
|
if (campaign) {
|
|
setIdentity({
|
|
external_id: campaign.external_id ?? "",
|
|
name: campaign.name ?? "",
|
|
status: campaign.status ?? "",
|
|
description: campaign.description ?? ""
|
|
});
|
|
setIdentityDirty(false);
|
|
}
|
|
await reload({ force: true });
|
|
}
|
|
|
|
async function prepareLifecycleAction(action: LifecycleAction, version?: CampaignVersionListItem) {
|
|
if (!campaign || lifecycleBusy || identityDirty) return;
|
|
setLifecycleBusy(true);
|
|
setError("");
|
|
setMessage("");
|
|
try {
|
|
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;
|
|
}
|
|
if (action === "copy_campaign") {
|
|
setCopyOptions({
|
|
...defaultCopyOptions,
|
|
name: `${campaign.name} (copy)`,
|
|
include_recipients: hasScope(auth, "campaigns:recipient:read")
|
|
});
|
|
}
|
|
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, copyOptions);
|
|
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 {
|
|
setLifecycleBusy(false);
|
|
}
|
|
}
|
|
|
|
function openScheduleDialog() {
|
|
if (!campaign || !data.currentVersion) return;
|
|
setScheduleDraft({
|
|
...defaultScheduleDraft(),
|
|
source_version_id: data.currentVersion.id,
|
|
name: campaign.name,
|
|
include_recipients: hasScope(auth, "campaigns:recipient:read")
|
|
});
|
|
setScheduleDialogOpen(true);
|
|
}
|
|
|
|
async function submitSchedule() {
|
|
if (!campaign || scheduleBusy) return;
|
|
setScheduleBusy(true);
|
|
setError("");
|
|
try {
|
|
const created = await createCampaignSchedule(settings, campaign.id, {
|
|
...scheduleDraft,
|
|
starts_at: new Date(scheduleDraft.starts_at).toISOString(),
|
|
max_occurrences: scheduleDraft.recurrence_kind === "once" ? 1 : scheduleDraft.max_occurrences
|
|
});
|
|
setSchedules((current) => [created, ...current]);
|
|
setScheduleDialogOpen(false);
|
|
setMessage(created.delivery_mode === "autonomous"
|
|
? "Autonomous schedule created from the exact approved source execution."
|
|
: "Manual schedule created. Each occurrence prepares a fresh draft for review.");
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setScheduleBusy(false);
|
|
}
|
|
}
|
|
|
|
async function toggleSchedule(schedule: CampaignSchedule) {
|
|
if (!campaign || scheduleBusy) return;
|
|
setScheduleBusy(true);
|
|
setError("");
|
|
try {
|
|
const updated = await setCampaignScheduleState(
|
|
settings,
|
|
campaign.id,
|
|
schedule.id,
|
|
!schedule.active,
|
|
schedule.resource_revision
|
|
);
|
|
setSchedules((current) => current.map((item) => item.id === updated.id ? updated : item));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setScheduleBusy(false);
|
|
}
|
|
}
|
|
|
|
function toggleExportScope(scope: CampaignTransferScope, checked: boolean) {
|
|
setExportScopes((current) => checked
|
|
? [...current, scope].filter((item, index, rows) => rows.indexOf(item) === index)
|
|
: current.filter((item) => item !== scope));
|
|
}
|
|
|
|
async function exportPortablePackage() {
|
|
if (!campaign || !data.currentVersion || exportBusy || exportScopes.length === 0) return;
|
|
setExportBusy(true);
|
|
setError("");
|
|
try {
|
|
const portablePackage = await exportCampaignPackage(
|
|
settings,
|
|
campaign.id,
|
|
data.currentVersion.id,
|
|
exportScopes
|
|
);
|
|
downloadJson(
|
|
`${safeFileStem(campaign.external_id || campaign.name)}-v${data.currentVersion.version_number ?? 1}.govoplan-campaign.json`,
|
|
portablePackage
|
|
);
|
|
setExportDialogOpen(false);
|
|
setMessage("Portable Campaign package downloaded. Keep recipient or delivery packages in an approved location.");
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setExportBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<PageLayout
|
|
archetype="editor"
|
|
mode="workspace"
|
|
title={campaign?.name || "i18n:govoplan-campaign.overview.0efc2e6b"}
|
|
description={<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>}
|
|
headerLoading={loading}
|
|
error={error}
|
|
success={message}
|
|
actions={<PageActionBar
|
|
variant="editor"
|
|
state={savingIdentity ? "saving" : identityDirty ? "dirty" : "clean"}
|
|
refreshable
|
|
reloadAction={{ onReload: () => void reload(), loading }}
|
|
primaryActions={<>
|
|
{canExport && <Button
|
|
onClick={() => setExportDialogOpen(true)}
|
|
disabled={loading || savingIdentity || identityDirty || exportBusy}
|
|
disabledReason={identityDirty ? "Save or discard overview changes before exporting." : undefined}>
|
|
<Download size={16} aria-hidden="true" />
|
|
Export package
|
|
</Button>}
|
|
{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>}
|
|
{canSchedule && <Button
|
|
onClick={openScheduleDialog}
|
|
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
|
disabledReason={identityDirty ? "Save or discard overview changes before scheduling." : undefined}>
|
|
<CalendarClock size={16} aria-hidden="true" />
|
|
Schedule
|
|
</Button>}
|
|
</>}
|
|
destructiveActions={<>
|
|
{canDelete && <Button
|
|
variant="danger"
|
|
helpContextId="campaign.overview"
|
|
helpModuleId="campaigns"
|
|
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={() => 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
|
|
</Button>}
|
|
</>}
|
|
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardOverview(), disabled: loading || lockBusy }}
|
|
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => void saveIdentity(), disabled: !campaign && identityDirty, disabledReason: !campaign && identityDirty ? "The campaign identity is not available." : undefined }}
|
|
/>}
|
|
>
|
|
|
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaign_overview.ffa1adf0">
|
|
<MetricGrid>
|
|
<MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" drilldown={{ label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => openSection("review", "#workflow-send") }} />
|
|
<MetricCard label="i18n:govoplan-campaign.needs_attention.a126722e" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="i18n:govoplan-campaign.review_first.741ac781" drilldown={{ label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => openSection("review", "#workflow-build-review") }} />
|
|
<MetricCard label="i18n:govoplan-campaign.sent.35f49dcf" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="i18n:govoplan-campaign.smtp_success.3591a856" drilldown={{ label: "i18n:govoplan-campaign.open_report.44f83158", onActivate: () => openSection("report") }} />
|
|
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" drilldown={{ label: "i18n:govoplan-campaign.open_report.44f83158", onActivate: () => openSection("report") }} />
|
|
</MetricGrid>
|
|
|
|
<Card
|
|
title="i18n:govoplan-campaign.campaign_identity.a00ca574"
|
|
collapsible
|
|
actions={<Link className="btn btn-secondary" to="wizard">i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Link>}>
|
|
<FormGrid columns={1} collapseAt="standard" className="campaign-identity-grid">
|
|
<FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e">
|
|
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
|
|
</FormField>
|
|
<FormField label="i18n:govoplan-campaign.mode.a7b93d21">
|
|
<select value={identity.status} onChange={(event) => patchIdentity("status", event.target.value)}>
|
|
{campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="i18n:govoplan-campaign.name.709a2322">
|
|
<input value={identity.name} onChange={(event) => patchIdentity("name", event.target.value)} />
|
|
</FormField>
|
|
<FormField label="i18n:govoplan-campaign.description.55f8ebc8">
|
|
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
|
|
</FormField>
|
|
</FormGrid>
|
|
</Card>
|
|
|
|
{(canSchedule || schedules.length > 0) && <Card
|
|
title="Schedules"
|
|
collapsible
|
|
actions={canSchedule ? <Button onClick={openScheduleDialog}>
|
|
<CalendarClock size={16} aria-hidden="true" />
|
|
Add schedule
|
|
</Button> : undefined}>
|
|
<p className="muted small-note">Manual schedules prepare independent drafts. Opt-in autonomous schedules reuse the exact approved build through Mail's durable delivery outbox.</p>
|
|
{schedules.length === 0 ? <p className="muted">No schedules configured.</p> : <div className="campaign-schedule-list">
|
|
{schedules.map((schedule) => <div className="campaign-schedule-row" key={schedule.id}>
|
|
<div className="campaign-schedule-main">
|
|
<strong>{schedule.name}</strong>
|
|
<span>{scheduleCadence(schedule)} · {schedule.occurrence_count}/{schedule.max_occurrences} occurrences</span>
|
|
<span><StatusBadge status={schedule.delivery_mode} /> {schedule.delivery_mode === "autonomous" ? "Approved source delivery" : "Draft preparation"}</span>
|
|
{schedule.next_fire_at && <span>Next: {formatDateTime(schedule.next_fire_at)}</span>}
|
|
{schedule.last_outcome && <span>Last outcome: <StatusBadge status={schedule.last_outcome} /> · recovery {schedule.last_recovery_state ?? "none"}</span>}
|
|
{schedule.last_error && <span className="danger-text">Paused: {schedule.last_error}</span>}
|
|
</div>
|
|
<div className="button-row compact-actions">
|
|
{schedule.last_campaign_id && <Link className="btn btn-secondary" to={`/campaigns/${schedule.last_campaign_id}`}>{schedule.delivery_mode === "autonomous" ? "Open approved source" : "Open latest draft"}</Link>}
|
|
{schedule.next_fire_at && <Button
|
|
iconOnly
|
|
aria-label={schedule.active ? "Pause schedule" : "Resume schedule"}
|
|
title={schedule.active ? "Pause schedule" : "Resume schedule"}
|
|
disabled={scheduleBusy || !canSchedule}
|
|
onClick={() => void toggleSchedule(schedule)}>
|
|
{schedule.active ? <Pause size={16} aria-hidden="true" /> : <Play size={16} aria-hidden="true" />}
|
|
</Button>}
|
|
</div>
|
|
</div>)}
|
|
</div>}
|
|
</Card>}
|
|
|
|
<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>}>
|
|
<MetricGrid spacing="inset">
|
|
<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" drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: "Fields" }), onActivate: () => openSection("fields") }} />
|
|
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: "Recipients" }), onActivate: () => openSection("recipients") }} />
|
|
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} drilldown={{ label: "i18n:govoplan-campaign.open_template_editor.1739b545", onActivate: () => openSection("template") }} />
|
|
</MetricGrid>
|
|
<MetricGrid spacing="inset">
|
|
<MetricCard label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} drilldown={{ label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => openSection("review", "#workflow-validate-review") }} />
|
|
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} drilldown={{ label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => openSection("review", "#workflow-validate-review") }} />
|
|
<MetricCard label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} drilldown={{ label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => openSection("review", "#workflow-build-review") }} />
|
|
<MetricCard label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: campaign?.name ?? campaignId }), onActivate: openQueue }} />
|
|
</MetricGrid>
|
|
<div className="admin-table-surface version-history-table-surface">
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-versions`}
|
|
rows={versions}
|
|
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.archived_at ? "archived-version-row" : version.id === data.currentVersion?.id ? "current-version-row" : undefined} />
|
|
|
|
</div>
|
|
</Card>
|
|
</LoadingFrame>
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(pendingLifecycleAction && pendingLifecycleAction.action !== "copy_campaign")}
|
|
title={lifecycleDialogTitle(pendingLifecycleAction)}
|
|
message={lifecycleDialogMessage(pendingLifecycleAction)}
|
|
confirmLabel={lifecycleDialogLabel(pendingLifecycleAction)}
|
|
tone="danger"
|
|
busy={lifecycleBusy}
|
|
onCancel={() => setPendingLifecycleAction(null)}
|
|
onConfirm={() => void applyLifecycleAction()} />
|
|
|
|
<Dialog
|
|
open={pendingLifecycleAction?.action === "copy_campaign"}
|
|
title="Copy campaign"
|
|
className="campaign-copy-dialog"
|
|
helpContextId="campaigns.action.copy-campaign"
|
|
closeDisabled={lifecycleBusy}
|
|
onClose={() => setPendingLifecycleAction(null)}
|
|
footer={<>
|
|
<Button onClick={() => setPendingLifecycleAction(null)} disabled={lifecycleBusy}>Cancel</Button>
|
|
<Button variant="primary" onClick={() => void applyLifecycleAction()} disabled={lifecycleBusy || !copyOptions.name?.trim()}>
|
|
{lifecycleBusy ? "Creating copy..." : "Create copy"}
|
|
</Button>
|
|
</>}>
|
|
<div className="campaign-copy-form">
|
|
<p className="muted small-note">
|
|
Create a new draft from version #{pendingLifecycleAction?.version?.version_number ?? "?"}. Delivery jobs, outcomes, locks, reports, and audit evidence are never copied.
|
|
</p>
|
|
<div className="campaign-copy-identity">
|
|
<FormField label="Campaign name" help="The new campaign receives an independent identity and version history.">
|
|
<input value={copyOptions.name ?? ""} onChange={(event) => setCopyOptions((current) => ({ ...current, name: event.target.value }))} />
|
|
</FormField>
|
|
<FormField label="Campaign ID" help="Leave blank to generate a unique ID from the source campaign.">
|
|
<input value={copyOptions.external_id ?? ""} onChange={(event) => setCopyOptions((current) => ({ ...current, external_id: event.target.value }))} placeholder="Generated automatically" />
|
|
</FormField>
|
|
</div>
|
|
<div className="campaign-copy-options">
|
|
<CopyOption
|
|
label="Recipients"
|
|
detail="Global address headers, recipient rows, imported audience provenance, and per-recipient values."
|
|
checked={copyOptions.include_recipients}
|
|
disabled={!hasScope(auth, "campaigns:recipient:read")}
|
|
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_recipients: checked }))} />
|
|
<CopyOption
|
|
label="Files"
|
|
detail="Campaign and recipient attachment rules. Managed files remain owned by Files and are referenced, not duplicated."
|
|
checked={copyOptions.include_files}
|
|
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_files: checked }))} />
|
|
<CopyOption
|
|
label="Shares"
|
|
detail="Current active user and group shares. Ownership always starts with the account creating the copy."
|
|
checked={copyOptions.include_shares}
|
|
disabled={!hasScope(auth, "campaigns:campaign:share")}
|
|
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_shares: checked }))} />
|
|
<CopyOption
|
|
label="Policies"
|
|
detail="Validation behavior and campaign settings."
|
|
checked={copyOptions.include_policies}
|
|
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_policies: checked }))} />
|
|
<CopyOption
|
|
label="Mail profile"
|
|
detail="References to the reusable Mail profile, its campaign policy, server selections, and credential selections. Secrets are never copied into Campaign."
|
|
checked={copyOptions.include_mail_profile}
|
|
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_mail_profile: checked }))} />
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={exportDialogOpen}
|
|
title="Export portable Campaign package"
|
|
className="campaign-copy-dialog"
|
|
helpContextId="campaigns.action.export-package"
|
|
closeDisabled={exportBusy}
|
|
onClose={() => setExportDialogOpen(false)}
|
|
footer={<>
|
|
<Button onClick={() => setExportDialogOpen(false)} disabled={exportBusy}>Cancel</Button>
|
|
<Button variant="primary" onClick={() => void exportPortablePackage()} disabled={exportBusy || exportScopes.length === 0}>
|
|
{exportBusy ? "Preparing package..." : "Download package"}
|
|
</Button>
|
|
</>}>
|
|
<div className="campaign-copy-form">
|
|
<p className="muted small-note">
|
|
Configuration-only is the privacy-safe default. The package contains JSON and attachment references, never file content, credentials, or transport secrets.
|
|
</p>
|
|
<div className="campaign-copy-options">
|
|
<CopyOption label="Metadata" detail="Campaign identity, description, and source status." checked={exportScopes.includes("metadata")} onChange={(checked) => toggleExportScope("metadata", checked)} />
|
|
<CopyOption label="Template and configuration" detail="Fields, template, validation and delivery settings. Deployment-bound Mail credentials are excluded." checked={exportScopes.includes("template_config")} onChange={(checked) => toggleExportScope("template_config", checked)} />
|
|
<CopyOption label="Recipients" detail="Recipient rows and import provenance. This can contain personal data and needs recipient-export authority." checked={exportScopes.includes("recipients")} disabled={!canExportRecipients} onChange={(checked) => toggleExportScope("recipients", checked)} />
|
|
<CopyOption label="Attachments" detail="Global and per-recipient attachment rules. File bytes are not embedded." checked={exportScopes.includes("attachments")} onChange={(checked) => toggleExportScope("attachments", checked)} />
|
|
<CopyOption label="Review state" detail="Aggregate validation, build, issue, and review evidence. Imports retain provenance but never replay approval state." checked={exportScopes.includes("review_state")} disabled={!canExportReview} onChange={(checked) => toggleExportScope("review_state", checked)} />
|
|
<CopyOption label="Delivery history" detail="Recipient-level delivery outcomes and safe provenance. Imports never recreate sent state." checked={exportScopes.includes("delivery_history")} disabled={!canExportDelivery} onChange={(checked) => toggleExportScope("delivery_history", checked)} />
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={scheduleDialogOpen}
|
|
title="Schedule campaign"
|
|
className="campaign-schedule-dialog"
|
|
helpContextId="campaigns.action.schedule-drafts"
|
|
closeDisabled={scheduleBusy}
|
|
onClose={() => setScheduleDialogOpen(false)}
|
|
footer={<>
|
|
<Button onClick={() => setScheduleDialogOpen(false)} disabled={scheduleBusy}>Cancel</Button>
|
|
<Button variant="primary" onClick={() => void submitSchedule()} disabled={scheduleBusy || !scheduleDraft.name.trim() || !scheduleDraft.starts_at}>
|
|
{scheduleBusy ? "Creating schedule..." : "Create schedule"}
|
|
</Button>
|
|
</>}>
|
|
<DismissibleAlert tone="info" resetKey={`campaign-schedule-safety-${scheduleDraft.delivery_mode}`}>
|
|
{scheduleDraft.delivery_mode === "autonomous"
|
|
? "Autonomous delivery is opt-in. It requires a built, explicitly approved Mail-only source version and rechecks approval, policy, credentials, transport health, recipients, attachments, and snapshot integrity before each occurrence. Unknown outcomes pause the schedule and are never retried automatically."
|
|
: "Manual mode creates a fresh draft at each due time. It never validates, approves, queues, or sends messages automatically and remains available without Mail."}
|
|
</DismissibleAlert>
|
|
<div className="campaign-schedule-form">
|
|
<FormField label="Execution mode" help="Mode is fixed for this schedule. Create a new schedule when the approved delivery plan changes.">
|
|
<select value={scheduleDraft.delivery_mode} onChange={(event) => setScheduleDraft((current) => ({ ...current, delivery_mode: event.target.value as CampaignSchedule["delivery_mode"] }))}>
|
|
<option value="manual">Manual draft preparation</option>
|
|
<option value="autonomous" disabled={!canAutonomousSchedule}>Autonomous approved delivery</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Schedule name">
|
|
<input value={scheduleDraft.name} onChange={(event) => setScheduleDraft((current) => ({ ...current, name: event.target.value }))} />
|
|
</FormField>
|
|
<FormField label="First occurrence">
|
|
<input type="datetime-local" value={scheduleDraft.starts_at} onChange={(event) => setScheduleDraft((current) => ({ ...current, starts_at: event.target.value }))} />
|
|
</FormField>
|
|
<FormField label="Recurrence">
|
|
<select value={scheduleDraft.recurrence_kind} onChange={(event) => {
|
|
const recurrence_kind = event.target.value as CampaignSchedule["recurrence_kind"];
|
|
setScheduleDraft((current) => ({ ...current, recurrence_kind, max_occurrences: recurrence_kind === "once" ? 1 : Math.max(2, current.max_occurrences) }));
|
|
}}>
|
|
<option value="once">Once</option>
|
|
<option value="daily">Daily</option>
|
|
<option value="weekly">Weekly</option>
|
|
<option value="monthly">Monthly</option>
|
|
</select>
|
|
</FormField>
|
|
{scheduleDraft.recurrence_kind !== "once" && <FormField label="Every">
|
|
<div className="campaign-schedule-interval">
|
|
<input type="number" min={1} max={365} value={scheduleDraft.interval_count} onChange={(event) => setScheduleDraft((current) => ({ ...current, interval_count: Math.max(1, Number(event.target.value) || 1) }))} />
|
|
<span>{scheduleDraft.recurrence_kind.replace("ly", "")} interval(s)</span>
|
|
</div>
|
|
</FormField>}
|
|
{scheduleDraft.recurrence_kind !== "once" && <FormField label="Maximum occurrences" help="Recurring schedules are bounded. Create a new schedule if the approved plan changes.">
|
|
<input type="number" min={2} max={1000} value={scheduleDraft.max_occurrences} onChange={(event) => setScheduleDraft((current) => ({ ...current, max_occurrences: Math.max(2, Number(event.target.value) || 2) }))} />
|
|
</FormField>}
|
|
<FormField label="Timezone">
|
|
<input value={scheduleDraft.timezone} onChange={(event) => setScheduleDraft((current) => ({ ...current, timezone: event.target.value }))} />
|
|
</FormField>
|
|
</div>
|
|
{scheduleDraft.delivery_mode === "manual" && <div className="campaign-copy-options">
|
|
<CopyOption label="Recipients" detail="Recipient rows and values used to prepare each draft." checked={scheduleDraft.include_recipients} disabled={!hasScope(auth, "campaigns:recipient:read")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_recipients: checked }))} />
|
|
<CopyOption label="Files" detail="Attachment rules and Files references; generated evidence is never copied." checked={scheduleDraft.include_files} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_files: checked }))} />
|
|
<CopyOption label="Shares" detail="Current active shares are sealed when the schedule is created." checked={scheduleDraft.include_shares} disabled={!hasScope(auth, "campaigns:campaign:share")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_shares: checked }))} />
|
|
<CopyOption label="Policies" detail="Campaign and validation policy configuration." checked={scheduleDraft.include_policies} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_policies: checked }))} />
|
|
<CopyOption label="Mail profile" detail="Reusable Mail profile references; credentials remain Mail-owned." checked={scheduleDraft.include_mail_profile} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_mail_profile: checked }))} />
|
|
</div>}
|
|
</Dialog>
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(pendingLockAction)}
|
|
title={lockDialogTitle(pendingLockAction)}
|
|
message={lockDialogMessage(pendingLockAction)}
|
|
confirmLabel={lockDialogLabel(pendingLockAction)}
|
|
tone={pendingLockAction?.action === "unlock" ? "default" : "danger"}
|
|
busy={lockBusy}
|
|
onCancel={() => setPendingLockAction(null)}
|
|
onConfirm={() => void applyLockAction()} />
|
|
|
|
</PageLayout>);
|
|
|
|
}
|
|
|
|
function localDateTimeValue(value: Date): string {
|
|
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
|
|
return local.toISOString().slice(0, 16);
|
|
}
|
|
|
|
function scheduleCadence(schedule: CampaignSchedule): string {
|
|
if (schedule.recurrence_kind === "once") return "One time";
|
|
const unit = { daily: "day", weekly: "week", monthly: "month" }[schedule.recurrence_kind];
|
|
return schedule.interval_count === 1 ? schedule.recurrence_kind : `Every ${schedule.interval_count} ${unit}s`;
|
|
}
|
|
|
|
function CopyOption({
|
|
label,
|
|
detail,
|
|
checked,
|
|
disabled = false,
|
|
onChange
|
|
}: {
|
|
label: string;
|
|
detail: string;
|
|
checked: boolean;
|
|
disabled?: boolean;
|
|
onChange: (checked: boolean) => void;
|
|
}) {
|
|
return <div className={`campaign-copy-option ${disabled ? "is-disabled" : ""}`}>
|
|
<div><strong>{label}</strong><small>{detail}</small></div>
|
|
<ToggleSwitch
|
|
label={`Copy ${label.toLowerCase()}`}
|
|
inactiveLabel="Exclude"
|
|
activeLabel="Include"
|
|
checked={checked}
|
|
disabled={disabled}
|
|
onChange={onChange} />
|
|
</div>;
|
|
}
|
|
|
|
type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
|
|
|
|
type CampaignVersionMetrics = {
|
|
fieldCount: number;
|
|
recipientCount: number;
|
|
templateHealthValue: string;
|
|
templateHealthTone: TemplateHealthTone;
|
|
templateHealthDetail: string;
|
|
};
|
|
|
|
function campaignVersionMetrics(version: CampaignVersionDetail | null): CampaignVersionMetrics {
|
|
const campaignJson = getCampaignJson(version);
|
|
const fields = asArray(campaignJson.fields).map(asRecord);
|
|
const entries = asRecord(campaignJson.entries);
|
|
const inlineEntries = asArray(entries.inline).map(asRecord);
|
|
const template = asRecord(campaignJson.template);
|
|
const globalValues = asRecord(campaignJson.global_values);
|
|
const bodyMode = normalizeTemplateBodyMode(textValue(template.body_mode, "both"));
|
|
const subject = textValue(template.subject);
|
|
const textBody = bodyMode !== "html" ? textValue(template.text) : "";
|
|
const htmlBody = bodyMode !== "text" ? textValue(template.html) : "";
|
|
const subjectOk = subject.trim().length > 0;
|
|
const bodyOk = bodyMode === "html" ? htmlBody.trim().length > 0 : bodyMode === "text" ? textBody.trim().length > 0 : Boolean(textBody.trim() || htmlBody.trim());
|
|
const localFieldNames = fields.map((field) => textValue(field.name || field.id)).filter(Boolean);
|
|
const globalFieldNames = Object.keys(globalValues).filter(Boolean);
|
|
const addressFieldNames = recipientAddressTemplateFieldOptions().map((field) => field.name);
|
|
const localAvailableNames = new Set([...localFieldNames, ...addressFieldNames]);
|
|
const globalAvailableNames = new Set(globalFieldNames);
|
|
const allAvailableNames = new Set([...localAvailableNames, ...globalAvailableNames]);
|
|
const placeholders = extractTemplatePlaceholders([subject, textBody, htmlBody].join("\n"));
|
|
const undefinedPlaceholders = buildUndefinedPlaceholders(placeholders, allAvailableNames, {
|
|
local: localAvailableNames,
|
|
global: globalAvailableNames
|
|
});
|
|
const placeholdersOk = undefinedPlaceholders.length === 0;
|
|
const score = [subjectOk, bodyOk, placeholdersOk].filter(Boolean).length;
|
|
return {
|
|
fieldCount: localFieldNames.length,
|
|
recipientCount: inlineEntries.filter((entry) => entry.active !== false).length,
|
|
templateHealthValue: `${score}/3`,
|
|
templateHealthTone: score === 3 ? "good" : score === 2 ? "warning" : "danger",
|
|
templateHealthDetail: [
|
|
subjectOk ? "i18n:govoplan-campaign.subject_ok.3dbdc3ed" : "i18n:govoplan-campaign.subject_missing.f545bf65",
|
|
bodyOk ? "i18n:govoplan-campaign.body_ok.cdb33005" : "i18n:govoplan-campaign.body_missing.bd58622f",
|
|
placeholdersOk ? "i18n:govoplan-campaign.placeholders_ok.3a07f3bc" : `${undefinedPlaceholders.length} undefined`].
|
|
join(" · ")
|
|
};
|
|
}
|
|
|
|
function normalizeTemplateBodyMode(value: string): "text" | "html" | "both" {
|
|
return value === "text" || value === "html" || value === "both" ? value : "both";
|
|
}
|
|
|
|
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 | 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" },
|
|
{ id: "lock", header: "i18n:govoplan-campaign.lock.891ebccd", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Current working version", label: "i18n:govoplan-campaign.current_working_version.eda99d70" }, { value: "Historical version", label: "i18n:govoplan-campaign.historical_version.8880931f" }, { value: "Temporarily locked", label: "i18n:govoplan-campaign.temporarily_locked.1716dc95" }, { value: "Permanently locked", label: "i18n:govoplan-campaign.permanently_locked.327d59fd" }, { value: "Delivery locked", label: "i18n:govoplan-campaign.delivery_locked.2b664305" }] }, render: (version) => versionLockLabel(version, currentVersionId), value: (version) => versionLockLabel(version, currentVersionId) },
|
|
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not validated", label: "i18n:govoplan-campaign.not_validated.11bb4178" }, { value: "Valid", label: "i18n:govoplan-campaign.valid.a4aefa35" }, { value: "Validation issues", label: "i18n:govoplan-campaign.validation_issues.528305b1" }] }, render: validationLabel, value: validationLabel },
|
|
{ id: "build", header: "i18n:govoplan-campaign.build.bbd80cf7", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not built", label: "i18n:govoplan-campaign.not_built.88bbe87a" }, { value: "Built", label: "i18n:govoplan-campaign.built.a6ad3f82" }, { value: "Build issues", label: "i18n:govoplan-campaign.build_issues.3e03bf8e" }] }, render: buildLabel, value: buildLabel },
|
|
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 190, sortable: true, filterable: true, filterType: "date", render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" },
|
|
{
|
|
id: "actions",
|
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
|
width: 180,
|
|
sticky: "end",
|
|
align: "right",
|
|
render: (version) => {
|
|
const isCurrent = version.id === currentVersionId;
|
|
const temporarilyLocked = isCurrent && isTemporaryUserLockedVersion(version);
|
|
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" }) }
|
|
]} />;
|
|
|
|
}
|
|
}];
|
|
|
|
}
|
|
|
|
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";
|
|
if (isFinalLockedVersion(version)) return "i18n:govoplan-campaign.permanent_delivery_lock.07932206";
|
|
if (canUnlockValidationVersion(version)) return "i18n:govoplan-campaign.temporary_validation_lock.fdc5545d";
|
|
if (version.locked_at) return "i18n:govoplan-campaign.temporarily_locked.1716dc95";
|
|
return "i18n:govoplan-campaign.editable.b91faec0";
|
|
}
|
|
|
|
function validationLabel(version: CampaignVersionListItem): string {
|
|
const validation = version.validation_summary ?? {};
|
|
if (validation.ok === true && isVersionReadyForDelivery(version)) return "i18n:govoplan-campaign.passed.271d60f4";
|
|
if (validation.ok === false) return "i18n:govoplan-campaign.needs_attention.a126722e";
|
|
if (validation.ok === true) return "i18n:govoplan-campaign.passed_unavailable_while_user_locked.d6771ff4";
|
|
return "i18n:govoplan-campaign.not_validated.11bb4178";
|
|
}
|
|
|
|
function buildLabel(version: CampaignVersionListItem): string {
|
|
const build = version.build_summary ?? {};
|
|
return String(build.built_count ?? build.ready_count ?? "i18n:govoplan-campaign.not_built.88bbe87a");
|
|
}
|
|
|
|
function lockDialogTitle(pending: PendingLockAction): string {
|
|
if (pending?.action === "temporary") return "i18n:govoplan-campaign.temporarily_lock_version.8ccc5708";
|
|
if (pending?.action === "unlock") return "i18n:govoplan-campaign.unlock_version.ebd2fd9a";
|
|
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_version_permanently.a8625753";
|
|
return "i18n:govoplan-campaign.confirm_lock_action.617986ee";
|
|
}
|
|
|
|
function lockDialogMessage(pending: PendingLockAction): string {
|
|
if (pending?.action === "temporary") {
|
|
return "i18n:govoplan-campaign.this_makes_the_version_read_only_without_making_.9d86667b";
|
|
}
|
|
if (pending?.action === "unlock") {
|
|
return "i18n:govoplan-campaign.this_removes_the_temporary_user_lock_and_makes_t.fdabd47a";
|
|
}
|
|
if (pending?.action === "permanent") {
|
|
return "i18n:govoplan-campaign.this_lock_cannot_be_removed_by_any_role_the_vers.14ae9b80";
|
|
}
|
|
return "i18n:govoplan-campaign.continue.4e6302ed";
|
|
}
|
|
|
|
function lockDialogLabel(pending: PendingLockAction): string {
|
|
if (pending?.action === "temporary") return "i18n:govoplan-campaign.lock_temporarily.3f91d346";
|
|
if (pending?.action === "unlock") return "i18n:govoplan-campaign.unlock.1526a17e";
|
|
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";
|
|
}
|