Refactor campaign recipient and review presentation
This commit is contained in:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user