first wokring prototype
This commit is contained in:
253
src/features/campaigns/AttachmentsDataPage.tsx
Normal file
253
src/features/campaigns/AttachmentsDataPage.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
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 ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { autosaveCampaignVersion } from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asArray, asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, stringifyPreview, versionLockReason } from "./utils/campaignView";
|
||||
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
|
||||
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||
|
||||
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
|
||||
|
||||
type AttachmentRule = Record<string, unknown>;
|
||||
|
||||
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 loadedVersionId = useRef<string | null>(null);
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version);
|
||||
const attachments = asRecord(draft?.attachments);
|
||||
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
|
||||
const entries = asRecord(draft?.entries);
|
||||
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;
|
||||
if (loadedVersionId.current === version.id) return;
|
||||
loadedVersionId.current = version.id;
|
||||
setDraft(ensureCampaignDraft(version));
|
||||
setDirty(false);
|
||||
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
|
||||
}, [version]);
|
||||
|
||||
|
||||
function patch(path: string[], value: unknown) {
|
||||
if (locked) return;
|
||||
setDraft((current) => updateNested(current ?? {}, path, value));
|
||||
setDirty(true);
|
||||
setLocalError("");
|
||||
}
|
||||
|
||||
function patchGlobalAttachment(index: number, patchValue: Partial<AttachmentRule>) {
|
||||
const nextRules = globalRules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patchValue } : rule);
|
||||
patch(["attachments", "global"], nextRules);
|
||||
}
|
||||
|
||||
function addGlobalAttachment() {
|
||||
const nextRules: AttachmentRule[] = [
|
||||
...globalRules,
|
||||
{
|
||||
id: `global-${Date.now()}`,
|
||||
label: "",
|
||||
base_dir: "",
|
||||
file_filter: "",
|
||||
required: true,
|
||||
include_subdirs: false,
|
||||
allow_multiple: false,
|
||||
missing_behavior: getText(attachments, "missing_behavior", "ask"),
|
||||
ambiguous_behavior: getText(attachments, "ambiguous_behavior", "ask")
|
||||
}
|
||||
];
|
||||
patch(["attachments", "global"], nextRules);
|
||||
}
|
||||
|
||||
function removeGlobalAttachment(index: number) {
|
||||
patch(["attachments", "global"], globalRules.filter((_, currentIndex) => currentIndex !== index));
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Attachments</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Manage files</Button>
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</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)}</div>}
|
||||
|
||||
{draft && (
|
||||
<>
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Attachment area">
|
||||
<dl className="detail-list">
|
||||
<div><dt>Attachment base path</dt><dd>{String(attachments.base_path || ".")}</dd></div>
|
||||
<div><dt>Global files</dt><dd>{globalRules.length}</dd></div>
|
||||
<div><dt>Per-recipient patterns</dt><dd>{individualRules.length}</dd></div>
|
||||
<div><dt>Upload support</dt><dd>Planned</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="Campaign file storage">
|
||||
<p className="muted">This section will become the Garage/S3-backed file picker for tenant, group and campaign attachment areas.</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>Upload campaign files</span>
|
||||
<span>Pick files for global attachment rules</span>
|
||||
<span>Resolve missing or ambiguous individual matches</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Global attachment files" actions={<Button onClick={addGlobalAttachment} disabled={locked}>Add file</Button>}>
|
||||
<div className="attachment-base-stack">
|
||||
<div className="attachment-base-grid">
|
||||
<FormField label="Attachment base path"><input value={getText(attachments, "base_path", ".")} disabled={locked} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
|
||||
<div className="attachment-base-toggle"><ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} disabled={locked} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} /></div>
|
||||
</div>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<FormField label="Default 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="Default 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{globalRules.length === 0 ? (
|
||||
<p className="muted small-note">No global files selected. Add files here only if every message should include them.</p>
|
||||
) : (
|
||||
<div className="app-table-wrap compact-table-wrap">
|
||||
<table className="app-table direct-attachment-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Base dir</th>
|
||||
<th>Selected file / pattern</th>
|
||||
<th>Required</th>
|
||||
<th>Include subdirs</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{globalRules.map((rule, index) => (
|
||||
<tr key={String(rule.id ?? index)}>
|
||||
<td><input value={getText(rule, "label")} disabled={locked} placeholder="Attachment label" onChange={(event) => patchGlobalAttachment(index, { label: event.target.value })} /></td>
|
||||
<td><input value={getText(rule, "base_dir")} disabled={locked} placeholder="optional/folder" onChange={(event) => patchGlobalAttachment(index, { base_dir: event.target.value })} /></td>
|
||||
<td><input value={getText(rule, "file_filter")} disabled={locked} placeholder="file.pdf or {{field}}.pdf" onChange={(event) => patchGlobalAttachment(index, { file_filter: event.target.value })} /></td>
|
||||
<td><ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={locked} onChange={(checked) => patchGlobalAttachment(index, { required: checked })} /></td>
|
||||
<td><ToggleSwitch label="Subdirs" checked={getBool(rule, "include_subdirs")} disabled={locked} onChange={(checked) => patchGlobalAttachment(index, { include_subdirs: checked })} /></td>
|
||||
<td className="table-action-cell"><Button variant="danger" onClick={() => removeGlobalAttachment(index)} disabled={locked}>Remove</Button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Per-recipient file patterns" actions={<Link to="../recipients"><Button>Open recipients</Button></Link>}>
|
||||
<p className="muted small-note">Individual file patterns can be edited on each recipient row. They are summarized here because file matching and upload review also belong to the Attachments workflow.</p>
|
||||
<div className="app-table-wrap data-table-wrap">
|
||||
<table className="app-table files-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Recipient / entry</th>
|
||||
<th>Label</th>
|
||||
<th>Base dir</th>
|
||||
<th>Filter</th>
|
||||
<th>Options</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{individualRules.length === 0 && (
|
||||
<tr><td colSpan={5} className="muted">No per-recipient file patterns are configured yet.</td></tr>
|
||||
)}
|
||||
{individualRules.map((rule, index) => <RuleRow key={`individual-${index}`} scope={`Entry ${rule.entry}`} rule={rule} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleRow({ scope, rule }: { scope: string; rule: Record<string, unknown> }) {
|
||||
return (
|
||||
<tr>
|
||||
<td>{scope}</td>
|
||||
<td>{String(rule.label || rule.id || "—")}</td>
|
||||
<td><code>{String(rule.base_dir || "—")}</code></td>
|
||||
<td><code>{String(rule.file_filter || "—")}</code></td>
|
||||
<td><code>{stringifyPreview({ required: rule.required, allow_multiple: rule.allow_multiple, zip: rule.zip }, 120)}</code></td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isRecord).map((rule) => ({
|
||||
id: getText(rule, "id", `global-${Math.random().toString(36).slice(2)}`),
|
||||
label: getText(rule, "label"),
|
||||
base_dir: getText(rule, "base_dir", ""),
|
||||
file_filter: getText(rule, "file_filter"),
|
||||
include_subdirs: getBool(rule, "include_subdirs"),
|
||||
required: getBool(rule, "required", true),
|
||||
allow_multiple: getBool(rule, "allow_multiple"),
|
||||
missing_behavior: getText(rule, "missing_behavior", "ask"),
|
||||
ambiguous_behavior: getText(rule, "ambiguous_behavior", "ask"),
|
||||
...(isRecord(rule.zip) ? { zip: rule.zip } : {})
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user