2305 lines
104 KiB
TypeScript
2305 lines
104 KiB
TypeScript
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 { TableActionGroup } from "@govoplan/core-webui";
|
|
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
|
|
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<CampaignRecipientAddressSource[]>([]);
|
|
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
|
|
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
|
|
const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null);
|
|
const [headerAddressEditor, setHeaderAddressEditor] = useState<HeaderAddressEditorState>(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<string, MailboxAddress[]> = {
|
|
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<string, unknown>[]) {
|
|
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<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) => 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<EntryAddressColumn["mergeKey"]>, 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 (
|
|
<>
|
|
<CampaignDraftPageScaffold
|
|
settings={settings}
|
|
campaignId={campaignId}
|
|
title="i18n:govoplan-campaign.sender_recipients.922c6d24"
|
|
loading={loading}
|
|
version={version}
|
|
versions={data.versions}
|
|
saveState={saveState}
|
|
onReload={discardDraft}
|
|
onSave={() => saveDraft("manual")}
|
|
dirty={dirty}
|
|
locked={locked}
|
|
draft={draft}
|
|
error={error}
|
|
localError={localError}
|
|
currentVersionId={data.campaign?.current_version_id}>
|
|
|
|
<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">
|
|
<AddressHeaderControl
|
|
columns={[fromAddressColumn]}
|
|
values={{ from: defaultFrom }}
|
|
emptyText="i18n:govoplan-campaign.no_global_from_address_configured.28141d2b"
|
|
disabled={locked}
|
|
onEdit={() => { if (!locked) setHeaderAddressEditor({ title: "Default sender", columns: [fromAddressColumn] }); }}
|
|
onCopy={() => void copyHeaderAddresses([fromAddressColumn])} />
|
|
</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">
|
|
<AddressHeaderControl
|
|
columns={[replyToAddressColumn]}
|
|
values={{ reply_to: globalReplyTo }}
|
|
emptyText="i18n:govoplan-campaign.no_global_reply_to_address_configured.d62a3b9b"
|
|
disabled={locked}
|
|
onEdit={() => { if (!locked) setHeaderAddressEditor({ title: "Global Reply-To", columns: [replyToAddressColumn] }); }}
|
|
onCopy={() => void copyHeaderAddresses([replyToAddressColumn])} />
|
|
</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="Global recipients" collapsible>
|
|
<div className="campaign-header-stack">
|
|
{recipientHeaderRows.map((row) => {
|
|
const column = getAddressColumn(row.key);
|
|
return (
|
|
<div className="campaign-header-grid" key={row.key}>
|
|
<FormField label={row.label}>
|
|
<AddressHeaderControl
|
|
columns={[column]}
|
|
values={{ [row.key]: globalRecipientValues[row.key] ?? [] }}
|
|
emptyText={row.emptyText}
|
|
disabled={locked}
|
|
onEdit={() => { if (!locked) setHeaderAddressEditor({ title: row.label, columns: [column] }); }}
|
|
onCopy={() => void copyHeaderAddresses([column])} />
|
|
</FormField>
|
|
<div className="campaign-header-toggle">
|
|
<ToggleSwitch
|
|
label={row.toggleLabel}
|
|
checked={getBool(recipientsSection, row.toggleKey, row.key === "to")}
|
|
disabled={locked}
|
|
onChange={(checked) => patch(["recipients", row.toggleKey], checked)} />
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card
|
|
title="i18n:govoplan-campaign.recipient_data.c2baaf10"
|
|
actions={
|
|
<div className="button-row compact-actions">
|
|
{(addressSourcesAvailable || addressSourcesLoading) &&
|
|
<Button disabled={locked || addressSourcesLoading} onClick={() => {setAddressSourceImportInitialId("");setAddressSourceImportOpen(true);}}>
|
|
Import address book/list
|
|
</Button>
|
|
}
|
|
<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
|
</div>
|
|
}>
|
|
{inlineEntries.length === 0 && Boolean(source.type) &&
|
|
<DismissibleAlert tone="info">i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.bdae9fb4</DismissibleAlert>
|
|
}
|
|
{staleAddressImports.length > 0 &&
|
|
<DismissibleAlert tone="warning" dismissible={false}>
|
|
<div className="stale-address-import-warning">
|
|
<span>Address imports are older than their source: {staleAddressImports.map((item) => item.sourceLabel || item.sourceId).join(", ")}.</span>
|
|
<div className="button-row compact-actions">
|
|
{staleAddressImports.map((item) =>
|
|
<Button
|
|
key={item.sourceId}
|
|
type="button"
|
|
onClick={() => {setAddressSourceImportInitialId(item.sourceId);setAddressSourceImportOpen(true);}}>
|
|
Re-import {item.sourceLabel || "source"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DismissibleAlert>
|
|
}
|
|
{!source.type &&
|
|
<div className="admin-table-surface recipient-profiles-table-surface">
|
|
<DataGrid
|
|
id={`campaign-${campaignId}-recipient-profiles`}
|
|
rows={inlineEntries}
|
|
columns={recipientProfileColumns({
|
|
settings,
|
|
campaignId,
|
|
draft: displayDraft,
|
|
locked,
|
|
filesModuleInstalled,
|
|
entries: inlineEntries,
|
|
fieldDefinitions,
|
|
individualAttachmentBasePaths,
|
|
zipConfig,
|
|
translateText,
|
|
openAddressEditor: setRecipientAddressEditorIndex,
|
|
updateEntry,
|
|
updateEntryAttachments,
|
|
updateEntryField,
|
|
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 recipient-merged-data-table"
|
|
pagination={{
|
|
page: recipientProfilesPage,
|
|
pageSize: recipientProfilesPageSize,
|
|
pageSizeOptions: [10, 25, 50, 100, 250],
|
|
onPageChange: setRecipientProfilesPage,
|
|
onPageSizeChange: (pageSize) => {
|
|
setRecipientProfilesPageSize(pageSize);
|
|
setRecipientProfilesPage(1);
|
|
}
|
|
}} />
|
|
|
|
</div>
|
|
}
|
|
</Card>
|
|
</CampaignDraftPageScaffold>
|
|
{importOpen &&
|
|
<RecipientImportDialog
|
|
settings={settings}
|
|
campaignId={campaignId}
|
|
existingEntries={inlineEntries}
|
|
existingFields={fieldDefinitions}
|
|
defaultAttachmentBasePath={defaultImportBasePath}
|
|
onCancel={() => setImportOpen(false)}
|
|
onImport={applyRecipientImport} />
|
|
|
|
}
|
|
{addressSourceImportOpen &&
|
|
<AddressSourceImportDialog
|
|
settings={settings}
|
|
campaignId={campaignId}
|
|
sources={addressSources}
|
|
initialSourceId={addressSourceImportInitialId}
|
|
onCancel={() => setAddressSourceImportOpen(false)}
|
|
onImport={applyAddressSourceImport} />
|
|
|
|
}
|
|
{recipientAddressEditorIndex !== null && inlineEntries[recipientAddressEditorIndex] &&
|
|
<RecipientAddressEditorDialog
|
|
entry={inlineEntries[recipientAddressEditorIndex]}
|
|
index={recipientAddressEditorIndex}
|
|
locked={locked}
|
|
recipientsSection={recipientsSection}
|
|
entryDefaults={entryDefaults}
|
|
updateEntryAddressList={updateEntryAddressList}
|
|
updateEntryAddressGroups={updateEntryAddressGroups}
|
|
updateEntryMerge={updateEntryMerge}
|
|
onClose={() => setRecipientAddressEditorIndex(null)} />
|
|
|
|
}
|
|
{headerAddressEditor &&
|
|
<HeaderAddressEditorDialog
|
|
title={headerAddressEditor.title}
|
|
columns={headerAddressEditor.columns}
|
|
values={headerAddressValues(headerAddressEditor.columns, recipientsSection)}
|
|
locked={locked}
|
|
onAddressesChange={updateHeaderAddressList}
|
|
onClose={() => setHeaderAddressEditor(null)} />
|
|
|
|
}
|
|
</>);
|
|
|
|
}
|
|
|
|
type RecipientAddressEditorDialogProps = {
|
|
entry: Record<string, unknown>;
|
|
index: number;
|
|
locked: boolean;
|
|
recipientsSection: Record<string, unknown>;
|
|
entryDefaults: Record<string, unknown>;
|
|
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
|
|
updateEntryAddressGroups: (index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) => void;
|
|
updateEntryMerge: (index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) => void;
|
|
onClose: () => void;
|
|
};
|
|
|
|
type HeaderAddressValues = Partial<Record<AddressFieldKey, MailboxAddress[]>>;
|
|
|
|
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<HTMLDivElement | null>(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 (
|
|
<div className="recipient-address-header-control">
|
|
<div className={`recipient-address-header-summary ${hasAddresses ? "" : "is-empty"}`}>
|
|
<div className="recipient-address-header-text" ref={textRef}>
|
|
{hasAddresses ?
|
|
<>
|
|
<span>{visibleSegments.join(", ")}</span>
|
|
{hiddenCount > 0 && <span className="recipient-extra-bubble">+{hiddenCount}</span>}
|
|
</> :
|
|
<span>{translateText(emptyText)}</span>
|
|
}
|
|
</div>
|
|
<TableActionGroup actions={[
|
|
{ id: "edit", label: "Edit addresses", icon: <Pencil aria-hidden="true" />, disabled, onClick: onEdit },
|
|
{ id: "copy", label: "Copy addresses", icon: <Copy aria-hidden="true" />, applicable: Boolean(copiedText), onClick: onCopy }
|
|
]} />
|
|
</div>
|
|
</div>);
|
|
}
|
|
|
|
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 (
|
|
<Dialog
|
|
open
|
|
title={title}
|
|
className="recipient-address-editor-modal"
|
|
bodyClassName="recipient-address-editor-body"
|
|
onClose={onClose}
|
|
footer={<Button onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
|
|
|
<div className="recipient-address-editor">
|
|
{columns.map((column) =>
|
|
<RecipientAddressCategoryEditor
|
|
key={column.key}
|
|
column={column}
|
|
addresses={values[column.key] ?? []}
|
|
merge={false}
|
|
locked={locked}
|
|
onAddressesChange={(addresses) => onAddressesChange(column.key, addresses)}
|
|
onPasteAddresses={applyPastedAddresses} />
|
|
|
|
)}
|
|
</div>
|
|
</Dialog>);
|
|
}
|
|
|
|
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 (
|
|
<Dialog
|
|
open
|
|
title={`Recipient ${index + 1} - addresses`}
|
|
className="recipient-address-editor-modal"
|
|
bodyClassName="recipient-address-editor-body"
|
|
onClose={onClose}
|
|
footer={<Button onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
|
|
|
<div className="recipient-address-editor">
|
|
{availableColumns.length === 0 &&
|
|
<p className="muted">No individual recipient address fields are enabled.</p>
|
|
}
|
|
{availableColumns.map((column) =>
|
|
<RecipientAddressCategoryEditor
|
|
key={column.key}
|
|
column={column}
|
|
addresses={getEntryAddresses(entry, column.key)}
|
|
merge={column.mergeKey ? getEntryMerge(entry, entryDefaults, column.mergeKey) : false}
|
|
locked={locked}
|
|
onAddressesChange={(addresses) => updateEntryAddressList(index, column.key, addresses)}
|
|
onMergeChange={column.mergeKey ? (merge) => updateEntryMerge(index, column.mergeKey!, merge) : undefined}
|
|
onPasteAddresses={applyPastedAddresses} />
|
|
|
|
)}
|
|
</div>
|
|
</Dialog>);
|
|
}
|
|
|
|
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<MailboxAddress[]>(() => 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<MailboxAddress>) {
|
|
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 (
|
|
<section className="recipient-address-category" tabIndex={locked ? undefined : 0} onPaste={handlePaste}>
|
|
<div className="recipient-address-category-header">
|
|
<h3>{column.label}</h3>
|
|
<div className="recipient-address-category-controls">
|
|
{onMergeChange ?
|
|
<ToggleSwitch
|
|
label="Merge mode"
|
|
inactiveLabel="Overwrite"
|
|
activeLabel="Merge"
|
|
checked={merge}
|
|
disabled={locked}
|
|
onChange={onMergeChange} /> :
|
|
null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="recipient-address-lines">
|
|
{column.allowMultiple && draftAddresses.length === 0 &&
|
|
<div className="recipient-address-empty-row">
|
|
<p className="muted recipient-address-empty">{column.emptyText}</p>
|
|
<TableActionGroup
|
|
className="data-grid-empty-row-actions"
|
|
actions={[{
|
|
id: "add",
|
|
label: translatedAddLabel,
|
|
icon: <Plus size={16} aria-hidden="true" />,
|
|
variant: "primary",
|
|
disabled: !canAdd,
|
|
onClick: () => addAddress()
|
|
}]}
|
|
/>
|
|
</div>
|
|
}
|
|
{draftAddresses.map((address, addressIndex) =>
|
|
<div className="recipient-address-edit-line" key={`${column.key}-${addressIndex}`}>
|
|
<input
|
|
value={address.name ?? ""}
|
|
disabled={locked}
|
|
placeholder="Name"
|
|
aria-label={`${column.label} name ${addressIndex + 1}`}
|
|
onChange={(event) => patchAddress(addressIndex, { name: event.target.value })} />
|
|
<input
|
|
value={address.email}
|
|
disabled={locked}
|
|
placeholder="Email"
|
|
type="email"
|
|
aria-label={`${column.label} email ${addressIndex + 1}`}
|
|
onChange={(event) => patchAddress(addressIndex, { email: event.target.value })} />
|
|
{column.allowMultiple &&
|
|
<TableActionGroup
|
|
className="recipient-address-line-actions"
|
|
actions={[
|
|
{ id: "add", label: translatedAddLabel, icon: <Plus size={16} aria-hidden="true" />, variant: "primary", disabled: !canAdd, onClick: () => addAddress(addressIndex) },
|
|
{ id: "move-up", label: translatedMoveUpLabel, icon: <ArrowUp size={16} aria-hidden="true" />, disabled: locked || addressIndex === 0, onClick: () => moveAddress(addressIndex, addressIndex - 1) },
|
|
{ id: "move-down", label: translatedMoveDownLabel, icon: <ArrowDown size={16} aria-hidden="true" />, disabled: locked || addressIndex >= draftAddresses.length - 1, onClick: () => moveAddress(addressIndex, addressIndex + 1) },
|
|
{ id: "remove", label: translatedRemoveLabel, icon: <Trash2 size={16} aria-hidden="true" />, variant: "danger", disabled: locked, onClick: () => removeAddress(addressIndex) }
|
|
]}
|
|
/>
|
|
}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>);
|
|
|
|
}
|
|
|
|
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<AddressSourceFilter>("all");
|
|
const [sourceQuery, setSourceQuery] = useState("");
|
|
const [mode, setMode] = useState<RecipientImportMode>("append");
|
|
const [snapshot, setSnapshot] = useState<CampaignRecipientAddressSourceSnapshot | null>(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 (
|
|
<Dialog
|
|
open
|
|
title="Import recipients from addresses"
|
|
className="recipient-import-modal"
|
|
bodyClassName="recipient-import-body"
|
|
closeDisabled={loading}
|
|
closeOnBackdrop={!loading}
|
|
onClose={onCancel}
|
|
footer={
|
|
<>
|
|
<Button onClick={onCancel} disabled={loading}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
|
<Button variant="primary" disabled={loading || !snapshot || snapshot.recipients.length === 0} onClick={() => snapshot && onImport(snapshot, mode)}>
|
|
Import recipients
|
|
</Button>
|
|
</>
|
|
}>
|
|
|
|
<div className="address-source-import-controls">
|
|
<div>
|
|
<SegmentedControl
|
|
ariaLabel="Address source type"
|
|
value={sourceFilter}
|
|
onChange={setSourceFilter}
|
|
size="content"
|
|
width="inline"
|
|
disabled={loading || sources.length === 0}
|
|
options={[
|
|
{ id: "all", label: `All (${sources.length})` },
|
|
{ id: "books", label: `Books (${sourceCounts.book})` },
|
|
{ id: "lists", label: `Lists (${sourceCounts.list})` }
|
|
]}
|
|
/>
|
|
</div>
|
|
<input
|
|
type="search"
|
|
value={sourceQuery}
|
|
disabled={loading || sources.length === 0}
|
|
placeholder="Search address sources"
|
|
aria-label="Search address sources"
|
|
onChange={(event) => setSourceQuery(event.target.value)}
|
|
/>
|
|
<Button type="button" onClick={openAddressBookModule}>
|
|
Manage address books
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="campaign-header-grid recipient-import-upload-grid">
|
|
<div className="address-source-picker" role="radiogroup" aria-label="Address source">
|
|
{filteredSources.map((source) =>
|
|
<button
|
|
type="button"
|
|
key={source.source_id}
|
|
className={`address-source-option ${source.source_id === selectedSourceId ? "is-selected" : ""}`}
|
|
disabled={loading}
|
|
role="radio"
|
|
aria-checked={source.source_id === selectedSourceId}
|
|
onClick={() => setSelectedSourceId(source.source_id)}>
|
|
<span className="address-source-option-main">
|
|
<strong>{source.source_label}</strong>
|
|
<span>{addressSourceTypeLabel(source)} - {addressSourceScopeLabel(source)}</span>
|
|
</span>
|
|
<span className="address-source-option-meta">
|
|
<span>{source.recipient_count} recipients</span>
|
|
<code>{shortSourceRevision(source.source_revision)}</code>
|
|
</span>
|
|
</button>
|
|
)}
|
|
{sources.length > 0 && filteredSources.length === 0 &&
|
|
<div className="empty-state compact-empty">No address sources match the current filter.</div>
|
|
}
|
|
</div>
|
|
<FormField label="Import mode">
|
|
<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>
|
|
|
|
{error && <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert>}
|
|
{loading && <DismissibleAlert tone="info" compact dismissible={false}>Loading address recipients...</DismissibleAlert>}
|
|
{!loading && sources.length === 0 && <DismissibleAlert tone="info" dismissible={false}>No address sources are available to this campaign. Create an address book or address list in the addresses module first.</DismissibleAlert>}
|
|
|
|
{snapshot &&
|
|
<>
|
|
<dl className="detail-list recipient-import-summary">
|
|
<div><dt>Source</dt><dd>{snapshot.source_label}</dd></div>
|
|
<div><dt>Type</dt><dd>{selectedSource ? addressSourceTypeLabel(selectedSource) : "Address source"}</dd></div>
|
|
<div><dt>Scope</dt><dd>{selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"}</dd></div>
|
|
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
|
|
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
|
|
</dl>
|
|
<AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
|
|
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
|
|
</>
|
|
}
|
|
</Dialog>);
|
|
}
|
|
|
|
type AddressSourceSnapshotRecipient = CampaignRecipientAddressSourceSnapshot["recipients"][number];
|
|
|
|
function AddressSourceRecipientPreviewGrid({ recipients }: {recipients: AddressSourceSnapshotRecipient[];}) {
|
|
const columns: DataGridColumn<AddressSourceSnapshotRecipient>[] = [
|
|
{ id: "row", header: "#", width: 64, value: (_recipient, index) => index + 1, render: (_recipient, index) => index + 1 },
|
|
{ id: "name", header: "Name", width: "minmax(180px, 1fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.display_name },
|
|
{ id: "email", header: "Email", width: "minmax(220px, 1.2fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.email },
|
|
{ id: "fields", header: "Fields", width: 100, sortable: true, value: (recipient) => Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length }
|
|
];
|
|
return <DataGrid id="campaign-address-source-import-preview" rows={recipients} columns={columns} getRowKey={(recipient) => `${recipient.contact_id}-${recipient.email}`} />;
|
|
}
|
|
|
|
type RecipientImportDialogProps = {
|
|
settings: ApiSettings;
|
|
campaignId: string;
|
|
existingEntries: Record<string, unknown>[];
|
|
existingFields: ReturnType<typeof getDraftFields>;
|
|
defaultAttachmentBasePath: AttachmentBasePath | null;
|
|
onCancel: () => void;
|
|
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) => void;
|
|
};
|
|
|
|
type RecipientImportStepId = "upload" | "parse" | "map" | "preview" | "files";
|
|
|
|
const allRecipientImportSteps: Array<{id: RecipientImportStepId;label: string;}> = [
|
|
{ id: "upload", label: "i18n:govoplan-campaign.upload.8bdf057f" },
|
|
{ id: "parse", label: "i18n:govoplan-campaign.parse.b7e45a36" },
|
|
{ id: "map", label: "i18n:govoplan-campaign.map.ab478f3e" },
|
|
{ id: "preview", label: "i18n:govoplan-campaign.preview.f1fbb2b4" },
|
|
{ id: "files", label: "i18n:govoplan-campaign.files.6ce6c512" }];
|
|
|
|
|
|
const recipientColumnKindOptions: Array<{value: RecipientColumnKind;label: string;}> = [
|
|
{ value: "ignore", label: "i18n:govoplan-campaign.ignore.98f55db5" },
|
|
{ value: "id", label: "i18n:govoplan-campaign.id.89f89c02" },
|
|
{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" },
|
|
{ value: "name", label: "i18n:govoplan-campaign.display_name.c7874aaa" },
|
|
{ value: "from", label: "i18n:govoplan-campaign.from.3f66052a" },
|
|
{ value: "to", label: "i18n:govoplan-campaign.to.ae79ea1e" },
|
|
{ value: "cc", label: "i18n:govoplan-campaign.cc.c5a976de" },
|
|
{ value: "bcc", label: "i18n:govoplan-campaign.bcc.4c0145a3" },
|
|
{ value: "reply_to", label: "i18n:govoplan-campaign.reply_to.c1733667" },
|
|
{ value: "field", label: "i18n:govoplan-campaign.existing_field.681f2b09" },
|
|
{ value: "new_field", label: "i18n:govoplan-campaign.new_field.57fc8a2e" },
|
|
{ value: "attachment_pattern", label: "i18n:govoplan-campaign.attachment_pattern.59649690" }];
|
|
|
|
|
|
const AUTOMATIC_MAPPING_PROFILE_MIN_SCORE = 0.55;
|
|
|
|
const RECIPIENT_IMPORT_ENCODINGS = [
|
|
{ value: "utf-8", label: "i18n:govoplan-campaign.utf_8.663b90c8" },
|
|
{ value: "windows-1252", label: "i18n:govoplan-campaign.windows_1252.6944986d" },
|
|
{ value: "iso-8859-1", label: "i18n:govoplan-campaign.iso_8859_1.e4d77380" },
|
|
{ value: "utf-16le", label: "i18n:govoplan-campaign.utf_16_le.8d74294c" },
|
|
{ value: "utf-16be", label: "i18n:govoplan-campaign.utf_16_be.1de115c7" }];
|
|
|
|
|
|
export function RecipientImportDialog({ settings, campaignId, existingEntries, existingFields, defaultAttachmentBasePath, onCancel, onImport }: RecipientImportDialogProps) {
|
|
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
|
const fileLinkCapability = useMemo<ImportFileLinkCapability | null>(() => {
|
|
if (!filesFileExplorer?.listFiles || !filesFileExplorer.resolveFilePatterns || !filesFileExplorer.shareFilesWithTarget) return null;
|
|
return {
|
|
listFiles: filesFileExplorer.listFiles,
|
|
resolveFilePatterns: filesFileExplorer.resolveFilePatterns,
|
|
shareFilesWithTarget: filesFileExplorer.shareFilesWithTarget
|
|
};
|
|
}, [filesFileExplorer]);
|
|
const recipientImportSteps = useMemo(
|
|
() => fileLinkCapability ? allRecipientImportSteps : allRecipientImportSteps.filter((step) => step.id !== "files"),
|
|
[fileLinkCapability]
|
|
);
|
|
const [activeStep, setActiveStep] = useState<RecipientImportStepId>("upload");
|
|
const [csvText, setCsvText] = useState("");
|
|
const [sourceType, setSourceType] = useState<RecipientImportSourceType>("text");
|
|
const [filename, setFilename] = useState("");
|
|
const [fileBuffer, setFileBuffer] = useState<ArrayBuffer | null>(null);
|
|
const [encoding, setEncoding] = useState("utf-8");
|
|
const [workbookSheets, setWorkbookSheets] = useState<RecipientImportSpreadsheetSheet[]>([]);
|
|
const [selectedSheetName, setSelectedSheetName] = useState("");
|
|
const [mode, setMode] = useState<RecipientImportMode>("append");
|
|
const [delimiter, setDelimiter] = useState<CsvDelimiter>("auto");
|
|
const [headerRows, setHeaderRows] = useState(1);
|
|
const [quoted, setQuoted] = useState(true);
|
|
const [valueSeparators, setValueSeparators] = useState(",;|");
|
|
const [mappings, setMappings] = useState<RecipientColumnMapping[]>([]);
|
|
const [mappingProfiles, setMappingProfiles] = useState<RecipientMappingProfile[]>([]);
|
|
const [mappingManuallyChanged, setMappingManuallyChanged] = useState(false);
|
|
const [mappingProfileError, setMappingProfileError] = useState("");
|
|
const [mappingProfileNotice, setMappingProfileNotice] = useState("");
|
|
const [fileError, setFileError] = useState("");
|
|
const [fileLinkResolution, setFileLinkResolution] = useState<ImportFileLinkResolution | null>(null);
|
|
const [fileLinkResolving, setFileLinkResolving] = useState(false);
|
|
const [fileLinking, setFileLinking] = useState(false);
|
|
const [fileLinkError, setFileLinkError] = useState("");
|
|
const [fileLinkNotice, setFileLinkNotice] = useState("");
|
|
const selectedSheet = useMemo(
|
|
() => workbookSheets.find((sheet) => sheet.name === selectedSheetName) ?? workbookSheets[0] ?? null,
|
|
[selectedSheetName, workbookSheets]
|
|
);
|
|
const hasContent = sourceType === "xlsx" ?
|
|
Boolean(selectedSheet && selectedSheet.rows.some((row) => row.some((cell) => cell.trim()))) :
|
|
csvText.trim().length > 0;
|
|
const table = useMemo(
|
|
() => {
|
|
if (!hasContent) return null;
|
|
if (sourceType === "xlsx") return selectedSheet ? buildImportTableFromRows(selectedSheet.rows, { headerRows }) : null;
|
|
return buildImportTable(csvText, { delimiter, headerRows, quoted });
|
|
},
|
|
[csvText, delimiter, hasContent, headerRows, quoted, selectedSheet, sourceType]
|
|
);
|
|
const tableHeaderKey = table ? `${table.delimiter}:${table.firstDataRowNumber}:${table.headers.join("\u001f")}` : "";
|
|
const mappingProfileMatches = useMemo(
|
|
() => table ? matchRecipientMappingProfiles(table, mappingProfiles) : [],
|
|
[mappingProfiles, table]
|
|
);
|
|
const parseReady = Boolean(table && table.dataRows.some((row) => row.some((cell) => cell.trim())));
|
|
const preview = useMemo(
|
|
() => table ? buildRecipientImportPreview(table, mappings, { existingFields, existingEntries, valueSeparators }) : null,
|
|
[existingEntries, existingFields, mappings, table, valueSeparators]
|
|
);
|
|
const activeStepIndex = recipientImportSteps.findIndex((step) => step.id === activeStep);
|
|
const nextStep = recipientImportSteps[activeStepIndex + 1]?.id ?? null;
|
|
const previousStep = recipientImportSteps[activeStepIndex - 1]?.id ?? null;
|
|
const isLastStep = activeStepIndex === recipientImportSteps.length - 1;
|
|
const mappedColumnCount = mappings.filter((mapping) => mapping.kind !== "ignore").length;
|
|
const patternRows = preview?.rows.filter((row) => row.patterns.length > 0).length ?? 0;
|
|
const renderedPatterns = useMemo(() => preview ? renderedImportPatterns(preview) : [], [preview]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setMappingProfileError("");
|
|
void listRecipientImportMappingProfiles(settings).
|
|
then((profiles) => {
|
|
if (!cancelled) setMappingProfiles(profiles);
|
|
}).
|
|
catch((err) => {
|
|
if (!cancelled) setMappingProfileError(err instanceof Error ? err.message : String(err));
|
|
});
|
|
return () => {cancelled = true;};
|
|
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
|
|
|
useEffect(() => {
|
|
if (!fileBuffer || sourceType !== "csv") return;
|
|
try {
|
|
setCsvText(decodeImportText(fileBuffer, encoding));
|
|
setFileError("");
|
|
} catch (err) {
|
|
setCsvText("");
|
|
setFileError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}, [encoding, fileBuffer, sourceType]);
|
|
|
|
useEffect(() => {
|
|
setMappingManuallyChanged(false);
|
|
}, [tableHeaderKey]);
|
|
|
|
useEffect(() => {
|
|
if (!table) {
|
|
setMappings([]);
|
|
setMappingProfileNotice("");
|
|
return;
|
|
}
|
|
if (mappingManuallyChanged) return;
|
|
const automaticMatch = mappingProfileMatches.find(isAutomaticMappingProfileMatch);
|
|
if (automaticMatch) {
|
|
setMappings(applyRecipientMappingProfile(table, automaticMatch.profile, existingFields));
|
|
setValueSeparators(automaticMatch.profile.valueSeparators || ",;|");
|
|
setMappingProfileNotice(mappingProfileMatchNotice(automaticMatch));
|
|
return;
|
|
}
|
|
setMappings(defaultColumnMappings(table.headers, existingFields));
|
|
setMappingProfileNotice("");
|
|
}, [existingFields, mappingManuallyChanged, mappingProfileMatches, table, tableHeaderKey]);
|
|
|
|
useEffect(() => {
|
|
if (!hasContent) setActiveStep("upload");
|
|
}, [hasContent]);
|
|
|
|
useEffect(() => {
|
|
if (activeStep === "files" && !fileLinkCapability) setActiveStep("preview");
|
|
}, [activeStep, fileLinkCapability]);
|
|
|
|
useEffect(() => {
|
|
if (activeStep === "files" && fileLinkCapability) void refreshFileLinks();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [activeStep, defaultAttachmentBasePath?.id, defaultAttachmentBasePath?.path, defaultAttachmentBasePath?.source, fileLinkCapability, renderedPatterns.length]);
|
|
|
|
async function readFile(file: File | undefined) {
|
|
if (!file) return;
|
|
setFilename(file.name);
|
|
setFileError("");
|
|
setWorkbookSheets([]);
|
|
setSelectedSheetName("");
|
|
try {
|
|
const buffer = await file.arrayBuffer();
|
|
if (isXlsxFile(file)) {
|
|
const sheets = await xlsxSheetsFromArrayBuffer(buffer);
|
|
if (sheets.length === 0) {
|
|
throw new Error("i18n:govoplan-campaign.workbook_contains_no_readable_sheets.02c18603");
|
|
}
|
|
setSourceType("xlsx");
|
|
setFileBuffer(null);
|
|
setCsvText("");
|
|
setWorkbookSheets(sheets);
|
|
setSelectedSheetName(sheets[0]?.name ?? "");
|
|
} else {
|
|
setSourceType("csv");
|
|
setFileBuffer(buffer);
|
|
setCsvText(decodeImportText(buffer, encoding));
|
|
}
|
|
setActiveStep("parse");
|
|
} catch (err) {
|
|
setFileError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
function canOpenStep(stepId: RecipientImportStepId): boolean {
|
|
if (stepId === "upload") return true;
|
|
if (stepId === "parse") return hasContent;
|
|
if (stepId === "map") return parseReady;
|
|
if (stepId === "files" && !fileLinkCapability) return false;
|
|
return parseReady && mappings.length > 0;
|
|
}
|
|
|
|
function stepStatus(stepId: RecipientImportStepId): string {
|
|
if (stepId === "upload") return filename || (hasContent ? "i18n:govoplan-campaign.pasted_data.5b58080b" : "i18n:govoplan-campaign.waiting.33d30632");
|
|
if (stepId === "parse") return table ? `${table.dataRows.length} rows` : "i18n:govoplan-campaign.set_parsing.be7b05fd";
|
|
if (stepId === "map") return `${mappedColumnCount} mapped`;
|
|
if (stepId === "preview") return preview ? `${preview.validCount} valid` : "i18n:govoplan-campaign.review.e29a79fe";
|
|
if (!preview || preview.patternCount === 0) return "i18n:govoplan-campaign.no_patterns.5e1e46fa";
|
|
if (fileLinkResolving) return "i18n:govoplan-campaign.checking.97876b83";
|
|
if (fileLinkResolution) return i18nMessage("i18n:govoplan-campaign.value_to_link.50cdc659", { value0: fileLinkResolution.linkableFiles.length });
|
|
if (fileLinkError) return "i18n:govoplan-campaign.needs_setup.522ebae4";
|
|
return `${renderedPatterns.length} patterns`;
|
|
}
|
|
|
|
function goNext() {
|
|
if (nextStep && canOpenStep(nextStep)) setActiveStep(nextStep);
|
|
}
|
|
|
|
async function refreshFileLinks() {
|
|
setFileLinkNotice("");
|
|
if (!fileLinkCapability) {
|
|
setFileLinkResolution(null);
|
|
setFileLinkError("");
|
|
return;
|
|
}
|
|
if (!preview || preview.patternCount === 0) {
|
|
setFileLinkResolution(null);
|
|
setFileLinkError("");
|
|
return;
|
|
}
|
|
if (!defaultAttachmentBasePath?.source) {
|
|
setFileLinkResolution(null);
|
|
setFileLinkError("i18n:govoplan-campaign.the_selected_attachment_source_is_not_connected_.9c528afa");
|
|
return;
|
|
}
|
|
setFileLinkResolving(true);
|
|
setFileLinkError("");
|
|
try {
|
|
setFileLinkResolution(await resolveImportedAttachmentLinks(fileLinkCapability, settings, campaignId, defaultAttachmentBasePath, preview));
|
|
} catch (err) {
|
|
setFileLinkResolution(null);
|
|
setFileLinkError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setFileLinkResolving(false);
|
|
}
|
|
}
|
|
|
|
async function linkImportedFiles() {
|
|
if (!fileLinkCapability || !fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return;
|
|
setFileLinking(true);
|
|
setFileLinkError("");
|
|
setFileLinkNotice("");
|
|
try {
|
|
const linkedCount = await bulkLinkFilesToCampaign(fileLinkCapability, settings, campaignId, fileLinkResolution.linkableFiles);
|
|
await refreshFileLinks();
|
|
setFileLinkNotice(`Linked ${linkedCount} file${linkedCount === 1 ? "" : "s"} to this campaign.`);
|
|
} catch (err) {
|
|
setFileLinkError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setFileLinking(false);
|
|
}
|
|
}
|
|
|
|
function rememberCurrentMappingProfile() {
|
|
if (!table || mappings.length === 0) return;
|
|
const tableSnapshot = table;
|
|
const mappingsSnapshot = mappings;
|
|
const fallbackProfiles = mappingProfiles;
|
|
const filenameSnapshot = filename;
|
|
const parseOptions = { headerRows, quoted };
|
|
const valueSeparatorsSnapshot = valueSeparators;
|
|
|
|
void (async () => {
|
|
try {
|
|
const latestProfiles = await listRecipientImportMappingProfiles(settings).catch(() => fallbackProfiles);
|
|
const reusableProfile = findReusableMappingProfile(tableSnapshot, latestProfiles);
|
|
const draftProfile = buildRecipientMappingProfile({
|
|
id: reusableProfile?.id,
|
|
name: reusableProfile?.name ?? defaultMappingProfileName(filenameSnapshot, tableSnapshot),
|
|
table: tableSnapshot,
|
|
mappings: mappingsSnapshot,
|
|
parseOptions,
|
|
valueSeparators: valueSeparatorsSnapshot,
|
|
createdAt: reusableProfile?.createdAt,
|
|
updatedAt: new Date().toISOString()
|
|
});
|
|
const payload = mappingProfilePayload(draftProfile);
|
|
await (reusableProfile ?
|
|
updateRecipientImportMappingProfile(settings, reusableProfile.id, payload) :
|
|
createRecipientImportMappingProfile(settings, payload));
|
|
} catch {
|
|
|
|
|
|
|
|
|
|
|
|
// Import acceptance must not be blocked by passive profile learning.
|
|
}})();}function confirmRecipientImport(nextPreview: RecipientImportPreview) {rememberCurrentMappingProfile();
|
|
const provenance = createRecipientImportProvenance({
|
|
preview: nextPreview,
|
|
mappings,
|
|
mode,
|
|
sourceType,
|
|
filename,
|
|
sheetName: sourceType === "xlsx" ? selectedSheet?.name ?? null : null,
|
|
encoding: sourceType === "xlsx" ? null : encoding,
|
|
delimiter: sourceType === "xlsx" ? null : formatDelimiter(nextPreview.table.delimiter),
|
|
headerRows,
|
|
quoted: sourceType === "xlsx" ? null : quoted,
|
|
valueSeparators
|
|
});
|
|
onImport(nextPreview, mode, provenance);
|
|
}
|
|
|
|
function replaceColumnMapping(nextMapping: RecipientColumnMapping) {
|
|
const columnCount = table?.headers.length ?? 0;
|
|
setMappingManuallyChanged(true);
|
|
setMappings((current) => {
|
|
const byColumn = new Map(current.map((mapping) => [mapping.columnIndex, mapping]));
|
|
byColumn.set(nextMapping.columnIndex, nextMapping);
|
|
return Array.from({ length: columnCount }, (_value, columnIndex) => byColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" });
|
|
});
|
|
}
|
|
|
|
function changeColumnKind(columnIndex: number, kind: RecipientColumnKind) {
|
|
const header = table?.headers[columnIndex] ?? `Column ${columnIndex + 1}`;
|
|
const current = mappings.find((mapping) => mapping.columnIndex === columnIndex);
|
|
const nextMapping: RecipientColumnMapping = { columnIndex, kind };
|
|
if (kind === "field") nextMapping.fieldName = current?.fieldName || existingFields[0]?.name || "";
|
|
if (kind === "new_field") nextMapping.newFieldName = current?.newFieldName || suggestImportFieldName(header);
|
|
replaceColumnMapping(nextMapping);
|
|
}
|
|
|
|
const mappingRows = (table?.headers ?? []).map((header, columnIndex) => ({
|
|
id: `${columnIndex}-${header}`,
|
|
header,
|
|
columnIndex,
|
|
mapping: mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const }
|
|
}));
|
|
type MappingRow = (typeof mappingRows)[number];
|
|
const mappingColumns: DataGridColumn<MappingRow>[] = [
|
|
{ id: "column", header: "i18n:govoplan-campaign.column.65ba00e9", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => row.header, render: (row) => <strong>{row.header}</strong> },
|
|
{ id: "sample", header: "i18n:govoplan-campaign.sample.58fabfa7", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (row) => table ? sampleColumnValue(table.dataRows, row.columnIndex) : "", render: (row) => <span className="mono-small">{table ? sampleColumnValue(table.dataRows, row.columnIndex) : ""}</span> },
|
|
{ id: "content", header: "i18n:govoplan-campaign.content.4f9be057", width: 190, value: (row) => row.mapping.kind, render: (row) => <select value={row.mapping.kind} onChange={(event) => changeColumnKind(row.columnIndex, event.target.value as RecipientColumnKind)}>{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select> },
|
|
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(200px, .9fr)", minWidth: 180, resizable: true, value: (row) => row.mapping.fieldName ?? row.mapping.newFieldName ?? "", render: (row) => <>{row.mapping.kind === "field" && <select value={row.mapping.fieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, fieldName: event.target.value, newFieldName: undefined })}><option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}</select>}{row.mapping.kind === "new_field" && <input value={row.mapping.newFieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, newFieldName: event.target.value, fieldName: undefined })} />}</> }
|
|
];
|
|
|
|
type PreviewRow = RecipientImportPreview["rows"][number];
|
|
const previewColumns: DataGridColumn<PreviewRow>[] = [
|
|
{ id: "row", header: "#", width: 68, sortable: true, value: (row) => row.rowNumber },
|
|
{ id: "to", header: "i18n:govoplan-campaign.to.ae79ea1e", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, filterable: true, value: (row) => formatAddressList(row.addresses.to) },
|
|
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => row.name },
|
|
{ id: "fields", header: "i18n:govoplan-campaign.fields.e8b68527", width: 100, sortable: true, value: (row) => Object.keys(row.fields).length },
|
|
{ id: "patterns", header: "i18n:govoplan-campaign.patterns.4d34f7a2", width: 110, sortable: true, value: (row) => row.patterns.length },
|
|
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, filterable: true, value: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552", render: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552" }
|
|
];
|
|
|
|
const stepContent = activeStep === "upload" ?
|
|
<>
|
|
<div className="campaign-header-grid recipient-import-upload-grid">
|
|
<FormField label="i18n:govoplan-campaign.recipient_file.693007d2">
|
|
<FileDropZone
|
|
accept=".csv,.tsv,.txt,.xlsx,text/csv,text/tab-separated-values,text/plain,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
multiple={false}
|
|
label="i18n:govoplan-campaign.drop_recipient_file_here.c7c2bddf"
|
|
actionLabel="i18n:govoplan-campaign.or_click_to_select_file.0e72f25d"
|
|
note={filename || "i18n:govoplan-campaign.csv_tsv_text_or_xlsx.5dcdac76"}
|
|
onFiles={(files) => readFile(files[0])} />
|
|
|
|
</FormField>
|
|
<FormField label="i18n:govoplan-campaign.import_mode.7d161dff">
|
|
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
|
|
<option value="append">i18n:govoplan-campaign.append_to_current_profiles.0b434d6f</option>
|
|
<option value="replace">i18n:govoplan-campaign.replace_current_profiles.3b3be5e2</option>
|
|
</select>
|
|
</FormField>
|
|
</div>
|
|
{fileError && <DismissibleAlert tone="danger" compact resetKey={fileError}>{fileError}</DismissibleAlert>}
|
|
{sourceType === "xlsx" ?
|
|
<DismissibleAlert tone="info" compact dismissible={false}>
|
|
i18n:govoplan-campaign.workbook_loaded.cfaf40eb {workbookSheets.length} sheet{workbookSheets.length === 1 ? "" : "s"}.
|
|
</DismissibleAlert> :
|
|
|
|
<FormField label={filename ? i18nMessage("i18n:govoplan-campaign.raw_content_value.15e098b8", { value0: filename }) : "i18n:govoplan-campaign.raw_content.c687fb57"}>
|
|
<textarea
|
|
className="json-editor recipient-import-textarea"
|
|
value={csvText}
|
|
onChange={(event) => {
|
|
setFileBuffer(null);
|
|
setSourceType(filename ? "csv" : "text");
|
|
setCsvText(event.target.value);
|
|
if (!event.target.value.trim()) setFilename("");
|
|
}}
|
|
spellCheck={false} />
|
|
|
|
</FormField>
|
|
}
|
|
</> :
|
|
activeStep === "parse" ?
|
|
<>
|
|
<div className="campaign-header-grid">
|
|
{sourceType === "xlsx" &&
|
|
<FormField label="i18n:govoplan-campaign.sheet.53bc47a7">
|
|
<select value={selectedSheet?.name ?? ""} onChange={(event) => setSelectedSheetName(event.target.value)}>
|
|
{workbookSheets.map((sheet) =>
|
|
<option key={sheet.name} value={sheet.name}>{sheet.name}</option>
|
|
)}
|
|
</select>
|
|
</FormField>
|
|
}
|
|
<FormField label="i18n:govoplan-campaign.header_rows.9814b9e3">
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
max={10}
|
|
value={headerRows}
|
|
onChange={(event) => setHeaderRows(Math.max(0, Number.parseInt(event.target.value, 10) || 0))} />
|
|
|
|
</FormField>
|
|
{sourceType !== "xlsx" &&
|
|
<>
|
|
<FormField label="i18n:govoplan-campaign.encoding.5821fec7">
|
|
<select value={encoding} onChange={(event) => setEncoding(event.target.value)}>
|
|
{RECIPIENT_IMPORT_ENCODINGS.map((option) =>
|
|
<option key={option.value} value={option.value}>{option.label}</option>
|
|
)}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="i18n:govoplan-campaign.separator.b4b289a7">
|
|
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value as CsvDelimiter)}>
|
|
<option value="auto">i18n:govoplan-campaign.auto.c614ba7c</option>
|
|
<option value=",">i18n:govoplan-campaign.comma.b9ee3dea</option>
|
|
<option value=";">i18n:govoplan-campaign.semicolon.727ceca9</option>
|
|
<option value={"\t"}>i18n:govoplan-campaign.tab.fe06eb64</option>
|
|
</select>
|
|
</FormField>
|
|
<div className="campaign-header-toggle recipient-import-toggles">
|
|
<ToggleSwitch label="i18n:govoplan-campaign.quoted_contents.f7fd335a" checked={quoted} onChange={setQuoted} />
|
|
</div>
|
|
</>
|
|
}
|
|
</div>
|
|
{table &&
|
|
<>
|
|
<dl className="detail-list recipient-import-summary">
|
|
<div><dt>{sourceType === "xlsx" ? "i18n:govoplan-campaign.sheet.53bc47a7" : "i18n:govoplan-campaign.separator.b4b289a7"}</dt><dd>{sourceType === "xlsx" ? selectedSheet?.name ?? "i18n:govoplan-campaign.workbook.b4418e62" : formatDelimiter(table.delimiter)}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.header_rows.9814b9e3</dt><dd>{table.headerRows.length}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.data_rows.3734724c</dt><dd>{table.dataRows.length}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.columns.cf723c59</dt><dd>{table.headers.length}</dd></div>
|
|
</dl>
|
|
<RecipientImportRawTable tableRows={table.rows} headerRowCount={table.headerRows.length} columnCount={table.headers.length} />
|
|
</>
|
|
}
|
|
</> :
|
|
activeStep === "map" ?
|
|
<>
|
|
{mappingProfileError && <DismissibleAlert tone="warning" compact resetKey={mappingProfileError}>{mappingProfileError}</DismissibleAlert>}
|
|
{mappingProfileNotice && <DismissibleAlert tone="info" compact resetKey={mappingProfileNotice}>{mappingProfileNotice}</DismissibleAlert>}
|
|
<div className="campaign-header-grid recipient-import-map-controls">
|
|
<FormField label="i18n:govoplan-campaign.multi_value_separators.6dd66f70">
|
|
<input
|
|
value={valueSeparators}
|
|
onChange={(event) => {
|
|
setMappingManuallyChanged(true);
|
|
setValueSeparators(event.target.value);
|
|
}} />
|
|
|
|
</FormField>
|
|
<FormField label="i18n:govoplan-campaign.mode.a7b93d21">
|
|
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
|
|
<option value="append">i18n:govoplan-campaign.append.6b3a6022</option>
|
|
<option value="replace">i18n:govoplan-campaign.replace.a7cf7b25</option>
|
|
</select>
|
|
</FormField>
|
|
</div>
|
|
<DataGrid id="campaign-recipient-import-mapping" rows={mappingRows} columns={mappingColumns} getRowKey={(row) => row.id} />
|
|
</> :
|
|
activeStep === "preview" ?
|
|
<>
|
|
{preview &&
|
|
<>
|
|
<dl className="detail-list recipient-import-summary">
|
|
<div><dt>i18n:govoplan-campaign.rows.52d0b352</dt><dd>{preview.validCount} i18n:govoplan-campaign.valid.210cece7 {preview.invalidCount} invalid</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.fields.e8b68527</dt><dd>{preview.fieldNamesToCreate.length} new</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.patterns.4d34f7a2</dt><dd>{preview.patternCount} from {patternRows} i18n:govoplan-campaign.row_s.61464061</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.source.6da13add</dt><dd>{preview.patternCount ? defaultAttachmentBasePath?.name ?? "i18n:govoplan-campaign.campaign_files.96e7004b" : "i18n:govoplan-campaign.no_patterns.5e1e46fa"}</dd></div>
|
|
</dl>
|
|
{preview.fieldNamesToCreate.length > 0 &&
|
|
<p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p>
|
|
}
|
|
<DataGrid
|
|
id="campaign-recipient-import-preview"
|
|
className="recipient-import-preview-grid"
|
|
rows={preview.rows.slice(0, 20)}
|
|
columns={previewColumns}
|
|
getRowKey={(row) => String(row.rowNumber)}
|
|
rowClassName={(row) => row.issues.length ? "is-invalid" : undefined}
|
|
/>
|
|
</>
|
|
}
|
|
</> :
|
|
|
|
<RecipientImportFileLinkStep
|
|
preview={preview}
|
|
basePath={defaultAttachmentBasePath}
|
|
resolution={fileLinkResolution}
|
|
resolving={fileLinkResolving}
|
|
linking={fileLinking}
|
|
error={fileLinkError}
|
|
notice={fileLinkNotice}
|
|
onRefresh={() => void refreshFileLinks()}
|
|
onLink={() => void linkImportedFiles()} />;
|
|
|
|
|
|
|
|
return (
|
|
<Dialog
|
|
open
|
|
title="i18n:govoplan-campaign.import_recipients.9d6dbe45"
|
|
className="recipient-import-modal"
|
|
bodyClassName="recipient-import-body"
|
|
closeDisabled={fileLinking || fileLinkResolving}
|
|
closeOnBackdrop={!fileLinking && !fileLinkResolving}
|
|
onClose={onCancel}
|
|
footer={
|
|
<>
|
|
<Button onClick={onCancel} disabled={fileLinking || fileLinkResolving}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
|
{previousStep && <Button onClick={() => setActiveStep(previousStep)} disabled={fileLinking || fileLinkResolving}>i18n:govoplan-campaign.back.b52b36b7</Button>}
|
|
{!isLastStep ?
|
|
<Button variant="primary" disabled={!nextStep || !canOpenStep(nextStep)} onClick={goNext}>i18n:govoplan-campaign.next.bc981983</Button> :
|
|
|
|
<Button variant="primary" disabled={!preview || preview.validCount === 0 || fileLinking || fileLinkResolving} onClick={() => preview && confirmRecipientImport(preview)}>i18n:govoplan-campaign.import_valid_rows.c3b2642b</Button>
|
|
}
|
|
</>
|
|
}>
|
|
|
|
<nav className="recipient-import-steps" aria-label="i18n:govoplan-campaign.recipient_import_steps.35dcf028">
|
|
<div className="recipient-import-step-track">
|
|
{recipientImportSteps.map((step, index) => {
|
|
const stepIndex = recipientImportSteps.findIndex((item) => item.id === step.id);
|
|
const stepState = stepIndex < activeStepIndex ? "is-complete" : step.id === activeStep ? "is-current" : "";
|
|
return (
|
|
<div className="recipient-import-step-group" key={step.id}>
|
|
<button
|
|
type="button"
|
|
className={`recipient-import-step-item ${stepState}`.trim()}
|
|
disabled={!canOpenStep(step.id)}
|
|
aria-current={step.id === activeStep ? "step" : undefined}
|
|
onClick={() => setActiveStep(step.id)}>
|
|
|
|
<span className="recipient-import-step-icon">{index + 1}</span>
|
|
<span className="recipient-import-step-copy">
|
|
<strong>{step.label}</strong>
|
|
<small>{stepStatus(step.id)}</small>
|
|
</span>
|
|
</button>
|
|
{index < recipientImportSteps.length - 1 && <span className="recipient-import-step-line" aria-hidden="true" />}
|
|
</div>);
|
|
|
|
})}
|
|
</div>
|
|
</nav>
|
|
<section className="recipient-import-step-panel">
|
|
{stepContent}
|
|
</section>
|
|
</Dialog>);
|
|
|
|
}
|
|
|
|
type RecipientImportFileLinkStepProps = {
|
|
preview: RecipientImportPreview | null;
|
|
basePath: AttachmentBasePath | null;
|
|
resolution: ImportFileLinkResolution | null;
|
|
resolving: boolean;
|
|
linking: boolean;
|
|
error: string;
|
|
notice: string;
|
|
onRefresh: () => void;
|
|
onLink: () => void;
|
|
};
|
|
|
|
function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving, linking, error, notice, onRefresh, onLink }: RecipientImportFileLinkStepProps) {
|
|
const linkableIds = new Set(resolution?.linkableFiles.map((file) => file.id) ?? []);
|
|
if (!preview || preview.patternCount === 0) {
|
|
return <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-campaign.no_attachment_patterns_were_imported_there_are_n.c01c8266</DismissibleAlert>;
|
|
}
|
|
|
|
type ResolvedFile = ImportFileLinkResolution["files"][number];
|
|
const fileColumns: DataGridColumn<ResolvedFile>[] = [
|
|
{ id: "file", header: "i18n:govoplan-campaign.file.2c3cafa4", width: "minmax(200px, .8fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (file) => file.filename, render: (file) => <strong>{file.filename}</strong> },
|
|
{ id: "path", header: "i18n:govoplan-campaign.path.519e3913", width: "minmax(260px, 1.3fr)", minWidth: 220, resizable: true, sortable: true, filterable: true, value: (file) => file.display_path },
|
|
{ id: "size", header: "i18n:govoplan-campaign.size.b7152342", width: 120, sortable: true, value: (file) => file.size_bytes, render: (file) => formatImportBytes(file.size_bytes) },
|
|
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 160, sortable: true, filterable: true, value: (file) => linkableIds.has(file.id) ? "needs-linking" : "linked", render: (file) => linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600" }
|
|
];
|
|
|
|
return (
|
|
<div className="recipient-import-file-step">
|
|
<div className="recipient-import-file-actions">
|
|
<div>
|
|
<strong>{basePath?.name ?? "i18n:govoplan-campaign.campaign_files.96e7004b"}</strong>
|
|
<p className="muted small-note">i18n:govoplan-campaign.files_matched_by_imported_recipient_attachment_p.9b97aca3</p>
|
|
</div>
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={onRefresh} disabled={resolving || linking}>{resolving ? "i18n:govoplan-campaign.checking.820d6004" : "i18n:govoplan-campaign.refresh_matches.11e36411"}</Button>
|
|
<Button variant="primary" onClick={onLink} disabled={resolving || linking || !resolution || resolution.linkableFiles.length === 0}>{linking ? "i18n:govoplan-campaign.linking.6f640897" : i18nMessage("i18n:govoplan-campaign.link_value_file_s.ca800d96", { value0: resolution?.linkableFiles.length ?? 0 })}</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <DismissibleAlert tone="warning" compact resetKey={error}>{error}</DismissibleAlert>}
|
|
{notice && <DismissibleAlert tone="success" compact resetKey={notice}>{notice}</DismissibleAlert>}
|
|
{resolving && <DismissibleAlert tone="info" compact dismissible={false}>i18n:govoplan-campaign.resolving_imported_file_patterns.3aea140f</DismissibleAlert>}
|
|
|
|
{resolution &&
|
|
<>
|
|
<dl className="detail-list recipient-import-summary">
|
|
<div><dt>i18n:govoplan-campaign.patterns.4d34f7a2</dt><dd>{resolution.patterns.length}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.matched_files.f79c63bb</dt><dd>{resolution.files.length}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.already_linked.7d6c3dd5</dt><dd>{resolution.linkedFiles.length}</dd></div>
|
|
<div><dt>i18n:govoplan-campaign.need_linking.a7617722</dt><dd>{resolution.linkableFiles.length}</dd></div>
|
|
</dl>
|
|
|
|
{resolution.unmatchedPatterns.length > 0 &&
|
|
<div className="recipient-import-unmatched-patterns">
|
|
<strong>i18n:govoplan-campaign.patterns_without_matches.172afdde</strong>
|
|
<ul>
|
|
{resolution.unmatchedPatterns.slice(0, 8).map((pattern) =>
|
|
<li key={pattern.key}>i18n:govoplan-campaign.row.9bf7a8e8 {pattern.rowNumber}: <code>{pattern.renderedPattern}</code></li>
|
|
)}
|
|
</ul>
|
|
{resolution.unmatchedPatterns.length > 8 && <p className="muted small-note">{resolution.unmatchedPatterns.length - 8} i18n:govoplan-campaign.more_unmatched_pattern_s.1dda9807</p>}
|
|
</div>
|
|
}
|
|
|
|
<DataGrid
|
|
id="campaign-recipient-import-file-links"
|
|
rows={resolution.files}
|
|
columns={fileColumns}
|
|
getRowKey={(file) => file.id}
|
|
emptyText="i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b"
|
|
/>
|
|
</>
|
|
}
|
|
</div>);
|
|
|
|
}
|
|
|
|
type RecipientImportRawTableProps = {
|
|
tableRows: string[][];
|
|
headerRowCount: number;
|
|
columnCount: number;
|
|
};
|
|
|
|
function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) {
|
|
const visibleRows = tableRows.slice(0, 15);
|
|
const safeColumnCount = Math.max(1, columnCount, ...visibleRows.map((row) => row.length));
|
|
const rows = visibleRows.map((cells, rowIndex) => ({ rowIndex, cells }));
|
|
type RawRow = (typeof rows)[number];
|
|
const columns: DataGridColumn<RawRow>[] = [
|
|
{ id: "row", header: "#", width: 64, sortable: true, value: (row) => row.rowIndex + 1 },
|
|
...Array.from({ length: safeColumnCount }, (_value, columnIndex): DataGridColumn<RawRow> => ({
|
|
id: `column-${columnIndex + 1}`,
|
|
header: String(columnIndex + 1),
|
|
width: "minmax(140px, 1fr)",
|
|
minWidth: 120,
|
|
resizable: true,
|
|
filterable: true,
|
|
value: (row) => row.cells[columnIndex] ?? ""
|
|
}))
|
|
];
|
|
return (
|
|
<DataGrid
|
|
id="campaign-recipient-import-raw"
|
|
className="recipient-import-raw-grid"
|
|
rows={rows}
|
|
columns={columns}
|
|
getRowKey={(row) => String(row.rowIndex)}
|
|
rowClassName={(row) => row.rowIndex < headerRowCount ? "is-header-row" : undefined}
|
|
emptyText="i18n:govoplan-campaign.no_rows_parsed.a7ccc3de"
|
|
initialFit="content"
|
|
/>);
|
|
|
|
}
|
|
|
|
function formatDelimiter(delimiter: "," | ";" | "\t"): string {
|
|
if (delimiter === "\t") return "i18n:govoplan-campaign.tab.fe06eb64";
|
|
if (delimiter === ";") return "i18n:govoplan-campaign.semicolon.727ceca9";
|
|
return "i18n:govoplan-campaign.comma.b9ee3dea";
|
|
}
|
|
|
|
function isXlsxFile(file: File): boolean {
|
|
const name = file.name.toLowerCase();
|
|
return name.endsWith(".xlsx") || file.type === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
}
|
|
|
|
function decodeImportText(buffer: ArrayBuffer, encoding: string): string {
|
|
try {
|
|
return new TextDecoder(encoding).decode(buffer).replace(/^\uFEFF/, "");
|
|
} catch (err) {
|
|
throw new Error(`Could not decode file as ${encoding}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
function mappingProfilePayload(profile: RecipientMappingProfile): RecipientImportMappingProfilePayload {
|
|
return {
|
|
name: profile.name,
|
|
columnCount: profile.columnCount,
|
|
headers: profile.headers,
|
|
normalizedHeaders: profile.normalizedHeaders,
|
|
orderedHeaderFingerprint: profile.orderedHeaderFingerprint,
|
|
unorderedHeaderFingerprint: profile.unorderedHeaderFingerprint,
|
|
delimiter: profile.delimiter,
|
|
headerRows: profile.headerRows,
|
|
quoted: profile.quoted,
|
|
valueSeparators: profile.valueSeparators,
|
|
mappings: profile.mappings
|
|
};
|
|
}
|
|
|
|
function defaultMappingProfileName(filename: string, table: {headers: string[];}): string {
|
|
const trimmedFilename = filename.trim().replace(/\.[^.]+$/, "");
|
|
if (trimmedFilename) return trimmedFilename;
|
|
const headerLabel = table.headers.slice(0, 3).map((header) => header.trim()).filter(Boolean).join(", ");
|
|
return headerLabel ? i18nMessage("i18n:govoplan-campaign.mapping_value", { value0: headerLabel }) : "i18n:govoplan-campaign.recipient_import_mapping.5fe80f6c";
|
|
}
|
|
|
|
function formatMappingProfileMatch(match: RecipientMappingProfileMatch): string {
|
|
if (match.mode === "ordered") return "i18n:govoplan-campaign.exact";
|
|
if (match.mode === "unordered") return "i18n:govoplan-campaign.same_headers.e523bbe4";
|
|
return `${Math.round(match.score * 100)}%`;
|
|
}
|
|
|
|
function isAutomaticMappingProfileMatch(match: RecipientMappingProfileMatch): boolean {
|
|
return match.mode === "ordered" || match.mode === "unordered" || match.score >= AUTOMATIC_MAPPING_PROFILE_MIN_SCORE;
|
|
}
|
|
|
|
function findReusableMappingProfile(table: {headers: string[];}, profiles: RecipientMappingProfile[]): RecipientMappingProfile | null {
|
|
const fingerprints = recipientImportHeaderFingerprints(table.headers);
|
|
return profiles.find((profile) => profile.orderedHeaderFingerprint === fingerprints.orderedHeaderFingerprint) ??
|
|
profiles.find((profile) => profile.unorderedHeaderFingerprint === fingerprints.unorderedHeaderFingerprint) ??
|
|
null;
|
|
}
|
|
|
|
function mappingProfileMatchNotice(match: RecipientMappingProfileMatch): string {
|
|
if (match.mode === "ordered") return i18nMessage("i18n:govoplan-campaign.using_mapping_from_previous_import_value.f86f53fd", { value0: match.profile.name });
|
|
if (match.mode === "unordered") return i18nMessage("i18n:govoplan-campaign.using_mapping_from_previous_import_value_matched.08910120", { value0: match.profile.name });
|
|
return i18nMessage("i18n:govoplan-campaign.using_mapping_derived_from_previous_import_value.008a66a3", { value0: match.profile.name, value1: formatMappingProfileMatch(match) });
|
|
}
|
|
|
|
function sampleColumnValue(rows: string[][], columnIndex: number): string {
|
|
return rows.map((row) => String(row[columnIndex] ?? "").trim()).find(Boolean) ?? "";
|
|
}
|
|
|
|
function formatAddressList(addresses: ImportedAddress[]): string {
|
|
return addresses.map((address) => address.name ? `${address.name} <${address.email}>` : address.email).join(", ");
|
|
}
|
|
|
|
function formatImportBytes(value?: number | null): string {
|
|
if (!value) return "";
|
|
if (value < 1024) return i18nMessage("i18n:govoplan-campaign.bytes_b", { value0: value });
|
|
if (value < 1024 * 1024) return i18nMessage("i18n:govoplan-campaign.bytes_kb", { value0: (value / 1024).toFixed(1) });
|
|
return i18nMessage("i18n:govoplan-campaign.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
|
|
}
|
|
|
|
function buildAddressSourceImportPreview(snapshot: CampaignRecipientAddressSourceSnapshot, existingEntries: Record<string, unknown>[]): RecipientImportPreview {
|
|
const headers = ["display_name", "email", "given_name", "family_name", "organization", "role_title", "phone", "tags"];
|
|
const tableRows = [
|
|
headers,
|
|
...snapshot.recipients.map((recipient) => headers.map((header) => {
|
|
if (header === "display_name") return recipient.display_name;
|
|
if (header === "email") return recipient.email;
|
|
return fieldValueToString((recipient.fields ?? {})[header]);
|
|
}))];
|
|
|
|
const table: RecipientImportTable = {
|
|
delimiter: ",",
|
|
headers,
|
|
headerRows: [headers],
|
|
rows: tableRows,
|
|
dataRows: tableRows.slice(1),
|
|
firstDataRowNumber: 2
|
|
};
|
|
const usedIds = new Set(existingEntries.map((entry) => String(entry.id || "")).filter(Boolean));
|
|
const fieldNamesToCreate = new Set<string>();
|
|
const rows: ImportedRecipientRow[] = snapshot.recipients.map((recipient, index) => {
|
|
const email = recipient.email.trim();
|
|
const name = recipient.display_name.trim();
|
|
const fields = stringFieldsFromAddressSource(recipient.fields);
|
|
Object.keys(fields).forEach((fieldName) => fieldNamesToCreate.add(fieldName));
|
|
const id = uniqueImportId(
|
|
`address-${recipient.contact_id || idFragmentFromEmail(email) || index + 1}`,
|
|
usedIds
|
|
);
|
|
const issues: string[] = [];
|
|
if (!email.includes("@")) issues.push(`${email || "email"} must contain @`);
|
|
return {
|
|
rowNumber: table.firstDataRowNumber + index,
|
|
id,
|
|
name,
|
|
email,
|
|
active: true,
|
|
addresses: {
|
|
from: [],
|
|
to: email ? [{ name, email }] : [],
|
|
cc: [],
|
|
bcc: [],
|
|
reply_to: []
|
|
},
|
|
fields,
|
|
patterns: [],
|
|
issues
|
|
};
|
|
});
|
|
|
|
return {
|
|
table,
|
|
rows,
|
|
fieldNamesToCreate: [...fieldNamesToCreate].sort(),
|
|
validCount: rows.filter((row) => row.issues.length === 0).length,
|
|
invalidCount: rows.filter((row) => row.issues.length > 0).length,
|
|
patternCount: 0
|
|
};
|
|
}
|
|
|
|
function createAddressSourceImportProvenance(
|
|
snapshot: CampaignRecipientAddressSourceSnapshot,
|
|
preview: RecipientImportPreview,
|
|
mode: RecipientImportMode)
|
|
: RecipientImportProvenance {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
id: `recipient-import-addresses-${safeImportIdFragment(snapshot.source_id)}-${Date.now().toString(36)}`,
|
|
imported_at: now,
|
|
mode,
|
|
source_type: "addresses",
|
|
source_id: snapshot.source_id,
|
|
source_label: snapshot.source_label,
|
|
source_revision: snapshot.source_revision,
|
|
source_provenance: snapshot.provenance,
|
|
filename: null,
|
|
sheet_name: null,
|
|
encoding: null,
|
|
delimiter: null,
|
|
header_rows: 0,
|
|
quoted: null,
|
|
value_separators: null,
|
|
rows_total: preview.rows.length,
|
|
valid_rows: preview.validCount,
|
|
invalid_rows: preview.invalidCount,
|
|
imported_rows: preview.validCount,
|
|
field_names_created: preview.fieldNamesToCreate.slice(),
|
|
attachment_patterns: 0,
|
|
mapping: []
|
|
};
|
|
}
|
|
|
|
function stringFieldsFromAddressSource(value: Record<string, unknown> | undefined): Record<string, string> {
|
|
const fields: Record<string, string> = {};
|
|
for (const [key, rawValue] of Object.entries(value ?? {})) {
|
|
const fieldValue = fieldValueToString(rawValue);
|
|
if (fieldValue) fields[key] = fieldValue;
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
function fieldValueToString(value: unknown): string {
|
|
if (value === null || value === undefined) return "";
|
|
if (Array.isArray(value)) return value.map(fieldValueToString).filter(Boolean).join(", ");
|
|
if (typeof value === "object") {
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
return String(value).trim();
|
|
}
|
|
|
|
function idFragmentFromEmail(value: string): string {
|
|
return safeImportIdFragment(value.split("@")[0] ?? "");
|
|
}
|
|
|
|
function safeImportIdFragment(value: string): string {
|
|
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "source";
|
|
}
|
|
|
|
function uniqueImportId(preferred: string, usedIds: Set<string>): string {
|
|
const base = safeImportIdFragment(preferred) || "recipient";
|
|
let candidate = base;
|
|
let counter = 2;
|
|
while (usedIds.has(candidate)) {
|
|
candidate = `${base}-${counter}`;
|
|
counter += 1;
|
|
}
|
|
usedIds.add(candidate);
|
|
return candidate;
|
|
}
|
|
|
|
|
|
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 = {
|
|
settings: ApiSettings;
|
|
campaignId: string;
|
|
draft: Record<string, unknown>;
|
|
locked: boolean;
|
|
filesModuleInstalled: boolean;
|
|
entries: Record<string, unknown>[];
|
|
fieldDefinitions: ReturnType<typeof getDraftFields>;
|
|
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
|
|
zipConfig: AttachmentZipCollection;
|
|
translateText: (value: string) => string;
|
|
openAddressEditor: (index: number) => void;
|
|
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
|
|
updateEntryAttachments: (index: number, attachments: AttachmentRule[]) => void;
|
|
updateEntryField: (index: number, field: string, value: unknown) => void;
|
|
addRecipient: (afterIndex?: number) => void;
|
|
moveEntry: (index: number, targetIndex: number) => void;
|
|
removeEntry: (index: number) => void;
|
|
};
|
|
|
|
function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
|
return [
|
|
{
|
|
id: "number",
|
|
header: "#",
|
|
width: 72,
|
|
sortable: true,
|
|
filterType: "integer",
|
|
sticky: "start",
|
|
render: (_entry, index) =>
|
|
<span className="recipient-data-number">
|
|
{index + 1}
|
|
</span>,
|
|
|
|
value: (_entry, index) => index + 1
|
|
},
|
|
{
|
|
id: "recipients",
|
|
header: "Recipient(s)",
|
|
width: "minmax(320px, 1.4fr)",
|
|
resizable: true,
|
|
filterable: true,
|
|
render: (entry, index) => {
|
|
const summary = recipientAddressSummary(entry, translateText);
|
|
const content = (
|
|
<>
|
|
<span className="recipient-address-editor-main">
|
|
<span className={`recipient-data-address ${summary.empty ? "is-empty" : ""}`}>{summary.primary}</span>
|
|
{summary.badges.map((badge) =>
|
|
<span className="recipient-extra-bubble" key={badge}>{badge}</span>
|
|
)}
|
|
</span>
|
|
{!locked && <Pencil aria-hidden="true" />}
|
|
</>
|
|
);
|
|
if (locked) {
|
|
return <div className="recipient-address-editor-trigger is-readonly">{content}</div>;
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="recipient-address-editor-trigger"
|
|
onClick={() => openAddressEditor(index)}>
|
|
{content}
|
|
</button>);
|
|
},
|
|
value: recipientAddressFilterValue
|
|
},
|
|
{ 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" },
|
|
...(individualAttachmentBasePaths.length > 0 ? [{
|
|
id: "attachments",
|
|
header: "i18n:govoplan-campaign.attachments.6771ade6",
|
|
width: 180,
|
|
filterable: true,
|
|
render: (entry, index) => {
|
|
const attachments = normalizeAttachmentRules(entry.attachments);
|
|
return (
|
|
<AttachmentRulesOverlay
|
|
title={i18nMessage("i18n:govoplan-campaign.attachments_for_recipient_value.9a8df82d", { value0: index + 1 })}
|
|
rules={attachments}
|
|
settings={settings}
|
|
campaignId={campaignId}
|
|
disabled={locked}
|
|
buttonLabel={`entries: ${attachments.length}`}
|
|
basePaths={individualAttachmentBasePaths}
|
|
zipConfig={zipConfig}
|
|
filesModuleInstalled={filesModuleInstalled}
|
|
previewContext={buildTemplatePreviewContext(draft, entry)}
|
|
onChange={(rules) => updateEntryAttachments(index, rules)} />);
|
|
|
|
|
|
},
|
|
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
|
|
}] : []),
|
|
...fieldDefinitions.filter((field) => field.can_override !== false).map((field): DataGridColumn<Record<string, unknown>> => ({
|
|
id: `field-${field.name}`,
|
|
header: field.label || field.name,
|
|
width: 190,
|
|
resizable: true,
|
|
sortable: true,
|
|
filterable: true,
|
|
filterType: field.type === "integer" ? "integer" : field.type === "double" ? "number" : field.type === "date" ? "date" : "text",
|
|
render: (entry, index) => {
|
|
const fields = asRecord(entry.fields);
|
|
return (
|
|
<FieldValueInput
|
|
className="recipient-field-input"
|
|
fieldType={field.type}
|
|
value={fields[field.name]}
|
|
disabled={locked}
|
|
onChange={(value) => updateEntryField(index, field.name, value)} />);
|
|
|
|
|
|
},
|
|
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
|
|
})),
|
|
{
|
|
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 recipientAddressSummary(entry: Record<string, unknown>, translateText: (value: string) => string): {primary: string;badges: string[];empty: boolean;} {
|
|
const to = getEntryAddresses(entry, "to");
|
|
const primaryAddress = to[0] ?? null;
|
|
const badges = [
|
|
addressCountBadge(Math.max(0, to.length - 1), "To"),
|
|
addressCountBadge(getEntryAddresses(entry, "cc").length, "CC"),
|
|
addressCountBadge(getEntryAddresses(entry, "bcc").length, "BCC"),
|
|
addressCountBadge(getEntryAddresses(entry, "reply_to").length, "Reply-To"),
|
|
addressCountBadge(getEntryAddresses(entry, "from").length, "From")].
|
|
filter((badge): badge is string => Boolean(badge));
|
|
|
|
if (!primaryAddress) {
|
|
return {
|
|
primary: translateText("i18n:govoplan-campaign.no_recipient_address.1301a488"),
|
|
badges,
|
|
empty: true
|
|
};
|
|
}
|
|
|
|
return {
|
|
primary: formatMailboxAddress(primaryAddress),
|
|
badges,
|
|
empty: false
|
|
};
|
|
}
|
|
|
|
function recipientAddressFilterValue(entry: Record<string, unknown>): string {
|
|
return recipientAddressOverlayColumns.
|
|
flatMap((column) => getEntryAddresses(entry, column.key)).
|
|
map(formatMailboxAddress).
|
|
join(", ");
|
|
}
|
|
|
|
function addressCountBadge(count: number, label: string): string | null {
|
|
if (count <= 0) return null;
|
|
return `+${count} ${label}`;
|
|
}
|
|
|
|
function getAddressColumn(key: AddressFieldKey): EntryAddressColumn {
|
|
const column = recipientAddressOverlayColumns.find((item) => item.key === key);
|
|
if (!column) throw new Error(`Unknown address column ${key}`);
|
|
return column;
|
|
}
|
|
|
|
function normalizeEditorAddressRows(column: EntryAddressColumn, addresses: MailboxAddress[]): MailboxAddress[] {
|
|
if (column.allowMultiple) return addresses;
|
|
return addresses.length > 0 ? addresses.slice(0, 1) : [{ name: "", email: "" }];
|
|
}
|
|
|
|
function formatMailboxAddress(address: MailboxAddress): string {
|
|
const name = String(address.name ?? "").trim();
|
|
const email = String(address.email ?? "").trim();
|
|
if (name && email) return `${name} <${email}>`;
|
|
return email || name;
|
|
}
|
|
|
|
function entryWithAddressList(entry: Record<string, unknown>, key: AddressFieldKey, addresses: MailboxAddress[]): Record<string, unknown> {
|
|
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 headerAddressValues(columns: EntryAddressColumn[], recipientsSection: Record<string, unknown>): HeaderAddressValues {
|
|
return Object.fromEntries(columns.map((column) => [
|
|
column.key,
|
|
column.key === "from" ? addressesFromValue(recipientsSection.from).slice(0, 1) : addressesFromValue(recipientsSection[column.key])]
|
|
)) as HeaderAddressValues;
|
|
}
|
|
|
|
function addressDisplaySegments(columns: EntryAddressColumn[], values: HeaderAddressValues): string[] {
|
|
return columns.flatMap((column) =>
|
|
(values[column.key] ?? []).map((address) => `${addressDisplayPrefix(column.key)}${formatMailboxAddress(address)}`)
|
|
).filter(Boolean);
|
|
}
|
|
|
|
function fitAddressSegments(segments: string[], availableWidth: number): string[] {
|
|
if (segments.length <= 1 || availableWidth <= 0) return segments;
|
|
if (measureAddressTextWidth(segments.join(", ")) <= availableWidth) return segments;
|
|
const maxWidth = Math.max(120, availableWidth - 42);
|
|
let usedWidth = 0;
|
|
const visible: string[] = [];
|
|
for (const segment of segments) {
|
|
const estimatedWidth = measureAddressTextWidth(`${visible.length > 0 ? ", " : ""}${segment}`);
|
|
if (visible.length > 0 && usedWidth + estimatedWidth > maxWidth) break;
|
|
visible.push(segment);
|
|
usedWidth += estimatedWidth;
|
|
}
|
|
return visible.length > 0 ? visible : segments.slice(0, 1);
|
|
}
|
|
|
|
function measureAddressTextWidth(value: string): number {
|
|
if (typeof document === "undefined") return value.length * 6.6;
|
|
const canvas = document.createElement("canvas");
|
|
const context = canvas.getContext("2d");
|
|
if (!context) return value.length * 6.6;
|
|
context.font = "13px system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif";
|
|
return context.measureText(value).width;
|
|
}
|
|
|
|
function formatAddressCollectionForClipboard(columns: EntryAddressColumn[], values: HeaderAddressValues): string {
|
|
return addressDisplaySegments(columns, values).join(", ");
|
|
}
|
|
|
|
function addressDisplayPrefix(key: AddressFieldKey): string {
|
|
if (key === "from") return "From: ";
|
|
if (key === "reply_to") return "Reply-To: ";
|
|
if (key === "to") return "To: ";
|
|
if (key === "cc") return "CC: ";
|
|
return "BCC: ";
|
|
}
|
|
|
|
function parsePastedAddressGroups(targetKey: AddressFieldKey, text: string): Array<{key: AddressFieldKey;addresses: MailboxAddress[];}> {
|
|
const tokens = splitPastedAddressText(text);
|
|
const hasPrefixedToken = tokens.some((token) => Boolean(prefixedAddressToken(token)));
|
|
const grouped = new Map<AddressFieldKey, MailboxAddress[]>();
|
|
for (const token of tokens) {
|
|
const prefixed = prefixedAddressToken(token);
|
|
const key = prefixed?.key ?? (hasPrefixedToken ? "to" : targetKey);
|
|
const addressText = prefixed?.text ?? token;
|
|
const address = parseMailboxAddressText(addressText);
|
|
if (!address?.email) continue;
|
|
grouped.set(key, dedupeAddresses([...(grouped.get(key) ?? []), address]));
|
|
}
|
|
return [...grouped.entries()].map(([key, addresses]) => ({ key, addresses }));
|
|
}
|
|
|
|
function splitPastedAddressText(text: string): string[] {
|
|
return text.
|
|
replace(/\r/g, "\n").
|
|
split(/\n|;/).
|
|
flatMap((part) => part.split(",")).
|
|
map((part) => part.trim()).
|
|
filter(Boolean);
|
|
}
|
|
|
|
function prefixedAddressToken(token: string): {key: AddressFieldKey;text: string;} | null {
|
|
const match = token.match(/^(from|sender|reply[-_\s]?to|to|cc|bcc)\s*:\s*(.+)$/i);
|
|
if (!match) return null;
|
|
const key = addressKeyFromPrefix(match[1]);
|
|
return key ? { key, text: match[2].trim() } : null;
|
|
}
|
|
|
|
function addressKeyFromPrefix(prefix: string): AddressFieldKey | null {
|
|
const normalized = prefix.toLowerCase().replace(/[-_\s]/g, "");
|
|
if (normalized === "from" || normalized === "sender") return "from";
|
|
if (normalized === "replyto") return "reply_to";
|
|
if (normalized === "to") return "to";
|
|
if (normalized === "cc") return "cc";
|
|
if (normalized === "bcc") return "bcc";
|
|
return null;
|
|
}
|
|
|
|
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 entryAddressEnabled(recipientsSection: Record<string, unknown>, key: EntryAddressColumn["key"]): boolean {
|
|
return getBool(recipientsSection, `allow_individual_${key}`, key === "to");
|
|
}
|
|
|
|
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;
|
|
}
|