Release v0.1.3

This commit is contained in:
2026-06-26 01:39:19 +02:00
parent 23318c709a
commit ab2343aa88
22 changed files with 2176 additions and 197 deletions

View File

@@ -1,7 +1,8 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FileDropZone } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
@@ -10,11 +11,33 @@ import VersionLine from "./components/VersionLine";
import { ToggleSwitch } from "@govoplan/core-webui";
import { EmailAddressInput } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool } from "./utils/draftEditor";
import { getDraftFields } from "./utils/fieldDefinitions";
import { normalizeAttachmentBasePaths, type AttachmentBasePath } from "./utils/attachments";
import {
buildImportTable,
buildRecipientImportPreview,
defaultColumnMappings,
importedRowsNeedAttachmentSource,
materializeRecipientImport,
type CsvDelimiter,
type ImportedAddress,
type RecipientColumnKind,
type RecipientColumnMapping,
type RecipientImportMode,
type RecipientImportPreview
} from "./utils/bulkImport";
import {
bulkLinkFilesToCampaign,
renderedImportPatterns,
resolveImportedAttachmentLinks,
type ImportFileLinkResolution
} from "./utils/fileLinking";
import {
addressesFromValue,
collectCampaignAddressSuggestions,
@@ -40,10 +63,13 @@ type EntryAddressColumn = {
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [importOpen, setImportOpen] = useState(false);
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
@@ -56,9 +82,13 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
});
const recipientsSection = asRecord(displayDraft.recipients);
const entries = asRecord(displayDraft.entries);
const attachments = asRecord(displayDraft.attachments);
const entryDefaults = asRecord(entries.defaults);
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments, true), [attachments]);
const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1);
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
@@ -148,6 +178,38 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex));
}
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
if (locked || !draft) return;
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
const currentAttachments = asRecord(draft.attachments);
let nextBasePaths = normalizeAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
let attachmentBasePath: AttachmentBasePath | null = null;
if (needsAttachmentSource) {
if (nextBasePaths.length === 0) {
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
}
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
}
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
const nextDraft = needsAttachmentSource
? {
...importedDraft,
attachments: {
...asRecord(importedDraft.attachments),
base_paths: nextBasePaths,
base_path: nextBasePaths[0]?.path || "."
}
}
: importedDraft;
setDraft(nextDraft);
markDirty();
setImportOpen(false);
}
return (
<div className="content-pad workspace-data-page">
@@ -244,7 +306,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
</div>
</Card>
<Card title="Recipient profiles" actions={<Button disabled>Import</Button>}>
<Card title="Recipient profiles" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>Import</Button>}>
{inlineEntries.length === 0 && Boolean(source.type) && (
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</DismissibleAlert>
)}
@@ -252,22 +314,608 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
<div className="admin-table-surface recipient-profiles-table-surface">
<DataGrid
id={`campaign-${campaignId}-recipient-profiles`}
rows={inlineEntries.slice(0, 100)}
rows={inlineEntries}
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry })}
getRowKey={(entry, index) => String(entry.id || index)}
emptyText="No recipient profiles are stored in the current version yet."
emptyAction={<DataGridEmptyAction onAdd={() => addRecipient(-1)} disabled={locked} label="Add first recipient" />}
className="recipient-table-wrap recipient-address-table"
pagination={{
page: recipientProfilesPage,
pageSize: recipientProfilesPageSize,
pageSizeOptions: [10, 25, 50, 100, 250],
onPageChange: setRecipientProfilesPage,
onPageSizeChange: (pageSize) => {
setRecipientProfilesPageSize(pageSize);
setRecipientProfilesPage(1);
}
}}
/>
</div>
)}
</Card>
</>
</LoadingFrame>
{importOpen && (
<RecipientImportDialog
settings={settings}
campaignId={campaignId}
existingEntries={inlineEntries}
existingFields={fieldDefinitions}
defaultAttachmentBasePath={defaultImportBasePath}
onCancel={() => setImportOpen(false)}
onImport={applyRecipientImport}
/>
)}
</div>
);
}
type RecipientImportDialogProps = {
settings: ApiSettings;
campaignId: string;
existingEntries: Record<string, unknown>[];
existingFields: ReturnType<typeof getDraftFields>;
defaultAttachmentBasePath: AttachmentBasePath | null;
onCancel: () => void;
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode) => void;
};
type RecipientImportStepId = "upload" | "parse" | "map" | "preview" | "files";
const recipientImportSteps: Array<{ id: RecipientImportStepId; label: string }> = [
{ id: "upload", label: "Upload" },
{ id: "parse", label: "Parse" },
{ id: "map", label: "Map" },
{ id: "preview", label: "Preview" },
{ id: "files", label: "Files" }
];
const recipientColumnKindOptions: Array<{ value: RecipientColumnKind; label: string }> = [
{ value: "ignore", label: "Ignore" },
{ value: "id", label: "ID" },
{ value: "active", label: "Active" },
{ value: "name", label: "Display name" },
{ value: "from", label: "From" },
{ value: "to", label: "To" },
{ value: "cc", label: "CC" },
{ value: "bcc", label: "BCC" },
{ value: "reply_to", label: "Reply-To" },
{ value: "field", label: "Existing field" },
{ value: "new_field", label: "New field" },
{ value: "attachment_pattern", label: "Attachment pattern" }
];
export function RecipientImportDialog({ settings, campaignId, existingEntries, existingFields, defaultAttachmentBasePath, onCancel, onImport }: RecipientImportDialogProps) {
const [activeStep, setActiveStep] = useState<RecipientImportStepId>("upload");
const [csvText, setCsvText] = useState("");
const [filename, setFilename] = useState("");
const [mode, setMode] = useState<RecipientImportMode>("append");
const [delimiter, setDelimiter] = useState<CsvDelimiter>("auto");
const [headerRows, setHeaderRows] = useState(1);
const [quoted, setQuoted] = useState(true);
const [valueSeparators, setValueSeparators] = useState(",;|");
const [mappings, setMappings] = useState<RecipientColumnMapping[]>([]);
const [fileError, setFileError] = useState("");
const [fileLinkResolution, setFileLinkResolution] = useState<ImportFileLinkResolution | null>(null);
const [fileLinkResolving, setFileLinkResolving] = useState(false);
const [fileLinking, setFileLinking] = useState(false);
const [fileLinkError, setFileLinkError] = useState("");
const [fileLinkNotice, setFileLinkNotice] = useState("");
const hasContent = csvText.trim().length > 0;
const table = useMemo(
() => hasContent ? buildImportTable(csvText, { delimiter, headerRows, quoted }) : null,
[csvText, delimiter, hasContent, headerRows, quoted]
);
const tableHeaderKey = table ? `${table.delimiter}:${table.firstDataRowNumber}:${table.headers.join("\u001f")}` : "";
const parseReady = Boolean(table && table.dataRows.some((row) => row.some((cell) => cell.trim())));
const preview = useMemo(
() => table ? buildRecipientImportPreview(table, mappings, { existingFields, existingEntries, valueSeparators }) : null,
[existingEntries, existingFields, mappings, table, valueSeparators]
);
const activeStepIndex = recipientImportSteps.findIndex((step) => step.id === activeStep);
const nextStep = recipientImportSteps[activeStepIndex + 1]?.id ?? null;
const previousStep = recipientImportSteps[activeStepIndex - 1]?.id ?? null;
const mappedColumnCount = mappings.filter((mapping) => mapping.kind !== "ignore").length;
const patternRows = preview?.rows.filter((row) => row.patterns.length > 0).length ?? 0;
const renderedPatterns = useMemo(() => preview ? renderedImportPatterns(preview) : [], [preview]);
useEffect(() => {
if (!table) {
setMappings([]);
return;
}
setMappings(defaultColumnMappings(table.headers, existingFields));
}, [existingFields, tableHeaderKey]);
useEffect(() => {
if (!hasContent) setActiveStep("upload");
}, [hasContent]);
useEffect(() => {
if (activeStep === "files") void refreshFileLinks();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeStep, defaultAttachmentBasePath?.id, defaultAttachmentBasePath?.path, defaultAttachmentBasePath?.source, renderedPatterns.length]);
async function readFile(file: File | undefined) {
if (!file) return;
setFilename(file.name);
setFileError("");
try {
setCsvText(await file.text());
setActiveStep("parse");
} catch (err) {
setFileError(err instanceof Error ? err.message : String(err));
}
}
function canOpenStep(stepId: RecipientImportStepId): boolean {
if (stepId === "upload") return true;
if (stepId === "parse") return hasContent;
if (stepId === "map") return parseReady;
return parseReady && mappings.length > 0;
}
function stepStatus(stepId: RecipientImportStepId): string {
if (stepId === "upload") return filename || (hasContent ? "Pasted data" : "Waiting");
if (stepId === "parse") return table ? `${table.dataRows.length} rows` : "Set parsing";
if (stepId === "map") return `${mappedColumnCount} mapped`;
if (stepId === "preview") return preview ? `${preview.validCount} valid` : "Review";
if (!preview || preview.patternCount === 0) return "No patterns";
if (fileLinkResolving) return "Checking";
if (fileLinkResolution) return `${fileLinkResolution.linkableFiles.length} to link`;
if (fileLinkError) return "Needs setup";
return `${renderedPatterns.length} patterns`;
}
function goNext() {
if (nextStep && canOpenStep(nextStep)) setActiveStep(nextStep);
}
async function refreshFileLinks() {
setFileLinkNotice("");
if (!preview || preview.patternCount === 0) {
setFileLinkResolution(null);
setFileLinkError("");
return;
}
if (!defaultAttachmentBasePath?.source) {
setFileLinkResolution(null);
setFileLinkError("The selected attachment source is not connected to a managed file space, so imported patterns cannot be bulk-linked here.");
return;
}
setFileLinkResolving(true);
setFileLinkError("");
try {
setFileLinkResolution(await resolveImportedAttachmentLinks(settings, campaignId, defaultAttachmentBasePath, preview));
} catch (err) {
setFileLinkResolution(null);
setFileLinkError(err instanceof Error ? err.message : String(err));
} finally {
setFileLinkResolving(false);
}
}
async function linkImportedFiles() {
if (!fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return;
setFileLinking(true);
setFileLinkError("");
setFileLinkNotice("");
try {
const linkedCount = await bulkLinkFilesToCampaign(settings, campaignId, fileLinkResolution.linkableFiles);
await refreshFileLinks();
setFileLinkNotice(`Linked ${linkedCount} file${linkedCount === 1 ? "" : "s"} to this campaign.`);
} catch (err) {
setFileLinkError(err instanceof Error ? err.message : String(err));
} finally {
setFileLinking(false);
}
}
function replaceColumnMapping(nextMapping: RecipientColumnMapping) {
const columnCount = table?.headers.length ?? 0;
setMappings((current) => {
const byColumn = new Map(current.map((mapping) => [mapping.columnIndex, mapping]));
byColumn.set(nextMapping.columnIndex, nextMapping);
return Array.from({ length: columnCount }, (_value, columnIndex) => byColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" });
});
}
function changeColumnKind(columnIndex: number, kind: RecipientColumnKind) {
const header = table?.headers[columnIndex] ?? `Column ${columnIndex + 1}`;
const current = mappings.find((mapping) => mapping.columnIndex === columnIndex);
const nextMapping: RecipientColumnMapping = { columnIndex, kind };
if (kind === "field") nextMapping.fieldName = current?.fieldName || existingFields[0]?.name || "";
if (kind === "new_field") nextMapping.newFieldName = current?.newFieldName || suggestImportFieldName(header);
replaceColumnMapping(nextMapping);
}
const stepContent = activeStep === "upload" ? (
<>
<div className="campaign-header-grid recipient-import-upload-grid">
<FormField label="Recipient file">
<FileDropZone
accept=".csv,.tsv,text/csv,text/tab-separated-values,text/plain"
multiple={false}
label="Drop recipient file here"
actionLabel="or click to select CSV"
note={filename || "CSV, TSV or text"}
onFiles={(files) => readFile(files[0])}
/>
</FormField>
<FormField label="Import mode">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
<option value="append">Append to current profiles</option>
<option value="replace">Replace current profiles</option>
</select>
</FormField>
</div>
{fileError && <DismissibleAlert tone="danger" compact resetKey={fileError}>{fileError}</DismissibleAlert>}
<FormField label={filename ? `Raw content: ${filename}` : "Raw content"}>
<textarea
className="json-editor recipient-import-textarea"
value={csvText}
onChange={(event) => {
setCsvText(event.target.value);
if (!event.target.value.trim()) setFilename("");
}}
spellCheck={false}
/>
</FormField>
</>
) : activeStep === "parse" ? (
<>
<div className="campaign-header-grid">
<FormField label="Header rows">
<input
type="number"
min={0}
max={10}
value={headerRows}
onChange={(event) => setHeaderRows(Math.max(0, Number.parseInt(event.target.value, 10) || 0))}
/>
</FormField>
<FormField label="Separator">
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value as CsvDelimiter)}>
<option value="auto">Auto</option>
<option value=",">Comma</option>
<option value=";">Semicolon</option>
<option value={"\t"}>Tab</option>
</select>
</FormField>
<div className="campaign-header-toggle recipient-import-toggles">
<ToggleSwitch label="Quoted contents" checked={quoted} onChange={setQuoted} />
</div>
</div>
{table && (
<>
<dl className="detail-list recipient-import-summary">
<div><dt>Separator</dt><dd>{formatDelimiter(table.delimiter)}</dd></div>
<div><dt>Header rows</dt><dd>{table.headerRows.length}</dd></div>
<div><dt>Data rows</dt><dd>{table.dataRows.length}</dd></div>
<div><dt>Columns</dt><dd>{table.headers.length}</dd></div>
</dl>
<RecipientImportRawTable tableRows={table.rows} headerRowCount={table.headerRows.length} columnCount={table.headers.length} />
</>
)}
</>
) : activeStep === "map" ? (
<>
<div className="campaign-header-grid recipient-import-map-controls">
<FormField label="Multi-value separators">
<input value={valueSeparators} onChange={(event) => setValueSeparators(event.target.value)} />
</FormField>
<FormField label="Mode">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
<option value="append">Append</option>
<option value="replace">Replace</option>
</select>
</FormField>
</div>
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-mapping-table">
<thead>
<tr>
<th>Column</th>
<th>Sample</th>
<th>Content</th>
<th>Field</th>
</tr>
</thead>
<tbody>
{table?.headers.map((header, columnIndex) => {
const mapping = mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const };
return (
<tr key={`${columnIndex}-${header}`}>
<td><strong>{header}</strong></td>
<td className="mono-small">{sampleColumnValue(table.dataRows, columnIndex)}</td>
<td>
<select value={mapping.kind} onChange={(event) => changeColumnKind(columnIndex, event.target.value as RecipientColumnKind)}>
{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</td>
<td>
{mapping.kind === "field" && (
<select
value={mapping.fieldName ?? ""}
onChange={(event) => replaceColumnMapping({ ...mapping, fieldName: event.target.value, newFieldName: undefined })}
>
<option value="">Select field</option>
{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
)}
{mapping.kind === "new_field" && (
<input
value={mapping.newFieldName ?? ""}
onChange={(event) => replaceColumnMapping({ ...mapping, newFieldName: event.target.value, fieldName: undefined })}
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
) : activeStep === "preview" ? (
<>
{preview && (
<>
<dl className="detail-list recipient-import-summary">
<div><dt>Rows</dt><dd>{preview.validCount} valid / {preview.invalidCount} invalid</dd></div>
<div><dt>Fields</dt><dd>{preview.fieldNamesToCreate.length} new</dd></div>
<div><dt>Patterns</dt><dd>{preview.patternCount} from {patternRows} row(s)</dd></div>
<div><dt>Source</dt><dd>{preview.patternCount ? defaultAttachmentBasePath?.name ?? "Campaign files" : "No patterns"}</dd></div>
</dl>
{preview.fieldNamesToCreate.length > 0 && (
<p className="muted small-note">New fields: {preview.fieldNamesToCreate.join(", ")}</p>
)}
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table">
<thead>
<tr>
<th>#</th>
<th>To</th>
<th>Name</th>
<th>Fields</th>
<th>Patterns</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{preview.rows.slice(0, 20).map((row) => (
<tr key={row.rowNumber} className={row.issues.length ? "is-invalid" : undefined}>
<td>{row.rowNumber}</td>
<td>{formatAddressList(row.addresses.to)}</td>
<td>{row.name}</td>
<td>{Object.keys(row.fields).length}</td>
<td>{row.patterns.length}</td>
<td>{row.issues.length ? row.issues.join(", ") : "Ready"}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</>
) : (
<RecipientImportFileLinkStep
preview={preview}
basePath={defaultAttachmentBasePath}
resolution={fileLinkResolution}
resolving={fileLinkResolving}
linking={fileLinking}
error={fileLinkError}
notice={fileLinkNotice}
onRefresh={() => void refreshFileLinks()}
onLink={() => void linkImportedFiles()}
/>
);
return (
<Dialog
open
title="Import recipients"
className="recipient-import-modal"
bodyClassName="recipient-import-body"
closeDisabled={fileLinking || fileLinkResolving}
closeOnBackdrop={!fileLinking && !fileLinkResolving}
onClose={onCancel}
footer={(
<>
<Button onClick={onCancel} disabled={fileLinking || fileLinkResolving}>Cancel</Button>
{previousStep && <Button onClick={() => setActiveStep(previousStep)} disabled={fileLinking || fileLinkResolving}>Back</Button>}
{activeStep !== "files" ? (
<Button variant="primary" disabled={!nextStep || !canOpenStep(nextStep)} onClick={goNext}>Next</Button>
) : (
<Button variant="primary" disabled={!preview || preview.validCount === 0 || fileLinking || fileLinkResolving} onClick={() => preview && onImport(preview, mode)}>Import valid rows</Button>
)}
</>
)}
>
<nav className="recipient-import-steps" aria-label="Recipient import steps">
<div className="recipient-import-step-track">
{recipientImportSteps.map((step, index) => {
const stepIndex = recipientImportSteps.findIndex((item) => item.id === step.id);
const stepState = stepIndex < activeStepIndex ? "is-complete" : step.id === activeStep ? "is-current" : "";
return (
<div className="recipient-import-step-group" key={step.id}>
<button
type="button"
className={`recipient-import-step-item ${stepState}`.trim()}
disabled={!canOpenStep(step.id)}
aria-current={step.id === activeStep ? "step" : undefined}
onClick={() => setActiveStep(step.id)}
>
<span className="recipient-import-step-icon">{index + 1}</span>
<span className="recipient-import-step-copy">
<strong>{step.label}</strong>
<small>{stepStatus(step.id)}</small>
</span>
</button>
{index < recipientImportSteps.length - 1 && <span className="recipient-import-step-line" aria-hidden="true" />}
</div>
);
})}
</div>
</nav>
<section className="recipient-import-step-panel">
{stepContent}
</section>
</Dialog>
);
}
type RecipientImportFileLinkStepProps = {
preview: RecipientImportPreview | null;
basePath: AttachmentBasePath | null;
resolution: ImportFileLinkResolution | null;
resolving: boolean;
linking: boolean;
error: string;
notice: string;
onRefresh: () => void;
onLink: () => void;
};
function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving, linking, error, notice, onRefresh, onLink }: RecipientImportFileLinkStepProps) {
const linkableIds = new Set(resolution?.linkableFiles.map((file) => file.id) ?? []);
if (!preview || preview.patternCount === 0) {
return <DismissibleAlert tone="info" dismissible={false}>No attachment patterns were imported. There are no files to link for this import.</DismissibleAlert>;
}
return (
<div className="recipient-import-file-step">
<div className="recipient-import-file-actions">
<div>
<strong>{basePath?.name ?? "Campaign files"}</strong>
<p className="muted small-note">Files matched by imported recipient attachment patterns should be linked to the campaign before send preview and sending can consider them.</p>
</div>
<div className="button-row compact-actions">
<Button onClick={onRefresh} disabled={resolving || linking}>{resolving ? "Checking…" : "Refresh matches"}</Button>
<Button variant="primary" onClick={onLink} disabled={resolving || linking || !resolution || resolution.linkableFiles.length === 0}>{linking ? "Linking…" : `Link ${resolution?.linkableFiles.length ?? 0} file(s)`}</Button>
</div>
</div>
{error && <DismissibleAlert tone="warning" compact resetKey={error}>{error}</DismissibleAlert>}
{notice && <DismissibleAlert tone="success" compact resetKey={notice}>{notice}</DismissibleAlert>}
{resolving && <DismissibleAlert tone="info" compact dismissible={false}>Resolving imported file patterns</DismissibleAlert>}
{resolution && (
<>
<dl className="detail-list recipient-import-summary">
<div><dt>Patterns</dt><dd>{resolution.patterns.length}</dd></div>
<div><dt>Matched files</dt><dd>{resolution.files.length}</dd></div>
<div><dt>Already linked</dt><dd>{resolution.linkedFiles.length}</dd></div>
<div><dt>Need linking</dt><dd>{resolution.linkableFiles.length}</dd></div>
</dl>
{resolution.unmatchedPatterns.length > 0 && (
<div className="recipient-import-unmatched-patterns">
<strong>Patterns without matches</strong>
<ul>
{resolution.unmatchedPatterns.slice(0, 8).map((pattern) => (
<li key={pattern.key}>Row {pattern.rowNumber}: <code>{pattern.renderedPattern}</code></li>
))}
</ul>
{resolution.unmatchedPatterns.length > 8 && <p className="muted small-note">{resolution.unmatchedPatterns.length - 8} more unmatched pattern(s).</p>}
</div>
)}
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-file-link-table">
<thead>
<tr>
<th>File</th>
<th>Path</th>
<th>Size</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{resolution.files.length === 0 ? (
<tr><td colSpan={4}>No files currently match the imported patterns.</td></tr>
) : resolution.files.map((file) => (
<tr key={file.id}>
<td><strong>{file.filename}</strong></td>
<td>{file.display_path}</td>
<td>{formatImportBytes(file.size_bytes)}</td>
<td>{linkableIds.has(file.id) ? "Needs linking" : "Linked"}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
);
}
type RecipientImportRawTableProps = {
tableRows: string[][];
headerRowCount: number;
columnCount: number;
};
function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) {
const visibleRows = tableRows.slice(0, 15);
const safeColumnCount = Math.max(1, columnCount, ...visibleRows.map((row) => row.length));
return (
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-raw-table">
<thead>
<tr>
<th>#</th>
{Array.from({ length: safeColumnCount }, (_value, index) => <th key={index}>{index + 1}</th>)}
</tr>
</thead>
<tbody>
{visibleRows.length === 0 ? (
<tr><td colSpan={safeColumnCount + 1}>No rows parsed.</td></tr>
) : visibleRows.map((row, rowIndex) => (
<tr key={rowIndex} className={rowIndex < headerRowCount ? "is-header-row" : undefined}>
<td>{rowIndex + 1}</td>
{Array.from({ length: safeColumnCount }, (_value, columnIndex) => <td key={columnIndex}>{row[columnIndex] ?? ""}</td>)}
</tr>
))}
</tbody>
</table>
</div>
);
}
function formatDelimiter(delimiter: "," | ";" | "\t"): string {
if (delimiter === "\t") return "Tab";
if (delimiter === ";") return "Semicolon";
return "Comma";
}
function sampleColumnValue(rows: string[][], columnIndex: number): string {
return rows.map((row) => String(row[columnIndex] ?? "").trim()).find(Boolean) ?? "";
}
function formatAddressList(addresses: ImportedAddress[]): string {
return addresses.map((address) => address.name ? `${address.name} <${address.email}>` : address.email).join(", ");
}
function formatImportBytes(value?: number | null): string {
if (!value) return "";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function suggestImportFieldName(value: string): string {
const cleaned = value.trim().replace(/^fields[._-]+/i, "").replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
return cleaned || "field";
}
type RecipientProfileColumnContext = {
locked: boolean;
entries: Record<string, unknown>[];