first version able to send

This commit is contained in:
2026-06-11 00:04:00 +02:00
parent be793fb3e7
commit 93fb55273c
16 changed files with 869 additions and 645 deletions

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
@@ -5,34 +6,82 @@ import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
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 { asRecord, formatDateTime, getDeliverySection, getNestedString } from "./utils/campaignView";
import { asArray, asRecord, formatDateTime, getDeliverySection, getNestedString, stringifyPreview, versionLockReason } from "./utils/campaignView";
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const version = data.currentVersion;
const cards = data.summary?.cards;
const delivery = getDeliverySection(version);
const rateLimit = asRecord(delivery.rate_limit);
const imapAppend = asRecord(delivery.imap_append_sent);
const retry = asRecord(delivery.retry);
const [sendBusy, setSendBusy] = useState(false);
const [sendMessage, setSendMessage] = useState("");
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
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;
setSendBusy(true);
setSendMessage(dryRun ? "Checking what would be sent…" : "Validating, building and sending campaign…");
setSendResult(null);
setError("");
try {
const response = await sendCampaignNow(settings, campaignId, {
version_id: version.id,
include_warnings: true,
check_files: false,
validate_before_send: true,
build_before_send: true,
dry_run: dryRun,
use_rate_limit: true,
enqueue_imap_task: false
});
const result = asRecord(response.result ?? response);
setSendResult(result);
const sent = result.sent_count ?? 0;
const failed = result.failed_count ?? 0;
setSendMessage(dryRun ? "Dry run finished." : `Send finished. Sent ${String(sent)} message(s), failed ${String(failed)}.`);
await reload();
} catch (err) {
setSendMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setSendBusy(false);
}
}
const queuedOrSending = ["queued", "sending"].includes(data.campaign?.status ?? "") || ["queued", "sending"].includes(version?.workflow_state ?? "");
useEffect(() => {
if (!queuedOrSending || loading || sendBusy) return;
const handle = window.setTimeout(() => { void reload(); }, 3000);
return () => window.clearTimeout(handle);
}, [queuedOrSending, loading, reload, sendBusy]);
const resultRows = asArray(sendResult?.results).map(asRecord);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Send</PageTitle>
<PageTitle loading={loading || sendBusy}>Send</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button onClick={reload} disabled={loading || sendBusy}>Reload</Button>
<Link to="../wizard/send"><Button variant="primary">Open Send Wizard</Button></Link>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{sendMessage && <div className="alert info">{sendMessage}</div>}
<LoadingFrame loading={loading} label="Loading send data…">
<LoadingFrame loading={loading || queuedOrSending} label={queuedOrSending ? "Refreshing send state…" : "Loading send data…"}>
<div className="metric-grid">
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
@@ -40,6 +89,47 @@ 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>}>
<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}>
{sendBusy ? "Sending…" : "Send now"}
</Button>
</div>
<dl className="detail-list compact-detail-list">
<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>
</dl>
{sendResult && (
<div className="send-result-panel">
<strong>Last send result</strong>
<p className="muted small-note">
Attempted {String(sendResult.attempted_count ?? "—")}, sent {String(sendResult.sent_count ?? "—")}, failed {String(sendResult.failed_count ?? "—")}, skipped {String(sendResult.skipped_count ?? "—")}.
</p>
{resultRows.length > 0 && (
<div className="app-table-wrap data-table-wrap">
<table className="app-table data-table compact-table">
<thead>
<tr><th>Status</th><th>Job</th><th>Message</th></tr>
</thead>
<tbody>
{resultRows.slice(0, 10).map((row, index) => (
<tr key={index}>
<td><StatusBadge status={String(row.status ?? "info")} /></td>
<td>{String(row.job_id ?? row.version_id ?? "—")}</td>
<td>{String(row.message ?? stringifyPreview(row, 180))}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</Card>
<div className="dashboard-grid">
<Card title="Delivery rate limit">
<dl className="detail-list">