Rework of campaign structure; locking

This commit is contained in:
2026-06-11 02:50:39 +02:00
parent 93fb55273c
commit 8791de0959
29 changed files with 810 additions and 538 deletions

View File

@@ -9,6 +9,14 @@ export type CampaignListResponse =
results?: CampaignListItem[];
};
export type CampaignUpdatePayload = {
external_id?: string | null;
name?: string | null;
status?: string | null;
description?: string | null;
};
export type CampaignCreateMinimalPayload = {
external_id?: string;
name?: string;
@@ -138,6 +146,17 @@ export async function getCampaign(settings: ApiSettings, campaignId: string): Pr
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
}
export async function updateCampaignMetadata(
settings: ApiSettings,
campaignId: string,
payload: CampaignUpdatePayload
): Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`, {
method: "PUT",
body: JSON.stringify(payload)
});
}
export async function createNewCampaign(
settings: ApiSettings,
overrides: CampaignCreateMinimalPayload = {}
@@ -177,6 +196,16 @@ export async function getCampaignVersion(
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
}
export async function unlockCampaignVersionValidation(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-validation`, {
method: "POST"
});
}
export async function updateCampaignVersion(
settings: ApiSettings,
campaignId: string,
@@ -189,6 +218,18 @@ export async function updateCampaignVersion(
});
}
export async function forkCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignVersionUpdatePayload = {}
): Promise<CampaignCreateResponse> {
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function autosaveCampaignVersion(
settings: ApiSettings,
campaignId: string,

View File

@@ -0,0 +1,44 @@
import Button from "./Button";
export type ConfirmDialogTone = "default" | "danger";
export type ConfirmDialogProps = {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
tone?: ConfirmDialogTone;
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
};
export default function ConfirmDialog({
open,
title,
message,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
tone = "default",
busy = false,
onConfirm,
onCancel
}: ConfirmDialogProps) {
if (!open) return null;
return (
<div className="confirm-backdrop" role="presentation" onMouseDown={(event) => {
if (event.target === event.currentTarget && !busy) onCancel();
}}>
<section className="confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title" aria-describedby="confirm-dialog-message">
<h2 id="confirm-dialog-title">{title}</h2>
<p id="confirm-dialog-message">{message}</p>
<div className="button-row compact-actions confirm-dialog-actions">
<Button onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
<Button variant={tone === "danger" ? "danger" : "primary"} onClick={onConfirm} disabled={busy}>{busy ? "Working…" : confirmLabel}</Button>
</div>
</section>
</div>
);
}

View File

@@ -5,10 +5,12 @@ import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { updateNested } from "./utils/draftEditor";
import { AttachmentRulesTable } from "./components/AttachmentRulesOverlay";
import { countIndividualAttachmentRules, createAttachmentBasePath, createAttachmentRule, ensureAttachmentBasePaths, mockAttachmentPathOptions, normalizeAttachmentBasePaths, normalizeAttachmentRules, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
@@ -73,7 +75,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Attachments</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button disabled>Manage files</Button>
@@ -84,7 +86,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing attachments." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>

View File

@@ -2,9 +2,9 @@ import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import VersionLine from "./components/VersionLine";
import LoadingFrame from "../../components/LoadingFrame";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { formatDateTime } from "./utils/campaignView";
export default function CampaignAuditPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
@@ -15,7 +15,7 @@ export default function CampaignAuditPage({ settings, campaignId }: { settings:
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Audit log</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>

View File

@@ -1,168 +0,0 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import ToggleSwitch from "../../components/ToggleSwitch";
import EmailAddressInput from "../../components/email/EmailAddressInput";
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../utils/emailAddresses";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { getBool, getText } from "./utils/draftEditor";
const campaignModeOptions = ["draft", "test", "send"];
export default function CampaignDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "campaign-settings",
unsavedTitle: "Unsaved general campaign data",
unsavedMessage: "General campaign data has unsaved changes. Save it before leaving, or discard it and continue."
});
const campaign = asRecord(displayDraft.campaign);
const recipients = asRecord(displayDraft.recipients);
const from = asRecord(recipients.from);
const defaultFrom = addressesFromValue(from);
const globalReplyTo = addressesFromValue(recipients.reply_to);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>General</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing general campaign data.</div>}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<div className="campaign-settings-stack">
<Card title="Campaign identity">
<div className="form-grid campaign-identity-grid">
<FormField label="Campaign ID">
<input value={getText(campaign, "id")} disabled={locked} onChange={(event) => patch(["campaign", "id"], event.target.value)} />
</FormField>
<FormField label="Mode">
<select value={getText(campaign, "mode", "draft")} disabled={locked} onChange={(event) => patch(["campaign", "mode"], event.target.value)}>
{campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</FormField>
<FormField label="Name">
<input value={getText(campaign, "name")} disabled={locked} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
</FormField>
<FormField label="Description">
<textarea rows={4} value={getText(campaign, "description")} disabled={locked} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
</FormField>
</div>
</Card>
<Card title="Campaign sender">
<div className="campaign-header-stack">
<div className="campaign-header-grid">
<FormField label="Default From address">
<EmailAddressInput
value={defaultFrom}
suggestions={addressSuggestions}
allowMultiple={false}
showAddButton={false}
disabled={locked}
addLabel={getText(from, "email") ? "Replace" : "Add sender"}
emptyText="No default sender configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses[0] ?? { name: "", email: "" })}
/>
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label="Allow individual senders"
checked={getBool(recipients, "allow_individual_from")}
disabled={locked}
onChange={(checked) => patch(["recipients", "allow_individual_from"], checked)}
/>
</div>
</div>
<div className="campaign-header-grid">
<FormField label="Global Reply-To address">
<EmailAddressInput
value={globalReplyTo.slice(0, 1)}
suggestions={addressSuggestions}
allowMultiple={false}
showAddButton={false}
disabled={locked}
addLabel={globalReplyTo.length ? "Replace" : "Add Reply-To"}
emptyText="No Reply-To address configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))}
/>
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label="Allow individual Reply-To"
checked={getBool(recipients, "allow_individual_reply_to")}
disabled={locked}
onChange={(checked) => patch(["recipients", "allow_individual_reply_to"], checked)}
/>
</div>
</div>
</div>
</Card>
<Card title="Related campaign areas">
<div className="related-link-grid">
<Link to="../recipients" className="related-link-card">
<strong>Recipients</strong>
<span>Recipient address profiles, global recipient headers and header overrides.</span>
</Link>
<Link to="../recipient-data" className="related-link-card">
<strong>Recipient data</strong>
<span>Maintain recipient-specific field values and attachment rules.</span>
</Link>
<Link to="../global-settings" className="related-link-card">
<strong>Global settings</strong>
<span>Policies, attachment defaults, delivery defaults and opt-ins.</span>
</Link>
<Link to="../fields" className="related-link-card">
<strong>Fields</strong>
<span>Define fields, global values and recipient override behavior.</span>
</Link>
<Link to="../files" className="related-link-card">
<strong>Attachments</strong>
<span>Configure global attachments and per-recipient file patterns.</span>
</Link>
</div>
</Card>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
</LoadingFrame>
</div>
);
}

View File

@@ -4,10 +4,12 @@ import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { asRecord, isAuditLockedVersion, isRecord } from "./utils/campaignView";
import { getBool, getText, updateNested } from "./utils/draftEditor";
import FieldValueInput from "./components/FieldValueInput";
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
@@ -140,7 +142,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Fields</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
@@ -151,7 +153,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{fieldNameWarning && <div className="alert warning">{fieldNameWarning}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing fields.</div>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing fields." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>

View File

@@ -2,9 +2,10 @@ import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import VersionLine from "./components/VersionLine";
import LoadingFrame from "../../components/LoadingFrame";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson } from "./utils/campaignView";
import { asRecord, getCampaignJson } from "./utils/campaignView";
import { downloadJson, safeFileStem } from "./utils/draftEditor";
export default function CampaignJsonView({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
@@ -19,7 +20,7 @@ export default function CampaignJsonView({ settings, campaignId }: { settings: A
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>JSON</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>

View File

@@ -109,7 +109,9 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
{campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}
</td>
<td className="updated-cell">{formatDateTime(campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at)}</td>
<td><Link className="table-action-link" to={`/campaigns/${campaign.id}`}>View</Link></td>
<td>
<Link to={`/campaigns/${campaign.id}`}><Button variant="primary">Open</Button></Link>
</td>
</tr>
))}
</tbody>

View File

@@ -1,91 +1,82 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import PageTitle from "../../components/PageTitle";
import ConfirmDialog from "../../components/ConfirmDialog";
import FormField from "../../components/FormField";
import LoadingFrame from "../../components/LoadingFrame";
import { createNewCampaign, publishCampaignVersion, updateCampaignVersion } from "../../api/campaigns";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import { publishCampaignVersion, updateCampaignMetadata, type CampaignVersionListItem } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import {
asArray,
asRecord,
cloneCampaignJsonForCopy,
getCampaignJson,
getString,
isAuditLockedVersion,
summaryValue,
timestampSlug,
versionLockReason
} from "./utils/campaignView";
import { addressesFromValue } from "../../utils/emailAddresses";
import { canUnlockValidationVersion, formatDateTime, isFinalLockedVersion, isUserLockedVersion, isVersionReadyForDelivery, summaryValue } from "./utils/campaignView";
const campaignModeOptions = ["draft", "test", "send"];
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const navigate = useNavigate();
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const [copying, setCopying] = useState(false);
const [locking, setLocking] = useState(false);
const campaign = data.campaign;
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" });
const [identityDirty, setIdentityDirty] = useState(false);
const [savingIdentity, setSavingIdentity] = useState(false);
const [lockingVersion, setLockingVersion] = useState<CampaignVersionListItem | null>(null);
const [lockBusy, setLockBusy] = useState(false);
const [message, setMessage] = useState("");
const campaign = data.campaign;
const currentVersion = data.currentVersion;
const campaignJson = getCampaignJson(currentVersion);
const locked = isAuditLockedVersion(currentVersion);
const cards = data.summary?.cards;
const overviewFacts = getOverviewFacts(campaignJson, campaign);
async function copyCampaign() {
if (!currentVersion) return;
setCopying(true);
setMessage("");
setError("");
try {
const copy = cloneCampaignJsonForCopy(campaignJson, campaign, timestampSlug());
const created = await createNewCampaign(settings, {
external_id: copy.externalId,
name: copy.name,
description: copy.description,
current_flow: "manual",
current_step: "copied"
useEffect(() => {
if (!campaign || identityDirty) return;
setIdentity({
external_id: campaign.external_id ?? "",
name: campaign.name ?? "",
status: campaign.status ?? "",
description: campaign.description ?? ""
});
await updateCampaignVersion(settings, created.campaign.id, created.version.id, {
campaign_json: copy.rawJson,
current_flow: "manual",
current_step: null,
workflow_state: "editing",
is_complete: false,
editor_state: {
copied_from_campaign_id: campaignId,
copied_from_version_id: currentVersion.id
}
});
navigate(`/campaigns/${created.campaign.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setCopying(false);
}
}
}, [campaign, identityDirty]);
async function lockCampaign() {
if (!currentVersion || locked) return;
const confirmed = window.confirm(
"Lock this campaign version for audit-safe use? The current version should no longer be edited afterwards; create a copy if you need a new working version."
);
if (!confirmed) return;
setLocking(true);
function patchIdentity(key: keyof typeof identity, value: string) {
setIdentity((current) => ({ ...current, [key]: value }));
setIdentityDirty(true);
setMessage("");
}
async function saveIdentity() {
if (!campaign || savingIdentity || !identityDirty) return;
setSavingIdentity(true);
setError("");
setMessage("");
try {
await publishCampaignVersion(settings, campaignId, currentVersion.id);
setMessage("Campaign version locked as the current audit-safe version.");
await updateCampaignMetadata(settings, campaign.id, {
external_id: identity.external_id,
name: identity.name,
status: identity.status,
description: identity.description
});
setIdentityDirty(false);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLocking(false);
setSavingIdentity(false);
}
}
async function lockAuditSnapshot() {
const version = lockingVersion;
if (!version || lockBusy) return;
setLockBusy(true);
setError("");
setMessage("");
try {
await publishCampaignVersion(settings, campaignId, version.id);
setMessage(`Version #${version.version_number} locked as audit-safe snapshot.`);
setLockingVersion(null);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLockBusy(false);
}
}
@@ -94,14 +85,12 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>{campaign?.name || "Overview"}</PageTitle>
<p className="mono-small">{campaign?.external_id || campaignId}</p>
<p className="mono-small">Campaign overview · version-independent identity and version history</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button onClick={copyCampaign} disabled={!currentVersion || copying}>{copying ? "Copying…" : "Copy campaign"}</Button>
<Button variant="primary" onClick={lockCampaign} disabled={!currentVersion || locked || locking}>
{locking ? "Locking…" : locked ? "Locked" : "Lock campaign"}
</Button>
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>Reload</Button>
<Link to="wizard/create"><Button>Edit with wizard</Button></Link>
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "Saving…" : "Save"}</Button>
</div>
</div>
@@ -109,133 +98,113 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
{message && <div className="alert success">{message}</div>}
<LoadingFrame loading={loading} label="Loading campaign overview…">
{locked && (
<div className="alert info">
This version is audit-safe and should be treated as read-only. {versionLockReason(currentVersion)} Only workflow state should change from here.
</div>
)}
<div className="metric-grid">
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Built and ready or warning" />
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review before sending" />
<MetricCard label="Sent" value={cards?.sent ?? "—"} tone="info" detail="SMTP success" />
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<Card title="Guided actions" actions={<span className="muted small-note">Wizards change or advance the campaign; data pages display and edit the current working draft.</span>}>
<div className="wizard-action-grid">
<WizardAction
title={locked ? "Create a new working copy" : "Edit campaign structure"}
description={locked ? "This version is locked. Copy the campaign before editing structural data." : "Open the structured create/edit wizard for overview, recipients, template and attachments."}
to="wizard/create"
label={locked ? "Open wizard read-only" : "Open Create Campaign"}
/>
<WizardAction
title="Resolve review issues"
description="Use a guided flow for validation issues, missing recipients or attachment decisions."
to="wizard/review"
label="Open Review Wizard"
/>
<WizardAction
title="Prepare sending"
description="Use the sending wizard for dry runs, rate limits, test sending and queue preparation."
to="wizard/send"
label="Open Send Wizard"
/>
<Card title="Campaign identity">
<div className="form-grid campaign-identity-grid">
<FormField label="Campaign ID">
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
</FormField>
<FormField label="Mode">
<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="Name">
<input value={identity.name} onChange={(event) => patchIdentity("name", event.target.value)} />
</FormField>
<FormField label="Description">
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
</FormField>
</div>
</Card>
<div className="overview-config-grid">
<ConfigShortcutCard
title="General"
description="Name, sender and global recipients."
facts={overviewFacts.campaignSettings}
actions={[{ to: "data", label: "General" }]}
/>
<ConfigShortcutCard
title="Global settings"
description="Policies, opt-ins and delivery defaults."
facts={overviewFacts.globalSettings}
actions={[{ to: "global-settings", label: "Global settings" }]}
/>
<ConfigShortcutCard
title="Fields"
description="Field definitions and global values."
facts={overviewFacts.fields}
actions={[{ to: "fields", label: "Fields" }]}
/>
<ConfigShortcutCard
title="Recipients"
description="Recipient list and per-recipient values."
facts={overviewFacts.recipients}
actions={[{ to: "recipients", label: "Recipients" }]}
/>
<ConfigShortcutCard
title="Template"
description="Message content, preview and field usage."
facts={overviewFacts.template}
actions={[{ to: "template", label: "Template" }]}
/>
<ConfigShortcutCard
title="Attachments"
description="Global attachments and per-recipient rules."
facts={overviewFacts.files}
actions={[{ to: "files", label: "Attachments" }]}
/>
<Card title="Version history">
<div className="app-table-wrap">
<table className="app-table version-history-table">
<thead>
<tr>
<th>Version</th>
<th>State</th>
<th>Lock</th>
<th>Validation</th>
<th>Build</th>
<th>Updated</th>
<th aria-label="Actions"></th>
</tr>
</thead>
<tbody>
{versions.length === 0 && (
<tr><td colSpan={7} className="muted">No versions found.</td></tr>
)}
{versions.map((version) => {
const isCurrentVersion = version.id === data.currentVersion?.id;
return (
<tr key={version.id} className={isCurrentVersion ? "current-version-row" : undefined}>
<td>#{version.version_number}</td>
<td><StatusBadge status={version.workflow_state ?? "editing"} /></td>
<td>{versionLockLabel(version)}</td>
<td>{validationLabel(version)}</td>
<td>{buildLabel(version)}</td>
<td>{formatDateTime(version.updated_at)}</td>
<td className="table-action-cell">
<div className="button-row compact-actions">
<Link to={`review?version=${version.id}`}><Button variant="primary">Open</Button></Link>
<Button
onClick={() => setLockingVersion(version)}
disabled={isUserLockedVersion(version) || isFinalLockedVersion(version) || canUnlockValidationVersion(version)}
>Lock</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
<Card title="Validation and build state">
<Card title="Current version state">
<div className="summary-grid overview-summary-grid">
<SummaryTile label="Validation errors" value={summaryValue(currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="Warnings" value={summaryValue(currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="Built messages" value={summaryValue(currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="Jobs total" value={cards?.jobs_total ?? "—"} />
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="Warnings" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="Built messages" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} />
</div>
<p className="muted">Review and sending are displayed on their own data pages; use the guided buttons above to change state.</p>
</Card>
</LoadingFrame>
<ConfirmDialog
open={Boolean(lockingVersion)}
title="Lock audit-safe snapshot?"
message="This is a user-requested final lock. The version cannot be validated, unlocked, built, dry-run or sent afterwards. Create an editable copy for future changes."
confirmLabel="Lock snapshot"
tone="danger"
busy={lockBusy}
onCancel={() => setLockingVersion(null)}
onConfirm={() => void lockAuditSnapshot()}
/>
</div>
);
}
type OverviewFact = {
label: string;
value: string | number;
};
function versionLockLabel(version: CampaignVersionListItem): string {
if (isUserLockedVersion(version)) return "User locked";
if (isFinalLockedVersion(version)) return "Final";
if (canUnlockValidationVersion(version)) return "Validation lock";
if (version.locked_at) return "Locked";
return "Editable";
}
function ConfigShortcutCard({
title,
description,
facts,
actions
}: {
title: string;
description: string;
facts: OverviewFact[];
actions: Array<{ to: string; label: string }>;
}) {
return (
<section className="overview-config-card">
<h3>{title}</h3>
<p>{description}</p>
<dl className="overview-config-facts">
{facts.map((fact) => (
<div key={fact.label}>
<dt>{fact.label}</dt>
<dd>{fact.value}</dd>
</div>
))}
</dl>
<div className="overview-config-actions">
{actions.map((action) => (
<Link key={action.to} to={action.to}>
<Button>{action.label}</Button>
</Link>
))}
</div>
</section>
);
function validationLabel(version: CampaignVersionListItem): string {
const validation = version.validation_summary ?? {};
if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed";
if (validation.ok === false) return "Needs attention";
if (validation.ok === true) return "Invalid for delivery";
return "Not validated";
}
function buildLabel(version: CampaignVersionListItem): string {
const build = version.build_summary ?? {};
return String(build.built_count ?? build.ready_count ?? "Not built");
}
function SummaryTile({ label, value }: { label: string; value: string | number }) {
@@ -246,98 +215,3 @@ function SummaryTile({ label, value }: { label: string; value: string | number }
</div>
);
}
function WizardAction({ title, description, to, label }: { title: string; description: string; to: string; label: string }) {
return (
<section className="wizard-action-card">
<h3>{title}</h3>
<p>{description}</p>
<Link to={to}><Button>{label}</Button></Link>
</section>
);
}
function getOverviewFacts(rawJson: Record<string, unknown>, campaign: { name?: string; external_id?: string; id?: string; status?: string } | null) {
const campaignSection = asRecord(rawJson.campaign);
const recipients = asRecord(rawJson.recipients);
const attachments = asRecord(rawJson.attachments);
const template = asRecord(rawJson.template);
const entries = asRecord(rawJson.entries);
const validationPolicy = asRecord(rawJson.validation_policy);
const delivery = asRecord(rawJson.delivery);
const fields = asArray(rawJson.fields).map(asRecord);
const globalValues = asRecord(rawJson.global_values);
const inlineEntries = asArray(entries.inline).map(asRecord);
const entrySource = asRecord(entries.source);
const globalAttachmentRules = asArray(attachments.global).map(asRecord);
const individualAttachmentRules = inlineEntries.reduce((count, entry) => count + asArray(entry.attachments).length, 0);
const globalRecipients = ["to", "cc", "bcc"].reduce((count, key) => count + addressesFromValue(recipients[key]).length, 0);
return {
campaignSettings: [
{ label: "Name", value: getString(campaignSection, "name", campaign?.name || "—") },
{ label: "Campaign ID", value: getString(campaignSection, "id", campaign?.external_id || campaign?.id || "—") },
{ label: "Sender", value: formatMailbox(recipients.from) }
],
globalSettings: [
{ label: "Mode", value: getString(campaignSection, "mode", campaign?.status || "draft") },
{ label: "Attachment policy", value: `${getString(attachments, "missing_behavior", "ask")} / ${getString(attachments, "ambiguous_behavior", "ask")}` },
{ label: "Delivery", value: getString(delivery, "mode", getString(validationPolicy, "send_without_attachments", "standard")) }
],
fields: [
{ label: "Fields", value: fields.length },
{ label: "Global values", value: Object.keys(globalValues).length },
{ label: "Required", value: fields.filter((field) => field.required === true).length }
],
recipients: [
{ label: "Recipients", value: recipientSummary(inlineEntries, entrySource) },
{ label: "Global recipients", value: globalRecipients },
{ label: "Source", value: sourceSummary(entrySource) }
],
template: [
{ label: "Subject", value: getString(template, "subject", "Not configured") },
{ label: "Source", value: templateSourceSummary(template) },
{ label: "Placeholders", value: countTemplatePlaceholders(template) }
],
files: [
{ label: "Base path", value: getString(attachments, "base_path", ".") },
{ label: "Global files", value: globalAttachmentRules.length },
{ label: "Individual rules", value: individualAttachmentRules }
]
};
}
function formatMailbox(value: unknown): string {
const [address] = addressesFromValue(value);
if (!address) return "Not configured";
return address.name ? `${address.name} <${address.email}>` : address.email;
}
function recipientSummary(inlineEntries: Record<string, unknown>[], source: Record<string, unknown>): string {
if (inlineEntries.length) return `${inlineEntries.length} inline`;
if (Object.keys(source).length) return "External source";
return "Not configured";
}
function sourceSummary(source: Record<string, unknown>): string {
if (!Object.keys(source).length) return "Inline / manual";
return getString(source, "type", getString(source, "path", "External"));
}
function templateSourceSummary(template: Record<string, unknown>): string {
const libraryId = getString(template, "library_id", "");
const templateId = getString(template, "template_id", "");
const source = getString(template, "source", "");
if (libraryId) return `Library: ${libraryId}`;
if (templateId) return `Library: ${templateId}`;
if (source) return source;
return "Inline campaign template";
}
function countTemplatePlaceholders(template: Record<string, unknown>): number {
const text = `${getString(template, "subject", "")}
${getString(template, "text", "")}
${getString(template, "html", "")}`;
const matches = text.match(/\{\{\s*[\w.-]+\s*\}\}/g) ?? [];
return new Set(matches.map((item) => item.replace(/[{}\s]/g, ""))).size;
}

View File

@@ -2,6 +2,7 @@ import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import VersionLine from "./components/VersionLine";
import LoadingFrame from "../../components/LoadingFrame";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { formatDateTime } from "./utils/campaignView";
@@ -16,7 +17,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Report</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at ?? data.summary?.generated_at)}</p>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>

View File

@@ -2,7 +2,6 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "re
import type { ApiSettings, CampaignWorkspaceSection } from "../../types";
import SectionSidebar from "../../layout/SectionSidebar";
import CampaignOverviewPage from "./CampaignOverviewPage";
import CampaignDataPage from "./CampaignDataPage";
import CampaignFieldsPage from "./CampaignFieldsPage";
import GlobalSettingsPage from "./GlobalSettingsPage";
import RecipientDataPage from "./RecipientDataPage";
@@ -22,7 +21,7 @@ import { CampaignUnsavedChangesProvider, useCampaignUnsavedChanges } from "./con
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
overview: "",
campaign: "data",
campaign: "recipients",
"global-settings": "global-settings",
fields: "fields",
recipients: "recipients",
@@ -65,7 +64,7 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
<section className="workspace-content">
<Routes>
<Route index element={<CampaignOverviewPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="data" element={<CampaignDataPage settings={settings} 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 || ""} />} />
<Route path="recipient-data" element={<RecipientDetailsPage settings={settings} campaignId={campaignId || ""} />} />
@@ -99,7 +98,7 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
const section = segments[2];
if (!section || section === "wizard" || section === "create") return "overview";
if (section === "data" || section === "campaign") return "campaign";
if (section === "data" || section === "campaign") return "recipients";
if (section === "global-settings" || section === "settings") return "global-settings";
if (section === "fields") return "fields";
if (section === "recipients") return "recipients";

View File

@@ -5,10 +5,12 @@ import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
@@ -57,7 +59,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Global settings</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
@@ -67,7 +69,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing global settings.</div>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing global settings." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
@@ -116,7 +118,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save current draft</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
</LoadingFrame>

View File

@@ -5,11 +5,13 @@ import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool, getNumber, getText } from "./utils/draftEditor";
const securityOptions = ["plain", "tls", "starttls"];
@@ -140,7 +142,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Server settings</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
@@ -150,7 +152,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing server settings.</div>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing server settings." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>

View File

@@ -5,11 +5,13 @@ import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import EmailAddressInput from "../../components/email/EmailAddressInput";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool } from "./utils/draftEditor";
import {
addressesFromValue,
@@ -54,6 +56,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
const defaultFrom = addressesFromValue(recipientsSection.from);
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
const globalRecipientValues: Record<string, MailboxAddress[]> = {
to: addressesFromValue(recipientsSection.to),
cc: addressesFromValue(recipientsSection.cc),
@@ -121,8 +125,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Recipients</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
<PageTitle loading={loading}>Sender & Recipients</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
@@ -132,10 +136,60 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing sender or recipient profiles." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<Card title="Campaign sender">
<div className="campaign-header-stack">
<div className="campaign-header-grid">
<FormField label="Default From address">
<EmailAddressInput
value={defaultFrom}
suggestions={addressSuggestions}
allowMultiple={false}
showAddButton={false}
disabled={locked}
addLabel={defaultFrom.length ? "Replace" : "Add sender"}
emptyText="No default sender configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses[0] ?? { name: "", email: "" })}
/>
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label="Allow individual senders"
checked={getBool(recipientsSection, "allow_individual_from")}
disabled={locked}
onChange={(checked) => patch(["recipients", "allow_individual_from"], checked)}
/>
</div>
</div>
<div className="campaign-header-grid">
<FormField label="Global Reply-To address">
<EmailAddressInput
value={globalReplyTo.slice(0, 1)}
suggestions={addressSuggestions}
allowMultiple={false}
showAddButton={false}
disabled={locked}
addLabel={globalReplyTo.length ? "Replace" : "Add Reply-To"}
emptyText="No Reply-To address configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))}
/>
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label="Allow individual Reply-To"
checked={getBool(recipientsSection, "allow_individual_reply_to")}
disabled={locked}
onChange={(checked) => patch(["recipients", "allow_individual_reply_to"], checked)}
/>
</div>
</div>
</div>
</Card>
<Card title="Global recipient headers">
<div className="campaign-header-stack">
{recipientHeaderRows.map((row) => (

View File

@@ -5,9 +5,11 @@ import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
import { getDraftFields } from "./utils/fieldDefinitions";
@@ -69,7 +71,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Recipient data</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
@@ -79,7 +81,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing recipient data." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>

View File

@@ -4,19 +4,24 @@ import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import Card from "../../components/Card";
import StatusBadge from "../../components/StatusBadge";
import { buildVersion, validateVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, stringifyPreview, summaryValue, versionLockReason } from "./utils/campaignView";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isFinalLockedVersion, isUserLockedVersion, isVersionReadyForDelivery, stringifyPreview, summaryValue, versionLockReason } from "./utils/campaignView";
export default function ReviewDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const auditSafe = isUserLockedVersion(version) || isFinalLockedVersion(version);
const issues = collectIssues(data.summary?.issues);
const validationSummary = asRecord(version?.validation_summary);
const buildSummary = asRecord(version?.build_summary);
const validationOk = validationSummary.ok === true;
const readyForDelivery = isVersionReadyForDelivery(version);
const [actionBusy, setActionBusy] = useState<"validate" | "build" | "" >("");
const [actionMessage, setActionMessage] = useState("");
@@ -27,7 +32,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
setError("");
try {
const result = await validateVersion(settings, version.id, false);
setActionMessage(result.ok ? "Validation passed. This version is now locked." : "Validation finished with issues. Fix the campaign and validate again.");
setActionMessage(result.ok ? "Validation passed. This version is now locked but can still be unlocked before sending." : "Validation finished with issues. Fix the campaign and validate again.");
await reload();
} catch (err) {
setActionMessage("");
@@ -40,7 +45,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
async function runBuild() {
if (!version || actionBusy) return;
setActionBusy("build");
setActionMessage("Building messages for the locked version…");
setActionMessage("Building the queue for the locked, validated version…");
setError("");
try {
const result = await buildVersion(settings, version.id, true);
@@ -59,7 +64,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading || Boolean(actionBusy)}>Review</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading || Boolean(actionBusy)}>Reload</Button>
@@ -69,15 +74,16 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
{error && <div className="alert danger">{error}</div>}
{actionMessage && <div className="alert info">{actionMessage}</div>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message={auditSafe ? "Audit-safe snapshot. Create a copy to continue." : "Validated snapshot. Unlock before sending, or copy to edit."} />}
<LoadingFrame loading={loading} label="Loading review data…">
<Card title="Review actions" actions={<span className="muted small-note">Validation locks the exact version that was checked.</span>}>
<Card title="Review actions" actions={<span className="muted small-note">Validation locks this version; unlocking invalidates validation before sending.</span>}>
<div className="button-row compact-actions">
<Button variant="primary" onClick={runValidate} disabled={!version || loading || Boolean(actionBusy)}>
{actionBusy === "validate" ? "Validating…" : validationOk ? "Validate again" : "Validate and lock"}
<Button variant="primary" onClick={runValidate} disabled={!version || loading || Boolean(actionBusy) || readyForDelivery || auditSafe}>
{actionBusy === "validate" ? "Validating…" : readyForDelivery ? "Validated and locked" : validationOk ? "Validate again" : "Validate and lock"}
</Button>
<Button onClick={runBuild} disabled={!version || loading || Boolean(actionBusy) || !validationOk}>
{actionBusy === "build" ? "Building…" : "Build messages"}
<Button onClick={runBuild} disabled={!version || loading || Boolean(actionBusy) || !readyForDelivery}>
{actionBusy === "build" ? "Building…" : "Build queue"}
</Button>
</div>
<dl className="detail-list compact-detail-list">

View File

@@ -2,18 +2,22 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import ConfirmDialog from "../../components/ConfirmDialog";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import StatusBadge from "../../components/StatusBadge";
import { sendCampaignNow } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, getDeliverySection, getNestedString, stringifyPreview, versionLockReason } from "./utils/campaignView";
import { asArray, asRecord, getDeliverySection, getNestedString, isAuditLockedVersion, isVersionReadyForDelivery, stringifyPreview, versionLockReason } from "./utils/campaignView";
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const cards = data.summary?.cards;
const delivery = getDeliverySection(version);
const rateLimit = asRecord(delivery.rate_limit);
@@ -22,12 +26,14 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
const [sendBusy, setSendBusy] = useState(false);
const [sendMessage, setSendMessage] = useState("");
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
const readyForDelivery = isVersionReadyForDelivery(version);
const hasBuild = Boolean(version?.build_summary);
async function runSendNow(dryRun = false) {
if (!version || sendBusy) return;
if (!dryRun && !window.confirm("Send this campaign version now? The validated version will remain locked as the sent audit snapshot.")) return;
if (!version || sendBusy || !readyForDelivery || !hasBuild) return;
setSendBusy(true);
setSendMessage(dryRun ? "Checking what would be sent…" : "Validating, building and sending campaign…");
setSendMessage(dryRun ? "Checking the built queue without sending…" : "Sending the locked campaign version…");
setSendResult(null);
setError("");
try {
@@ -35,8 +41,8 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
version_id: version.id,
include_warnings: true,
check_files: false,
validate_before_send: true,
build_before_send: true,
validate_before_send: false,
build_before_send: false,
dry_run: dryRun,
use_rate_limit: true,
enqueue_imap_task: false
@@ -70,7 +76,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading || sendBusy}>Send</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading || sendBusy}>Reload</Button>
@@ -80,6 +86,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
{error && <div className="alert danger">{error}</div>}
{sendMessage && <div className="alert info">{sendMessage}</div>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Send snapshot. Copy to edit." />}
<LoadingFrame loading={loading || queuedOrSending} label={queuedOrSending ? "Refreshing send state…" : "Loading send data…"}>
<div className="metric-grid">
@@ -89,10 +96,12 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<Card title="Send campaign" actions={<span className="muted small-note">Small-campaign synchronous send; larger campaigns can use queue workers later.</span>}>
<Card title="Send campaign" actions={<span className="muted small-note">Requires a validated, locked and built version. Sending makes it final.</span>}>
{!readyForDelivery && <div className="alert warning compact-alert">Validate and lock this version in Review before dry-run or sending.</div>}
{readyForDelivery && !hasBuild && <div className="alert warning compact-alert">Build the queue in Review before dry-run or sending.</div>}
<div className="button-row compact-actions">
<Button onClick={() => void runSendNow(true)} disabled={!version || loading || sendBusy}>Dry run</Button>
<Button variant="primary" onClick={() => void runSendNow(false)} disabled={!version || loading || sendBusy}>
<Button onClick={() => void runSendNow(true)} disabled={!version || loading || sendBusy || !readyForDelivery || !hasBuild}>Dry run</Button>
<Button variant="primary" onClick={() => setSendConfirmOpen(true)} disabled={!version || loading || sendBusy || !readyForDelivery || !hasBuild}>
{sendBusy ? "Sending…" : "Send now"}
</Button>
</div>
@@ -100,7 +109,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
<div><dt>Campaign status</dt><dd><StatusBadge status={data.campaign?.status ?? "unknown"} /></dd></div>
<div><dt>Version state</dt><dd>{version?.workflow_state ?? "—"}</dd></div>
<div><dt>Version lock</dt><dd>{versionLockReason(version)}</dd></div>
<div><dt>Validation/build</dt><dd>{version?.validation_summary ? "Validation available" : "Will validate before send"} · {version?.build_summary ? "Build available" : "Will build before send"}</dd></div>
<div><dt>Validation/build</dt><dd>{readyForDelivery ? "Validated and locked" : "Not ready"} · {hasBuild ? "Build available" : "Not built"}</dd></div>
</dl>
{sendResult && (
<div className="send-result-panel">
@@ -156,6 +165,19 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
</p>
</Card>
</LoadingFrame>
<ConfirmDialog
open={sendConfirmOpen}
title="Send this version now?"
message="This sends the built queue and keeps this version as the final audit snapshot. Further changes require a new editable copy."
confirmLabel="Send now"
tone="danger"
busy={sendBusy}
onCancel={() => setSendConfirmOpen(false)}
onConfirm={() => {
setSendConfirmOpen(false);
void runSendNow(false);
}}
/>
</div>
);
}

View File

@@ -5,9 +5,11 @@ import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { cloneJson, getBool, getText } from "./utils/draftEditor";
import { humanizeFieldName } from "./utils/fieldDefinitions";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type TemplatePlaceholder, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
@@ -127,7 +129,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Template</PageTitle>
<p className="mono-small">{saveState}</p>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button disabled>Manage templates</Button>
@@ -138,7 +140,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing the template." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>

View File

@@ -0,0 +1,101 @@
import { useState } from "react";
import type { ApiSettings } from "../../../types";
import Button from "../../../components/Button";
import ConfirmDialog from "../../../components/ConfirmDialog";
import { forkCampaignVersion, unlockCampaignVersionValidation, type CampaignVersionDetail, type CampaignVersionListItem } from "../../../api/campaigns";
import { canUnlockValidationVersion, formatDateTime } from "../utils/campaignView";
type LockedVersionNoticeProps = {
settings: ApiSettings;
campaignId: string;
version: CampaignVersionDetail | CampaignVersionListItem | null;
reload: () => Promise<void>;
message?: string;
};
export default function LockedVersionNotice({ settings, campaignId, version, reload, message }: LockedVersionNoticeProps) {
const [busy, setBusy] = useState(false);
const [localError, setLocalError] = useState("");
const [localMessage, setLocalMessage] = useState("");
const [unlockConfirmOpen, setUnlockConfirmOpen] = useState(false);
const canUnlock = canUnlockValidationVersion(version);
const noticeText = message ?? (canUnlock ? "Validated version. Unlock to edit, or create a copy." : "Final snapshot. Create a copy to edit.");
const lockInfo = getCompactLockInfo(version, canUnlock);
async function unlockValidation() {
if (!version || busy) return;
setBusy(true);
setLocalError("");
setLocalMessage("");
try {
await unlockCampaignVersionValidation(settings, campaignId, version.id);
setLocalMessage("Validation invalidated; version is editable again.");
await reload();
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function createEditableCopy() {
if (!version || busy) return;
setBusy(true);
setLocalError("");
setLocalMessage("");
try {
const result = await forkCampaignVersion(settings, campaignId, version.id, {
current_flow: "manual",
current_step: version.current_step ?? null
});
setLocalMessage(`Created editable version #${result.version.version_number}.`);
await reload();
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<div className="alert info locked-version-notice">
<div className="locked-version-copy">
<strong>Locked version.</strong>{" "}
<span>{noticeText}</span>
{lockInfo && <span className="locked-version-reason"> {lockInfo}</span>}
{localMessage && <span className="locked-version-feedback"> {localMessage}</span>}
{localError && <span className="locked-version-error"> {localError}</span>}
</div>
<div className="button-row compact-actions locked-version-actions">
{canUnlock && (
<Button onClick={() => setUnlockConfirmOpen(true)} disabled={!version || busy}>
{busy ? "Working…" : "Unlock validation"}
</Button>
)}
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
{busy ? "Creating copy…" : "Create editable copy"}
</Button>
</div>
<ConfirmDialog
open={unlockConfirmOpen}
title="Unlock validation?"
message="This unlocks the version for editing and clears validation/build results and generated jobs for this version."
confirmLabel="Unlock validation"
tone="danger"
busy={busy}
onCancel={() => setUnlockConfirmOpen(false)}
onConfirm={() => {
setUnlockConfirmOpen(false);
void unlockValidation();
}}
/>
</div>
);
}
function getCompactLockInfo(version: CampaignVersionDetail | CampaignVersionListItem | null, canUnlock: boolean): string {
if (!version?.locked_at) return "";
const label = canUnlock ? "Validated" : "Locked";
return `${label} ${formatDateTime(version.locked_at)}.`;
}

View File

@@ -0,0 +1,127 @@
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
import { useCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
import { formatDateTime } from "../utils/campaignView";
type VersionLineProps = {
version: CampaignVersionDetail | CampaignVersionListItem | null;
versions?: CampaignVersionListItem[];
status?: string;
loadedAt?: string | null;
};
export default function VersionLine({ version, versions = [], status, loadedAt }: VersionLineProps) {
const location = useLocation();
const navigate = useNavigate();
const { requestNavigation } = useCampaignUnsavedChanges();
const sorted = versions.slice().sort((a, b) => (a.version_number ?? 0) - (b.version_number ?? 0));
const currentIndex = version ? sorted.findIndex((item) => item.id === version.id) : -1;
const first = currentIndex > 0 ? sorted[0] : null;
const previous = currentIndex > 0 ? sorted[currentIndex - 1] : null;
const next = currentIndex >= 0 && currentIndex < sorted.length - 1 ? sorted[currentIndex + 1] : null;
const latest = currentIndex >= 0 && currentIndex < sorted.length - 1 ? sorted[sorted.length - 1] : null;
const suffix = status ?? `Loaded ${formatDateTime(loadedAt ?? version?.updated_at)}`;
function openVersion(target: CampaignVersionListItem) {
const targetUrl = versionTarget(location.pathname, location.search, target.id);
requestNavigation(() => navigate(targetUrl));
}
return (
<p className="mono-small version-line">
<VersionArrow
icon="first"
target={first}
fallbackLabel="Already at first version"
onOpen={openVersion}
/>
<VersionArrow
icon="previous"
target={previous}
fallbackLabel="No older version"
onOpen={openVersion}
/>
<span>Version {version ? `#${version.version_number}` : "—"}</span>
<VersionArrow
icon="next"
target={next}
fallbackLabel="No newer version"
onOpen={openVersion}
/>
<VersionArrow
icon="latest"
target={latest}
fallbackLabel="Already at latest version"
onOpen={openVersion}
/>
<span className="version-line-separator">·</span>
<span>{suffix}</span>
</p>
);
}
type VersionArrowProps = {
icon: "first" | "previous" | "next" | "latest";
target: CampaignVersionListItem | null;
fallbackLabel: string;
onOpen: (target: CampaignVersionListItem) => void;
};
function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps) {
const Icon = getVersionIcon(icon);
const actionLabel = getVersionActionLabel(icon, target);
if (!target) {
return (
<span className="version-arrow disabled" title={fallbackLabel} aria-label={fallbackLabel}>
<Icon aria-hidden="true" />
</span>
);
}
return (
<button
type="button"
className="version-arrow"
onClick={() => onOpen(target)}
title={actionLabel}
aria-label={actionLabel}
>
<Icon aria-hidden="true" />
</button>
);
}
function getVersionIcon(icon: VersionArrowProps["icon"]) {
switch (icon) {
case "first":
return ArrowBigLeftDash;
case "previous":
return ArrowBigLeft;
case "next":
return ArrowBigRight;
case "latest":
return ArrowBigRightDash;
}
}
function getVersionActionLabel(icon: VersionArrowProps["icon"], target: CampaignVersionListItem | null): string {
const versionLabel = target ? `version #${target.version_number}` : "version";
switch (icon) {
case "first":
return `Open first ${versionLabel}`;
case "previous":
return `Open previous ${versionLabel}`;
case "next":
return `Open next ${versionLabel}`;
case "latest":
return `Open latest ${versionLabel}`;
}
}
function versionTarget(pathname: string, search: string, versionId: string): string {
const params = new URLSearchParams(search);
params.set("version", versionId);
return `${pathname}?${params.toString()}`;
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { ApiSettings } from "../../../types";
import {
getCampaign,
@@ -6,8 +7,7 @@ import {
getCampaignVersion,
listCampaignVersions,
type CampaignSummary,
type CampaignVersionDetail,
type CampaignVersionListItem
type CampaignVersionDetail
} from "../../../api/campaigns";
import type { CampaignWorkspaceData } from "../utils/campaignView";
@@ -34,6 +34,8 @@ export function useCampaignWorkspaceData(
includeSummary = false,
includeVersions = false
} = options;
const [searchParams] = useSearchParams();
const selectedVersionId = searchParams.get("version");
const [data, setData] = useState<CampaignWorkspaceData>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -44,24 +46,20 @@ export function useCampaignWorkspaceData(
setError("");
try {
const needsCampaign = includeCurrentVersion || includeVersions;
const shouldLoadVersions = includeCurrentVersion || includeVersions;
const summaryPromise = includeSummary ? getCampaignSummary(settings, campaignId) : Promise.resolve(null);
const campaign = needsCampaign ? await getCampaign(settings, campaignId) : null;
let versions: CampaignVersionListItem[] = [];
let selectedVersionId = campaign?.current_version_id;
if (includeVersions || (includeCurrentVersion && !selectedVersionId)) {
versions = await listCampaignVersions(settings, campaignId);
selectedVersionId = selectedVersionId ?? versions[0]?.id;
}
const versions = shouldLoadVersions ? await listCampaignVersions(settings, campaignId) : [];
const wantedVersionId = selectedVersionId || campaign?.current_version_id || versions[0]?.id;
const [versionResult, summaryResult] = await Promise.allSettled([
includeCurrentVersion && selectedVersionId ? getCampaignVersion(settings, campaignId, selectedVersionId) : Promise.resolve(null),
includeCurrentVersion && wantedVersionId ? getCampaignVersion(settings, campaignId, wantedVersionId) : Promise.resolve(null),
summaryPromise
]);
setData({
campaign,
versions: includeVersions ? versions : [],
versions,
currentVersion: versionResult.status === "fulfilled" ? (versionResult.value as CampaignVersionDetail | null) : null,
summary: summaryResult.status === "fulfilled" ? (summaryResult.value as CampaignSummary | null) : null
});
@@ -71,7 +69,7 @@ export function useCampaignWorkspaceData(
} finally {
setLoading(false);
}
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions]);
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId]);
useEffect(() => {
reload();

View File

@@ -52,19 +52,41 @@ export function getFields(version: CampaignVersionDetail | null): unknown[] {
return asArray(getCampaignJson(version).fields);
}
export function isUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
return Boolean(version?.published_at);
}
export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version) return false;
return ["queued", "sending", "completed", "archived", "cancelled"].includes(version.workflow_state ?? "");
}
export function isVersionReadyForDelivery(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version?.locked_at || isUserLockedVersion(version)) return false;
const validation = asRecord(version.validation_summary);
return validation.ok === true;
}
export function canUnlockValidationVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version) return false;
if (isUserLockedVersion(version)) return false;
if (!isVersionReadyForDelivery(version)) return false;
if (isFinalLockedVersion(version)) return false;
return ["approved", "built"].includes(version.workflow_state ?? "");
}
export function isAuditLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version) return false;
if (version.locked_at || version.published_at) return true;
return ["queued", "sending", "completed", "archived", "cancelled"].includes(version.workflow_state ?? "");
return isFinalLockedVersion(version);
}
export function versionLockReason(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
if (!version) return "No campaign version is loaded.";
if (canUnlockValidationVersion(version)) return `Validation locked at ${formatDateTime(version.locked_at)}. Unlocking will invalidate validation and build state.`;
if (isUserLockedVersion(version)) return `User locked at ${formatDateTime(version.published_at)}. Audit-safe; copy only.`;
if (isFinalLockedVersion(version)) return `Final state: ${humanize(version.workflow_state ?? "locked")}.`;
if (version.locked_at) return `Locked at ${formatDateTime(version.locked_at)}.`;
if (version.published_at) return `Published at ${formatDateTime(version.published_at)}.`;
if (["queued", "sending", "completed", "archived", "cancelled"].includes(version.workflow_state ?? "")) {
return `Workflow state is ${humanize(version.workflow_state ?? "locked")}.`;
}
return "Editable working version.";
}

View File

@@ -5,9 +5,10 @@ import Stepper from "../../../components/Stepper";
import Card from "../../../components/Card";
import Button from "../../../components/Button";
import PageTitle from "../../../components/PageTitle";
import LockedVersionNotice from "../components/LockedVersionNotice";
import { validatePartial } from "../../../api/campaigns";
import { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData";
import { isAuditLockedVersion, versionLockReason } from "../utils/campaignView";
import { isAuditLockedVersion } from "../utils/campaignView";
import { useCampaignDraftEditor } from "../hooks/useCampaignDraftEditor";
import { AttachmentsStep, BasicsStep, FieldsStep, RecipientsStep, ReviewStep, SenderStep, SendStep, TemplateStep } from "./steps/CreateWizardSteps";
@@ -87,11 +88,15 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
<div className="save-state">Locked</div>
</div>
<Card>
<div className="alert info">
{versionLockReason(data.currentVersion)} Create or copy a working version before editing campaign data, recipients, template or attachment rules.
</div>
<LockedVersionNotice
settings={settings}
campaignId={campaignId}
version={data.currentVersion}
reload={reload}
message="Create an editable copy before continuing the creation wizard."
/>
<div className="button-row">
<Link to="../.."><Button variant="primary">Back to overview</Button></Link>
<Link to="../.."><Button>Back to overview</Button></Link>
</div>
</Card>
</div>

View File

@@ -23,12 +23,12 @@ export default function BreadcrumbBar({ pathname }: { pathname: string }) {
}
const campaignRouteLabels: Record<string, string> = {
data: "General",
campaign: "General",
data: "Sender & Recipients",
campaign: "Sender & Recipients",
settings: "Global settings",
"global-settings": "Global settings",
fields: "Fields",
recipients: "Recipients",
recipients: "Sender & Recipients",
"recipient-data": "Recipient data",
template: "Template",
files: "Attachments",

View File

@@ -8,10 +8,9 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
{
title: "CAMPAIGN",
items: [
{ id: "campaign", label: "General" },
{ id: "fields", label: "Fields" },
{ id: "files", label: "Attachments" },
{ id: "recipients", label: "Recipients" },
{ id: "recipients", label: "Sender & Recipients" },
{ id: "recipient-data", label: "Recipient data" },
{ id: "template", label: "Template" }
]

View File

@@ -1031,3 +1031,96 @@
margin: 5px 0 0;
color: var(--muted);
}
.locked-version-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.locked-version-copy {
flex: 1 1 auto;
min-width: 0;
}
.locked-version-reason {
white-space: nowrap;
}
.locked-version-feedback {
color: var(--success, #157347);
font-weight: 600;
}
.locked-version-error {
color: var(--danger, #b42318);
font-weight: 600;
}
@media (max-width: 760px) {
.locked-version-notice {
align-items: stretch;
flex-direction: column;
}
}
/* Version navigation ---------------------------------------------------- */
.version-line {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
margin: 6px 0 0;
color: var(--muted);
}
.version-arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
color: var(--text, #243247);
text-decoration: none;
line-height: 1;
font-size: 0.72rem;
}
.version-arrow svg {
width: 1rem;
height: 1rem;
stroke-width: 1.8;
}
.version-arrow:hover {
background: var(--surface-subtle, rgba(15, 23, 42, 0.08));
color: var(--text, #111827);
}
.version-arrow.disabled {
opacity: 0.24;
pointer-events: none;
}
.version-line-separator {
opacity: 0.55;
margin-inline: 0.1rem;
}
.locked-version-actions {
flex: 0 0 auto;
flex-wrap: nowrap;
justify-content: flex-end;
}
.version-line button.version-arrow {
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
font: inherit;
}
/* Overview version history refinements. */
.version-history-table .current-version-row td {
font-weight: 700;
}

View File

@@ -511,3 +511,42 @@
font-size: 0.9rem;
font-weight: 600;
}
/* Reusable confirm dialog ------------------------------------------------ */
.confirm-backdrop {
position: fixed;
inset: 0;
z-index: 12000;
display: grid;
place-items: center;
padding: 1.5rem;
background: rgba(15, 23, 42, 0.36);
backdrop-filter: blur(2px);
}
.confirm-dialog {
width: min(460px, 100%);
padding: 1.25rem;
border: 1px solid var(--line, rgba(15, 23, 42, 0.14));
border-radius: var(--radius-lg, 18px);
background: var(--surface, #fff);
color: var(--text, #172033);
box-shadow: var(--shadow-strong, 0 24px 64px rgba(15, 23, 42, 0.25));
}
.confirm-dialog h2 {
margin: 0 0 0.55rem;
color: var(--text-strong, #111827);
font-size: 1.05rem;
}
.confirm-dialog p {
margin: 0;
color: var(--muted, #5f6b7a);
line-height: 1.5;
}
.confirm-dialog-actions {
justify-content: flex-end;
margin-top: 1.1rem;
}

View File

@@ -113,7 +113,7 @@
.campaign-table th:nth-child(4),
.campaign-table td:nth-child(4) { width: 230px; }
.campaign-table th:nth-child(5),
.campaign-table td:nth-child(5) { width: 86px; text-align: right; }
.campaign-table td:nth-child(5) { width: 105px; text-align: right; }
.campaign-table td.updated-cell {
color: #696660;
font-size: 12px;

View File

@@ -11,7 +11,7 @@ const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
template: { id: "campaign.template", title: "Template" },
files: { id: "campaign.attachments", title: "Attachments" },
attachments: { id: "campaign.attachments", title: "Attachments" },
recipients: { id: "campaign.recipients", title: "Recipients" },
recipients: { id: "campaign.recipients", title: "Sender & Recipients" },
"recipient-data": { id: "campaign.recipient-data", title: "Recipient data" },
"mail-settings": { id: "campaign.server-settings", title: "Server settings" },
"server-settings": { id: "campaign.server-settings", title: "Server settings" },