Code cleanup and deduplication

This commit is contained in:
2026-06-10 17:26:00 +02:00
parent fcc46b06fe
commit be793fb3e7
27 changed files with 474 additions and 851 deletions

View File

@@ -3,22 +3,33 @@ import Card from "../../components/Card";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
type AdminSection = "overview" | "system" | "tenants" | "users" | "groups" | "roles" | "api-keys" | "mail-servers" | "settings" | "audit";
const adminSections: { id: AdminSection; label: string }[] = [
{ id: "system", label: "System" },
{ id: "tenants", label: "Tenants" }
];
const tenantSections: { id: AdminSection; label: string }[] = [
{ id: "users", label: "Users" },
{ id: "groups", label: "Groups" },
{ id: "roles", label: "Roles" },
{ id: "api-keys", label: "API keys" },
{ id: "mail-servers", label: "Mail servers" },
{ id: "settings", label: "Settings" },
{ id: "audit", label: "Audit" }
const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [
{
items: [{ id: "overview", label: "Overview" }]
},
{
title: "ADMIN",
items: [
{ id: "system", label: "System" },
{ id: "tenants", label: "Tenants" }
]
},
{
title: "TENANT",
items: [
{ id: "users", label: "Users" },
{ id: "groups", label: "Groups" },
{ id: "roles", label: "Roles" },
{ id: "api-keys", label: "API keys" },
{ id: "mail-servers", label: "Mail servers" },
{ id: "settings", label: "Settings" },
{ id: "audit", label: "Audit" }
]
}
];
const sectionTitles: Record<AdminSection, { title: string; description: string }> = {
@@ -40,25 +51,7 @@ export default function AdminPage() {
return (
<div className="workspace module-workspace">
<aside className="section-sidebar">
<button className={`section-link ${active === "overview" ? "active" : ""}`} onClick={() => setActive("overview")}>
Overview
</button>
<div className="section-title section-title-lower">ADMIN</div>
{adminSections.map((section) => (
<button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}>
{section.label}
</button>
))}
<div className="section-title section-title-lower">TENANT</div>
{tenantSections.map((section) => (
<button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}>
{section.label}
</button>
))}
</aside>
<ModuleSubnav active={active} groups={adminSubnav} onSelect={setActive} />
<section className="workspace-content">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
@@ -6,25 +6,30 @@ import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import ToggleSwitch from "../../components/ToggleSwitch";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { getBool, getText, updateNested } from "./utils/draftEditor";
import { AttachmentRulesTable, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
type PathChooserState = { index: number };
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const displayDraft = draft ?? ensureCampaignDraft(null);
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "files",
unsavedTitle: "Unsaved attachment settings",
unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue."
});
const attachments = asRecord(displayDraft.attachments);
const basePaths = useMemo(() => normalizeBasePaths(attachments.base_paths, attachments), [attachments]);
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
@@ -33,21 +38,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
const inlineEntries = asArray(entries.inline).map(asRecord);
const individualRules = inlineEntries.flatMap((entry, index) => asArray(entry.attachments).map((rule) => ({ entry: String(entry.id || index + 1), ...asRecord(rule) })));
useEffect(() => {
if (!version) return;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function patchBasePaths(paths: AttachmentBasePath[]) {
if (locked) return;
const normalized = paths.length > 0 ? paths : [createBasePath("Campaign files", ".")];
@@ -55,8 +47,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
const withPaths = updateNested(current ?? {}, ["attachments", "base_paths"], normalized);
return updateNested(withPaths, ["attachments", "base_path"], normalized[0]?.path || ".");
});
setDirty(true);
setLocalError("");
markDirty();
}
function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) {
@@ -87,38 +78,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
]);
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "files",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved attachment settings",
message: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
@@ -9,24 +9,29 @@ 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 { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
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 [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const displayDraft = draft ?? ensureCampaignDraft(null);
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);
@@ -34,53 +39,9 @@ export default function CampaignDataPage({ settings, campaignId }: { settings: A
const globalReplyTo = addressesFromValue(recipients.reply_to);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
useEffect(() => {
if (!version) return;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "campaign-settings",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved general campaign data",
message: "General campaign data has unsaved changes. Save it before leaving, or discard it and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">

View File

@@ -1,16 +1,14 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef } from "react";
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 { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { getBool, getText, updateNested } from "./utils/draftEditor";
import FieldValueInput from "./components/FieldValueInput";
const fieldTypeOptions = ["string", "integer", "double", "date", "password"];
@@ -26,35 +24,35 @@ type FieldDefinition = {
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const fieldValueKeys = useRef<string[]>([]);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const displayDraft = draft ?? ensureCampaignDraft(null);
const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "campaign-fields",
unsavedTitle: "Unsaved fields",
unsavedMessage: "Campaign fields have unsaved changes. Save them before leaving, or discard them and continue.",
transformLoadedDraft: (loadedVersion, loadedDraft) => migrateFieldOverridePolicy(loadedDraft, asRecord(loadedVersion.editor_state)),
onLoaded: (_loadedVersion, loadedDraft) => {
fieldValueKeys.current = normalizeFields(loadedDraft.fields).map((field) => field.name);
}
});
const fields = useMemo(() => normalizeFields(displayDraft.fields), [displayDraft.fields]);
const globalValues = asRecord(displayDraft.global_values);
const fieldNameWarning = useMemo(() => describeFieldNameProblem(fields), [fields]);
const canSave = dirty && !locked && Boolean(draft) && !fieldNameWarning;
useEffect(() => {
if (!version) return;
const nextDraft = migrateFieldOverridePolicy(ensureCampaignDraft(version), asRecord(version.editor_state));
fieldValueKeys.current = normalizeFields(nextDraft.fields).map((field) => field.name);
setDraft(nextDraft);
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patchDraft(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
markDirty();
}
function patchFields(nextFields: FieldDefinition[]) {
@@ -97,8 +95,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
setDirty(true);
setLocalError("");
markDirty();
}
function addField() {
@@ -110,8 +107,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
setDirty(true);
setLocalError("");
markDirty();
}
function deleteField(index: number) {
@@ -126,8 +122,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
setDirty(true);
setLocalError("");
markDirty();
}
function setGlobalValue(key: string, value: unknown) {
@@ -138,44 +133,16 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
setField(index, { can_override: allowed });
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
async function saveFields(): Promise<boolean> {
const fieldProblem = describeFieldNameProblem(fields);
if (fieldProblem) {
setLocalError(fieldProblem);
setSaveState("Save blocked");
return false;
}
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "campaign-fields",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
return saveDraft("manual");
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved fields",
message: "Fields have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
@@ -186,7 +153,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
<Button variant="primary" onClick={saveFields} disabled={!canSave}>Save</Button>
</div>
</div>
@@ -240,7 +207,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
<Button variant="primary" onClick={saveFields} disabled={!canSave}>Save</Button>
</div>
</>
</LoadingFrame>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
@@ -6,11 +6,10 @@ import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import ToggleSwitch from "../../components/ToggleSwitch";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { cloneJson, ensureCampaignDraft, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
@@ -18,15 +17,24 @@ type EditorState = Record<string, unknown>;
export default function GlobalSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [editorState, setEditorState] = useState<EditorState>({});
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const displayDraft = draft ?? ensureCampaignDraft(null);
const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "global-settings",
unsavedTitle: "Unsaved global settings",
unsavedMessage: "Global settings have unsaved changes. Save them before leaving, or discard them and continue.",
extraPayload: () => ({ editor_state: editorState }),
onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})),
onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState))
});
const validationPolicy = asRecord(displayDraft.validation_policy);
const attachments = asRecord(displayDraft.attachments);
const delivery = asRecord(displayDraft.delivery);
@@ -35,63 +43,14 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
const statusTracking = asRecord(displayDraft.status_tracking);
const optIns = asRecord(editorState.opt_ins);
useEffect(() => {
if (!version) return;
setDraft(ensureCampaignDraft(version));
setEditorState(cloneJson(version.editor_state ?? {}));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function patchEditor(path: string[], value: unknown) {
if (locked) return;
setEditorState((current) => updateNested(current, path, value));
setDirty(true);
setLocalError("");
markDirty();
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
editor_state: editorState,
current_flow: "manual",
current_step: "global-settings",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setEditorState(cloneJson(saved.editor_state ?? editorState));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved global settings",
message: "Global settings have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
@@ -6,21 +6,16 @@ import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import ToggleSwitch from "../../components/ToggleSwitch";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { getBool, getNumber, getText } from "./utils/draftEditor";
const securityOptions = ["plain", "tls", "starttls"];
export default function MailSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const [smtpTestResult, setSmtpTestResult] = useState<MailConnectionTestResponse | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailConnectionTestResponse | null>(null);
const [folderResult, setFolderResult] = useState<MailImapFolderListResponse | null>(null);
@@ -28,7 +23,17 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const displayDraft = draft ?? ensureCampaignDraft(null);
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "mail-settings",
unsavedTitle: "Unsaved server settings",
unsavedMessage: "Server settings have unsaved changes. Save them before leaving, or discard them and continue."
});
const server = asRecord(displayDraft.server);
const smtp = asRecord(server.smtp);
const imap = asRecord(server.imap);
@@ -37,46 +42,8 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
const imapEnabled = getBool(imap, "enabled");
const imapDisabled = locked || !imapEnabled;
useEffect(() => {
if (!version) return;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "mail-settings",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
function toggleImap(enabled: boolean) {
patch(["server", "imap", "enabled"], enabled);
@@ -85,12 +52,6 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved server settings",
message: "Server settings have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
function emptyToNull(value: string, trim = true): string | null {
const normalized = trim ? value.trim() : value;

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
@@ -7,11 +7,10 @@ import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import ToggleSwitch from "../../components/ToggleSwitch";
import EmailAddressInput from "../../components/email/EmailAddressInput";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { getBool } from "./utils/draftEditor";
import {
addressesFromValue,
collectCampaignAddressSuggestions,
@@ -36,14 +35,20 @@ type EntryAddressColumn = {
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const displayDraft = draft ?? ensureCampaignDraft(null);
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "recipients",
unsavedTitle: "Unsaved recipient changes",
unsavedMessage: "Recipient address data has unsaved changes. Save it before leaving, or discard it and continue."
});
const recipientsSection = asRecord(displayDraft.recipients);
const entries = asRecord(displayDraft.entries);
const inlineEntries = asArray(entries.inline).map(asRecord);
@@ -63,19 +68,6 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
return columns;
}, [recipientsSection]);
useEffect(() => {
if (!version) return;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
patch(["entries", "inline"], nextEntries);
@@ -124,37 +116,6 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
}
async function saveRecipients(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "recipients",
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;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved recipient changes",
message: "Recipient addresses or recipient header settings have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveRecipients("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
@@ -165,7 +126,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveRecipients("manual")} disabled={!dirty || locked || !draft}>Save</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</div>
@@ -258,7 +219,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveRecipients("manual")} disabled={!dirty || locked}>Save</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
</LoadingFrame>

View File

@@ -1,15 +1,14 @@
import { useEffect, useMemo, useState } from "react";
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 PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { getBool, getText } from "./utils/draftEditor";
import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay, { type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
import { addressesFromValue } from "../../utils/emailAddresses";
@@ -23,14 +22,20 @@ type FieldDefinition = {
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const displayDraft = draft ?? ensureCampaignDraft(null);
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "recipient-data",
unsavedTitle: "Unsaved recipient data changes",
unsavedMessage: "Recipient field values or attachments have unsaved changes. Save them before leaving, or discard them and continue."
});
const entries = asRecord(displayDraft.entries);
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
@@ -42,19 +47,6 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
return enabled.length > 0 ? enabled : attachmentBasePaths;
}, [attachmentBasePaths]);
useEffect(() => {
if (!version) return;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
patch(["entries", "inline"], nextEntries);
@@ -79,37 +71,6 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
updateEntry(index, (entry) => ({ ...entry, attachments }));
}
async function saveRecipientData(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "recipient-data",
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;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved recipient data changes",
message: "Recipient field values or attachment rules have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveRecipientData("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
@@ -120,7 +81,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveRecipientData("manual")} disabled={!dirty || locked || !draft}>Save</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</div>
@@ -190,7 +151,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveRecipientData("manual")} disabled={!dirty || locked}>Save</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
</LoadingFrame>

View File

@@ -1,16 +1,14 @@
import { useEffect, useMemo, useRef, useState } 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 { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { cloneJson, ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { cloneJson, getBool, getText } from "./utils/draftEditor";
type BodyMode = "text" | "html";
type EditorTarget = "subject" | "text" | "html";
@@ -30,10 +28,6 @@ type UndefinedPlaceholder = TemplatePlaceholder & {
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const [bodyMode, setBodyMode] = useState<BodyMode>("text");
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
const [previewOpen, setPreviewOpen] = useState(false);
@@ -45,7 +39,19 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const displayDraft = draft ?? ensureCampaignDraft(null);
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "template",
unsavedTitle: "Unsaved template changes",
unsavedMessage: "The template has unsaved changes. Save them before leaving, or discard them and continue.",
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${new Date(loadedVersion.autosaved_at).toLocaleString()}` : "Loaded",
onLoaded: () => setPreviewIndex(0)
});
const template = asRecord(displayDraft.template);
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
@@ -70,24 +76,11 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
const previewText = renderPreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
const previewHtml = renderPreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
useEffect(() => {
if (!version) return;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setPreviewIndex(0);
setSaveState(version.autosaved_at ? `Loaded autosave ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
useEffect(() => {
if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1));
}, [previewIndex, previewEntries.length]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function patchTemplateText(target: EditorTarget, value: string) {
patch(["template", target], value);
@@ -140,42 +133,10 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
next.template = nextTemplate;
return next;
});
setDirty(true);
setLocalError("");
markDirty();
setUndefinedDialog(null);
}
async function saveTemplate(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "template",
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;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved template changes",
message: "The template has unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveTemplate("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
@@ -187,7 +148,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
<div className="button-row compact-actions">
<Button disabled>Manage templates</Button>
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveTemplate("manual")} disabled={!dirty || locked || !draft}>Save</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</div>
@@ -277,7 +238,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveTemplate("manual")} disabled={!dirty || locked || !draft}>Save</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</>
</LoadingFrame>

View File

@@ -1,20 +0,0 @@
import FormField from "../../../components/FormField";
export default function AttachmentRuleCard() {
return (
<div className="attachment-rule-card">
<div className="attachment-rule-header">
<strong>Attachment rule</strong>
<span className="muted">Personalized documents</span>
</div>
<div className="form-grid compact">
<FormField label="Base directory"><input placeholder="xls/" /></FormField>
<FormField label="File filter"><input placeholder="ab????-${local::number}-*.XLSX" /></FormField>
<FormField label="Include subdirectories"><select><option>No</option><option>Yes</option></select></FormField>
<FormField label="Allow multiple matches"><select><option>Yes</option><option>No</option></select></FormField>
<FormField label="Missing behavior"><select><option>Ask</option><option>Block</option><option>Warn</option><option>Drop</option><option>Continue</option></select></FormField>
<FormField label="Ambiguous behavior"><select><option>Ask</option><option>Block</option><option>Warn</option><option>Drop</option><option>Continue</option></select></FormField>
</div>
</div>
);
}

View File

@@ -1,24 +0,0 @@
export default function FieldMappingTable() {
const rows = [
["email", "E-Mail", "required", "person@example.org", "ok"],
["fields.number", "Dienststelle", "required", "123456", "ok"],
["fields.password", "Passwort", "optional", "••••••", "ok"]
];
return (
<div className="mapping-table">
<div className="mapping-header">
<span>Campaign field</span>
<span>Source column</span>
<span>Required</span>
<span>Preview</span>
<span>Status</span>
</div>
{rows.map((row) => (
<div className="mapping-row" key={row[0]}>
{row.map((cell, index) => <span key={index}>{cell}</span>)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ApiSettings } from "../../../types";
import { autosaveCampaignVersion, type CampaignVersionDetail, type CampaignVersionUpdatePayload } from "../../../api/campaigns";
import { formatDateTime, getCampaignJson } from "../utils/campaignView";
import { ensureCampaignDraft, updateNested } from "../utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
type StepValue = string | (() => string | null | undefined);
type UseCampaignDraftEditorOptions = {
settings: ApiSettings;
campaignId: string;
version: CampaignVersionDetail | null;
locked: boolean;
reload: () => Promise<void>;
setError: (message: string) => void;
currentStep: StepValue;
currentFlow?: string;
workflowState?: string;
isComplete?: boolean;
unsavedTitle: string;
unsavedMessage: string;
loadedLabel?: (version: CampaignVersionDetail) => string;
transformLoadedDraft?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => Record<string, unknown>;
extraPayload?: () => Partial<CampaignVersionUpdatePayload>;
onLoaded?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => void;
onSaved?: (version: CampaignVersionDetail) => void;
};
function resolveStep(value: StepValue): string | null | undefined {
return typeof value === "function" ? value() : value;
}
function defaultLoadedLabel(version: CampaignVersionDetail): string {
return version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded";
}
export function useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep,
currentFlow = "manual",
workflowState = "editing",
isComplete = false,
unsavedTitle,
unsavedMessage,
loadedLabel = defaultLoadedLabel,
transformLoadedDraft,
extraPayload,
onLoaded,
onSaved
}: UseCampaignDraftEditorOptions) {
const loadedLabelRef = useRef(loadedLabel);
const transformLoadedDraftRef = useRef(transformLoadedDraft);
const onLoadedRef = useRef(onLoaded);
useEffect(() => {
loadedLabelRef.current = loadedLabel;
transformLoadedDraftRef.current = transformLoadedDraft;
onLoadedRef.current = onLoaded;
}, [loadedLabel, transformLoadedDraft, onLoaded]);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
useEffect(() => {
if (!version) return;
const initialDraft = ensureCampaignDraft(version);
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
setDraft(loadedDraft);
setDirty(false);
setLocalError("");
setSaveState(loadedLabelRef.current(version));
onLoadedRef.current?.(version, loadedDraft);
}, [version]);
const markDirty = useCallback(() => {
setDirty(true);
setLocalError("");
}, []);
const patch = useCallback((path: string[], value: unknown) => {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
markDirty();
}, [locked, markDirty]);
const saveDraft = useCallback(async (_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: currentFlow,
current_step: resolveStep(currentStep),
workflow_state: workflowState,
is_complete: isComplete,
...(extraPayload?.() ?? {})
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
onSaved?.(saved);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: unsavedTitle,
message: unsavedMessage,
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return {
draft,
setDraft,
displayDraft: draft ?? ensureCampaignDraft(null),
dirty,
setDirty,
saveState,
setSaveState,
localError,
setLocalError,
patch,
markDirty,
saveDraft
};
}

View File

@@ -10,11 +10,11 @@ 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 { 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";
import { asArray, asRecord, isAuditLockedVersion, stringifyPreview, summaryValue, versionLockReason } from "../utils/campaignView";
import { getBool, getNumber, getText, parseJsonTextarea, stringifyJson } from "../utils/draftEditor";
import { useCampaignDraftEditor } from "../hooks/useCampaignDraftEditor";
const steps: WizardStep[] = [
{ id: "basics", label: "Basics", description: "Name and scenario" },
@@ -31,61 +31,35 @@ 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 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;
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);
const { draft, dirty, saveState, patch, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError: setLocalError,
currentFlow: "create",
currentStep: () => activeStep,
unsavedTitle: "Unsaved wizard changes",
unsavedMessage: "This campaign wizard has unsaved changes. Save them before leaving, or discard them and continue.",
onLoaded: (loadedVersion) => {
if (loadedVersion.current_step && steps.some((step) => step.id === loadedVersion.current_step)) {
setActiveStep(loadedVersion.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);
@@ -110,12 +84,6 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
}
}
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 (

View File

@@ -1,22 +0,0 @@
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

@@ -1,24 +0,0 @@
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

@@ -1,18 +0,0 @@
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

@@ -1,29 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,22 +0,0 @@
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

@@ -1,18 +0,0 @@
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

@@ -1,21 +0,0 @@
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>
);
}

View File

@@ -3,6 +3,7 @@ import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
type StorageRecord = {
id: string;
@@ -58,13 +59,21 @@ const storages: StorageRecord[] = [
}
];
const storageSections: { id: StorageSection; label: string }[] = [
{ id: "browse", label: "Browse" },
{ id: "upload", label: "Upload" },
{ id: "settings", label: "Settings" },
{ id: "retention", label: "Retention" },
{ id: "bulk", label: "Bulk actions" },
{ id: "activity", label: "Activity" }
const storageSubnav = (onBack: () => void): ModuleSubnavGroup<StorageSection>[] => [
{
items: [{ actionId: "file-storages", label: "← File storages", primary: true, onClick: onBack }]
},
{
title: "STORAGE",
items: [
{ id: "browse", label: "Browse" },
{ id: "upload", label: "Upload" },
{ id: "settings", label: "Settings" },
{ id: "retention", label: "Retention" },
{ id: "bulk", label: "Bulk actions" },
{ id: "activity", label: "Activity" }
]
}
];
const demoFiles = [
@@ -89,21 +98,7 @@ export default function FilesPage() {
if (selectedStorage) {
return (
<div className="workspace module-workspace">
<aside className="section-sidebar">
<button className="section-link section-link-primary" onClick={() => setSelectedStorageId(null)}>
File storages
</button>
<div className="section-title section-title-lower">STORAGE</div>
{storageSections.map((section) => (
<button
key={section.id}
className={`section-link ${active === section.id ? "active" : ""}`}
onClick={() => setActive(section.id)}
>
{section.label}
</button>
))}
</aside>
<ModuleSubnav active={active} groups={storageSubnav(() => setSelectedStorageId(null))} onSelect={setActive} />
<section className="workspace-content">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">

View File

@@ -6,14 +6,20 @@ import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch";
import { apiFetch } from "../../api/client";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
type SettingsSection = "interface" | "workspace" | "local-connection" | "notifications";
const sections: { id: SettingsSection; label: string }[] = [
{ id: "interface", label: "Interface" },
{ id: "workspace", label: "Workspace" },
{ id: "local-connection", label: "Local connection" },
{ id: "notifications", label: "Notifications" }
const settingsSubnav: ModuleSubnavGroup<SettingsSection>[] = [
{
title: "UI SETTINGS",
items: [
{ id: "interface", label: "Interface" },
{ id: "workspace", label: "Workspace" },
{ id: "local-connection", label: "Local connection" },
{ id: "notifications", label: "Notifications" }
]
}
];
export default function SettingsPage({ settings, onSettingsChange }: { settings: ApiSettings; onSettingsChange: (settings: ApiSettings) => void }) {
@@ -40,14 +46,7 @@ export default function SettingsPage({ settings, onSettingsChange }: { settings:
return (
<div className="workspace module-workspace">
<aside className="section-sidebar">
<div className="section-title">UI SETTINGS</div>
{sections.map((section) => (
<button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}>
{section.label}
</button>
))}
</aside>
<ModuleSubnav active={active} groups={settingsSubnav} onSelect={setActive} />
<section className="workspace-content">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">

View File

@@ -4,6 +4,7 @@ import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import FieldLabel from "../../components/help/FieldLabel";
import StatusBadge from "../../components/StatusBadge";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
type TemplateRecord = {
id: string;
@@ -63,13 +64,21 @@ const templateRecords: TemplateRecord[] = [
}
];
const templateSections: { id: TemplateDetailSection; label: string }[] = [
{ id: "overview", label: "Overview" },
{ id: "content", label: "Content" },
{ id: "fields", label: "Fields" },
{ id: "preview", label: "Preview" },
{ id: "usage", label: "Usage" },
{ id: "versions", label: "Versions" }
const templateSubnav = (onBack: () => void): ModuleSubnavGroup<TemplateDetailSection>[] => [
{
items: [{ actionId: "template-library", label: "← Template library", primary: true, onClick: onBack }]
},
{
title: "TEMPLATE",
items: [
{ id: "overview", label: "Overview" },
{ id: "content", label: "Content" },
{ id: "fields", label: "Fields" },
{ id: "preview", label: "Preview" },
{ id: "usage", label: "Usage" },
{ id: "versions", label: "Versions" }
]
}
];
export default function TemplatesPage() {
@@ -88,21 +97,7 @@ export default function TemplatesPage() {
if (selectedTemplate) {
return (
<div className="workspace module-workspace">
<aside className="section-sidebar">
<button className="section-link section-link-primary" onClick={() => setSelectedId(null)}>
Template library
</button>
<div className="section-title section-title-lower">TEMPLATE</div>
{templateSections.map((section) => (
<button
key={section.id}
className={`section-link ${active === section.id ? "active" : ""}`}
onClick={() => setActive(section.id)}
>
{section.label}
</button>
))}
</aside>
<ModuleSubnav active={active} groups={templateSubnav(() => setSelectedId(null))} onSelect={setActive} />
<section className="workspace-content">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">