first wokring prototype

This commit is contained in:
2026-06-10 04:10:02 +02:00
parent 50d779a537
commit 7491c0a1b4
90 changed files with 10799 additions and 1 deletions

View File

@@ -0,0 +1,340 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import PageTitle from "../../components/PageTitle";
import { createNewCampaign, publishCampaignVersion, updateCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import {
asArray,
asRecord,
cloneCampaignJsonForCopy,
getCampaignJson,
getString,
isAuditLockedVersion,
summaryValue,
timestampSlug,
versionLockReason
} from "./utils/campaignView";
import { addressesFromValue } from "../../utils/emailAddresses";
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const navigate = useNavigate();
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [copying, setCopying] = useState(false);
const [locking, setLocking] = useState(false);
const [message, setMessage] = useState("");
const campaign = data.campaign;
const currentVersion = data.currentVersion;
const campaignJson = getCampaignJson(currentVersion);
const locked = isAuditLockedVersion(currentVersion);
const cards = data.summary?.cards;
const overviewFacts = getOverviewFacts(campaignJson, campaign);
async function copyCampaign() {
if (!currentVersion) return;
setCopying(true);
setMessage("");
setError("");
try {
const copy = cloneCampaignJsonForCopy(campaignJson, campaign, timestampSlug());
const created = await createNewCampaign(settings, {
external_id: copy.externalId,
name: copy.name,
description: copy.description,
current_flow: "manual",
current_step: "copied"
});
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() {
if (!currentVersion || locked) return;
const confirmed = window.confirm(
"Lock this campaign version for audit-safe use? The current version should no longer be edited afterwards; create a copy if you need a new working version."
);
if (!confirmed) return;
setLocking(true);
setMessage("");
setError("");
try {
await publishCampaignVersion(settings, campaignId, currentVersion.id);
setMessage("Campaign version locked as the current audit-safe version.");
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLocking(false);
}
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>{campaign?.name || "Overview"}</PageTitle>
<p className="mono-small">{campaign?.external_id || campaignId}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button onClick={copyCampaign} disabled={!currentVersion || copying}>{copying ? "Copying…" : "Copy campaign"}</Button>
<Button variant="primary" onClick={lockCampaign} disabled={!currentVersion || locked || locking}>
{locking ? "Locking…" : locked ? "Locked" : "Lock campaign"}
</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{message && <div className="alert success">{message}</div>}
{locked && (
<div className="alert info">
This version is audit-safe and should be treated as read-only. {versionLockReason(currentVersion)} Only workflow state should change from here.
</div>
)}
<div className="metric-grid">
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Built and ready or warning" />
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review before sending" />
<MetricCard label="Sent" value={cards?.sent ?? "—"} tone="info" detail="SMTP success" />
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<Card title="Guided actions" actions={<span className="muted small-note">Wizards change or advance the campaign; data pages display and edit the current working draft.</span>}>
<div className="wizard-action-grid">
<WizardAction
title={locked ? "Create a new working copy" : "Edit campaign structure"}
description={locked ? "This version is locked. Copy the campaign before editing structural data." : "Open the structured create/edit wizard for overview, recipients, template and attachments."}
to="wizard/create"
label={locked ? "Open wizard read-only" : "Open Create Campaign"}
/>
<WizardAction
title="Resolve review issues"
description="Use a guided flow for validation issues, missing recipients or attachment decisions."
to="wizard/review"
label="Open Review Wizard"
/>
<WizardAction
title="Prepare sending"
description="Use the sending wizard for dry runs, rate limits, test sending and queue preparation."
to="wizard/send"
label="Open Send Wizard"
/>
</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>
</div>
);
}
type OverviewFact = {
label: string;
value: string | number;
};
function ConfigShortcutCard({
title,
description,
facts,
actions
}: {
title: string;
description: string;
facts: OverviewFact[];
actions: Array<{ to: string; label: string }>;
}) {
return (
<section className="overview-config-card">
<h3>{title}</h3>
<p>{description}</p>
<dl className="overview-config-facts">
{facts.map((fact) => (
<div key={fact.label}>
<dt>{fact.label}</dt>
<dd>{fact.value}</dd>
</div>
))}
</dl>
<div className="overview-config-actions">
{actions.map((action) => (
<Link key={action.to} to={action.to}>
<Button>{action.label}</Button>
</Link>
))}
</div>
</section>
);
}
function SummaryTile({ label, value }: { label: string; value: string | number }) {
return (
<div className="summary-tile">
<span>{label}</span>
<strong>{value}</strong>
</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;
}