import { useEffect, useMemo, useRef, useState, type ClipboardEvent } from "react"; import { ArrowDown, ArrowUp, Copy, Pencil, Plus, Trash2 } from "lucide-react"; import type { ApiSettings } from "../../types"; import { createRecipientImportMappingProfile, listCampaignRecipientAddressSources, listRecipientImportMappingProfiles, snapshotCampaignRecipientAddressSource, updateRecipientImportMappingProfile, type CampaignRecipientAddressSource, type CampaignRecipientAddressSourceSnapshot, type RecipientImportMappingProfilePayload } from "../../api/campaigns"; import { Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; import { FileDropZone } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui"; import { usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui"; import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold"; import { ToggleSwitch } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; import { SegmentedControl } from "@govoplan/core-webui"; import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { getBool } from "./utils/draftEditor"; import { getDraftFields } from "./utils/fieldDefinitions"; import FieldValueInput from "./components/FieldValueInput"; import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay"; import { buildTemplatePreviewContext } from "./utils/templatePlaceholders"; import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments"; import { applyRecipientMappingProfile, buildImportTable, buildImportTableFromRows, buildRecipientImportPreview, createRecipientImportProvenance, createRecipientMappingProfile as buildRecipientMappingProfile, defaultColumnMappings, matchRecipientMappingProfiles, materializeRecipientImportWithAttachmentDefaults, recipientImportHeaderFingerprints, xlsxSheetsFromArrayBuffer, type CsvDelimiter, type ImportedAddress, type ImportedRecipientRow, type RecipientColumnKind, type RecipientColumnMapping, type RecipientImportMode, type RecipientImportPreview, type RecipientImportTable, type RecipientImportProvenance, type RecipientImportSourceType, type RecipientImportSpreadsheetSheet, type RecipientMappingProfile, type RecipientMappingProfileMatch } from "./utils/bulkImport"; import { bulkLinkFilesToCampaign, type ImportFileLinkCapability, renderedImportPatterns, resolveImportedAttachmentLinks, type ImportFileLinkResolution } from "./utils/fileLinking"; import { addressesFromValue, dedupeAddresses, parseMailboxAddressText, type MailboxAddress } from "@govoplan/core-webui"; import { insertAfter, moveArrayItem, i18nMessage, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui"; const recipientHeaderRows = [ { key: "to", label: "i18n:govoplan-campaign.to.ae79ea1e", toggleKey: "allow_individual_to", toggleLabel: "i18n:govoplan-campaign.allow_individual_to.966cb33e", addLabel: "i18n:govoplan-campaign.add_recipient.a989d1f1", emptyText: "i18n:govoplan-campaign.no_global_recipients_configured.d4e20e92" }, { key: "cc", label: "i18n:govoplan-campaign.cc.c5a976de", toggleKey: "allow_individual_cc", toggleLabel: "i18n:govoplan-campaign.allow_individual_cc.0457c0e2", addLabel: "i18n:govoplan-campaign.add_cc.bcb39ea3", emptyText: "i18n:govoplan-campaign.no_global_cc_recipients_configured.8afb23c1" }, { key: "bcc", label: "i18n:govoplan-campaign.bcc.4c0145a3", toggleKey: "allow_individual_bcc", toggleLabel: "i18n:govoplan-campaign.allow_individual_bcc.a932d94b", addLabel: "i18n:govoplan-campaign.add_bcc.ae9feacc", emptyText: "i18n:govoplan-campaign.no_global_bcc_recipients_configured.86ef64ab" }] as const; type RecipientAddressKey = "to" | "cc" | "bcc"; type AddressFieldKey = RecipientAddressKey | "from" | "reply_to"; type AddressSourceFilter = "all" | "books" | "lists"; type HeaderAddressEditorState = { title: string; columns: EntryAddressColumn[]; } | null; type EntryAddressColumn = { key: AddressFieldKey; label: string; allowMultiple: boolean; addLabel: string; emptyText: string; mergeKey?: "merge_to" | "merge_cc" | "merge_bcc" | "merge_reply_to"; }; const recipientAddressOverlayColumns: EntryAddressColumn[] = [ { key: "from", label: "i18n:govoplan-campaign.from.3f66052a", allowMultiple: false, addLabel: "i18n:govoplan-campaign.set_from.11fe7396", emptyText: "i18n:govoplan-campaign.uses_global_from.4cf8e6ce" }, { key: "reply_to", label: "i18n:govoplan-campaign.reply_to.c1733667", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_reply_to.84b195f4", emptyText: "i18n:govoplan-campaign.uses_global_reply_to.72c8d931", mergeKey: "merge_reply_to" }, { key: "to", label: "i18n:govoplan-campaign.to.ae79ea1e", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_to.768a148b", emptyText: "i18n:govoplan-campaign.no_recipient_address.1301a488", mergeKey: "merge_to" }, { key: "cc", label: "i18n:govoplan-campaign.cc.c5a976de", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_cc.bcb39ea3", emptyText: "i18n:govoplan-campaign.no_cc.5463a081", mergeKey: "merge_cc" }, { key: "bcc", label: "i18n:govoplan-campaign.bcc.4c0145a3", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_bcc.ae9feacc", emptyText: "i18n:govoplan-campaign.no_bcc.48cade21", mergeKey: "merge_bcc" }]; export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { const { translateText } = usePlatformLanguage(); const filesModuleInstalled = usePlatformModuleInstalled("files"); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const [importOpen, setImportOpen] = useState(false); const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false); const [addressSourceImportInitialId, setAddressSourceImportInitialId] = useState(""); const [addressSourcesAvailable, setAddressSourcesAvailable] = useState(false); const [addressSourcesLoading, setAddressSourcesLoading] = useState(false); const [addressSources, setAddressSources] = useState([]); const [recipientProfilesPage, setRecipientProfilesPage] = useState(1); const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10); const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState(null); const [headerAddressEditor, setHeaderAddressEditor] = useState(null); const version = data.currentVersion; const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({ settings, campaignId, version, locked, reload, setError, currentStep: "recipients", unsavedTitle: "i18n:govoplan-campaign.unsaved_recipient_changes.4184b4ed", unsavedMessage: "i18n:govoplan-campaign.recipient_address_data_has_unsaved_changes_save_.563c3b94" }); const recipientsSection = asRecord(displayDraft.recipients); const entries = asRecord(displayDraft.entries); const attachments = asRecord(displayDraft.attachments); const entryDefaults = asRecord(entries.defaults); const inlineEntries = asArray(entries.inline).map(asRecord); const source = asRecord(entries.source); const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]); const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]); const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]); const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments, true), [attachments]); const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]); const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]); const addressSourceRevisionById = useMemo( () => new Map(addressSources.map((addressSource) => [addressSource.source_id, addressSource.source_revision])), [addressSources] ); const staleAddressImports = useMemo(() => { return asArray(entries.imports). map(asRecord). filter((record) => record.source_type === "addresses"). map((record) => { const sourceId = typeof record.source_id === "string" ? record.source_id : ""; const importedRevision = typeof record.source_revision === "string" ? record.source_revision : ""; const currentRevision = sourceId ? addressSourceRevisionById.get(sourceId) ?? "" : ""; return { sourceId, sourceLabel: typeof record.source_label === "string" ? record.source_label : sourceId, importedRevision, currentRevision }; }). filter((record) => record.sourceId && record.currentRevision && record.importedRevision && record.currentRevision !== record.importedRevision); }, [addressSourceRevisionById, entries.imports]); const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1); const globalReplyTo = addressesFromValue(recipientsSection.reply_to); const globalRecipientValues: Record = { to: addressesFromValue(recipientsSection.to), cc: addressesFromValue(recipientsSection.cc), bcc: addressesFromValue(recipientsSection.bcc) }; const fromAddressColumn = getAddressColumn("from"); const replyToAddressColumn = getAddressColumn("reply_to"); useEffect(() => { let cancelled = false; setAddressSourcesLoading(true); void listCampaignRecipientAddressSources(settings, campaignId). then((response) => { if (cancelled) return; setAddressSourcesAvailable(response.available); setAddressSources(response.available ? response.sources ?? [] : []); }). catch(() => { if (cancelled) return; setAddressSourcesAvailable(false); setAddressSources([]); }). finally(() => { if (!cancelled) setAddressSourcesLoading(false); }); return () => {cancelled = true;}; }, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); useEffect(() => { setRecipientAddressEditorIndex((current) => { if (current === null) return null; if (inlineEntries.length === 0) return null; return Math.max(0, Math.min(current, inlineEntries.length - 1)); }); }, [inlineEntries.length]); function replaceInlineEntries(nextEntries: Record[]) { patch(["entries", "inline"], nextEntries); } function addRecipient(afterIndex = inlineEntries.length - 1) { const nextIndex = inlineEntries.length + 1; const insertIndex = Math.max(0, Math.min(afterIndex + 1, inlineEntries.length)); const newEntry = { id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`), active: true, to: [], cc: [], bcc: [], name: "", email: "", from: [], reply_to: [], merge_to: false, merge_cc: true, merge_bcc: true, merge_reply_to: true }; replaceInlineEntries(insertAfter(inlineEntries, afterIndex, newEntry)); setRecipientAddressEditorIndex(insertIndex); } function updateEntry(index: number, updater: (entry: Record) => Record) { const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry); replaceInlineEntries(nextEntries); } function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) { updateEntry(index, (entry) => entryWithAddressList(entry, key, addresses)); } function updateEntryAddressGroups(index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) { updateEntry(index, (entry) => { let nextEntry = entry; for (const group of groups) { const currentAddresses = getEntryAddresses(nextEntry, group.key); nextEntry = entryWithAddressList(nextEntry, group.key, dedupeAddresses([...currentAddresses, ...group.addresses])); } return nextEntry; }); } function updateEntryMerge(index: number, mergeKey: NonNullable, checked: boolean) { updateEntry(index, (entry) => { const next = { ...entry, [mergeKey]: checked }; delete next[mergeKey.replace("merge_", "combine_")]; return next; }); } function updateEntryField(index: number, field: string, value: unknown) { updateEntry(index, (entry) => ({ ...entry, fields: { ...asRecord(entry.fields), [field]: value } })); } function updateEntryAttachments(index: number, attachments: AttachmentRule[]) { updateEntry(index, (entry) => ({ ...entry, attachments })); } function removeEntry(index: number) { replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index)); setRecipientAddressEditorIndex((current) => { if (current === null) return null; if (current === index) return null; return current > index ? current - 1 : current; }); } function moveEntry(index: number, targetIndex: number) { if (locked || index === targetIndex) return; replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex)); setRecipientAddressEditorIndex((current) => { if (current === null) return null; if (current === index) return targetIndex; if (index < current && targetIndex >= current) return current - 1; if (index > current && targetIndex <= current) return current + 1; return current; }); } function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) { if (locked || !draft) return; setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode, provenance })); markDirty(); setImportOpen(false); } function applyAddressSourceImport(snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) { if (locked || !draft) return; const preview = buildAddressSourceImportPreview(snapshot, inlineEntries); const provenance = createAddressSourceImportProvenance(snapshot, preview, mode); applyRecipientImport(preview, mode, provenance); setAddressSourceImportOpen(false); } function updateHeaderAddressList(key: AddressFieldKey, addresses: MailboxAddress[]) { patch(["recipients", key], key === "from" ? addresses.slice(0, 1) : addresses); } async function copyHeaderAddresses(columns: EntryAddressColumn[]) { const text = formatAddressCollectionForClipboard(columns, headerAddressValues(columns, recipientsSection)); if (!text) return; await navigator.clipboard?.writeText(text); } return ( <> saveDraft("manual")} dirty={dirty} locked={locked} draft={draft} error={error} localError={localError} currentVersionId={data.campaign?.current_version_id}>
{ if (!locked) setHeaderAddressEditor({ title: "Default sender", columns: [fromAddressColumn] }); }} onCopy={() => void copyHeaderAddresses([fromAddressColumn])} />
patch(["recipients", "allow_individual_from"], checked)} />
{ if (!locked) setHeaderAddressEditor({ title: "Global Reply-To", columns: [replyToAddressColumn] }); }} onCopy={() => void copyHeaderAddresses([replyToAddressColumn])} />
patch(["recipients", "allow_individual_reply_to"], checked)} />
{recipientHeaderRows.map((row) => { const column = getAddressColumn(row.key); return (
{ if (!locked) setHeaderAddressEditor({ title: row.label, columns: [column] }); }} onCopy={() => void copyHeaderAddresses([column])} />
patch(["recipients", row.toggleKey], checked)} />
); })}
{(addressSourcesAvailable || addressSourcesLoading) && } }> {inlineEntries.length === 0 && Boolean(source.type) && i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.bdae9fb4 } {staleAddressImports.length > 0 &&
Address imports are older than their source: {staleAddressImports.map((item) => item.sourceLabel || item.sourceId).join(", ")}.
{staleAddressImports.map((item) => )}
} {!source.type &&
String(entry.id || index)} emptyText="i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.478b8026" emptyAction={ addRecipient(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_recipient.e540d52a" />} className="recipient-table-wrap recipient-address-table recipient-merged-data-table" pagination={{ page: recipientProfilesPage, pageSize: recipientProfilesPageSize, pageSizeOptions: [10, 25, 50, 100, 250], onPageChange: setRecipientProfilesPage, onPageSizeChange: (pageSize) => { setRecipientProfilesPageSize(pageSize); setRecipientProfilesPage(1); } }} />
}
{importOpen && setImportOpen(false)} onImport={applyRecipientImport} /> } {addressSourceImportOpen && setAddressSourceImportOpen(false)} onImport={applyAddressSourceImport} /> } {recipientAddressEditorIndex !== null && inlineEntries[recipientAddressEditorIndex] && setRecipientAddressEditorIndex(null)} /> } {headerAddressEditor && setHeaderAddressEditor(null)} /> } ); } type RecipientAddressEditorDialogProps = { entry: Record; index: number; locked: boolean; recipientsSection: Record; entryDefaults: Record; updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void; updateEntryAddressGroups: (index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) => void; updateEntryMerge: (index: number, mergeKey: NonNullable, checked: boolean) => void; onClose: () => void; }; type HeaderAddressValues = Partial>; type AddressHeaderControlProps = { columns: EntryAddressColumn[]; values: HeaderAddressValues; emptyText: string; disabled: boolean; onEdit: () => void; onCopy: () => void; }; function AddressHeaderControl({ columns, values, emptyText, disabled, onEdit, onCopy }: AddressHeaderControlProps) { const { translateText } = usePlatformLanguage(); const textRef = useRef(null); const [availableWidth, setAvailableWidth] = useState(0); const segments = useMemo(() => addressDisplaySegments(columns, values), [columns, values]); const visibleSegments = useMemo(() => fitAddressSegments(segments, availableWidth), [availableWidth, segments]); const hiddenCount = Math.max(0, segments.length - visibleSegments.length); const hasAddresses = segments.length > 0; const copiedText = formatAddressCollectionForClipboard(columns, values); useEffect(() => { const element = textRef.current; if (!element) return; const updateWidth = () => setAvailableWidth(element.clientWidth); updateWidth(); const observer = new ResizeObserver(updateWidth); observer.observe(element); return () => observer.disconnect(); }, []); return (
{hasAddresses ? <> {visibleSegments.join(", ")} {hiddenCount > 0 && +{hiddenCount}} : {translateText(emptyText)} }
); } type HeaderAddressEditorDialogProps = { title: string; columns: EntryAddressColumn[]; values: HeaderAddressValues; locked: boolean; onAddressesChange: (key: AddressFieldKey, addresses: MailboxAddress[]) => void; onClose: () => void; }; function HeaderAddressEditorDialog({ title, columns, values, locked, onAddressesChange, onClose }: HeaderAddressEditorDialogProps) { const columnKeys = new Set(columns.map((column) => column.key)); function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean { const groups = parsePastedAddressGroups(targetKey, text).filter((group) => columnKeys.has(group.key)); if (groups.length === 0) return false; const nextValues: HeaderAddressValues = { ...values }; for (const group of groups) { nextValues[group.key] = dedupeAddresses([...(nextValues[group.key] ?? []), ...group.addresses]); } for (const group of groups) { onAddressesChange(group.key, nextValues[group.key] ?? []); } return true; } return ( i18n:govoplan-campaign.close.bbfa773e}>
{columns.map((column) => onAddressesChange(column.key, addresses)} onPasteAddresses={applyPastedAddresses} /> )}
); } function RecipientAddressEditorDialog({ entry, index, locked, recipientsSection, entryDefaults, updateEntryAddressList, updateEntryAddressGroups, updateEntryMerge, onClose }: RecipientAddressEditorDialogProps) { const availableColumns = recipientAddressOverlayColumns.filter((column) => entryAddressEnabled(recipientsSection, column.key)); const availableKeys = new Set(availableColumns.map((column) => column.key)); function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean { const groups = parsePastedAddressGroups(targetKey, text).filter((group) => availableKeys.has(group.key)); if (groups.length === 0) return false; updateEntryAddressGroups(index, groups); return true; } return ( i18n:govoplan-campaign.close.bbfa773e}>
{availableColumns.length === 0 &&

No individual recipient address fields are enabled.

} {availableColumns.map((column) => updateEntryAddressList(index, column.key, addresses)} onMergeChange={column.mergeKey ? (merge) => updateEntryMerge(index, column.mergeKey!, merge) : undefined} onPasteAddresses={applyPastedAddresses} /> )}
); } type RecipientAddressCategoryEditorProps = { column: EntryAddressColumn; addresses: MailboxAddress[]; merge: boolean; locked: boolean; onAddressesChange: (addresses: MailboxAddress[]) => void; onMergeChange?: (merge: boolean) => void; onPasteAddresses?: (targetKey: AddressFieldKey, text: string) => boolean; }; function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAddressesChange, onMergeChange, onPasteAddresses }: RecipientAddressCategoryEditorProps) { const { translateText } = usePlatformLanguage(); const [draftAddresses, setDraftAddresses] = useState(() => normalizeEditorAddressRows(column, addresses)); const canAdd = !locked && column.allowMultiple; const translatedAddLabel = translateText(column.addLabel); const translatedMoveUpLabel = "Move address up"; const translatedMoveDownLabel = "Move address down"; const translatedRemoveLabel = "Remove address"; useEffect(() => { setDraftAddresses(normalizeEditorAddressRows(column, addresses)); }, [addresses, column]); function commitAddresses(nextAddresses: MailboxAddress[]) { setDraftAddresses(nextAddresses); onAddressesChange(nextAddresses.filter((address) => String(address.email ?? "").trim() || String(address.name ?? "").trim())); } function patchAddress(addressIndex: number, patch: Partial) { commitAddresses(draftAddresses.map((address, currentIndex) => currentIndex === addressIndex ? { ...address, ...patch } : address)); } function addAddress(afterIndex = draftAddresses.length - 1) { if (!column.allowMultiple) return; const insertIndex = Math.max(0, Math.min(afterIndex + 1, draftAddresses.length)); const nextAddresses = [ ...draftAddresses.slice(0, insertIndex), { name: "", email: "" }, ...draftAddresses.slice(insertIndex)]; setDraftAddresses(nextAddresses); } function removeAddress(addressIndex: number) { commitAddresses(draftAddresses.filter((_, currentIndex) => currentIndex !== addressIndex)); } function moveAddress(addressIndex: number, targetIndex: number) { if (addressIndex === targetIndex) return; commitAddresses(moveArrayItem(draftAddresses, addressIndex, targetIndex)); } function handlePaste(event: ClipboardEvent) { if (locked || !onPasteAddresses) return; const text = event.clipboardData.getData("text/plain"); if (onPasteAddresses(column.key, text)) event.preventDefault(); } return (

{column.label}

{onMergeChange ? : null}
{column.allowMultiple && draftAddresses.length === 0 &&

{column.emptyText}

} {draftAddresses.map((address, addressIndex) =>
patchAddress(addressIndex, { name: event.target.value })} /> patchAddress(addressIndex, { email: event.target.value })} /> {column.allowMultiple &&
}
)}
); } type AddressSourceImportDialogProps = { settings: ApiSettings; campaignId: string; sources: CampaignRecipientAddressSource[]; initialSourceId?: string; onCancel: () => void; onImport: (snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) => void; }; function addressSourceType(source: CampaignRecipientAddressSource): "book" | "list" { return source.source_id.startsWith("addresses:address_list:") || source.source_kind === "address_list" ? "list" : "book"; } function addressSourceTypeLabel(source: CampaignRecipientAddressSource): string { if (addressSourceType(source) === "list") return "Address list"; if (source.source_kind && source.source_kind !== "local") return `${source.source_kind} address book`; return "Address book"; } function addressSourceScopeLabel(source: CampaignRecipientAddressSource): string { const provenance = asRecord(source.provenance); const scopeType = String(provenance.scope_type ?? "").trim(); const scopeId = String(provenance.scope_id ?? "").trim(); if (!scopeType) return "Unknown scope"; const label = scopeType.charAt(0).toUpperCase() + scopeType.slice(1); return scopeId && scopeId !== scopeType ? `${label} - ${scopeId}` : label; } function addressSourceSearchText(source: CampaignRecipientAddressSource): string { const provenance = asRecord(source.provenance); return [ source.source_label, source.source_kind, source.source_id, addressSourceTypeLabel(source), addressSourceScopeLabel(source), provenance.address_book_id, provenance.address_list_id, provenance.scope_type, provenance.scope_id, provenance.tenant_id ].map((value) => String(value ?? "").toLowerCase()).join(" "); } function shortSourceRevision(value: string): string { if (!value) return "no revision"; if (value.length <= 24) return value; return `${value.slice(0, 19)}...`; } function AddressSourceImportDialog({ settings, campaignId, sources, initialSourceId = "", onCancel, onImport }: AddressSourceImportDialogProps) { const navigate = useGuardedNavigate(); const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.source_id || ""); const [sourceFilter, setSourceFilter] = useState("all"); const [sourceQuery, setSourceQuery] = useState(""); const [mode, setMode] = useState("append"); const [snapshot, setSnapshot] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const sourceCounts = useMemo(() => { return sources.reduce( (counts, source) => { counts[addressSourceType(source)] += 1; return counts; }, { book: 0, list: 0 } ); }, [sources]); const filteredSources = useMemo(() => { const query = sourceQuery.trim().toLowerCase(); return sources.filter((source) => { const type = addressSourceType(source); if (sourceFilter === "books" && type !== "book") return false; if (sourceFilter === "lists" && type !== "list") return false; if (!query) return true; return addressSourceSearchText(source).includes(query); }); }, [sourceFilter, sourceQuery, sources]); const selectedSource = useMemo( () => sources.find((source) => source.source_id === selectedSourceId) ?? null, [selectedSourceId, sources] ); useEffect(() => { if (filteredSources.some((source) => source.source_id === selectedSourceId)) return; setSelectedSourceId(filteredSources[0]?.source_id ?? ""); }, [filteredSources, selectedSourceId]); useEffect(() => { if (initialSourceId && sources.some((source) => source.source_id === initialSourceId)) { setSourceFilter("all"); setSourceQuery(""); setSelectedSourceId(initialSourceId); } }, [initialSourceId, sources]); useEffect(() => { if (!selectedSourceId) { setSnapshot(null); return; } let cancelled = false; setLoading(true); setError(""); void snapshotCampaignRecipientAddressSource(settings, campaignId, selectedSourceId). then((nextSnapshot) => { if (!cancelled) setSnapshot(nextSnapshot); }). catch((err) => { if (cancelled) return; setSnapshot(null); setError(err instanceof Error ? err.message : String(err)); }). finally(() => { if (!cancelled) setLoading(false); }); return () => {cancelled = true;}; }, [campaignId, selectedSourceId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); function openAddressBookModule() { onCancel(); navigate("/address-book"); } return ( }>
setSourceQuery(event.target.value)} />
{filteredSources.map((source) => )} {sources.length > 0 && filteredSources.length === 0 &&
No address sources match the current filter.
}
{error && {error}} {loading && Loading address recipients...} {!loading && sources.length === 0 && No address sources are available to this campaign. Create an address book or address list in the addresses module first.} {snapshot && <>
Source
{snapshot.source_label}
Type
{selectedSource ? addressSourceTypeLabel(selectedSource) : "Address source"}
Scope
{selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"}
Recipients
{snapshot.recipients.length}
Revision
{snapshot.source_revision}
{snapshot.recipients.slice(0, 20).map((recipient, index) => )}
# Name Email Fields
{index + 1} {recipient.display_name} {recipient.email} {Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length}
{snapshot.recipients.length > 20 &&

{snapshot.recipients.length - 20} more recipients will be imported.

} }
); } 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 stepContent = activeStep === "upload" ? <>
readFile(files[0])} />
{fileError && {fileError}} {sourceType === "xlsx" ? i18n:govoplan-campaign.workbook_loaded.cfaf40eb {workbookSheets.length} sheet{workbookSheets.length === 1 ? "" : "s"}. :