initial commit after split
This commit is contained in:
158
webui/src/features/campaigns/GlobalSettingsPage.tsx
Normal file
158
webui/src/features/campaigns/GlobalSettingsPage.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useState } from "react";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import CampaignAccessCard from "./components/CampaignAccessCard";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { hasScope } from "../../utils/permissions";
|
||||
import { RetentionPolicyEditor } from "../privacy/RetentionPolicyManagement";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
|
||||
|
||||
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
|
||||
|
||||
type EditorState = Record<string, unknown>;
|
||||
|
||||
export default function GlobalSettingsPage({ settings, auth, campaignId }: { settings: ApiSettings; auth: AuthInfo; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [editorState, setEditorState] = useState<EditorState>({});
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
locked,
|
||||
reload,
|
||||
setError,
|
||||
currentStep: "global-settings",
|
||||
unsavedTitle: "Unsaved global settings",
|
||||
unsavedMessage: "Policies 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);
|
||||
const rateLimit = asRecord(delivery.rate_limit);
|
||||
const retry = asRecord(delivery.retry);
|
||||
const statusTracking = asRecord(displayDraft.status_tracking);
|
||||
const optIns = asRecord(editorState.opt_ins);
|
||||
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
|
||||
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
|
||||
|
||||
|
||||
|
||||
function patchEditor(path: string[], value: unknown) {
|
||||
if (locked) return;
|
||||
setEditorState((current) => updateNested(current, path, value));
|
||||
markDirty();
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Campaign settings</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</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 && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<>
|
||||
{data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />}
|
||||
|
||||
{canReadRetentionPolicy && (
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType="campaign"
|
||||
scopeId={campaignId}
|
||||
title="Campaign retention policy"
|
||||
description="Campaign-level retention limits applied after system, tenant and owner policy. Blank values inherit."
|
||||
canWrite={canWriteRetentionPolicy}
|
||||
locked={locked}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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="Unsent attachment file" value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], 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="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 Attachments. These settings define campaign-wide behavior used by validation and review. Individual-attachment permission is configured per attachment base path.</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</Button>
|
||||
</div>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user