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; export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const [draft, setDraft] = useState | null>(null); const [dirty, setDirty] = useState(false); const [saveState, setSaveState] = useState("Loaded"); const [localError, setLocalError] = useState(""); const loadedVersionId = useRef(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) { 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 { 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 (
Attachments

Version {version ? `#${version.version_number}` : "—"} · {saveState}

{error &&
{error}
} {localError &&
{localError}
} {locked &&
This version is read-only. {versionLockReason(version)}
} {draft && ( <>
Attachment base path
{String(attachments.base_path || ".")}
Global files
{globalRules.length}
Per-recipient patterns
{individualRules.length}
Upload support
Planned

This section will become the Garage/S3-backed file picker for tenant, group and campaign attachment areas.

Upload campaign files Pick files for global attachment rules Resolve missing or ambiguous individual matches
Add file}>
patch(["attachments", "base_path"], event.target.value)} />
patch(["attachments", "allow_individual"], checked)} />
{globalRules.length === 0 ? (

No global files selected. Add files here only if every message should include them.

) : (
{globalRules.map((rule, index) => ( ))}
Label Base dir Selected file / pattern Required Include subdirs
patchGlobalAttachment(index, { label: event.target.value })} /> patchGlobalAttachment(index, { base_dir: event.target.value })} /> patchGlobalAttachment(index, { file_filter: event.target.value })} /> patchGlobalAttachment(index, { required: checked })} /> patchGlobalAttachment(index, { include_subdirs: checked })} />
)}
}>

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.

{individualRules.length === 0 && ( )} {individualRules.map((rule, index) => )}
Recipient / entry Label Base dir Filter Options
No per-recipient file patterns are configured yet.
)}
); } function RuleRow({ scope, rule }: { scope: string; rule: Record }) { return ( {scope} {String(rule.label || rule.id || "—")} {String(rule.base_dir || "—")} {String(rule.file_filter || "—")} {stringifyPreview({ required: rule.required, allow_multiple: rule.allow_multiple, zip: rule.zip }, 120)} ); } 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 } : {}) })); }