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,405 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings, WizardStep } from "../../../types";
import Stepper from "../../../components/Stepper";
import Card from "../../../components/Card";
import Button from "../../../components/Button";
import FormField from "../../../components/FormField";
import PageTitle from "../../../components/PageTitle";
import ToggleSwitch from "../../../components/ToggleSwitch";
import EmailAddressInput from "../../../components/email/EmailAddressInput";
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../../utils/emailAddresses";
import MetricCard from "../../../components/MetricCard";
import { autosaveCampaignVersion, validatePartial } from "../../../api/campaigns";
import { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, stringifyPreview, summaryValue, versionLockReason } from "../utils/campaignView";
import { ensureCampaignDraft, getBool, getNumber, getText, parseJsonTextarea, stringifyJson, updateNested } from "../utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
const steps: WizardStep[] = [
{ id: "basics", label: "Basics", description: "Name and scenario" },
{ id: "sender", label: "Sender", description: "Mail account and headers" },
{ id: "fields", label: "Fields", description: "Define campaign data" },
{ id: "recipients", label: "Recipients", description: "Import and map source data" },
{ id: "template", label: "Template", description: "Subject and body" },
{ id: "attachments", label: "Attachments", description: "Rules and ZIP options" },
{ id: "review", label: "Review", description: "Validate before build" },
{ id: "send", label: "Send", description: "Test and queue" }
];
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
export default function CreateWizard({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const [activeStep, setActiveStep] = useState("basics");
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loading…");
const [localError, setLocalError] = useState("");
const [validationMessage, setValidationMessage] = useState("");
const loadedVersionId = useRef<string | null>(null);
const index = steps.findIndex((s) => s.id === activeStep);
const { data, loading, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
useEffect(() => {
if (!version) return;
if (loadedVersionId.current === version.id) return;
loadedVersionId.current = version.id;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
if (version.current_step && steps.some((step) => step.id === version.current_step)) {
setActiveStep(version.current_step);
}
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function patchRoot(key: string, value: unknown) {
patch([key], value);
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "create",
current_step: activeStep,
workflow_state: "editing",
is_complete: false
});
setDraft(ensureCampaignDraft(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
setSaveState("Save failed");
return false;
}
}
function selectStep(stepId: string) {
setActiveStep(stepId);
}
function nextStep() {
setActiveStep(steps[Math.min(steps.length - 1, index + 1)].id);
}
function previousStep() {
setActiveStep(steps[Math.max(0, index - 1)].id);
}
async function validateCurrentStep() {
if (!version || !draft) return;
setValidationMessage("Validating…");
try {
const result = await validatePartial(settings, campaignId, version.id, { campaign_json: draft, section: activeStep });
setValidationMessage(`${result.error_count} errors, ${result.warning_count} warnings, ${result.info_count} info messages.`);
} catch (err) {
setValidationMessage(err instanceof Error ? err.message : String(err));
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved wizard changes",
message: "This campaign wizard has unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
if (locked) {
return (
<div className="wizard-page">
<div className="wizard-card locked-wizard-card">
<div className="wizard-body standalone-wizard-body">
<div className="wizard-heading">
<div>
<PageTitle>Create campaign</PageTitle>
</div>
<div className="save-state">Locked</div>
</div>
<Card>
<div className="alert info">
{versionLockReason(data.currentVersion)} Create or copy a working version before editing campaign data, recipients, template or attachment rules.
</div>
<div className="button-row">
<Link to="../.."><Button variant="primary">Back to overview</Button></Link>
</div>
</Card>
</div>
</div>
</div>
);
}
return (
<div className="wizard-page">
<div className="wizard-card">
<Stepper steps={steps} activeStep={activeStep} onSelect={selectStep} />
<div className="wizard-body">
<div className="wizard-heading">
<div>
<PageTitle loading={loading}>Create campaign</PageTitle>
</div>
<div className="save-state">{saveState}</div>
</div>
{localError && <div className="alert danger">{localError}</div>}
{validationMessage && <div className="alert info">{validationMessage}</div>}
<Card>
{draft && activeStep === "basics" && <BasicsStep draft={draft} patch={patch} />}
{draft && activeStep === "sender" && <SenderStep draft={draft} patch={patch} />}
{draft && activeStep === "fields" && <FieldsStep draft={draft} patchRoot={patchRoot} />}
{draft && activeStep === "recipients" && <RecipientsStep draft={draft} patchRoot={patchRoot} />}
{draft && activeStep === "template" && <TemplateStep draft={draft} patch={patch} />}
{draft && activeStep === "attachments" && <AttachmentsStep draft={draft} patch={patch} />}
{draft && activeStep === "review" && <ReviewStep version={version} onValidate={validateCurrentStep} />}
{draft && activeStep === "send" && <SendStep draft={draft} patch={patch} />}
</Card>
<div className="wizard-footer">
<Button onClick={previousStep}>Back</Button>
<Button onClick={() => saveDraft("manual")} disabled={!dirty}>{dirty ? "Save now" : "Saved"}</Button>
<Button onClick={validateCurrentStep}>Validate step</Button>
<Button variant="primary" onClick={nextStep}>Continue</Button>
</div>
</div>
</div>
</div>
);
}
function BasicsStep({ draft, patch }: StepProps) {
const campaign = asRecord(draft.campaign);
return (
<div className="form-grid">
<FormField label="Campaign name" help="A human-readable name shown in lists and reports.">
<input value={getText(campaign, "name")} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
</FormField>
<FormField label="Campaign ID" help="Stable technical identifier.">
<input value={getText(campaign, "id")} onChange={(event) => patch(["campaign", "id"], event.target.value)} />
</FormField>
<FormField label="Mode">
<select value={getText(campaign, "mode", "draft")} onChange={(event) => patch(["campaign", "mode"], event.target.value)}>
<option value="draft">Draft</option>
<option value="test">Test</option>
<option value="send">Send</option>
</select>
</FormField>
<FormField label="Description">
<textarea rows={5} value={getText(campaign, "description")} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
</FormField>
</div>
);
}
function SenderStep({ draft, patch }: StepProps) {
const recipients = asRecord(draft.recipients);
const from = asRecord(recipients.from);
const suggestions = collectCampaignAddressSuggestions(draft);
const globalTo = addressesFromValue(recipients.to);
const globalCc = addressesFromValue(recipients.cc);
const globalBcc = addressesFromValue(recipients.bcc);
const globalReplyTo = addressesFromValue(recipients.reply_to);
const server = asRecord(draft.server);
const smtp = asRecord(server.smtp);
const delivery = asRecord(draft.delivery);
const imapAppend = asRecord(delivery.imap_append_sent);
return (
<div className="form-grid">
<FormField label="Default From address">
<EmailAddressInput
value={addressesFromValue(from)}
suggestions={suggestions}
allowMultiple={false}
showAddButton={false}
addLabel={getText(from, "email") ? "Replace" : "Add sender"}
emptyText="No default sender configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses[0] ?? { name: "", email: "" })}
/>
</FormField>
<FormField label="Global recipients">
<EmailAddressInput
value={globalTo}
suggestions={suggestions}
allowMultiple
addLabel="Add recipient"
emptyText="No global recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "to"], addresses)}
/>
</FormField>
<FormField label="CC">
<EmailAddressInput
value={globalCc}
suggestions={suggestions}
allowMultiple
addLabel="Add CC"
emptyText="No global CC recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "cc"], addresses)}
/>
</FormField>
<FormField label="BCC">
<EmailAddressInput
value={globalBcc}
suggestions={suggestions}
allowMultiple
addLabel="Add BCC"
emptyText="No global BCC recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "bcc"], addresses)}
/>
</FormField>
<FormField label="Reply-To">
<EmailAddressInput
value={globalReplyTo.slice(0, 1)}
suggestions={suggestions}
allowMultiple={false}
showAddButton={false}
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>
<FormField label="SMTP host"><input value={getText(smtp, "host")} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
<FormField label="SMTP port"><input type="number" value={getNumber(smtp, "port", 587)} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
<ToggleSwitch label="Append successful messages to Sent via IMAP" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div>
);
}
function FieldsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) {
return (
<div>
<div className="step-intro">
<h2>Campaign fields</h2>
<p>Define reusable fields for templates, attachment rules, ZIP passwords and recipient data.</p>
</div>
<JsonEditor value={draft.fields ?? []} onValid={(value) => patchRoot("fields", value)} />
</div>
);
}
function RecipientsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) {
return (
<div>
<div className="step-intro">
<h2>Recipients</h2>
<p>Store inline recipients or source/mapping configuration. A table editor will replace this JSON editor in the recipient section pass.</p>
</div>
<JsonEditor value={draft.entries ?? { inline: [] }} onValid={(value) => patchRoot("entries", value)} />
</div>
);
}
function TemplateStep({ draft, patch }: StepProps) {
const template = asRecord(draft.template);
return (
<div>
<div className="step-intro">
<h2>Template</h2>
<p>Compose the subject and body. Merge fields can later be inserted from the field picker.</p>
</div>
<div className="form-grid">
<FormField label="Subject"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField>
<FormField label="Plain text body"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField>
<FormField label="HTML body"><textarea rows={8} value={getText(template, "html")} onChange={(event) => patch(["template", "html"], event.target.value)} /></FormField>
</div>
</div>
);
}
function AttachmentsStep({ draft, patch }: StepProps) {
const attachments = asRecord(draft.attachments);
return (
<div>
<div className="step-intro">
<h2>Attachments</h2>
<p>Configure campaign-wide attachment behavior and global matching rules.</p>
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Campaign attachment base path"><input value={getText(attachments, "base_path", ".")} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
<FormField label="Missing behavior"><select value={getText(attachments, "missing_behavior", "ask")} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Ambiguous behavior"><select value={getText(attachments, "ambiguous_behavior", "ask")} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
</div>
<JsonEditor value={attachments.global ?? []} onValid={(value) => patch(["attachments", "global"], value)} />
</div>
);
}
function ReviewStep({ version, onValidate }: { version: unknown; onValidate: () => void }) {
const record = asRecord(version);
return (
<div>
<div className="step-intro">
<h2>Review setup</h2>
<p>Validate the campaign definition before building message drafts.</p>
</div>
<div className="metric-grid inside">
<MetricCard label="Errors" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" />
<MetricCard label="Warnings" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" />
<MetricCard label="Built" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
</div>
<Button variant="primary" onClick={onValidate}>Validate campaign</Button>
</div>
);
}
function SendStep({ draft, patch }: StepProps) {
const delivery = asRecord(draft.delivery);
const rateLimit = asRecord(delivery.rate_limit);
return (
<div>
<div className="step-intro">
<h2>Send preparation</h2>
<p>Configure rate limits and prepare the final send workflow.</p>
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
</div>
<p className="muted">Test send and queue actions remain in the Send Wizard for now.</p>
</div>
);
}
type StepProps = {
draft: Record<string, unknown>;
patch: (path: string[], value: unknown) => void;
};
function JsonEditor({ value, onValid }: { value: unknown; onValid: (value: unknown) => void }) {
const [text, setText] = useState(stringifyJson(value));
const [error, setError] = useState("");
useEffect(() => {
setText(stringifyJson(value));
setError("");
}, [value]);
function change(nextText: string) {
setText(nextText);
const parsed = parseJsonTextarea(nextText, value);
setError(parsed.error);
if (!parsed.error) onValid(parsed.value);
}
return (
<div className="json-edit-block">
<textarea rows={12} value={text} onChange={(event) => change(event.target.value)} />
{error ? <p className="form-help danger-text">Invalid JSON: {error}</p> : <p className="form-help">Valid JSON is saved with the wizard draft.</p>}
{Array.isArray(value) && value.length > 0 && <p className="form-help">Preview: {stringifyPreview(asArray(value)[0], 140)}</p>}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import Card from "../../../components/Card";
import MetricCard from "../../../components/MetricCard";
import Button from "../../../components/Button";
export default function ReviewWizard() {
return (
<div className="content-pad">
<div className="page-heading">
<h1>Review Wizard</h1>
</div>
<div className="metric-grid">
<MetricCard label="Needs review" value="—" tone="warning" />
<MetricCard label="Missing attachments" value="—" tone="warning" />
<MetricCard label="Ambiguous matches" value="—" tone="info" />
<MetricCard label="Blocked" value="—" tone="danger" />
</div>
<Card title="Resolution workflow">
<p className="muted">This wizard will guide users through issues one class at a time.</p>
<Button variant="primary">Start review</Button>
</Card>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import Card from "../../../components/Card";
import Button from "../../../components/Button";
export default function SendWizard() {
return (
<div className="content-pad">
<div className="page-heading">
<h1>Send Wizard</h1>
</div>
<div className="dashboard-grid">
<Card title="Test send">
<p className="muted">Send one generated message to a test address.</p>
<Button>Open test-send dialog</Button>
</Card>
<Card title="Queue estimate">
<p className="muted">Estimated duration will be based on ready jobs and rate limits.</p>
<Button variant="primary">Queue dry run</Button>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import FormField from "../../../../components/FormField";
import Button from "../../../../components/Button";
import AttachmentRuleCard from "../../components/AttachmentRuleCard";
export default function AttachmentsStep() {
return (
<div>
<div className="step-intro">
<h2>Attachments</h2>
<p>Configure the campaign base path and one or more attachment matching rules.</p>
</div>
<FormField label="Campaign attachment base path">
<input placeholder="./data/attachments" />
</FormField>
<AttachmentRuleCard />
<div className="button-row">
<Button>Add attachment rule</Button>
<Button variant="primary">Resolve attachments</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import FormField from "../../../../components/FormField";
export default function BasicsStep() {
return (
<div className="form-grid">
<FormField label="Campaign name" help="A human-readable name shown in lists and reports.">
<input placeholder="Rechnungslegung 2026-05" />
</FormField>
<FormField label="Campaign ID" help="Stable technical identifier.">
<input placeholder="rechnungslegung-2026-05" />
</FormField>
<FormField label="Scenario">
<select>
<option>Personalized documents with attachments</option>
<option>Simple bulk message</option>
<option>Recurring monthly campaign</option>
</select>
</FormField>
<FormField label="Description">
<textarea rows={5} placeholder="Describe the purpose of this campaign…" />
</FormField>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import FieldMappingTable from "../../components/FieldMappingTable";
import Button from "../../../../components/Button";
export default function FieldsStep() {
return (
<div>
<div className="step-intro">
<h2>Campaign fields</h2>
<p>Define reusable fields for templates, attachment rules, ZIP passwords and recipient data.</p>
</div>
<div className="button-row">
<Button variant="primary">Add field wizard</Button>
<Button>Add manually</Button>
</div>
<FieldMappingTable />
</div>
);
}

View File

@@ -0,0 +1,29 @@
import FieldMappingTable from "../../components/FieldMappingTable";
import Button from "../../../../components/Button";
import FormField from "../../../../components/FormField";
export default function RecipientsStep() {
return (
<div>
<div className="step-intro">
<h2>Recipients</h2>
<p>Upload or reference a recipient source, then map source columns to campaign fields.</p>
</div>
<div className="form-grid compact">
<FormField label="Source type">
<select>
<option>CSV file</option>
<option>Inline recipients</option>
<option>JSON file</option>
</select>
</FormField>
<FormField label="Source path"><input placeholder="./data/recipients.csv" /></FormField>
</div>
<div className="button-row">
<Button>Preview source</Button>
<Button variant="primary">Auto-map columns</Button>
</div>
<FieldMappingTable />
</div>
);
}

View File

@@ -0,0 +1,19 @@
import MetricCard from "../../../../components/MetricCard";
import Button from "../../../../components/Button";
export default function ReviewStep() {
return (
<div>
<div className="step-intro">
<h2>Review setup</h2>
<p>Validate the campaign definition before building message drafts.</p>
</div>
<div className="metric-grid inside">
<MetricCard label="Ready" value="—" tone="good" />
<MetricCard label="Warnings" value="—" tone="warning" />
<MetricCard label="Needs review" value="—" tone="info" />
</div>
<Button variant="primary">Validate campaign</Button>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import FormField from "../../../../components/FormField";
import Button from "../../../../components/Button";
export default function SendStep() {
return (
<div>
<div className="step-intro">
<h2>Send preparation</h2>
<p>Configure rate limits and prepare the final send workflow.</p>
</div>
<div className="form-grid compact">
<FormField label="Messages per minute"><input type="number" defaultValue={5} min={1} /></FormField>
<FormField label="Concurrency"><input type="number" defaultValue={1} min={1} /></FormField>
</div>
<div className="button-row">
<Button>Send test</Button>
<Button>Queue dry run</Button>
<Button variant="primary">Open Send Wizard</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import FormField from "../../../../components/FormField";
export default function SenderStep() {
return (
<div className="form-grid">
<FormField label="From name"><input placeholder="Office" /></FormField>
<FormField label="From email"><input placeholder="office@example.org" /></FormField>
<FormField label="Reply-To"><input placeholder="reply@example.org" /></FormField>
<FormField label="IMAP append to Sent">
<select>
<option>Enabled, auto-detect Sent folder</option>
<option>Disabled</option>
<option>Enabled, manual folder</option>
</select>
</FormField>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import FormField from "../../../../components/FormField";
import Button from "../../../../components/Button";
export default function TemplateStep() {
return (
<div>
<div className="step-intro">
<h2>Template</h2>
<p>Compose the subject and body. Merge fields can be inserted from the field picker.</p>
</div>
<div className="button-row">
<Button>Insert merge field</Button>
<Button>Preview recipient</Button>
</div>
<div className="form-grid">
<FormField label="Subject"><input placeholder="Ihre Unterlagen für ${global::monthyear}" /></FormField>
<FormField label="Plain text body"><textarea rows={12} placeholder="Sehr geehrte/r ${local::name}, …" /></FormField>
</div>
</div>
);
}