Code cleanup and deduplication
This commit is contained in:
@@ -3,15 +3,24 @@ 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 }[] = [
|
||||
const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [
|
||||
{
|
||||
items: [{ id: "overview", label: "Overview" }]
|
||||
},
|
||||
{
|
||||
title: "ADMIN",
|
||||
items: [
|
||||
{ id: "system", label: "System" },
|
||||
{ id: "tenants", label: "Tenants" }
|
||||
];
|
||||
|
||||
const tenantSections: { id: AdminSection; label: string }[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "TENANT",
|
||||
items: [
|
||||
{ id: "users", label: "Users" },
|
||||
{ id: "groups", label: "Groups" },
|
||||
{ id: "roles", label: "Roles" },
|
||||
@@ -19,6 +28,8 @@ const tenantSections: { id: AdminSection; label: string }[] = [
|
||||
{ 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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
143
src/features/campaigns/hooks/useCampaignDraftEditor.ts
Normal file
143
src/features/campaigns/hooks/useCampaignDraftEditor.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }[] = [
|
||||
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">
|
||||
|
||||
@@ -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 }[] = [
|
||||
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">
|
||||
|
||||
@@ -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 }[] = [
|
||||
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">
|
||||
|
||||
62
src/layout/ModuleSubnav.tsx
Normal file
62
src/layout/ModuleSubnav.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
export type ModuleSubnavItem<T extends string> = {
|
||||
id: T;
|
||||
label: string;
|
||||
subtle?: boolean;
|
||||
primary?: boolean;
|
||||
};
|
||||
|
||||
export type ModuleSubnavAction = {
|
||||
actionId: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
subtle?: boolean;
|
||||
primary?: boolean;
|
||||
};
|
||||
|
||||
export type ModuleSubnavEntry<T extends string> = ModuleSubnavItem<T> | ModuleSubnavAction;
|
||||
|
||||
export type ModuleSubnavGroup<T extends string> = {
|
||||
title?: string;
|
||||
items: ModuleSubnavEntry<T>[];
|
||||
};
|
||||
|
||||
function isAction<T extends string>(entry: ModuleSubnavEntry<T>): entry is ModuleSubnavAction {
|
||||
return "actionId" in entry;
|
||||
}
|
||||
|
||||
export default function ModuleSubnav<T extends string>({
|
||||
active,
|
||||
groups,
|
||||
onSelect,
|
||||
className = ""
|
||||
}: {
|
||||
active: T;
|
||||
groups: ModuleSubnavGroup<T>[];
|
||||
onSelect: (section: T) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<aside className={`section-sidebar ${className}`.trim()}>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div className="section-group" key={`${group.title ?? "group"}-${groupIndex}`}>
|
||||
{group.title && <div className={`section-title ${groupIndex > 0 ? "section-title-lower" : ""}`.trim()}>{group.title}</div>}
|
||||
{group.items.map((entry) => {
|
||||
const key = isAction(entry) ? entry.actionId : entry.id;
|
||||
const activeClass = !isAction(entry) && active === entry.id ? " active" : "";
|
||||
const primaryClass = entry.primary ? " section-link-primary" : "";
|
||||
const subtleClass = entry.subtle ? " subtle" : "";
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className={`section-link${primaryClass}${subtleClass}${activeClass}`}
|
||||
onClick={() => (isAction(entry) ? entry.onClick() : onSelect(entry.id))}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,36 @@
|
||||
import type { CampaignWorkspaceSection } from "../types";
|
||||
import ModuleSubnav, { type ModuleSubnavGroup } from "./ModuleSubnav";
|
||||
|
||||
const campaignItems: { id: CampaignWorkspaceSection; label: string }[] = [
|
||||
const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
||||
{
|
||||
items: [{ id: "overview", label: "Overview", primary: true }]
|
||||
},
|
||||
{
|
||||
title: "CAMPAIGN",
|
||||
items: [
|
||||
{ id: "campaign", label: "General" },
|
||||
{ id: "fields", label: "Fields" },
|
||||
{ id: "files", label: "Attachments" },
|
||||
{ id: "recipients", label: "Recipients" },
|
||||
{ id: "recipient-data", label: "Recipient data" },
|
||||
{ id: "template", label: "Template" },
|
||||
];
|
||||
|
||||
const sendItems: { id: CampaignWorkspaceSection; label: string }[] = [
|
||||
{ id: "template", label: "Template" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "SEND CAMPAIGN",
|
||||
items: [
|
||||
{ id: "mail-settings", label: "Server settings" },
|
||||
{ id: "global-settings", label: "Global settings" },
|
||||
{ id: "review", label: "Review" },
|
||||
{ id: "send", label: "Send" },
|
||||
{ id: "report", label: "Report" },
|
||||
{ id: "audit", label: "Audit log" },
|
||||
{ id: "audit", label: "Audit log" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "ADVANCED",
|
||||
items: [{ id: "json", label: "JSON", subtle: true }]
|
||||
}
|
||||
];
|
||||
|
||||
export default function SectionSidebar({
|
||||
@@ -25,26 +40,5 @@ export default function SectionSidebar({
|
||||
active: CampaignWorkspaceSection;
|
||||
onSelect: (section: CampaignWorkspaceSection) => void;
|
||||
}) {
|
||||
return (
|
||||
<aside className="section-sidebar">
|
||||
<button className={`section-link section-link-primary ${active === "overview" ? "active" : ""}`} onClick={() => onSelect("overview")}>Overview</button>
|
||||
|
||||
<div className="section-title section-title-lower">CAMPAIGN</div>
|
||||
{campaignItems.map((item) => (
|
||||
<button key={item.id} className={`section-link ${active === item.id ? "active" : ""}`} onClick={() => onSelect(item.id)}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="section-title section-title-lower">SEND CAMPAIGN</div>
|
||||
{sendItems.map((item) => (
|
||||
<button key={item.id} className={`section-link ${active === item.id ? "active" : ""}`} onClick={() => onSelect(item.id)}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="section-title section-title-lower">ADVANCED</div>
|
||||
<button className={`section-link subtle ${active === "json" ? "active" : ""}`} onClick={() => onSelect("json")}>JSON</button>
|
||||
</aside>
|
||||
);
|
||||
return <ModuleSubnav active={active} groups={campaignSubnav} onSelect={onSelect} />;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
};
|
||||
|
||||
export default function Titlebar({ settings, auth, onSettingsChange, onAuthChange }: Props) {
|
||||
export default function Titlebar({ settings, auth, onAuthChange }: Props) {
|
||||
const [accountOpen, setAccountOpen] = useState(false);
|
||||
const [tenantOpen, setTenantOpen] = useState(false);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
|
||||
Reference in New Issue
Block a user