Rework of campaign structure; locking

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

View File

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