import { DescriptionList } from "@govoplan/core-webui"; 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[]; existingFields: ReturnType; 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("files.fileExplorer"); const fileLinkCapability = useMemo(() => { 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("upload"); const [csvText, setCsvText] = useState(""); const [sourceType, setSourceType] = useState("text"); const [filename, setFilename] = useState(""); const [fileBuffer, setFileBuffer] = useState(null); const [encoding, setEncoding] = useState("utf-8"); const [workbookSheets, setWorkbookSheets] = useState([]); const [selectedSheetName, setSelectedSheetName] = useState(""); const [mode, setMode] = useState("append"); const [delimiter, setDelimiter] = useState("auto"); const [headerRows, setHeaderRows] = useState(1); const [quoted, setQuoted] = useState(true); const [valueSeparators, setValueSeparators] = useState(",;|"); const [mappings, setMappings] = useState([]); const [mappingProfiles, setMappingProfiles] = useState([]); const [mappingManuallyChanged, setMappingManuallyChanged] = useState(false); const [mappingProfileError, setMappingProfileError] = useState(""); const [mappingProfileNotice, setMappingProfileNotice] = useState(""); const [fileError, setFileError] = useState(""); const [fileLinkResolution, setFileLinkResolution] = useState(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[] = [ { 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) => {row.header} }, { 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) => {table ? sampleColumnValue(table.dataRows, row.columnIndex) : ""} }, { id: "content", header: "i18n:govoplan-campaign.content.4f9be057", width: 190, value: (row) => row.mapping.kind, render: (row) => }, { 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" && }{row.mapping.kind === "new_field" && replaceColumnMapping({ ...row.mapping, newFieldName: event.target.value, fieldName: undefined })} />} } ]; type PreviewRow = RecipientImportPreview["rows"][number]; const previewColumns: DataGridColumn[] = [ { 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" ? <>
readFile(files[0])} />
{fileError && {fileError}} {sourceType === "xlsx" ? i18n:govoplan-campaign.workbook_loaded.cfaf40eb {workbookSheets.length} sheet{workbookSheets.length === 1 ? "" : "s"}. :