UI changes, attachment page redesign

This commit is contained in:
2026-06-10 11:14:16 +02:00
parent d666dd90ee
commit 1f34435893
12 changed files with 725 additions and 209 deletions

Binary file not shown.

View File

@@ -1,20 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { createPortal } from "react-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 { asArray, asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { AttachmentRulesTable, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
type AttachmentRule = Record<string, unknown>;
type PathChooserState = { index: number };
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
@@ -22,11 +20,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const attachments = asRecord(draft?.attachments);
const basePaths = useMemo(() => normalizeBasePaths(attachments.base_paths, attachments), [attachments]);
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
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) })));
@@ -46,31 +46,43 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
setLocalError("");
}
function patchGlobalAttachment(index: number, patchValue: Partial<AttachmentRule>) {
const nextRules = globalRules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patchValue } : rule);
patch(["attachments", "global"], nextRules);
function patchBasePaths(paths: AttachmentBasePath[]) {
if (locked) return;
const normalized = paths.length > 0 ? paths : [createBasePath("Campaign files", ".")];
setDraft((current) => {
const withPaths = updateNested(current ?? {}, ["attachments", "base_paths"], normalized);
return updateNested(withPaths, ["attachments", "base_path"], normalized[0]?.path || ".");
});
setDirty(true);
setLocalError("");
}
function addGlobalAttachment() {
const nextRules: AttachmentRule[] = [
function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) {
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
}
function addBasePath() {
patchBasePaths([...basePaths, createBasePath("New attachment source", ".")]);
}
function removeBasePath(index: number) {
patchBasePaths(basePaths.filter((_, currentIndex) => currentIndex !== index));
}
function addGlobalAttachmentRule() {
if (locked) return;
const firstBasePath = basePaths[0]?.path ?? "";
patch(["attachments", "global"], [
...globalRules,
{
id: `global-${Date.now()}`,
id: `attachment-${Date.now()}`,
label: "",
base_dir: "",
base_dir: firstBasePath,
file_filter: "",
required: true,
include_subdirs: false,
allow_multiple: false,
missing_behavior: getText(attachments, "missing_behavior", "ask"),
ambiguous_behavior: getText(attachments, "ambiguous_behavior", "ask")
include_subdirs: false
}
];
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> {
@@ -126,90 +138,61 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
{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">
<Card
title="Attachment sources"
actions={<Button variant="primary" onClick={addBasePath} disabled={locked}>Add base path</Button>}
>
<div className="app-table-wrap attachment-sources-table-wrap">
<table className="app-table attachment-sources-table">
<thead>
<tr>
<th>Label</th>
<th>Base dir</th>
<th>Selected file / pattern</th>
<th>Required</th>
<th>Include subdirs</th>
<th>Name</th>
<th>Path</th>
<th>Individual attachments</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>
{basePaths.map((basePath, index) => (
<tr key={basePath.id}>
<td><input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} /></td>
<td>
<div className="field-with-action">
<input value={basePath.path} disabled={locked} readOnly placeholder="attachments" />
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose</Button>
</div>
</td>
<td><ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => patchBasePath(index, { allow_individual: checked })} /></td>
<td className="table-action-cell"><Button variant="danger" onClick={() => removeBasePath(index)} disabled={locked || basePaths.length <= 1}>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
title="Global Attachments"
actions={<Button variant="primary" onClick={addGlobalAttachmentRule} disabled={locked}>Add file</Button>}
>
<AttachmentRulesTable
rules={globalRules}
disabled={locked}
emptyText="No global attachments are configured yet. Add files here only if every message should include them."
basePaths={basePaths}
showAddButton={false}
onChange={(rules) => patch(["attachments", "global"], rules)}
/>
</Card>
<Card title="Statistics">
<dl className="detail-list">
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div>
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div>
<div><dt>Per-recipient patterns</dt><dd>{individualRules.length}</dd></div>
<div><dt>Upload support</dt><dd>Planned</dd></div>
</dl>
<p className="muted small-note">The current storage browser is a mock. The next backend-alignment step can connect these base paths and file rules to campaign, group and tenant storage.</p>
</Card>
<div className="button-row page-bottom-actions">
@@ -217,32 +200,93 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
</div>
</>
)}
{pathChooser && (
<MockPathChooserOverlay
onClose={() => setPathChooser(null)}
onSelect={(path) => {
patchBasePath(pathChooser.index, path);
setPathChooser(null);
}}
/>
)}
</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 MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; onSelect: (path: Partial<AttachmentBasePath>) => void }) {
const paths: Array<Partial<AttachmentBasePath> & { label: string }> = [
{ label: "Campaign attachments", path: "attachments" },
{ label: "Campaign root", path: "." },
{ label: "Shared group files", path: "group/shared" },
{ label: "Tenant templates", path: "tenant/templates" },
{ label: "Personal upload area", path: "user/uploads" }
];
return createPortal(
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-path-chooser-title">
<div className="modal-panel attachment-rules-modal">
<header className="modal-header">
<h2 id="mock-path-chooser-title">Choose attachment base path</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body attachment-rules-body">
<p className="muted small-note">Mock chooser for now. Later this will browse uploaded directories in the available campaign, group, tenant or user spaces.</p>
<div className="placeholder-stack">
{paths.map((path) => (
<Button key={path.path} onClick={() => onSelect(path)}>
{path.label}: <code>{path.path}</code>
</Button>
))}
</div>
</div>
<footer className="modal-footer">
<Button onClick={onClose}>Cancel</Button>
</footer>
</div>
</div>,
document.body
);
}
function createBasePath(name: string, path: string): AttachmentBasePath {
return {
id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name,
path,
allow_individual: false
};
}
function normalizeBasePaths(value: unknown, attachments: Record<string, unknown>): AttachmentBasePath[] {
if (Array.isArray(value) && value.length > 0) {
return value.filter(isRecord).map((basePath, index) => ({
id: getText(basePath, "id", `base-path-${index + 1}`),
name: getText(basePath, "name", `Base path ${index + 1}`),
source: getText(basePath, "source"),
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
allow_individual: getBool(basePath, "allow_individual")
}));
}
return [{
id: "base-path-campaign",
name: "Campaign files",
path: getText(attachments, "base_path", "."),
allow_individual: getBool(attachments, "allow_individual")
}];
}
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)}`),
type: getText(rule, "type"),
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 } : {})

View File

@@ -10,6 +10,7 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import FieldValueInput from "./components/FieldValueInput";
const fieldTypeOptions = ["string", "integer", "double", "date", "password"];
@@ -127,7 +128,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
setLocalError("");
}
function setGlobalValue(key: string, value: string) {
function setGlobalValue(key: string, value: unknown) {
patchGlobalValues({ ...globalValues, [key]: value });
}
@@ -226,9 +227,9 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
</select>
</td>
<td><ToggleSwitch label="Required" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} /></td>
<td><input value={valueToText(globalValues[field.name])} disabled={locked || !field.name} placeholder="Optional default" onChange={(event) => setGlobalValue(field.name, event.target.value)} /></td>
<td><FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="Optional default" onChange={(value) => setGlobalValue(field.name, value)} /></td>
<td><ToggleSwitch label="Can override" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} /></td>
<td className="table-action-cell"><Button variant="danger" disabled={locked} onClick={() => deleteField(index)}>Delete</Button></td>
<td className="table-action-cell"><Button variant="danger" disabled={locked} onClick={() => deleteField(index)}>Remove</Button></td>
</tr>
))}
</tbody>
@@ -300,9 +301,3 @@ function humanizeFieldName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
function valueToText(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}

View File

@@ -128,10 +128,9 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
<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>
<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>

View File

@@ -1,18 +1,18 @@
import { useEffect, useMemo, 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 StatusBadge from "../../components/StatusBadge";
import ToggleSwitch from "../../components/ToggleSwitch";
import EmailAddressInput from "../../components/email/EmailAddressInput";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, stringifyPreview, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, parseJsonTextarea, stringifyJson, updateNested } from "./utils/draftEditor";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay, { type AttachmentRule } from "./components/AttachmentRulesOverlay";
import {
addressesFromValue,
collectCampaignAddressSuggestions,
@@ -25,6 +25,13 @@ const recipientHeaderRows = [
{ key: "bcc", label: "BCC", toggleKey: "allow_individual_bcc", toggleLabel: "Allow individual BCC", addLabel: "Add BCC", emptyText: "No global BCC recipients configured." }
];
type FieldDefinition = {
name: string;
label: string;
type: string;
can_override: boolean;
};
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
@@ -38,13 +45,15 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
const entries = asRecord(draft?.entries);
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const fieldNames = useMemo(() => getDraftFieldNames(draft), [draft]);
const fieldDefinitions = useMemo(() => getDraftFields(draft), [draft]);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]);
const globalRecipientValues: Record<string, MailboxAddress[]> = {
to: addressesFromValue(recipientsSection.to),
cc: addressesFromValue(recipientsSection.cc),
bcc: addressesFromValue(recipientsSection.bcc)
};
const allowIndividualCc = getBool(recipientsSection, "allow_individual_cc");
const allowIndividualBcc = getBool(recipientsSection, "allow_individual_bcc");
useEffect(() => {
if (!version) return;
@@ -53,7 +62,6 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
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));
@@ -65,17 +73,18 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
patch(["entries", "inline"], nextEntries);
}
function appendRecipient(address: MailboxAddress) {
const nextEntry = {
id: `recipient-${inlineEntries.length + 1}`,
function addRecipient() {
const nextIndex = inlineEntries.length + 1;
const newEntry = {
id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`),
active: true,
to: [address],
name: address.name ?? "",
email: address.email,
to: [],
name: "",
email: "",
fields: {},
attachments: []
};
replaceInlineEntries([...inlineEntries, nextEntry]);
replaceInlineEntries([...inlineEntries, newEntry]);
}
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
@@ -83,17 +92,22 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
replaceInlineEntries(nextEntries);
}
function updateEntryRecipient(index: number, addresses: MailboxAddress[]) {
function updateEntryAddressList(index: number, key: "to" | "cc" | "bcc", addresses: MailboxAddress[]) {
updateEntry(index, (entry) => {
const nextEntry = { ...entry, [key]: addresses };
if (key === "to") {
const address = addresses[0] ?? { name: "", email: "" };
updateEntry(index, (entry) => ({
...entry,
to: address.email ? [address] : [],
return {
...nextEntry,
name: address.name ?? "",
email: address.email
}));
};
}
return nextEntry;
});
}
function updateEntryField(index: number, field: string, value: string) {
function updateEntryField(index: number, field: string, value: unknown) {
updateEntry(index, (entry) => ({
...entry,
fields: {
@@ -103,13 +117,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
}));
}
function updateEntryAttachments(index: number, text: string) {
const parsed = parseJsonTextarea(text, asArray(inlineEntries[index]?.attachments));
if (parsed.error) {
setLocalError(`Invalid attachment JSON in row ${index + 1}: ${parsed.error}`);
return;
}
updateEntry(index, (entry) => ({ ...entry, attachments: parsed.value }));
function updateEntryAttachments(index: number, attachments: AttachmentRule[]) {
updateEntry(index, (entry) => ({ ...entry, attachments }));
}
function removeEntry(index: number) {
@@ -195,80 +204,109 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
</div>
</Card>
<Card title="Recipients" actions={<span className="muted small-note">Editable inline recipients with mail-style address chips, field values and individual attachment config.</span>}>
<Card
title="Recipients"
actions={[<Button disabled={true}>Import</Button>, <Button variant="primary" onClick={addRecipient} disabled={locked}>Add recipient</Button>]}
>
{draft && inlineEntries.length === 0 && !source.type && <p className="muted">No recipient data is stored in the current version yet.</p>}
{draft && inlineEntries.length === 0 && Boolean(source.type) && (
<div className="alert info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</div>
)}
{draft && (
{draft && inlineEntries.length > 0 && (
<div className="app-table-wrap recipient-table-wrap">
<table className="app-table recipient-table recipient-editor-table">
<thead>
<tr>
<th>#</th>
<th>Recipient</th>
<th>Status</th>
{fieldNames.map((field) => <th key={field}>{field}</th>)}
<th>Individual attachments</th>
<th>Recipients</th>
<th>Attachments</th>
{fieldDefinitions.map((field) => <th key={field.name}>{field.label || field.name}</th>)}
<th aria-label="Actions"></th>
</tr>
</thead>
<tbody>
<tr className="recipient-add-row">
<td className="mono-small">+</td>
<td colSpan={Math.max(2, fieldNames.length + 3)}>
<EmailAddressInput
value={[]}
suggestions={addressSuggestions}
clearOnAdd
disabled={locked || !draft}
addLabel="Add recipient"
emptyText="Add a new inline recipient."
onAddressAdded={appendRecipient}
/>
</td>
<td></td>
</tr>
{inlineEntries.slice(0, 100).map((entry, index) => {
const recipient = primaryRecipient(entry);
const to = addressesFromValue(entry.to);
const cc = addressesFromValue(entry.cc);
const bcc = addressesFromValue(entry.bcc);
const fields = asRecord(entry.fields);
const attachments = asArray(entry.attachments);
const attachments = normalizeAttachmentRules(entry.attachments);
return (
<tr key={String(entry.id || index)}>
<td className="mono-small">{index + 1}</td>
<td>
<td className="recipient-cell">
<div className="recipient-address-stack">
<div className="recipient-address-line">
<span className="recipient-address-label">To</span>
<EmailAddressInput
value={recipient.email ? [recipient] : []}
value={to.length ? to : fallbackRecipientAddress(entry)}
suggestions={addressSuggestions}
allowMultiple={false}
compact
disabled={locked}
addLabel={recipient.email ? "Replace" : "Add"}
addLabel={to.length ? "Replace" : "Add"}
emptyText="No recipient address."
onChange={(addresses) => updateEntryRecipient(index, addresses)}
onChange={(addresses) => updateEntryAddressList(index, "to", addresses)}
/>
</div>
{allowIndividualCc && (
<div className="recipient-address-line">
<span className="recipient-address-label">CC</span>
<EmailAddressInput
value={cc}
suggestions={addressSuggestions}
allowMultiple
compact
disabled={locked}
addLabel="Add CC"
emptyText="No CC."
onChange={(addresses) => updateEntryAddressList(index, "cc", addresses)}
/>
</div>
)}
{allowIndividualBcc && (
<div className="recipient-address-line">
<span className="recipient-address-label">BCC</span>
<EmailAddressInput
value={bcc}
suggestions={addressSuggestions}
allowMultiple
compact
disabled={locked}
addLabel="Add BCC"
emptyText="No BCC."
onChange={(addresses) => updateEntryAddressList(index, "bcc", addresses)}
/>
</div>
)}
<ToggleSwitch
label="Active"
checked={entry.active !== false}
disabled={locked}
onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))}
/>
</div>
</td>
<td>
<AttachmentRulesOverlay
title={`Attachments for recipient ${index + 1}`}
rules={attachments}
disabled={locked}
onChange={(rules) => updateEntryAttachments(index, rules)}
/>
</td>
<td><StatusBadge status={String(entry.active === false ? "inactive" : "active")} /></td>
{fieldNames.map((field) => (
<td key={field}>
<input
{fieldDefinitions.map((field) => (
<td key={field.name}>
<FieldValueInput
className="recipient-field-input"
value={String(fields[field] ?? "")}
disabled={locked}
onChange={(event) => updateEntryField(index, field, event.target.value)}
fieldType={field.type}
value={fields[field.name]}
disabled={locked || field.can_override === false}
placeholder={field.can_override === false ? "Uses global value" : undefined}
onChange={(value) => updateEntryField(index, field.name, value)}
/>
</td>
))}
<td>
<textarea
className="recipient-attachments-input"
rows={2}
value={attachments.length ? stringifyJson(attachments) : "[]"}
disabled={locked}
title={attachments.length ? stringifyPreview(attachments, 180) : undefined}
onChange={(event) => updateEntryAttachments(index, event.target.value)}
/>
</td>
<td><Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button></td>
</tr>
);
@@ -288,15 +326,40 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
);
}
function getDraftFieldNames(draft: Record<string, unknown> | null): string[] {
function getDraftFields(draft: Record<string, unknown> | null): FieldDefinition[] {
return asArray(draft?.fields)
.map((field) => asRecord(field))
.map((field) => String(field.name || field.id || ""))
.filter(Boolean);
.map((field) => ({
name: getText(field, "name") || getText(field, "id"),
label: getText(field, "label"),
type: normalizeFieldType(getText(field, "type", "string")),
can_override: getBool(field, "can_override", true)
}))
.filter((field) => Boolean(field.name));
}
function primaryRecipient(entry: Record<string, unknown>): MailboxAddress {
const to = addressesFromValue(entry.to)[0];
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
return to ?? direct ?? { name: "", email: "" };
function normalizeFieldType(value: string): string {
return ["integer", "double", "date", "password"].includes(value) ? value : "string";
}
function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddress[] {
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
return direct?.email ? [direct] : [];
}
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((rule) => ({ ...rule }));
}
function uniqueEntryId(entries: Record<string, unknown>[], preferred: string): string {
const existing = new Set(entries.map((entry) => String(entry.id || "")).filter(Boolean));
if (!existing.has(preferred)) return preferred;
let counter = entries.length + 1;
let candidate = `recipient-${counter}`;
while (existing.has(candidate)) {
counter += 1;
candidate = `recipient-${counter}`;
}
return candidate;
}

View File

@@ -186,7 +186,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
<p className="mono-small">{saveState}</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Select template</Button>
<Button disabled>Manage templates</Button>
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveTemplate("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
@@ -239,6 +239,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
</FormField>
)}
<div className="button-row template-editor-actions">
<Button disabled>Load from library</Button>
<Button disabled>Save to library</Button>
</div>
</div>

View File

@@ -0,0 +1,246 @@
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import Button from "../../../components/Button";
import ToggleSwitch from "../../../components/ToggleSwitch";
import { getBool, getText } from "../utils/draftEditor";
export type AttachmentRule = Record<string, unknown>;
export type AttachmentBasePath = {
id: string;
name: string;
path: string;
source?: string;
allow_individual?: boolean;
};
type AttachmentRulesOverlayProps = {
title: string;
rules: AttachmentRule[];
disabled?: boolean;
buttonLabel?: string;
emptyText?: string;
basePaths?: AttachmentBasePath[];
onChange: (rules: AttachmentRule[]) => void;
};
type AttachmentRulesTableProps = {
rules: AttachmentRule[];
disabled?: boolean;
emptyText?: string;
basePaths?: AttachmentBasePath[];
showAddButton?: boolean;
onChange: (rules: AttachmentRule[]) => void;
};
type FileChooserState = {
ruleIndex: number;
basePath: string;
};
export default function AttachmentRulesOverlay({
title,
rules,
disabled = false,
buttonLabel,
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
onChange
}: AttachmentRulesOverlayProps) {
const [open, setOpen] = useState(false);
const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]);
const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`;
const dialog = open ? createPortal(
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
<div className="modal-panel attachment-rules-modal">
<header className="modal-header">
<h2 id="attachment-rules-title">{title}</h2>
<button className="modal-close" onClick={() => setOpen(false)}>×</button>
</header>
<div className="modal-body attachment-rules-body">
<p className="muted small-note">Use direct files for fixed attachments and rules/patterns for files resolved during build.</p>
<AttachmentRulesTable
rules={rules}
disabled={disabled}
emptyText={emptyText}
basePaths={basePaths}
onChange={onChange}
/>
</div>
<footer className="modal-footer">
<Button variant="primary" onClick={() => setOpen(false)}>Close</Button>
</footer>
</div>
</div>,
document.body
) : null;
return (
<>
<Button className="attachment-summary-button" onClick={() => setOpen(true)} disabled={disabled && rules.length === 0} title={`${summary.direct} direct file(s), ${summary.rules} rule(s) / pattern(s)`}>
{label}
</Button>
{dialog}
</>
);
}
export function AttachmentRulesTable({
rules,
disabled = false,
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
showAddButton = true,
onChange
}: AttachmentRulesTableProps) {
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
function patchRule(index: number, patch: Partial<AttachmentRule>) {
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
}
function addRule() {
const firstBasePath = basePaths[0]?.path ?? "";
onChange([
...rules,
{
id: `attachment-${Date.now()}`,
label: "",
base_dir: firstBasePath,
file_filter: "",
required: true,
include_subdirs: false
}
]);
}
function removeRule(index: number) {
onChange(rules.filter((_, currentIndex) => currentIndex !== index));
}
function openFileChooser(ruleIndex: number) {
const rule = rules[ruleIndex] ?? {};
setFileChooser({ ruleIndex, basePath: getText(rule, "base_dir", basePaths[0]?.path ?? "") });
}
return (
<>
{rules.length === 0 ? (
<p className="muted attachment-rules-empty">{emptyText}</p>
) : (
<div className="app-table-wrap attachment-rules-table-wrap">
<table className="app-table attachment-rules-table">
<thead>
<tr>
<th>Label</th>
<th>Base path</th>
<th>File / pattern</th>
<th>Required</th>
<th>Subdirs</th>
<th></th>
</tr>
</thead>
<tbody>
{rules.map((rule, index) => {
const currentBasePath = getText(rule, "base_dir", basePaths[0]?.path ?? "");
return (
<tr key={String(rule.id ?? index)}>
<td><input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} /></td>
<td>
{basePaths.length > 0 ? (
<select value={currentBasePath} disabled={disabled} onChange={(event) => patchRule(index, { base_dir: event.target.value })}>
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.path}>{basePath.name || basePath.path}</option>)}
</select>
) : (
<input value={currentBasePath} disabled={disabled} readOnly placeholder="optional/folder" />
)}
</td>
<td>
<div className="field-with-action">
<input value={getText(rule, "file_filter")} disabled={disabled} readOnly placeholder="file.pdf or {{local:id}}.pdf" />
<Button onClick={() => openFileChooser(index)} disabled={disabled}>Choose</Button>
</div>
</td>
<td><ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} /></td>
<td><ToggleSwitch label="Subdirs" checked={getBool(rule, "include_subdirs")} disabled={disabled} onChange={(checked) => patchRule(index, { include_subdirs: checked })} /></td>
<td className="table-action-cell"><Button variant="danger" onClick={() => removeRule(index)} disabled={disabled}>Remove</Button></td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{showAddButton && (
<div className="button-row compact-actions">
<Button onClick={addRule} disabled={disabled}>Add file</Button>
</div>
)}
{fileChooser && (
<MockFileChooserOverlay
basePath={fileChooser.basePath}
onSelect={(fileFilter) => {
patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
setFileChooser(null);
}}
onClose={() => setFileChooser(null)}
/>
)}
</>
);
}
function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) {
const files = [
"welcome.pdf",
"terms-and-conditions.pdf",
"invoice_{{local:invoice_number}}.pdf",
"{{local:recipient_id}}/certificate.pdf",
"attachments/{{local:email}}/*.pdf"
];
return createPortal(
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-file-chooser-title">
<div className="modal-panel attachment-rules-modal">
<header className="modal-header">
<h2 id="mock-file-chooser-title">Choose file or pattern</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body attachment-rules-body">
<p className="muted small-note">Mock chooser for now. Later this will browse uploaded files below <code>{basePath || "."}</code>.</p>
<div className="placeholder-stack">
{files.map((file) => (
<Button key={file} onClick={() => onSelect(file)}>
<code>{file}</code>
</Button>
))}
</div>
</div>
<footer className="modal-footer">
<Button onClick={onClose}>Cancel</Button>
</footer>
</div>
</div>,
document.body
);
}
export function summarizeAttachmentRules(rules: AttachmentRule[]): { direct: number; rules: number } {
return rules.reduce<{ direct: number; rules: number }>((summary, rule) => {
if (isDirectAttachmentRule(rule)) {
summary.direct += 1;
} else {
summary.rules += 1;
}
return summary;
}, { direct: 0, rules: 0 });
}
function isDirectAttachmentRule(rule: AttachmentRule): boolean {
const explicitType = getText(rule, "type");
if (explicitType === "direct") return true;
if (explicitType === "pattern") return false;
const fileFilter = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
if (!fileFilter) return false;
return !/[{}*?\[\]]/.test(fileFilter);
}

View File

@@ -0,0 +1,59 @@
type FieldValueInputProps = {
fieldType?: string;
value: unknown;
disabled?: boolean;
placeholder?: string;
className?: string;
onChange: (value: unknown) => void;
};
export default function FieldValueInput({ fieldType = "string", value, disabled = false, placeholder, className, onChange }: FieldValueInputProps) {
const normalizedType = normalizeFieldType(fieldType);
const inputType = inputTypeForField(normalizedType);
const step = normalizedType === "integer" ? "1" : normalizedType === "double" ? "any" : undefined;
return (
<input
className={className}
type={inputType}
step={step}
value={valueToInputText(value, normalizedType)}
disabled={disabled}
placeholder={placeholder}
autoComplete={normalizedType === "password" ? "new-password" : undefined}
onChange={(event) => onChange(inputValueToFieldValue(normalizedType, event.target.value))}
/>
);
}
function normalizeFieldType(fieldType: string): string {
return ["integer", "double", "date", "password"].includes(fieldType) ? fieldType : "string";
}
function inputTypeForField(fieldType: string): string {
if (fieldType === "integer" || fieldType === "double") return "number";
if (fieldType === "date") return "date";
if (fieldType === "password") return "password";
return "text";
}
function valueToInputText(value: unknown, fieldType: string): string {
if (value === undefined || value === null) return "";
if (fieldType === "date" && typeof value === "string") return value.slice(0, 10);
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function inputValueToFieldValue(fieldType: string, value: string): unknown {
if (value === "") return "";
if (fieldType === "integer") {
const numberValue = Number.parseInt(value, 10);
return Number.isFinite(numberValue) ? numberValue : value;
}
if (fieldType === "double") {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : value;
}
return value;
}

View File

@@ -3,9 +3,9 @@ import type { CampaignWorkspaceSection } from "../types";
const campaignItems: { id: CampaignWorkspaceSection; label: string }[] = [
{ id: "campaign", label: "General" },
{ id: "fields", label: "Fields" },
{ id: "files", label: "Attachments" },
{ id: "recipients", label: "Recipients" },
{ id: "template", label: "Template" },
{ id: "files", label: "Attachments" }
];
const sendItems: { id: CampaignWorkspaceSection; label: string }[] = [
@@ -14,7 +14,7 @@ const sendItems: { id: CampaignWorkspaceSection; label: string }[] = [
{ id: "review", label: "Review" },
{ id: "send", label: "Send" },
{ id: "report", label: "Report" },
{ id: "audit", label: "Audit log" }
{ id: "audit", label: "Audit log" },
];
export default function SectionSidebar({

View File

@@ -739,3 +739,93 @@
@media (max-width: 860px) {
.campaign-identity-grid { grid-template-columns: 1fr; }
}
/* Recipient editor compaction and reusable attachment rule overlay. */
.recipient-editor-table th:nth-child(1),
.recipient-editor-table td:nth-child(1) { width: 42px; }
.recipient-editor-table th:nth-child(2),
.recipient-editor-table td:nth-child(2) { min-width: 500px; }
.recipient-editor-table th:nth-child(3),
.recipient-editor-table td:nth-child(3) { min-width: 192px; }
.recipient-editor-table th:last-child,
.recipient-editor-table td:last-child { width: 123px; }
.recipient-editor-table td { vertical-align: top; }
.recipient-cell { white-space: normal !important; }
.recipient-address-stack {
display: grid;
gap: 8px;
min-width: 320px;
}
.recipient-address-line {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
.recipient-address-label {
display: inline-flex;
justify-content: center;
min-height: 24px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-soft);
color: var(--muted);
font-size: 11px;
font-weight: 800;
line-height: 22px;
text-transform: uppercase;
}
.recipient-field-input {
min-width: 140px;
}
.attachment-summary-button {
white-space: nowrap;
}
.attachment-summary-note {
margin-top: 12px;
}
.attachment-rules-modal {
width: min(1120px, 100%);
}
.attachment-rules-body {
display: grid;
gap: 12px;
}
.attachment-rules-empty {
border: 1px dashed var(--line-dark);
border-radius: 8px;
background: var(--panel-soft);
padding: 14px;
}
.attachment-sources-table th:nth-child(1),
.attachment-sources-table td:nth-child(1) { min-width: 280px; }
.attachment-sources-table th:nth-child(2),
.attachment-sources-table td:nth-child(2) { min-width: 200px; }
.attachment-sources-table th:nth-child(3),
.attachment-sources-table td:nth-child(3) { width: 207px; }
.attachment-sources-table th:last-child,
.attachment-sources-table td:last-child { width: 123px; }
@media (max-width: 900px) {
.recipient-editor-table th:nth-child(2),
.recipient-editor-table td:nth-child(2) { min-width: 300px; }
.recipient-address-stack { min-width: 260px; }
}
.attachment-rules-table th:nth-child(1),
.attachment-rules-table td:nth-child(1) { min-width: 160px; }
.attachment-rules-table th:nth-child(2),
.attachment-rules-table td:nth-child(2) { width: 280px; }
.attachment-rules-table th:nth-child(3),
.attachment-rules-table td:nth-child(3) { min-width: 230px; }
.attachment-rules-table th:nth-child(4),
.attachment-rules-table td:nth-child(4),
.attachment-rules-table th:nth-child(5),
.attachment-rules-table td:nth-child(5) { width: 175px; }
.attachment-rules-table th:last-child,
.attachment-rules-table td:last-child { width: 123px; }
@media (max-width: 900px) {
.recipient-editor-table th:nth-child(2),
.recipient-editor-table td:nth-child(2) { min-width: 300px; }
.recipient-address-stack { min-width: 260px; }
}

View File

@@ -452,3 +452,22 @@
transform: translate(-50%, 0);
transition-delay: .35s, .35s, .35s;
}
.field-with-action {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.field-with-action input,
.field-with-action select,
.field-with-action textarea {
flex: 1 1 auto;
min-width: 0;
}
.field-with-action button,
.field-with-action .button {
flex: 0 0 auto;
white-space: nowrap;
}

View File

@@ -45,7 +45,7 @@
.panel, .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: scroll; }
.card-header { min-height: 56px; padding: 0 24px; border-bottom: 1px solid var(--line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
.card-actions { margin-left: auto; }
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
.card-body { padding: 22px 24px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 12px; margin-bottom: 18px; }
.metric-grid.inside { margin: 14px 0; }