Refactor campaign recipient and review presentation

This commit is contained in:
2026-07-31 02:48:56 +02:00
parent 5f7503598c
commit e689fdf495
30 changed files with 2175 additions and 1977 deletions
@@ -0,0 +1,832 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings } from "../../../types";
import {
createRecipientImportMappingProfile,
listRecipientImportMappingProfiles,
updateRecipientImportMappingProfile,
type RecipientImportMappingProfilePayload
} from "../../../api/campaigns";
import {
Button,
DataGrid,
Dialog,
DismissibleAlert,
FileDropZone,
FormField,
ToggleSwitch,
i18nMessage,
usePlatformUiCapability,
type DataGridColumn,
type FilesFileExplorerUiCapability
} from "@govoplan/core-webui";
import { getDraftFields } from "../utils/fieldDefinitions";
import type { AttachmentBasePath } from "../utils/attachments";
import {
applyRecipientMappingProfile,
buildImportTable,
buildImportTableFromRows,
buildRecipientImportPreview,
createRecipientImportProvenance,
createRecipientMappingProfile as buildRecipientMappingProfile,
defaultColumnMappings,
matchRecipientMappingProfiles,
recipientImportHeaderFingerprints,
xlsxSheetsFromArrayBuffer,
type CsvDelimiter,
type ImportedAddress,
type RecipientColumnKind,
type RecipientColumnMapping,
type RecipientImportMode,
type RecipientImportPreview,
type RecipientImportProvenance,
type RecipientImportSourceType,
type RecipientImportSpreadsheetSheet,
type RecipientMappingProfile,
type RecipientMappingProfileMatch
} from "../utils/bulkImport";
import {
bulkLinkFilesToCampaign,
renderedImportPatterns,
resolveImportedAttachmentLinks,
type ImportFileLinkCapability,
type ImportFileLinkResolution
} from "../utils/fileLinking";
type RecipientImportDialogProps = {
settings: ApiSettings;
campaignId: string;
existingEntries: Record<string, unknown>[];
existingFields: ReturnType<typeof getDraftFields>;
defaultAttachmentBasePath: AttachmentBasePath | null;
onCancel: () => void;
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) => void;
};
type RecipientImportStepId = "upload" | "parse" | "map" | "preview" | "files";
const allRecipientImportSteps: Array<{id: RecipientImportStepId;label: string;}> = [
{ id: "upload", label: "i18n:govoplan-campaign.upload.8bdf057f" },
{ id: "parse", label: "i18n:govoplan-campaign.parse.b7e45a36" },
{ id: "map", label: "i18n:govoplan-campaign.map.ab478f3e" },
{ id: "preview", label: "i18n:govoplan-campaign.preview.f1fbb2b4" },
{ id: "files", label: "i18n:govoplan-campaign.files.6ce6c512" }];
const recipientColumnKindOptions: Array<{value: RecipientColumnKind;label: string;}> = [
{ value: "ignore", label: "i18n:govoplan-campaign.ignore.98f55db5" },
{ value: "id", label: "i18n:govoplan-campaign.id.89f89c02" },
{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" },
{ value: "name", label: "i18n:govoplan-campaign.display_name.c7874aaa" },
{ value: "from", label: "i18n:govoplan-campaign.from.3f66052a" },
{ value: "to", label: "i18n:govoplan-campaign.to.ae79ea1e" },
{ value: "cc", label: "i18n:govoplan-campaign.cc.c5a976de" },
{ value: "bcc", label: "i18n:govoplan-campaign.bcc.4c0145a3" },
{ value: "reply_to", label: "i18n:govoplan-campaign.reply_to.c1733667" },
{ value: "field", label: "i18n:govoplan-campaign.existing_field.681f2b09" },
{ value: "new_field", label: "i18n:govoplan-campaign.new_field.57fc8a2e" },
{ value: "attachment_pattern", label: "i18n:govoplan-campaign.attachment_pattern.59649690" }];
const AUTOMATIC_MAPPING_PROFILE_MIN_SCORE = 0.55;
const RECIPIENT_IMPORT_ENCODINGS = [
{ value: "utf-8", label: "i18n:govoplan-campaign.utf_8.663b90c8" },
{ value: "windows-1252", label: "i18n:govoplan-campaign.windows_1252.6944986d" },
{ value: "iso-8859-1", label: "i18n:govoplan-campaign.iso_8859_1.e4d77380" },
{ value: "utf-16le", label: "i18n:govoplan-campaign.utf_16_le.8d74294c" },
{ value: "utf-16be", label: "i18n:govoplan-campaign.utf_16_be.1de115c7" }];
export function RecipientImportDialog({ settings, campaignId, existingEntries, existingFields, defaultAttachmentBasePath, onCancel, onImport }: RecipientImportDialogProps) {
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const fileLinkCapability = useMemo<ImportFileLinkCapability | null>(() => {
if (!filesFileExplorer?.listFiles || !filesFileExplorer.resolveFilePatterns || !filesFileExplorer.shareFilesWithTarget) return null;
return {
listFiles: filesFileExplorer.listFiles,
resolveFilePatterns: filesFileExplorer.resolveFilePatterns,
shareFilesWithTarget: filesFileExplorer.shareFilesWithTarget
};
}, [filesFileExplorer]);
const recipientImportSteps = useMemo(
() => fileLinkCapability ? allRecipientImportSteps : allRecipientImportSteps.filter((step) => step.id !== "files"),
[fileLinkCapability]
);
const [activeStep, setActiveStep] = useState<RecipientImportStepId>("upload");
const [csvText, setCsvText] = useState("");
const [sourceType, setSourceType] = useState<RecipientImportSourceType>("text");
const [filename, setFilename] = useState("");
const [fileBuffer, setFileBuffer] = useState<ArrayBuffer | null>(null);
const [encoding, setEncoding] = useState("utf-8");
const [workbookSheets, setWorkbookSheets] = useState<RecipientImportSpreadsheetSheet[]>([]);
const [selectedSheetName, setSelectedSheetName] = 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 [mappingProfiles, setMappingProfiles] = useState<RecipientMappingProfile[]>([]);
const [mappingManuallyChanged, setMappingManuallyChanged] = useState(false);
const [mappingProfileError, setMappingProfileError] = useState("");
const [mappingProfileNotice, setMappingProfileNotice] = useState("");
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 selectedSheet = useMemo(
() => workbookSheets.find((sheet) => sheet.name === selectedSheetName) ?? workbookSheets[0] ?? null,
[selectedSheetName, workbookSheets]
);
const hasContent = sourceType === "xlsx" ?
Boolean(selectedSheet && selectedSheet.rows.some((row) => row.some((cell) => cell.trim()))) :
csvText.trim().length > 0;
const table = useMemo(
() => {
if (!hasContent) return null;
if (sourceType === "xlsx") return selectedSheet ? buildImportTableFromRows(selectedSheet.rows, { headerRows }) : null;
return buildImportTable(csvText, { delimiter, headerRows, quoted });
},
[csvText, delimiter, hasContent, headerRows, quoted, selectedSheet, sourceType]
);
const tableHeaderKey = table ? `${table.delimiter}:${table.firstDataRowNumber}:${table.headers.join("\u001f")}` : "";
const mappingProfileMatches = useMemo(
() => table ? matchRecipientMappingProfiles(table, mappingProfiles) : [],
[mappingProfiles, table]
);
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 isLastStep = activeStepIndex === recipientImportSteps.length - 1;
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(() => {
let cancelled = false;
setMappingProfileError("");
void listRecipientImportMappingProfiles(settings).
then((profiles) => {
if (!cancelled) setMappingProfiles(profiles);
}).
catch((err) => {
if (!cancelled) setMappingProfileError(err instanceof Error ? err.message : String(err));
});
return () => {cancelled = true;};
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (!fileBuffer || sourceType !== "csv") return;
try {
setCsvText(decodeImportText(fileBuffer, encoding));
setFileError("");
} catch (err) {
setCsvText("");
setFileError(err instanceof Error ? err.message : String(err));
}
}, [encoding, fileBuffer, sourceType]);
useEffect(() => {
setMappingManuallyChanged(false);
}, [tableHeaderKey]);
useEffect(() => {
if (!table) {
setMappings([]);
setMappingProfileNotice("");
return;
}
if (mappingManuallyChanged) return;
const automaticMatch = mappingProfileMatches.find(isAutomaticMappingProfileMatch);
if (automaticMatch) {
setMappings(applyRecipientMappingProfile(table, automaticMatch.profile, existingFields));
setValueSeparators(automaticMatch.profile.valueSeparators || ",;|");
setMappingProfileNotice(mappingProfileMatchNotice(automaticMatch));
return;
}
setMappings(defaultColumnMappings(table.headers, existingFields));
setMappingProfileNotice("");
}, [existingFields, mappingManuallyChanged, mappingProfileMatches, table, tableHeaderKey]);
useEffect(() => {
if (!hasContent) setActiveStep("upload");
}, [hasContent]);
useEffect(() => {
if (activeStep === "files" && !fileLinkCapability) setActiveStep("preview");
}, [activeStep, fileLinkCapability]);
useEffect(() => {
if (activeStep === "files" && fileLinkCapability) void refreshFileLinks();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeStep, defaultAttachmentBasePath?.id, defaultAttachmentBasePath?.path, defaultAttachmentBasePath?.source, fileLinkCapability, renderedPatterns.length]);
async function readFile(file: File | undefined) {
if (!file) return;
setFilename(file.name);
setFileError("");
setWorkbookSheets([]);
setSelectedSheetName("");
try {
const buffer = await file.arrayBuffer();
if (isXlsxFile(file)) {
const sheets = await xlsxSheetsFromArrayBuffer(buffer);
if (sheets.length === 0) {
throw new Error("i18n:govoplan-campaign.workbook_contains_no_readable_sheets.02c18603");
}
setSourceType("xlsx");
setFileBuffer(null);
setCsvText("");
setWorkbookSheets(sheets);
setSelectedSheetName(sheets[0]?.name ?? "");
} else {
setSourceType("csv");
setFileBuffer(buffer);
setCsvText(decodeImportText(buffer, encoding));
}
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;
if (stepId === "files" && !fileLinkCapability) return false;
return parseReady && mappings.length > 0;
}
function stepStatus(stepId: RecipientImportStepId): string {
if (stepId === "upload") return filename || (hasContent ? "i18n:govoplan-campaign.pasted_data.5b58080b" : "i18n:govoplan-campaign.waiting.33d30632");
if (stepId === "parse") return table ? `${table.dataRows.length} rows` : "i18n:govoplan-campaign.set_parsing.be7b05fd";
if (stepId === "map") return `${mappedColumnCount} mapped`;
if (stepId === "preview") return preview ? `${preview.validCount} valid` : "i18n:govoplan-campaign.review.e29a79fe";
if (!preview || preview.patternCount === 0) return "i18n:govoplan-campaign.no_patterns.5e1e46fa";
if (fileLinkResolving) return "i18n:govoplan-campaign.checking.97876b83";
if (fileLinkResolution) return i18nMessage("i18n:govoplan-campaign.value_to_link.50cdc659", { value0: fileLinkResolution.linkableFiles.length });
if (fileLinkError) return "i18n:govoplan-campaign.needs_setup.522ebae4";
return `${renderedPatterns.length} patterns`;
}
function goNext() {
if (nextStep && canOpenStep(nextStep)) setActiveStep(nextStep);
}
async function refreshFileLinks() {
setFileLinkNotice("");
if (!fileLinkCapability) {
setFileLinkResolution(null);
setFileLinkError("");
return;
}
if (!preview || preview.patternCount === 0) {
setFileLinkResolution(null);
setFileLinkError("");
return;
}
if (!defaultAttachmentBasePath?.source) {
setFileLinkResolution(null);
setFileLinkError("i18n:govoplan-campaign.the_selected_attachment_source_is_not_connected_.9c528afa");
return;
}
setFileLinkResolving(true);
setFileLinkError("");
try {
setFileLinkResolution(await resolveImportedAttachmentLinks(fileLinkCapability, settings, campaignId, defaultAttachmentBasePath, preview));
} catch (err) {
setFileLinkResolution(null);
setFileLinkError(err instanceof Error ? err.message : String(err));
} finally {
setFileLinkResolving(false);
}
}
async function linkImportedFiles() {
if (!fileLinkCapability || !fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return;
setFileLinking(true);
setFileLinkError("");
setFileLinkNotice("");
try {
const linkedCount = await bulkLinkFilesToCampaign(fileLinkCapability, 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 rememberCurrentMappingProfile() {
if (!table || mappings.length === 0) return;
const tableSnapshot = table;
const mappingsSnapshot = mappings;
const fallbackProfiles = mappingProfiles;
const filenameSnapshot = filename;
const parseOptions = { headerRows, quoted };
const valueSeparatorsSnapshot = valueSeparators;
void (async () => {
try {
const latestProfiles = await listRecipientImportMappingProfiles(settings).catch(() => fallbackProfiles);
const reusableProfile = findReusableMappingProfile(tableSnapshot, latestProfiles);
const draftProfile = buildRecipientMappingProfile({
id: reusableProfile?.id,
name: reusableProfile?.name ?? defaultMappingProfileName(filenameSnapshot, tableSnapshot),
table: tableSnapshot,
mappings: mappingsSnapshot,
parseOptions,
valueSeparators: valueSeparatorsSnapshot,
createdAt: reusableProfile?.createdAt,
updatedAt: new Date().toISOString()
});
const payload = mappingProfilePayload(draftProfile);
await (reusableProfile ?
updateRecipientImportMappingProfile(settings, reusableProfile.id, payload) :
createRecipientImportMappingProfile(settings, payload));
} catch {
// Import acceptance must not be blocked by passive profile learning.
}})();}function confirmRecipientImport(nextPreview: RecipientImportPreview) {rememberCurrentMappingProfile();
const provenance = createRecipientImportProvenance({
preview: nextPreview,
mappings,
mode,
sourceType,
filename,
sheetName: sourceType === "xlsx" ? selectedSheet?.name ?? null : null,
encoding: sourceType === "xlsx" ? null : encoding,
delimiter: sourceType === "xlsx" ? null : formatDelimiter(nextPreview.table.delimiter),
headerRows,
quoted: sourceType === "xlsx" ? null : quoted,
valueSeparators
});
onImport(nextPreview, mode, provenance);
}
function replaceColumnMapping(nextMapping: RecipientColumnMapping) {
const columnCount = table?.headers.length ?? 0;
setMappingManuallyChanged(true);
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 mappingRows = (table?.headers ?? []).map((header, columnIndex) => ({
id: `${columnIndex}-${header}`,
header,
columnIndex,
mapping: mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const }
}));
type MappingRow = (typeof mappingRows)[number];
const mappingColumns: DataGridColumn<MappingRow>[] = [
{ id: "column", header: "i18n:govoplan-campaign.column.65ba00e9", width: "minmax(180px, .8fr)", minWidth: 160, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.header, render: (row) => <strong>{row.header}</strong> },
{ id: "sample", header: "i18n:govoplan-campaign.sample.58fabfa7", width: "minmax(220px, 1fr)", minWidth: 180, maxWidth: 520, resizable: true, filterable: true, value: (row) => table ? sampleColumnValue(table.dataRows, row.columnIndex) : "", render: (row) => <span className="mono-small">{table ? sampleColumnValue(table.dataRows, row.columnIndex) : ""}</span> },
{ id: "content", header: "i18n:govoplan-campaign.content.4f9be057", width: 190, value: (row) => row.mapping.kind, render: (row) => <select value={row.mapping.kind} onChange={(event) => changeColumnKind(row.columnIndex, event.target.value as RecipientColumnKind)}>{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select> },
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(200px, .9fr)", minWidth: 180, maxWidth: 460, resizable: true, value: (row) => row.mapping.fieldName ?? row.mapping.newFieldName ?? "", render: (row) => <>{row.mapping.kind === "field" && <select value={row.mapping.fieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, fieldName: event.target.value, newFieldName: undefined })}><option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}</select>}{row.mapping.kind === "new_field" && <input value={row.mapping.newFieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, newFieldName: event.target.value, fieldName: undefined })} />}</> }
];
type PreviewRow = RecipientImportPreview["rows"][number];
const previewColumns: DataGridColumn<PreviewRow>[] = [
{ id: "row", header: "#", width: 68, sortable: true, value: (row) => row.rowNumber },
{ id: "to", header: "i18n:govoplan-campaign.to.ae79ea1e", width: "minmax(220px, 1fr)", minWidth: 190, maxWidth: 520, resizable: true, filterable: true, value: (row) => formatAddressList(row.addresses.to) },
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, .8fr)", minWidth: 160, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.name },
{ id: "fields", header: "i18n:govoplan-campaign.fields.e8b68527", width: 100, sortable: true, value: (row) => Object.keys(row.fields).length },
{ id: "patterns", header: "i18n:govoplan-campaign.patterns.4d34f7a2", width: 110, sortable: true, value: (row) => row.patterns.length },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: "minmax(220px, 1fr)", minWidth: 190, maxWidth: 600, resizable: true, filterable: true, value: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552", render: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552" }
];
const stepContent = activeStep === "upload" ?
<>
<div className="campaign-header-grid recipient-import-upload-grid">
<FormField label="i18n:govoplan-campaign.recipient_file.693007d2">
<FileDropZone
accept=".csv,.tsv,.txt,.xlsx,text/csv,text/tab-separated-values,text/plain,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
multiple={false}
label="i18n:govoplan-campaign.drop_recipient_file_here.c7c2bddf"
actionLabel="i18n:govoplan-campaign.or_click_to_select_file.0e72f25d"
note={filename || "i18n:govoplan-campaign.csv_tsv_text_or_xlsx.5dcdac76"}
onFiles={(files) => readFile(files[0])} />
</FormField>
<FormField label="i18n:govoplan-campaign.import_mode.7d161dff">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
<option value="append">i18n:govoplan-campaign.append_to_current_profiles.0b434d6f</option>
<option value="replace">i18n:govoplan-campaign.replace_current_profiles.3b3be5e2</option>
</select>
</FormField>
</div>
{fileError && <DismissibleAlert tone="danger" compact resetKey={fileError}>{fileError}</DismissibleAlert>}
{sourceType === "xlsx" ?
<DismissibleAlert tone="info" compact dismissible={false}>
i18n:govoplan-campaign.workbook_loaded.cfaf40eb {workbookSheets.length} sheet{workbookSheets.length === 1 ? "" : "s"}.
</DismissibleAlert> :
<FormField label={filename ? i18nMessage("i18n:govoplan-campaign.raw_content_value.15e098b8", { value0: filename }) : "i18n:govoplan-campaign.raw_content.c687fb57"}>
<textarea
className="json-editor recipient-import-textarea"
value={csvText}
onChange={(event) => {
setFileBuffer(null);
setSourceType(filename ? "csv" : "text");
setCsvText(event.target.value);
if (!event.target.value.trim()) setFilename("");
}}
spellCheck={false} />
</FormField>
}
</> :
activeStep === "parse" ?
<>
<div className="campaign-header-grid">
{sourceType === "xlsx" &&
<FormField label="i18n:govoplan-campaign.sheet.53bc47a7">
<select value={selectedSheet?.name ?? ""} onChange={(event) => setSelectedSheetName(event.target.value)}>
{workbookSheets.map((sheet) =>
<option key={sheet.name} value={sheet.name}>{sheet.name}</option>
)}
</select>
</FormField>
}
<FormField label="i18n:govoplan-campaign.header_rows.9814b9e3">
<input
type="number"
min={0}
max={10}
value={headerRows}
onChange={(event) => setHeaderRows(Math.max(0, Number.parseInt(event.target.value, 10) || 0))} />
</FormField>
{sourceType !== "xlsx" &&
<>
<FormField label="i18n:govoplan-campaign.encoding.5821fec7">
<select value={encoding} onChange={(event) => setEncoding(event.target.value)}>
{RECIPIENT_IMPORT_ENCODINGS.map((option) =>
<option key={option.value} value={option.value}>{option.label}</option>
)}
</select>
</FormField>
<FormField label="i18n:govoplan-campaign.separator.b4b289a7">
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value as CsvDelimiter)}>
<option value="auto">i18n:govoplan-campaign.auto.c614ba7c</option>
<option value=",">i18n:govoplan-campaign.comma.b9ee3dea</option>
<option value=";">i18n:govoplan-campaign.semicolon.727ceca9</option>
<option value={"\t"}>i18n:govoplan-campaign.tab.fe06eb64</option>
</select>
</FormField>
<div className="campaign-header-toggle recipient-import-toggles">
<ToggleSwitch label="i18n:govoplan-campaign.quoted_contents.f7fd335a" checked={quoted} onChange={setQuoted} />
</div>
</>
}
</div>
{table &&
<>
<dl className="detail-list recipient-import-summary">
<div><dt>{sourceType === "xlsx" ? "i18n:govoplan-campaign.sheet.53bc47a7" : "i18n:govoplan-campaign.separator.b4b289a7"}</dt><dd>{sourceType === "xlsx" ? selectedSheet?.name ?? "i18n:govoplan-campaign.workbook.b4418e62" : formatDelimiter(table.delimiter)}</dd></div>
<div><dt>i18n:govoplan-campaign.header_rows.9814b9e3</dt><dd>{table.headerRows.length}</dd></div>
<div><dt>i18n:govoplan-campaign.data_rows.3734724c</dt><dd>{table.dataRows.length}</dd></div>
<div><dt>i18n:govoplan-campaign.columns.cf723c59</dt><dd>{table.headers.length}</dd></div>
</dl>
<RecipientImportRawTable tableRows={table.rows} headerRowCount={table.headerRows.length} columnCount={table.headers.length} />
</>
}
</> :
activeStep === "map" ?
<>
{mappingProfileError && <DismissibleAlert tone="warning" compact resetKey={mappingProfileError}>{mappingProfileError}</DismissibleAlert>}
{mappingProfileNotice && <DismissibleAlert tone="info" compact resetKey={mappingProfileNotice}>{mappingProfileNotice}</DismissibleAlert>}
<div className="campaign-header-grid recipient-import-map-controls">
<FormField label="i18n:govoplan-campaign.multi_value_separators.6dd66f70">
<input
value={valueSeparators}
onChange={(event) => {
setMappingManuallyChanged(true);
setValueSeparators(event.target.value);
}} />
</FormField>
<FormField label="i18n:govoplan-campaign.mode.a7b93d21">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
<option value="append">i18n:govoplan-campaign.append.6b3a6022</option>
<option value="replace">i18n:govoplan-campaign.replace.a7cf7b25</option>
</select>
</FormField>
</div>
<DataGrid id="campaign-recipient-import-mapping" rows={mappingRows} columns={mappingColumns} getRowKey={(row) => row.id} />
</> :
activeStep === "preview" ?
<>
{preview &&
<>
<dl className="detail-list recipient-import-summary">
<div><dt>i18n:govoplan-campaign.rows.52d0b352</dt><dd>{preview.validCount} i18n:govoplan-campaign.valid.210cece7 {preview.invalidCount} invalid</dd></div>
<div><dt>i18n:govoplan-campaign.fields.e8b68527</dt><dd>{preview.fieldNamesToCreate.length} new</dd></div>
<div><dt>i18n:govoplan-campaign.patterns.4d34f7a2</dt><dd>{preview.patternCount} from {patternRows} i18n:govoplan-campaign.row_s.61464061</dd></div>
<div><dt>i18n:govoplan-campaign.source.6da13add</dt><dd>{preview.patternCount ? defaultAttachmentBasePath?.name ?? "i18n:govoplan-campaign.campaign_files.96e7004b" : "i18n:govoplan-campaign.no_patterns.5e1e46fa"}</dd></div>
</dl>
{preview.fieldNamesToCreate.length > 0 &&
<p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p>
}
<DataGrid
id="campaign-recipient-import-preview"
className="recipient-import-preview-grid"
rows={preview.rows.slice(0, 20)}
columns={previewColumns}
getRowKey={(row) => String(row.rowNumber)}
rowClassName={(row) => row.issues.length ? "is-invalid" : undefined}
/>
</>
}
</> :
<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="i18n:govoplan-campaign.import_recipients.9d6dbe45"
className="recipient-import-modal"
bodyClassName="recipient-import-body"
closeDisabled={fileLinking || fileLinkResolving}
closeOnBackdrop={!fileLinking && !fileLinkResolving}
onClose={onCancel}
footer={
<>
<Button onClick={onCancel} disabled={fileLinking || fileLinkResolving}>i18n:govoplan-campaign.cancel.77dfd213</Button>
{previousStep && <Button onClick={() => setActiveStep(previousStep)} disabled={fileLinking || fileLinkResolving}>i18n:govoplan-campaign.back.b52b36b7</Button>}
{!isLastStep ?
<Button variant="primary" disabled={!nextStep || !canOpenStep(nextStep)} onClick={goNext}>i18n:govoplan-campaign.next.bc981983</Button> :
<Button variant="primary" disabled={!preview || preview.validCount === 0 || fileLinking || fileLinkResolving} onClick={() => preview && confirmRecipientImport(preview)}>i18n:govoplan-campaign.import_valid_rows.c3b2642b</Button>
}
</>
}>
<nav className="recipient-import-steps" aria-label="i18n:govoplan-campaign.recipient_import_steps.35dcf028">
<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}>i18n:govoplan-campaign.no_attachment_patterns_were_imported_there_are_n.c01c8266</DismissibleAlert>;
}
type ResolvedFile = ImportFileLinkResolution["files"][number];
const fileColumns: DataGridColumn<ResolvedFile>[] = [
{ id: "file", header: "i18n:govoplan-campaign.file.2c3cafa4", width: "minmax(200px, .8fr)", minWidth: 180, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (file) => file.filename, render: (file) => <strong>{file.filename}</strong> },
{ id: "path", header: "i18n:govoplan-campaign.path.519e3913", width: "minmax(260px, 1.3fr)", minWidth: 220, maxWidth: 680, resizable: true, sortable: true, filterable: true, value: (file) => file.display_path },
{ id: "size", header: "i18n:govoplan-campaign.size.b7152342", width: 120, sortable: true, value: (file) => file.size_bytes, render: (file) => formatImportBytes(file.size_bytes) },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 160, sortable: true, filterable: true, value: (file) => linkableIds.has(file.id) ? "needs-linking" : "linked", render: (file) => linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600" }
];
return (
<div className="recipient-import-file-step">
<div className="recipient-import-file-actions">
<div>
<strong>{basePath?.name ?? "i18n:govoplan-campaign.campaign_files.96e7004b"}</strong>
<p className="muted small-note">i18n:govoplan-campaign.files_matched_by_imported_recipient_attachment_p.9b97aca3</p>
</div>
<div className="button-row compact-actions">
<Button onClick={onRefresh} disabled={resolving || linking}>{resolving ? "i18n:govoplan-campaign.checking.820d6004" : "i18n:govoplan-campaign.refresh_matches.11e36411"}</Button>
<Button variant="primary" onClick={onLink} disabled={resolving || linking || !resolution || resolution.linkableFiles.length === 0}>{linking ? "i18n:govoplan-campaign.linking.6f640897" : i18nMessage("i18n:govoplan-campaign.link_value_file_s.ca800d96", { value0: resolution?.linkableFiles.length ?? 0 })}</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}>i18n:govoplan-campaign.resolving_imported_file_patterns.3aea140f</DismissibleAlert>}
{resolution &&
<>
<dl className="detail-list recipient-import-summary">
<div><dt>i18n:govoplan-campaign.patterns.4d34f7a2</dt><dd>{resolution.patterns.length}</dd></div>
<div><dt>i18n:govoplan-campaign.matched_files.f79c63bb</dt><dd>{resolution.files.length}</dd></div>
<div><dt>i18n:govoplan-campaign.already_linked.7d6c3dd5</dt><dd>{resolution.linkedFiles.length}</dd></div>
<div><dt>i18n:govoplan-campaign.need_linking.a7617722</dt><dd>{resolution.linkableFiles.length}</dd></div>
</dl>
{resolution.unmatchedPatterns.length > 0 &&
<div className="recipient-import-unmatched-patterns">
<strong>i18n:govoplan-campaign.patterns_without_matches.172afdde</strong>
<ul>
{resolution.unmatchedPatterns.slice(0, 8).map((pattern) =>
<li key={pattern.key}>i18n:govoplan-campaign.row.9bf7a8e8 {pattern.rowNumber}: <code>{pattern.renderedPattern}</code></li>
)}
</ul>
{resolution.unmatchedPatterns.length > 8 && <p className="muted small-note">{resolution.unmatchedPatterns.length - 8} i18n:govoplan-campaign.more_unmatched_pattern_s.1dda9807</p>}
</div>
}
<DataGrid
id="campaign-recipient-import-file-links"
rows={resolution.files}
columns={fileColumns}
getRowKey={(file) => file.id}
emptyText="i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b"
/>
</>
}
</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));
const rows = visibleRows.map((cells, rowIndex) => ({ rowIndex, cells }));
type RawRow = (typeof rows)[number];
const columns: DataGridColumn<RawRow>[] = [
{ id: "row", header: "#", width: 64, sortable: true, value: (row) => row.rowIndex + 1 },
...Array.from({ length: safeColumnCount }, (_value, columnIndex): DataGridColumn<RawRow> => ({
id: `column-${columnIndex + 1}`,
header: String(columnIndex + 1),
width: "minmax(140px, 1fr)",
minWidth: 120,
maxWidth: 360,
resizable: true,
filterable: true,
value: (row) => row.cells[columnIndex] ?? ""
}))
];
return (
<DataGrid
id="campaign-recipient-import-raw"
className="recipient-import-raw-grid"
rows={rows}
columns={columns}
getRowKey={(row) => String(row.rowIndex)}
rowClassName={(row) => row.rowIndex < headerRowCount ? "is-header-row" : undefined}
emptyText="i18n:govoplan-campaign.no_rows_parsed.a7ccc3de"
initialFit="content"
/>);
}
function formatDelimiter(delimiter: "," | ";" | "\t"): string {
if (delimiter === "\t") return "i18n:govoplan-campaign.tab.fe06eb64";
if (delimiter === ";") return "i18n:govoplan-campaign.semicolon.727ceca9";
return "i18n:govoplan-campaign.comma.b9ee3dea";
}
function isXlsxFile(file: File): boolean {
const name = file.name.toLowerCase();
return name.endsWith(".xlsx") || file.type === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}
function decodeImportText(buffer: ArrayBuffer, encoding: string): string {
try {
return new TextDecoder(encoding).decode(buffer).replace(/^\uFEFF/, "");
} catch (err) {
throw new Error(`Could not decode file as ${encoding}: ${err instanceof Error ? err.message : String(err)}`);
}
}
function mappingProfilePayload(profile: RecipientMappingProfile): RecipientImportMappingProfilePayload {
return {
name: profile.name,
columnCount: profile.columnCount,
headers: profile.headers,
normalizedHeaders: profile.normalizedHeaders,
orderedHeaderFingerprint: profile.orderedHeaderFingerprint,
unorderedHeaderFingerprint: profile.unorderedHeaderFingerprint,
delimiter: profile.delimiter,
headerRows: profile.headerRows,
quoted: profile.quoted,
valueSeparators: profile.valueSeparators,
mappings: profile.mappings
};
}
function defaultMappingProfileName(filename: string, table: {headers: string[];}): string {
const trimmedFilename = filename.trim().replace(/\.[^.]+$/, "");
if (trimmedFilename) return trimmedFilename;
const headerLabel = table.headers.slice(0, 3).map((header) => header.trim()).filter(Boolean).join(", ");
return headerLabel ? i18nMessage("i18n:govoplan-campaign.mapping_value", { value0: headerLabel }) : "i18n:govoplan-campaign.recipient_import_mapping.5fe80f6c";
}
function formatMappingProfileMatch(match: RecipientMappingProfileMatch): string {
if (match.mode === "ordered") return "i18n:govoplan-campaign.exact";
if (match.mode === "unordered") return "i18n:govoplan-campaign.same_headers.e523bbe4";
return `${Math.round(match.score * 100)}%`;
}
function isAutomaticMappingProfileMatch(match: RecipientMappingProfileMatch): boolean {
return match.mode === "ordered" || match.mode === "unordered" || match.score >= AUTOMATIC_MAPPING_PROFILE_MIN_SCORE;
}
function findReusableMappingProfile(table: {headers: string[];}, profiles: RecipientMappingProfile[]): RecipientMappingProfile | null {
const fingerprints = recipientImportHeaderFingerprints(table.headers);
return profiles.find((profile) => profile.orderedHeaderFingerprint === fingerprints.orderedHeaderFingerprint) ??
profiles.find((profile) => profile.unorderedHeaderFingerprint === fingerprints.unorderedHeaderFingerprint) ??
null;
}
function mappingProfileMatchNotice(match: RecipientMappingProfileMatch): string {
if (match.mode === "ordered") return i18nMessage("i18n:govoplan-campaign.using_mapping_from_previous_import_value.f86f53fd", { value0: match.profile.name });
if (match.mode === "unordered") return i18nMessage("i18n:govoplan-campaign.using_mapping_from_previous_import_value_matched.08910120", { value0: match.profile.name });
return i18nMessage("i18n:govoplan-campaign.using_mapping_derived_from_previous_import_value.008a66a3", { value0: match.profile.name, value1: formatMappingProfileMatch(match) });
}
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 i18nMessage("i18n:govoplan-campaign.bytes_b", { value0: value });
if (value < 1024 * 1024) return i18nMessage("i18n:govoplan-campaign.bytes_kb", { value0: (value / 1024).toFixed(1) });
return i18nMessage("i18n:govoplan-campaign.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
}
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";
}