Rework of campaign structure; locking
This commit is contained in:
@@ -9,6 +9,14 @@ export type CampaignListResponse =
|
|||||||
results?: CampaignListItem[];
|
results?: CampaignListItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type CampaignUpdatePayload = {
|
||||||
|
external_id?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignCreateMinimalPayload = {
|
export type CampaignCreateMinimalPayload = {
|
||||||
external_id?: string;
|
external_id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -138,6 +146,17 @@ export async function getCampaign(settings: ApiSettings, campaignId: string): Pr
|
|||||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
|
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(
|
export async function createNewCampaign(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
overrides: CampaignCreateMinimalPayload = {}
|
overrides: CampaignCreateMinimalPayload = {}
|
||||||
@@ -177,6 +196,16 @@ export async function getCampaignVersion(
|
|||||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
|
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(
|
export async function updateCampaignVersion(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
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(
|
export async function autosaveCampaignVersion(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
|
|||||||
44
src/components/ConfirmDialog.tsx
Normal file
44
src/components/ConfirmDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ import Button from "../../components/Button";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
|
import VersionLine from "./components/VersionLine";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
import { updateNested } from "./utils/draftEditor";
|
import { updateNested } from "./utils/draftEditor";
|
||||||
import { AttachmentRulesTable } from "./components/AttachmentRulesOverlay";
|
import { AttachmentRulesTable } from "./components/AttachmentRulesOverlay";
|
||||||
import { countIndividualAttachmentRules, createAttachmentBasePath, createAttachmentRule, ensureAttachmentBasePaths, mockAttachmentPathOptions, normalizeAttachmentBasePaths, normalizeAttachmentRules, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
|
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 className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Attachments</PageTitle>
|
<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>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button disabled>Manage files</Button>
|
<Button disabled>Manage files</Button>
|
||||||
@@ -84,7 +86,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
|
|
||||||
{error && <div className="alert danger">{error}</div>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
{localError && <div className="alert danger">{localError}</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…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import type { ApiSettings } from "../../types";
|
|||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import VersionLine from "./components/VersionLine";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { formatDateTime } from "./utils/campaignView";
|
|
||||||
|
|
||||||
export default function CampaignAuditPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function CampaignAuditPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
|
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 className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Audit log</PageTitle>
|
<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>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,12 @@ import Button from "../../components/Button";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
|
import VersionLine from "./components/VersionLine";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
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 { getBool, getText, updateNested } from "./utils/draftEditor";
|
||||||
import FieldValueInput from "./components/FieldValueInput";
|
import FieldValueInput from "./components/FieldValueInput";
|
||||||
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
|
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 className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Fields</PageTitle>
|
<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>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<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>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
{localError && <div className="alert danger">{localError}</div>}
|
{localError && <div className="alert danger">{localError}</div>}
|
||||||
{fieldNameWarning && <div className="alert warning">{fieldNameWarning}</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…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import type { ApiSettings } from "../../types";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import VersionLine from "./components/VersionLine";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { asRecord, formatDateTime, getCampaignJson } from "./utils/campaignView";
|
import { asRecord, getCampaignJson } from "./utils/campaignView";
|
||||||
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
||||||
|
|
||||||
export default function CampaignJsonView({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
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 className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>JSON</PageTitle>
|
<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>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
|||||||
{campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}
|
{campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="updated-cell">{formatDateTime(campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at)}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,91 +1,82 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import MetricCard from "../../components/MetricCard";
|
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import FormField from "../../components/FormField";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
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 { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import {
|
import { canUnlockValidationVersion, formatDateTime, isFinalLockedVersion, isUserLockedVersion, isVersionReadyForDelivery, summaryValue } from "./utils/campaignView";
|
||||||
asArray,
|
|
||||||
asRecord,
|
const campaignModeOptions = ["draft", "test", "send"];
|
||||||
cloneCampaignJsonForCopy,
|
|
||||||
getCampaignJson,
|
|
||||||
getString,
|
|
||||||
isAuditLockedVersion,
|
|
||||||
summaryValue,
|
|
||||||
timestampSlug,
|
|
||||||
versionLockReason
|
|
||||||
} from "./utils/campaignView";
|
|
||||||
import { addressesFromValue } from "../../utils/emailAddresses";
|
|
||||||
|
|
||||||
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
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 { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||||
const [copying, setCopying] = useState(false);
|
const campaign = data.campaign;
|
||||||
const [locking, setLocking] = useState(false);
|
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 [message, setMessage] = useState("");
|
||||||
|
|
||||||
const campaign = data.campaign;
|
useEffect(() => {
|
||||||
const currentVersion = data.currentVersion;
|
if (!campaign || identityDirty) return;
|
||||||
const campaignJson = getCampaignJson(currentVersion);
|
setIdentity({
|
||||||
const locked = isAuditLockedVersion(currentVersion);
|
external_id: campaign.external_id ?? "",
|
||||||
const cards = data.summary?.cards;
|
name: campaign.name ?? "",
|
||||||
const overviewFacts = getOverviewFacts(campaignJson, campaign);
|
status: campaign.status ?? "",
|
||||||
|
description: campaign.description ?? ""
|
||||||
|
});
|
||||||
|
}, [campaign, identityDirty]);
|
||||||
|
|
||||||
async function copyCampaign() {
|
function patchIdentity(key: keyof typeof identity, value: string) {
|
||||||
if (!currentVersion) return;
|
setIdentity((current) => ({ ...current, [key]: value }));
|
||||||
setCopying(true);
|
setIdentityDirty(true);
|
||||||
setMessage("");
|
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"
|
|
||||||
});
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function lockCampaign() {
|
async function saveIdentity() {
|
||||||
if (!currentVersion || locked) return;
|
if (!campaign || savingIdentity || !identityDirty) return;
|
||||||
const confirmed = window.confirm(
|
setSavingIdentity(true);
|
||||||
"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);
|
|
||||||
setMessage("");
|
|
||||||
setError("");
|
setError("");
|
||||||
|
setMessage("");
|
||||||
try {
|
try {
|
||||||
await publishCampaignVersion(settings, campaignId, currentVersion.id);
|
await updateCampaignMetadata(settings, campaign.id, {
|
||||||
setMessage("Campaign version locked as the current audit-safe version.");
|
external_id: identity.external_id,
|
||||||
|
name: identity.name,
|
||||||
|
status: identity.status,
|
||||||
|
description: identity.description
|
||||||
|
});
|
||||||
|
setIdentityDirty(false);
|
||||||
await reload();
|
await reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} 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 className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>{campaign?.name || "Overview"}</PageTitle>
|
<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>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>Reload</Button>
|
||||||
<Button onClick={copyCampaign} disabled={!currentVersion || copying}>{copying ? "Copying…" : "Copy campaign"}</Button>
|
<Link to="wizard/create"><Button>Edit with wizard</Button></Link>
|
||||||
<Button variant="primary" onClick={lockCampaign} disabled={!currentVersion || locked || locking}>
|
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "Saving…" : "Save"}</Button>
|
||||||
{locking ? "Locking…" : locked ? "Locked" : "Lock campaign"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -109,133 +98,113 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
{message && <div className="alert success">{message}</div>}
|
{message && <div className="alert success">{message}</div>}
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="Loading campaign overview…">
|
<LoadingFrame loading={loading} label="Loading campaign overview…">
|
||||||
|
<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>
|
||||||
|
|
||||||
{locked && (
|
<Card title="Version history">
|
||||||
<div className="alert info">
|
<div className="app-table-wrap">
|
||||||
This version is audit-safe and should be treated as read-only. {versionLockReason(currentVersion)} Only workflow state should change from here.
|
<table className="app-table version-history-table">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<div className="metric-grid">
|
<Card title="Current version state">
|
||||||
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Built and ready or warning" />
|
<div className="summary-grid overview-summary-grid">
|
||||||
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review before sending" />
|
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
||||||
<MetricCard label="Sent" value={cards?.sent ?? "—"} tone="info" detail="SMTP success" />
|
<SummaryTile label="Warnings" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
||||||
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
<SummaryTile label="Built messages" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
||||||
</div>
|
<SummaryTile label="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} />
|
||||||
|
</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>}>
|
</Card>
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</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" }]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card title="Validation and build 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 ?? "—"} />
|
|
||||||
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type OverviewFact = {
|
function versionLockLabel(version: CampaignVersionListItem): string {
|
||||||
label: string;
|
if (isUserLockedVersion(version)) return "User locked";
|
||||||
value: string | number;
|
if (isFinalLockedVersion(version)) return "Final";
|
||||||
};
|
if (canUnlockValidationVersion(version)) return "Validation lock";
|
||||||
|
if (version.locked_at) return "Locked";
|
||||||
|
return "Editable";
|
||||||
|
}
|
||||||
|
|
||||||
function ConfigShortcutCard({
|
function validationLabel(version: CampaignVersionListItem): string {
|
||||||
title,
|
const validation = version.validation_summary ?? {};
|
||||||
description,
|
if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed";
|
||||||
facts,
|
if (validation.ok === false) return "Needs attention";
|
||||||
actions
|
if (validation.ok === true) return "Invalid for delivery";
|
||||||
}: {
|
return "Not validated";
|
||||||
title: string;
|
}
|
||||||
description: string;
|
|
||||||
facts: OverviewFact[];
|
function buildLabel(version: CampaignVersionListItem): string {
|
||||||
actions: Array<{ to: string; label: string }>;
|
const build = version.build_summary ?? {};
|
||||||
}) {
|
return String(build.built_count ?? build.ready_count ?? "Not built");
|
||||||
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 SummaryTile({ label, value }: { label: string; value: string | number }) {
|
function SummaryTile({ label, value }: { label: string; value: string | number }) {
|
||||||
@@ -246,98 +215,3 @@ function SummaryTile({ label, value }: { label: string; value: string | number }
|
|||||||
</div>
|
</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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ApiSettings } from "../../types";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import VersionLine from "./components/VersionLine";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { formatDateTime } from "./utils/campaignView";
|
import { formatDateTime } from "./utils/campaignView";
|
||||||
@@ -16,7 +17,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
|||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Report</PageTitle>
|
<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>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "re
|
|||||||
import type { ApiSettings, CampaignWorkspaceSection } from "../../types";
|
import type { ApiSettings, CampaignWorkspaceSection } from "../../types";
|
||||||
import SectionSidebar from "../../layout/SectionSidebar";
|
import SectionSidebar from "../../layout/SectionSidebar";
|
||||||
import CampaignOverviewPage from "./CampaignOverviewPage";
|
import CampaignOverviewPage from "./CampaignOverviewPage";
|
||||||
import CampaignDataPage from "./CampaignDataPage";
|
|
||||||
import CampaignFieldsPage from "./CampaignFieldsPage";
|
import CampaignFieldsPage from "./CampaignFieldsPage";
|
||||||
import GlobalSettingsPage from "./GlobalSettingsPage";
|
import GlobalSettingsPage from "./GlobalSettingsPage";
|
||||||
import RecipientDataPage from "./RecipientDataPage";
|
import RecipientDataPage from "./RecipientDataPage";
|
||||||
@@ -22,7 +21,7 @@ import { CampaignUnsavedChangesProvider, useCampaignUnsavedChanges } from "./con
|
|||||||
|
|
||||||
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
||||||
overview: "",
|
overview: "",
|
||||||
campaign: "data",
|
campaign: "recipients",
|
||||||
"global-settings": "global-settings",
|
"global-settings": "global-settings",
|
||||||
fields: "fields",
|
fields: "fields",
|
||||||
recipients: "recipients",
|
recipients: "recipients",
|
||||||
@@ -65,7 +64,7 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
|
|||||||
<section className="workspace-content">
|
<section className="workspace-content">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<CampaignOverviewPage settings={settings} campaignId={campaignId || ""} />} />
|
<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="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="recipient-data" element={<RecipientDetailsPage 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];
|
const section = segments[2];
|
||||||
|
|
||||||
if (!section || section === "wizard" || section === "create") return "overview";
|
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 === "global-settings" || section === "settings") return "global-settings";
|
||||||
if (section === "fields") return "fields";
|
if (section === "fields") return "fields";
|
||||||
if (section === "recipients") return "recipients";
|
if (section === "recipients") return "recipients";
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import Card from "../../components/Card";
|
|||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
|
import VersionLine from "./components/VersionLine";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
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";
|
import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
|
||||||
|
|
||||||
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
|
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 className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Global settings</PageTitle>
|
<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>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<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>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
{localError && <div className="alert danger">{localError}</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…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
@@ -116,7 +118,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="button-row page-bottom-actions">
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import Card from "../../components/Card";
|
|||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
|
import VersionLine from "./components/VersionLine";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail";
|
import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
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";
|
import { getBool, getNumber, getText } from "./utils/draftEditor";
|
||||||
|
|
||||||
const securityOptions = ["plain", "tls", "starttls"];
|
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 className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Server settings</PageTitle>
|
<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>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<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>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
{localError && <div className="alert danger">{localError}</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…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import Card from "../../components/Card";
|
|||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
|
import VersionLine from "./components/VersionLine";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
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 { getBool } from "./utils/draftEditor";
|
||||||
import {
|
import {
|
||||||
addressesFromValue,
|
addressesFromValue,
|
||||||
@@ -54,6 +56,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||||
const source = asRecord(entries.source);
|
const source = asRecord(entries.source);
|
||||||
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
|
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
|
||||||
|
const defaultFrom = addressesFromValue(recipientsSection.from);
|
||||||
|
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
|
||||||
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
||||||
to: addressesFromValue(recipientsSection.to),
|
to: addressesFromValue(recipientsSection.to),
|
||||||
cc: addressesFromValue(recipientsSection.cc),
|
cc: addressesFromValue(recipientsSection.cc),
|
||||||
@@ -121,8 +125,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Recipients</PageTitle>
|
<PageTitle loading={loading}>Sender & Recipients</PageTitle>
|
||||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<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>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
{localError && <div className="alert danger">{localError}</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…">
|
<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">
|
<Card title="Global recipient headers">
|
||||||
<div className="campaign-header-stack">
|
<div className="campaign-header-stack">
|
||||||
{recipientHeaderRows.map((row) => (
|
{recipientHeaderRows.map((row) => (
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import Button from "../../components/Button";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
|
import VersionLine from "./components/VersionLine";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
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 FieldValueInput from "./components/FieldValueInput";
|
||||||
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
|
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
|
||||||
import { getDraftFields } from "./utils/fieldDefinitions";
|
import { getDraftFields } from "./utils/fieldDefinitions";
|
||||||
@@ -69,7 +71,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
|||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Recipient data</PageTitle>
|
<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>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<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>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
{localError && <div className="alert danger">{localError}</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…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -4,19 +4,24 @@ import type { ApiSettings } from "../../types";
|
|||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
|
import VersionLine from "./components/VersionLine";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
import { buildVersion, validateVersion } from "../../api/campaigns";
|
import { buildVersion, validateVersion } from "../../api/campaigns";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
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 }) {
|
export default function ReviewDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
|
const locked = isAuditLockedVersion(version);
|
||||||
|
const auditSafe = isUserLockedVersion(version) || isFinalLockedVersion(version);
|
||||||
const issues = collectIssues(data.summary?.issues);
|
const issues = collectIssues(data.summary?.issues);
|
||||||
const validationSummary = asRecord(version?.validation_summary);
|
const validationSummary = asRecord(version?.validation_summary);
|
||||||
const buildSummary = asRecord(version?.build_summary);
|
const buildSummary = asRecord(version?.build_summary);
|
||||||
const validationOk = validationSummary.ok === true;
|
const validationOk = validationSummary.ok === true;
|
||||||
|
const readyForDelivery = isVersionReadyForDelivery(version);
|
||||||
const [actionBusy, setActionBusy] = useState<"validate" | "build" | "" >("");
|
const [actionBusy, setActionBusy] = useState<"validate" | "build" | "" >("");
|
||||||
const [actionMessage, setActionMessage] = useState("");
|
const [actionMessage, setActionMessage] = useState("");
|
||||||
|
|
||||||
@@ -27,7 +32,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const result = await validateVersion(settings, version.id, false);
|
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();
|
await reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionMessage("");
|
setActionMessage("");
|
||||||
@@ -40,7 +45,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
|
|||||||
async function runBuild() {
|
async function runBuild() {
|
||||||
if (!version || actionBusy) return;
|
if (!version || actionBusy) return;
|
||||||
setActionBusy("build");
|
setActionBusy("build");
|
||||||
setActionMessage("Building messages for the locked version…");
|
setActionMessage("Building the queue for the locked, validated version…");
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const result = await buildVersion(settings, version.id, true);
|
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 className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading || Boolean(actionBusy)}>Review</PageTitle>
|
<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>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading || Boolean(actionBusy)}>Reload</Button>
|
<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>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
{actionMessage && <div className="alert info">{actionMessage}</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…">
|
<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">
|
<div className="button-row compact-actions">
|
||||||
<Button variant="primary" onClick={runValidate} disabled={!version || loading || Boolean(actionBusy)}>
|
<Button variant="primary" onClick={runValidate} disabled={!version || loading || Boolean(actionBusy) || readyForDelivery || auditSafe}>
|
||||||
{actionBusy === "validate" ? "Validating…" : validationOk ? "Validate again" : "Validate and lock"}
|
{actionBusy === "validate" ? "Validating…" : readyForDelivery ? "Validated and locked" : validationOk ? "Validate again" : "Validate and lock"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={runBuild} disabled={!version || loading || Boolean(actionBusy) || !validationOk}>
|
<Button onClick={runBuild} disabled={!version || loading || Boolean(actionBusy) || !readyForDelivery}>
|
||||||
{actionBusy === "build" ? "Building…" : "Build messages"}
|
{actionBusy === "build" ? "Building…" : "Build queue"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<dl className="detail-list compact-detail-list">
|
<dl className="detail-list compact-detail-list">
|
||||||
|
|||||||
@@ -2,18 +2,22 @@ import { useEffect, useState } from "react";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
|
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
|
import VersionLine from "./components/VersionLine";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import MetricCard from "../../components/MetricCard";
|
import MetricCard from "../../components/MetricCard";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
import { sendCampaignNow } from "../../api/campaigns";
|
import { sendCampaignNow } from "../../api/campaigns";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
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 }) {
|
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
|
const locked = isAuditLockedVersion(version);
|
||||||
const cards = data.summary?.cards;
|
const cards = data.summary?.cards;
|
||||||
const delivery = getDeliverySection(version);
|
const delivery = getDeliverySection(version);
|
||||||
const rateLimit = asRecord(delivery.rate_limit);
|
const rateLimit = asRecord(delivery.rate_limit);
|
||||||
@@ -22,12 +26,14 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
|||||||
const [sendBusy, setSendBusy] = useState(false);
|
const [sendBusy, setSendBusy] = useState(false);
|
||||||
const [sendMessage, setSendMessage] = useState("");
|
const [sendMessage, setSendMessage] = useState("");
|
||||||
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
|
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) {
|
async function runSendNow(dryRun = false) {
|
||||||
if (!version || sendBusy) return;
|
if (!version || sendBusy || !readyForDelivery || !hasBuild) return;
|
||||||
if (!dryRun && !window.confirm("Send this campaign version now? The validated version will remain locked as the sent audit snapshot.")) return;
|
|
||||||
setSendBusy(true);
|
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);
|
setSendResult(null);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -35,8 +41,8 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
|||||||
version_id: version.id,
|
version_id: version.id,
|
||||||
include_warnings: true,
|
include_warnings: true,
|
||||||
check_files: false,
|
check_files: false,
|
||||||
validate_before_send: true,
|
validate_before_send: false,
|
||||||
build_before_send: true,
|
build_before_send: false,
|
||||||
dry_run: dryRun,
|
dry_run: dryRun,
|
||||||
use_rate_limit: true,
|
use_rate_limit: true,
|
||||||
enqueue_imap_task: false
|
enqueue_imap_task: false
|
||||||
@@ -70,7 +76,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
|||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading || sendBusy}>Send</PageTitle>
|
<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>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading || sendBusy}>Reload</Button>
|
<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>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
{sendMessage && <div className="alert info">{sendMessage}</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…"}>
|
<LoadingFrame loading={loading || queuedOrSending} label={queuedOrSending ? "Refreshing send state…" : "Loading send data…"}>
|
||||||
<div className="metric-grid">
|
<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" />
|
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
||||||
</div>
|
</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">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => void runSendNow(true)} disabled={!version || loading || sendBusy}>Dry run</Button>
|
<Button onClick={() => void runSendNow(true)} disabled={!version || loading || sendBusy || !readyForDelivery || !hasBuild}>Dry run</Button>
|
||||||
<Button variant="primary" onClick={() => void runSendNow(false)} disabled={!version || loading || sendBusy}>
|
<Button variant="primary" onClick={() => setSendConfirmOpen(true)} disabled={!version || loading || sendBusy || !readyForDelivery || !hasBuild}>
|
||||||
{sendBusy ? "Sending…" : "Send now"}
|
{sendBusy ? "Sending…" : "Send now"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>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 state</dt><dd>{version?.workflow_state ?? "—"}</dd></div>
|
||||||
<div><dt>Version lock</dt><dd>{versionLockReason(version)}</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>
|
</dl>
|
||||||
{sendResult && (
|
{sendResult && (
|
||||||
<div className="send-result-panel">
|
<div className="send-result-panel">
|
||||||
@@ -156,6 +165,19 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
|||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
</LoadingFrame>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import Card from "../../components/Card";
|
|||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
|
import VersionLine from "./components/VersionLine";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
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 { cloneJson, getBool, getText } from "./utils/draftEditor";
|
||||||
import { humanizeFieldName } from "./utils/fieldDefinitions";
|
import { humanizeFieldName } from "./utils/fieldDefinitions";
|
||||||
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type TemplatePlaceholder, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
|
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 className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Template</PageTitle>
|
<PageTitle loading={loading}>Template</PageTitle>
|
||||||
<p className="mono-small">{saveState}</p>
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button disabled>Manage templates</Button>
|
<Button disabled>Manage templates</Button>
|
||||||
@@ -138,7 +140,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
|
|
||||||
{error && <div className="alert danger">{error}</div>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
{localError && <div className="alert danger">{localError}</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…">
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
|
|||||||
101
src/features/campaigns/components/LockedVersionNotice.tsx
Normal file
101
src/features/campaigns/components/LockedVersionNotice.tsx
Normal 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)}.`;
|
||||||
|
}
|
||||||
127
src/features/campaigns/components/VersionLine.tsx
Normal file
127
src/features/campaigns/components/VersionLine.tsx
Normal 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()}`;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import type { ApiSettings } from "../../../types";
|
import type { ApiSettings } from "../../../types";
|
||||||
import {
|
import {
|
||||||
getCampaign,
|
getCampaign,
|
||||||
@@ -6,8 +7,7 @@ import {
|
|||||||
getCampaignVersion,
|
getCampaignVersion,
|
||||||
listCampaignVersions,
|
listCampaignVersions,
|
||||||
type CampaignSummary,
|
type CampaignSummary,
|
||||||
type CampaignVersionDetail,
|
type CampaignVersionDetail
|
||||||
type CampaignVersionListItem
|
|
||||||
} from "../../../api/campaigns";
|
} from "../../../api/campaigns";
|
||||||
import type { CampaignWorkspaceData } from "../utils/campaignView";
|
import type { CampaignWorkspaceData } from "../utils/campaignView";
|
||||||
|
|
||||||
@@ -34,6 +34,8 @@ export function useCampaignWorkspaceData(
|
|||||||
includeSummary = false,
|
includeSummary = false,
|
||||||
includeVersions = false
|
includeVersions = false
|
||||||
} = options;
|
} = options;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const selectedVersionId = searchParams.get("version");
|
||||||
const [data, setData] = useState<CampaignWorkspaceData>(initialData);
|
const [data, setData] = useState<CampaignWorkspaceData>(initialData);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -44,24 +46,20 @@ export function useCampaignWorkspaceData(
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const needsCampaign = includeCurrentVersion || includeVersions;
|
const needsCampaign = includeCurrentVersion || includeVersions;
|
||||||
|
const shouldLoadVersions = includeCurrentVersion || includeVersions;
|
||||||
const summaryPromise = includeSummary ? getCampaignSummary(settings, campaignId) : Promise.resolve(null);
|
const summaryPromise = includeSummary ? getCampaignSummary(settings, campaignId) : Promise.resolve(null);
|
||||||
const campaign = needsCampaign ? await getCampaign(settings, campaignId) : null;
|
const campaign = needsCampaign ? await getCampaign(settings, campaignId) : null;
|
||||||
let versions: CampaignVersionListItem[] = [];
|
const versions = shouldLoadVersions ? await listCampaignVersions(settings, campaignId) : [];
|
||||||
let selectedVersionId = campaign?.current_version_id;
|
const wantedVersionId = selectedVersionId || campaign?.current_version_id || versions[0]?.id;
|
||||||
|
|
||||||
if (includeVersions || (includeCurrentVersion && !selectedVersionId)) {
|
|
||||||
versions = await listCampaignVersions(settings, campaignId);
|
|
||||||
selectedVersionId = selectedVersionId ?? versions[0]?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [versionResult, summaryResult] = await Promise.allSettled([
|
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
|
summaryPromise
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setData({
|
setData({
|
||||||
campaign,
|
campaign,
|
||||||
versions: includeVersions ? versions : [],
|
versions,
|
||||||
currentVersion: versionResult.status === "fulfilled" ? (versionResult.value as CampaignVersionDetail | null) : null,
|
currentVersion: versionResult.status === "fulfilled" ? (versionResult.value as CampaignVersionDetail | null) : null,
|
||||||
summary: summaryResult.status === "fulfilled" ? (summaryResult.value as CampaignSummary | null) : null
|
summary: summaryResult.status === "fulfilled" ? (summaryResult.value as CampaignSummary | null) : null
|
||||||
});
|
});
|
||||||
@@ -71,7 +69,7 @@ export function useCampaignWorkspaceData(
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions]);
|
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
|
|||||||
@@ -52,19 +52,41 @@ export function getFields(version: CampaignVersionDetail | null): unknown[] {
|
|||||||
return asArray(getCampaignJson(version).fields);
|
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 {
|
export function isAuditLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||||
if (!version) return false;
|
if (!version) return false;
|
||||||
if (version.locked_at || version.published_at) return true;
|
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 {
|
export function versionLockReason(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
|
||||||
if (!version) return "No campaign version is loaded.";
|
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.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.";
|
return "Editable working version.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import Stepper from "../../../components/Stepper";
|
|||||||
import Card from "../../../components/Card";
|
import Card from "../../../components/Card";
|
||||||
import Button from "../../../components/Button";
|
import Button from "../../../components/Button";
|
||||||
import PageTitle from "../../../components/PageTitle";
|
import PageTitle from "../../../components/PageTitle";
|
||||||
|
import LockedVersionNotice from "../components/LockedVersionNotice";
|
||||||
import { validatePartial } from "../../../api/campaigns";
|
import { validatePartial } from "../../../api/campaigns";
|
||||||
import { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData";
|
||||||
import { isAuditLockedVersion, versionLockReason } from "../utils/campaignView";
|
import { isAuditLockedVersion } from "../utils/campaignView";
|
||||||
import { useCampaignDraftEditor } from "../hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "../hooks/useCampaignDraftEditor";
|
||||||
import { AttachmentsStep, BasicsStep, FieldsStep, RecipientsStep, ReviewStep, SenderStep, SendStep, TemplateStep } from "./steps/CreateWizardSteps";
|
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 className="save-state">Locked</div>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<div className="alert info">
|
<LockedVersionNotice
|
||||||
{versionLockReason(data.currentVersion)} Create or copy a working version before editing campaign data, recipients, template or attachment rules.
|
settings={settings}
|
||||||
</div>
|
campaignId={campaignId}
|
||||||
|
version={data.currentVersion}
|
||||||
|
reload={reload}
|
||||||
|
message="Create an editable copy before continuing the creation wizard."
|
||||||
|
/>
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
<Link to="../.."><Button variant="primary">Back to overview</Button></Link>
|
<Link to="../.."><Button>Back to overview</Button></Link>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ export default function BreadcrumbBar({ pathname }: { pathname: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const campaignRouteLabels: Record<string, string> = {
|
const campaignRouteLabels: Record<string, string> = {
|
||||||
data: "General",
|
data: "Sender & Recipients",
|
||||||
campaign: "General",
|
campaign: "Sender & Recipients",
|
||||||
settings: "Global settings",
|
settings: "Global settings",
|
||||||
"global-settings": "Global settings",
|
"global-settings": "Global settings",
|
||||||
fields: "Fields",
|
fields: "Fields",
|
||||||
recipients: "Recipients",
|
recipients: "Sender & Recipients",
|
||||||
"recipient-data": "Recipient data",
|
"recipient-data": "Recipient data",
|
||||||
template: "Template",
|
template: "Template",
|
||||||
files: "Attachments",
|
files: "Attachments",
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
|||||||
{
|
{
|
||||||
title: "CAMPAIGN",
|
title: "CAMPAIGN",
|
||||||
items: [
|
items: [
|
||||||
{ id: "campaign", label: "General" },
|
|
||||||
{ id: "fields", label: "Fields" },
|
{ id: "fields", label: "Fields" },
|
||||||
{ id: "files", label: "Attachments" },
|
{ id: "files", label: "Attachments" },
|
||||||
{ id: "recipients", label: "Recipients" },
|
{ id: "recipients", label: "Sender & Recipients" },
|
||||||
{ id: "recipient-data", label: "Recipient data" },
|
{ id: "recipient-data", label: "Recipient data" },
|
||||||
{ id: "template", label: "Template" }
|
{ id: "template", label: "Template" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1031,3 +1031,96 @@
|
|||||||
margin: 5px 0 0;
|
margin: 5px 0 0;
|
||||||
color: var(--muted);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -511,3 +511,42 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@
|
|||||||
.campaign-table th:nth-child(4),
|
.campaign-table th:nth-child(4),
|
||||||
.campaign-table td:nth-child(4) { width: 230px; }
|
.campaign-table td:nth-child(4) { width: 230px; }
|
||||||
.campaign-table th:nth-child(5),
|
.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 {
|
.campaign-table td.updated-cell {
|
||||||
color: #696660;
|
color: #696660;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
|
|||||||
template: { id: "campaign.template", title: "Template" },
|
template: { id: "campaign.template", title: "Template" },
|
||||||
files: { id: "campaign.attachments", title: "Attachments" },
|
files: { id: "campaign.attachments", title: "Attachments" },
|
||||||
attachments: { 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" },
|
"recipient-data": { id: "campaign.recipient-data", title: "Recipient data" },
|
||||||
"mail-settings": { id: "campaign.server-settings", title: "Server settings" },
|
"mail-settings": { id: "campaign.server-settings", title: "Server settings" },
|
||||||
"server-settings": { id: "campaign.server-settings", title: "Server settings" },
|
"server-settings": { id: "campaign.server-settings", title: "Server settings" },
|
||||||
|
|||||||
Reference in New Issue
Block a user