1305 lines
62 KiB
TypeScript
1305 lines
62 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import type { ApiSettings } from "../../types";
|
|
import {
|
|
createRecipientImportMappingProfile,
|
|
listRecipientImportMappingProfiles,
|
|
updateRecipientImportMappingProfile,
|
|
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 { PageTitle } from "@govoplan/core-webui";
|
|
import { LoadingFrame } from "@govoplan/core-webui";
|
|
import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
|
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
|
import VersionLine from "./components/VersionLine";
|
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
|
import { EmailAddressInput } from "@govoplan/core-webui";
|
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
|
import { Dialog } from "@govoplan/core-webui";
|
|
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
|
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
|
import { getBool } from "./utils/draftEditor";
|
|
import { getDraftFields } from "./utils/fieldDefinitions";
|
|
import { normalizeAttachmentBasePaths, type AttachmentBasePath } from "./utils/attachments";
|
|
import {
|
|
applyRecipientMappingProfile,
|
|
buildImportTable,
|
|
buildImportTableFromRows,
|
|
buildRecipientImportPreview,
|
|
createRecipientImportProvenance,
|
|
createRecipientMappingProfile as buildRecipientMappingProfile,
|
|
defaultColumnMappings,
|
|
importedRowsNeedAttachmentSource,
|
|
matchRecipientMappingProfiles,
|
|
materializeRecipientImport,
|
|
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,
|
|
type ImportFileLinkCapability,
|
|
renderedImportPatterns,
|
|
resolveImportedAttachmentLinks,
|
|
type ImportFileLinkResolution } from
|
|
"./utils/fileLinking";
|
|
import {
|
|
addressesFromValue,
|
|
collectCampaignAddressSuggestions,
|
|
type MailboxAddress } from
|
|
"@govoplan/core-webui";
|
|
import { insertAfter, moveArrayItem, i18nMessage } 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 EntryAddressColumn = {
|
|
key: RecipientAddressKey | "from" | "reply_to";
|
|
label: string;
|
|
allowMultiple: boolean;
|
|
addLabel: string;
|
|
emptyText: string;
|
|
mergeKey?: "merge_to" | "merge_cc" | "merge_bcc" | "merge_reply_to";
|
|
};
|
|
|
|
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
|
const [importOpen, setImportOpen] = useState(false);
|
|
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
|
|
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
|
|
|
|
const version = data.currentVersion;
|
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
|
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, 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 importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments, true), [attachments]);
|
|
const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]);
|
|
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
|
|
const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1);
|
|
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
|
|
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
|
to: addressesFromValue(recipientsSection.to),
|
|
cc: addressesFromValue(recipientsSection.cc),
|
|
bcc: addressesFromValue(recipientsSection.bcc)
|
|
};
|
|
const entryAddressColumns = useMemo<EntryAddressColumn[]>(() => {
|
|
const columns: EntryAddressColumn[] = [];
|
|
if (getBool(recipientsSection, "allow_individual_from")) {
|
|
columns.push({ 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" });
|
|
}
|
|
if (getBool(recipientsSection, "allow_individual_reply_to")) {
|
|
columns.push({ 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" });
|
|
}
|
|
if (getBool(recipientsSection, "allow_individual_to")) {
|
|
columns.push({ 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" });
|
|
}
|
|
if (getBool(recipientsSection, "allow_individual_cc")) columns.push({ 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" });
|
|
if (getBool(recipientsSection, "allow_individual_bcc")) columns.push({ 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" });
|
|
return columns;
|
|
}, [recipientsSection]);
|
|
|
|
|
|
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
|
|
patch(["entries", "inline"], nextEntries);
|
|
}
|
|
|
|
function addRecipient(afterIndex = inlineEntries.length - 1) {
|
|
const nextIndex = inlineEntries.length + 1;
|
|
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));
|
|
}
|
|
|
|
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
|
|
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) => {
|
|
if (key === "from") {
|
|
return { ...entry, from: addresses.slice(0, 1) };
|
|
}
|
|
const nextEntry = { ...entry, [key]: addresses };
|
|
if (key === "to") {
|
|
const address = addresses[0] ?? { name: "", email: "" };
|
|
return {
|
|
...nextEntry,
|
|
name: address.name ?? "",
|
|
email: address.email
|
|
};
|
|
}
|
|
return nextEntry;
|
|
});
|
|
}
|
|
|
|
function updateEntryMerge(index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) {
|
|
updateEntry(index, (entry) => {
|
|
const next = { ...entry, [mergeKey]: checked };
|
|
delete next[mergeKey.replace("merge_", "combine_")];
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function removeEntry(index: number) {
|
|
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
|
|
}
|
|
|
|
function moveEntry(index: number, targetIndex: number) {
|
|
if (locked || index === targetIndex) return;
|
|
replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex));
|
|
}
|
|
|
|
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) {
|
|
if (locked || !draft) return;
|
|
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
|
|
const currentAttachments = asRecord(draft.attachments);
|
|
let nextBasePaths = normalizeAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
|
|
let attachmentBasePath: AttachmentBasePath | null = null;
|
|
|
|
if (needsAttachmentSource) {
|
|
if (nextBasePaths.length === 0) {
|
|
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
|
|
}
|
|
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
|
|
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
|
|
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
|
|
}
|
|
|
|
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath, provenance });
|
|
const nextDraft = needsAttachmentSource ?
|
|
{
|
|
...importedDraft,
|
|
attachments: {
|
|
...asRecord(importedDraft.attachments),
|
|
base_paths: nextBasePaths,
|
|
base_path: nextBasePaths[0]?.path || "."
|
|
}
|
|
} :
|
|
importedDraft;
|
|
setDraft(nextDraft);
|
|
markDirty();
|
|
setImportOpen(false);
|
|
}
|
|
|
|
|
|
return (
|
|
<div className="content-pad workspace-data-page">
|
|
<div className="page-heading split workspace-heading">
|
|
<div>
|
|
<PageTitle loading={loading}>i18n:govoplan-campaign.sender_recipients.922c6d24</PageTitle>
|
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
|
</div>
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
|
|
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
|
<>
|
|
<Card title="i18n:govoplan-campaign.campaign_sender.b12e8ae2" collapsible>
|
|
<div className="campaign-header-stack">
|
|
<div className="campaign-header-grid">
|
|
<FormField label="i18n:govoplan-campaign.default_from_address.b9ee6d77">
|
|
<EmailAddressInput
|
|
value={defaultFrom}
|
|
suggestions={addressSuggestions}
|
|
allowMultiple={false}
|
|
disabled={locked}
|
|
addLabel="i18n:govoplan-campaign.set_from.11fe7396"
|
|
emptyText="i18n:govoplan-campaign.no_global_from_address_configured.28141d2b"
|
|
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses.slice(0, 1))} />
|
|
|
|
</FormField>
|
|
<div className="campaign-header-toggle">
|
|
<ToggleSwitch
|
|
label="i18n:govoplan-campaign.allow_individual_senders.f85fda12"
|
|
checked={getBool(recipientsSection, "allow_individual_from")}
|
|
disabled={locked}
|
|
onChange={(checked) => patch(["recipients", "allow_individual_from"], checked)} />
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<div className="campaign-header-grid">
|
|
<FormField label="i18n:govoplan-campaign.global_reply_to_address.bec1a89b">
|
|
<EmailAddressInput
|
|
value={globalReplyTo}
|
|
suggestions={addressSuggestions}
|
|
allowMultiple
|
|
disabled={locked}
|
|
addLabel="i18n:govoplan-campaign.add_reply_to.84b195f4"
|
|
emptyText="i18n:govoplan-campaign.no_global_reply_to_address_configured.d62a3b9b"
|
|
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses)} />
|
|
|
|
</FormField>
|
|
<div className="campaign-header-toggle">
|
|
<ToggleSwitch
|
|
label="i18n:govoplan-campaign.allow_individual_reply_to.028f8044"
|
|
checked={getBool(recipientsSection, "allow_individual_reply_to")}
|
|
disabled={locked}
|
|
onChange={(checked) => patch(["recipients", "allow_individual_reply_to"], checked)} />
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card title="i18n:govoplan-campaign.global_recipient_headers.e3d87f85" collapsible>
|
|
<div className="campaign-header-stack">
|
|
{recipientHeaderRows.map((row) =>
|
|
<div className="campaign-header-grid" key={row.key}>
|
|
<FormField label={row.label}>
|
|
<EmailAddressInput
|
|
value={globalRecipientValues[row.key] ?? []}
|
|
suggestions={addressSuggestions}
|
|
allowMultiple
|
|
disabled={locked}
|
|
addLabel={row.addLabel}
|
|
emptyText={row.emptyText}
|
|
onChange={(addresses: MailboxAddress[]) => patch(["recipients", row.key], addresses)} />
|
|
|
|
</FormField>
|
|
<div className="campaign-header-toggle">
|
|
<ToggleSwitch
|
|
label={row.toggleLabel}
|
|
checked={getBool(recipientsSection, row.toggleKey)}
|
|
disabled={locked}
|
|
onChange={(checked) => patch(["recipients", row.toggleKey], checked)} />
|
|
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card title="i18n:govoplan-campaign.recipient_profiles.3dc9671c" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>}>
|
|
{inlineEntries.length === 0 && Boolean(source.type) &&
|
|
<DismissibleAlert tone="info">i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.bdae9fb4</DismissibleAlert>
|
|
}
|
|
{!source.type &&
|
|
<div className="admin-table-surface recipient-profiles-table-surface">
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-recipient-profiles`}
|
|
rows={inlineEntries}
|
|
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry })}
|
|
getRowKey={(entry, index) => String(entry.id || index)}
|
|
emptyText="i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.478b8026"
|
|
emptyAction={<DataGridEmptyAction onAdd={() => addRecipient(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_recipient.e540d52a" />}
|
|
className="recipient-table-wrap recipient-address-table"
|
|
pagination={{
|
|
page: recipientProfilesPage,
|
|
pageSize: recipientProfilesPageSize,
|
|
pageSizeOptions: [10, 25, 50, 100, 250],
|
|
onPageChange: setRecipientProfilesPage,
|
|
onPageSizeChange: (pageSize) => {
|
|
setRecipientProfilesPageSize(pageSize);
|
|
setRecipientProfilesPage(1);
|
|
}
|
|
}} />
|
|
|
|
</div>
|
|
}
|
|
</Card>
|
|
</>
|
|
</LoadingFrame>
|
|
|
|
{importOpen &&
|
|
<RecipientImportDialog
|
|
settings={settings}
|
|
campaignId={campaignId}
|
|
existingEntries={inlineEntries}
|
|
existingFields={fieldDefinitions}
|
|
defaultAttachmentBasePath={defaultImportBasePath}
|
|
onCancel={() => setImportOpen(false)}
|
|
onImport={applyRecipientImport} />
|
|
|
|
}
|
|
</div>);
|
|
|
|
}
|
|
|
|
type RecipientImportDialogProps = {
|
|
settings: ApiSettings;
|
|
campaignId: string;
|
|
existingEntries: Record<string, unknown>[];
|
|
existingFields: ReturnType<typeof getDraftFields>;
|
|
defaultAttachmentBasePath: AttachmentBasePath | null;
|
|
onCancel: () => void;
|
|
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode, 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 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>
|
|
<div className="admin-table-surface recipient-import-preview-surface">
|
|
<table className="recipient-import-preview-table recipient-import-mapping-table">
|
|
<thead>
|
|
<tr>
|
|
<th>i18n:govoplan-campaign.column.65ba00e9</th>
|
|
<th>i18n:govoplan-campaign.sample.58fabfa7</th>
|
|
<th>i18n:govoplan-campaign.content.4f9be057</th>
|
|
<th>i18n:govoplan-campaign.field.c326a466</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{table?.headers.map((header, columnIndex) => {
|
|
const mapping = mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const };
|
|
return (
|
|
<tr key={`${columnIndex}-${header}`}>
|
|
<td><strong>{header}</strong></td>
|
|
<td className="mono-small">{sampleColumnValue(table.dataRows, columnIndex)}</td>
|
|
<td>
|
|
<select value={mapping.kind} onChange={(event) => changeColumnKind(columnIndex, event.target.value as RecipientColumnKind)}>
|
|
{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
</select>
|
|
</td>
|
|
<td>
|
|
{mapping.kind === "field" &&
|
|
<select
|
|
value={mapping.fieldName ?? ""}
|
|
onChange={(event) => replaceColumnMapping({ ...mapping, fieldName: event.target.value, newFieldName: undefined })}>
|
|
|
|
<option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>
|
|
{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
|
|
</select>
|
|
}
|
|
{mapping.kind === "new_field" &&
|
|
<input
|
|
value={mapping.newFieldName ?? ""}
|
|
onChange={(event) => replaceColumnMapping({ ...mapping, newFieldName: event.target.value, fieldName: undefined })} />
|
|
|
|
}
|
|
</td>
|
|
</tr>);
|
|
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</> :
|
|
activeStep === "preview" ?
|
|
<>
|
|
{preview &&
|
|
<>
|
|
<dl className="detail-list recipient-import-summary">
|
|
<div><dt>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>
|
|
}
|
|
<div className="admin-table-surface recipient-import-preview-surface">
|
|
<table className="recipient-import-preview-table">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>i18n:govoplan-campaign.to.ae79ea1e</th>
|
|
<th>i18n:govoplan-campaign.name.709a2322</th>
|
|
<th>i18n:govoplan-campaign.fields.e8b68527</th>
|
|
<th>i18n:govoplan-campaign.patterns.4d34f7a2</th>
|
|
<th>i18n:govoplan-campaign.status.bae7d5be</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{preview.rows.slice(0, 20).map((row) =>
|
|
<tr key={row.rowNumber} className={row.issues.length ? "is-invalid" : undefined}>
|
|
<td>{row.rowNumber}</td>
|
|
<td>{formatAddressList(row.addresses.to)}</td>
|
|
<td>{row.name}</td>
|
|
<td>{Object.keys(row.fields).length}</td>
|
|
<td>{row.patterns.length}</td>
|
|
<td>{row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552"}</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
}
|
|
</> :
|
|
|
|
<RecipientImportFileLinkStep
|
|
preview={preview}
|
|
basePath={defaultAttachmentBasePath}
|
|
resolution={fileLinkResolution}
|
|
resolving={fileLinkResolving}
|
|
linking={fileLinking}
|
|
error={fileLinkError}
|
|
notice={fileLinkNotice}
|
|
onRefresh={() => void refreshFileLinks()}
|
|
onLink={() => void linkImportedFiles()} />;
|
|
|
|
|
|
|
|
return (
|
|
<Dialog
|
|
open
|
|
title="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>;
|
|
}
|
|
|
|
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>
|
|
}
|
|
|
|
<div className="admin-table-surface recipient-import-preview-surface">
|
|
<table className="recipient-import-preview-table recipient-import-file-link-table">
|
|
<thead>
|
|
<tr>
|
|
<th>i18n:govoplan-campaign.file.2c3cafa4</th>
|
|
<th>i18n:govoplan-campaign.path.519e3913</th>
|
|
<th>i18n:govoplan-campaign.size.b7152342</th>
|
|
<th>i18n:govoplan-campaign.status.bae7d5be</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{resolution.files.length === 0 ?
|
|
<tr><td colSpan={4}>i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b</td></tr> :
|
|
resolution.files.map((file) =>
|
|
<tr key={file.id}>
|
|
<td><strong>{file.filename}</strong></td>
|
|
<td>{file.display_path}</td>
|
|
<td>{formatImportBytes(file.size_bytes)}</td>
|
|
<td>{linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600"}</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
}
|
|
</div>);
|
|
|
|
}
|
|
|
|
type RecipientImportRawTableProps = {
|
|
tableRows: string[][];
|
|
headerRowCount: number;
|
|
columnCount: number;
|
|
};
|
|
|
|
function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) {
|
|
const visibleRows = tableRows.slice(0, 15);
|
|
const safeColumnCount = Math.max(1, columnCount, ...visibleRows.map((row) => row.length));
|
|
return (
|
|
<div className="admin-table-surface recipient-import-preview-surface">
|
|
<table className="recipient-import-preview-table recipient-import-raw-table">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
{Array.from({ length: safeColumnCount }, (_value, index) => <th key={index}>{index + 1}</th>)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visibleRows.length === 0 ?
|
|
<tr><td colSpan={safeColumnCount + 1}>i18n:govoplan-campaign.no_rows_parsed.a7ccc3de</td></tr> :
|
|
visibleRows.map((row, rowIndex) =>
|
|
<tr key={rowIndex} className={rowIndex < headerRowCount ? "is-header-row" : undefined}>
|
|
<td>{rowIndex + 1}</td>
|
|
{Array.from({ length: safeColumnCount }, (_value, columnIndex) => <td key={columnIndex}>{row[columnIndex] ?? ""}</td>)}
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>);
|
|
|
|
}
|
|
|
|
function formatDelimiter(delimiter: "," | ";" | "\t"): string {
|
|
if (delimiter === "\t") return "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";
|
|
}
|
|
|
|
type RecipientProfileColumnContext = {
|
|
locked: boolean;
|
|
entries: Record<string, unknown>[];
|
|
entryDefaults: Record<string, unknown>;
|
|
entryAddressColumns: EntryAddressColumn[];
|
|
addressSuggestions: MailboxAddress[];
|
|
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
|
|
updateEntryMerge: (index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) => void;
|
|
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
|
|
addRecipient: (afterIndex?: number) => void;
|
|
moveEntry: (index: number, targetIndex: number) => void;
|
|
removeEntry: (index: number) => void;
|
|
};
|
|
|
|
function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
|
return [
|
|
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
|
...entryAddressColumns.map((column): DataGridColumn<Record<string, unknown>> => ({
|
|
id: column.key,
|
|
header: column.label,
|
|
width: column.key === "to" ? "minmax(260px, 1.2fr)" : 250,
|
|
resizable: true,
|
|
filterable: true,
|
|
render: (entry, index) =>
|
|
<div className="recipient-address-cell">
|
|
<EmailAddressInput
|
|
value={getEntryAddresses(entry, column.key)}
|
|
suggestions={addressSuggestions}
|
|
allowMultiple={column.allowMultiple}
|
|
compact
|
|
disabled={locked}
|
|
addLabel={column.addLabel}
|
|
emptyText={column.emptyText}
|
|
onChange={(addresses) => updateEntryAddressList(index, column.key, addresses)} />
|
|
|
|
{column.mergeKey &&
|
|
<div className="recipient-address-merge">
|
|
<ToggleSwitch
|
|
label="i18n:govoplan-campaign.merge.ea8f0d02"
|
|
checked={getEntryMerge(entry, entryDefaults, column.mergeKey)}
|
|
disabled={locked}
|
|
onChange={(checked) => updateEntryMerge(index, column.mergeKey!, checked)} />
|
|
|
|
<span className="muted">i18n:govoplan-campaign.merge_with_global.ba230098 {column.label}</span>
|
|
</div>
|
|
}
|
|
</div>,
|
|
|
|
value: (entry) => getEntryAddresses(entry, column.key).map((address) => `${address.name ?? ""} ${address.email ?? ""}`).join(", ")
|
|
})),
|
|
{ id: "active", header: "i18n:govoplan-campaign.active.a733b809", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" }, { value: "inactive", label: "i18n:govoplan-campaign.inactive.09af574c" }] }, render: (entry, index) => <ToggleSwitch label="i18n:govoplan-campaign.active.a733b809" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" },
|
|
{
|
|
id: "actions",
|
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
|
width: 180,
|
|
sticky: "end",
|
|
render: (_entry, index) =>
|
|
<DataGridRowActions
|
|
disabled={locked}
|
|
onAddBelow={() => addRecipient(index)}
|
|
onRemove={() => removeEntry(index)}
|
|
onMoveUp={index > 0 ? () => moveEntry(index, index - 1) : undefined}
|
|
onMoveDown={index < entries.length - 1 ? () => moveEntry(index, index + 1) : undefined}
|
|
addLabel="i18n:govoplan-campaign.add_recipient_below.52fb47d8"
|
|
removeLabel="i18n:govoplan-campaign.remove_recipient.d1bc9f53"
|
|
moveUpLabel="i18n:govoplan-campaign.move_recipient_up.90c6bccc"
|
|
moveDownLabel="i18n:govoplan-campaign.move_recipient_down.ead5466c" />
|
|
|
|
|
|
}];
|
|
|
|
}
|
|
|
|
function getEntryMerge(entry: Record<string, unknown>, defaults: Record<string, unknown>, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>): boolean {
|
|
const legacyKey = mergeKey.replace("merge_", "combine_");
|
|
if (typeof entry[mergeKey] === "boolean") return entry[mergeKey] as boolean;
|
|
if (typeof entry[legacyKey] === "boolean") return entry[legacyKey] as boolean;
|
|
if (typeof defaults[mergeKey] === "boolean") return defaults[mergeKey] as boolean;
|
|
if (typeof defaults[legacyKey] === "boolean") return defaults[legacyKey] as boolean;
|
|
return true;
|
|
}
|
|
|
|
function getEntryAddresses(entry: Record<string, unknown>, key: EntryAddressColumn["key"]): MailboxAddress[] {
|
|
if (key === "to") {
|
|
const explicit = addressesFromValue(entry.to);
|
|
return explicit.length ? explicit : fallbackRecipientAddress(entry);
|
|
}
|
|
if (key === "from") return addressesFromValue(entry.from).slice(0, 1);
|
|
if (key === "reply_to") return addressesFromValue(entry.reply_to);
|
|
return addressesFromValue(entry[key]);
|
|
}
|
|
|
|
function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddress[] {
|
|
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
|
|
return direct?.email ? [direct] : [];
|
|
}
|
|
|
|
function uniqueEntryId(entries: Record<string, unknown>[], preferred: string): string {
|
|
const existing = new Set(entries.map((entry) => String(entry.id || "")).filter(Boolean));
|
|
if (!existing.has(preferred)) return preferred;
|
|
let counter = entries.length + 1;
|
|
let candidate = `recipient-${counter}`;
|
|
while (existing.has(candidate)) {
|
|
counter += 1;
|
|
candidate = `recipient-${counter}`;
|
|
}
|
|
return candidate;
|
|
}
|