179 lines
11 KiB
TypeScript
179 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } 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 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";
|
|
|
|
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
|
|
|
|
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 loadedVersionId = useRef<string | null>(null);
|
|
|
|
const version = data.currentVersion;
|
|
const locked = isAuditLockedVersion(version);
|
|
const validationPolicy = asRecord(draft?.validation_policy);
|
|
const attachments = asRecord(draft?.attachments);
|
|
const delivery = asRecord(draft?.delivery);
|
|
const rateLimit = asRecord(delivery.rate_limit);
|
|
const retry = asRecord(delivery.retry);
|
|
const statusTracking = asRecord(draft?.status_tracking);
|
|
const optIns = asRecord(editorState.opt_ins);
|
|
|
|
useEffect(() => {
|
|
if (!version) return;
|
|
if (loadedVersionId.current === version.id) return;
|
|
loadedVersionId.current = version.id;
|
|
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("");
|
|
}
|
|
|
|
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">
|
|
<div className="page-heading split workspace-heading">
|
|
<div>
|
|
<PageTitle loading={loading}>Global settings</PageTitle>
|
|
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
|
|
</div>
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={reload} disabled={loading}>Reload</Button>
|
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <div className="alert danger">{error}</div>}
|
|
{localError && <div className="alert danger">{localError}</div>}
|
|
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing global settings.</div>}
|
|
|
|
{draft && (
|
|
<>
|
|
<div className="dashboard-grid">
|
|
<Card title="Validation policy">
|
|
<PolicySelect label="Missing required attachment" value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />
|
|
<PolicySelect label="Missing optional attachment" value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />
|
|
<PolicySelect label="Ambiguous attachment match" value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />
|
|
<PolicySelect label="Missing email address" value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />
|
|
<PolicySelect label="Template error" value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />
|
|
<ToggleSwitch label="Ignore empty fields" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />
|
|
</Card>
|
|
|
|
<Card title="Attachment defaults">
|
|
<div className="form-grid compact responsive-form-grid">
|
|
<FormField label="Missing behavior">
|
|
<select value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select>
|
|
</FormField>
|
|
<FormField label="Ambiguous behavior">
|
|
<select value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select>
|
|
</FormField>
|
|
<ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} disabled={locked} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
|
|
<ToggleSwitch label="Send without attachments" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />
|
|
</div>
|
|
<p className="muted small-note">The actual global and per-recipient attachment rules live in Files. These settings define the campaign-wide defaults used by validation and review.</p>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="dashboard-grid below-grid">
|
|
<Card title="Delivery defaults">
|
|
<div className="form-grid compact responsive-form-grid">
|
|
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
|
|
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
|
|
<FormField label="Max attempts"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
|
|
<ToggleSwitch label="Status tracking" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
|
|
</div>
|
|
</Card>
|
|
|
|
<Card title="Opt-ins and local assistance">
|
|
<div className="toggle-grid">
|
|
<ToggleSwitch label="Suggest addresses from this campaign" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
|
|
<ToggleSwitch label="Remember newly used addresses" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
|
|
<ToggleSwitch label="Show guided warnings while editing" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
|
|
</div>
|
|
<p className="muted small-note">These opt-ins are stored in the draft editor metadata for now. A later backend patch can make address-book storage tenant/user aware.</p>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="button-row page-bottom-actions">
|
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save current draft</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PolicySelect({ label, value, disabled, onChange, options = behaviorOptions }: { label: string; value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
|
|
return (
|
|
<FormField label={label}>
|
|
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
|
|
{options.map((option) => <option key={option} value={option}>{option}</option>)}
|
|
</select>
|
|
</FormField>
|
|
);
|
|
}
|