first wokring prototype

This commit is contained in:
2026-06-10 04:10:02 +02:00
parent 50d779a537
commit 7491c0a1b4
90 changed files with 10799 additions and 1 deletions

View 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 } : {})
}));
}