Files
govoplan-campaign/webui/src/features/campaigns/RecipientDataPage.tsx
T
zemion c51fc180fb
Module Package Release / publish-packages (push) Successful in 12s
Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
2026-09-08 01:32:26 +02:00

713 lines
31 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { CircleCheck, CircleX } from "lucide-react";
import type { ApiSettings } from "../../types";
import {
getCampaignPostboxCatalog,
listCampaignRecipientAddressSources,
listCampaignRecipientDistributionLists,
snapshotCampaignRecipientAddressSource,
type CampaignDistributionListExpansion,
type CampaignDistributionListSource,
type CampaignPostboxCatalog,
type CampaignRecipientAddressSource,
type CampaignRecipientAddressSourceSnapshot } from
"../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
import { ToggleSwitch } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { DataGrid, DataGridEmptyAction, type DataGridQueryState } 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 PostboxTargetsDialog, {
normalizePostboxTargets
} from "./components/PostboxTargetsDialog";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentZipCollection, type AttachmentRule } from "./utils/attachments";
import {
materializeRecipientImportWithAttachmentDefaults,
type RecipientImportMode,
type RecipientImportPreview,
type RecipientImportProvenance } from
"./utils/bulkImport";
import {
buildAddressSourceImportPreview,
createAddressSourceImportProvenance
} from "./utils/addressSourceImport";
import { addressesFromValue, type MailboxAddress } from "@govoplan/core-webui";
import { i18nMessage, insertAfter, moveArrayItem, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
import DistributionListImportDialog from "./recipients/DistributionListImportDialog";
import {
distributionListDrift,
materializeDistributionListExpansion,
type DistributionRouteSelections
} from "./utils/distributionListImport";
import {
AddressHeaderControl,
HeaderAddressEditorDialog,
RecipientAddressEditorDialog,
entryWithAddressValues,
formatAddressCollectionForClipboard,
getAddressColumn,
getEntryAddresses,
headerAddressValues,
hiddenRecipientAddressMatch,
recipientAddressFilterValue,
recipientAddressSummary,
recipientHeaderRows,
type AddressFieldKey,
type EntryAddressColumn,
type EntryAddressMergeValues,
type HeaderAddressEditorState,
type HeaderAddressValues
} from "./recipients/RecipientAddressEditor";
import { RecipientImportDialog } from "./recipients/RecipientImportDialog";
import { recipientProfileColumns } from "./recipients/recipientProfileColumns";
type RecipientBulkActivation = {
active: boolean;
count: number;
};
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { translateText } = usePlatformLanguage();
const filesModuleInstalled = usePlatformModuleInstalled("files");
const postboxModuleInstalled = usePlatformModuleInstalled("postbox");
const templatesModuleInstalled = usePlatformModuleInstalled("templates");
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 [distributionListImportOpen, setDistributionListImportOpen] = useState(false);
const [distributionListImportInitialId, setDistributionListImportInitialId] = useState("");
const [distributionListsAvailable, setDistributionListsAvailable] = useState(false);
const [distributionListsLoading, setDistributionListsLoading] = useState(false);
const [distributionLists, setDistributionLists] = useState<CampaignDistributionListSource[]>([]);
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
const [recipientProfilesQuery, setRecipientProfilesQuery] = useState<DataGridQueryState>({ sort: null, filters: {} });
const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null);
const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState<number | null>(null);
const [bulkActivation, setBulkActivation] = useState<RecipientBulkActivation | null>(null);
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
const [headerAddressEditor, setHeaderAddressEditor] = useState<HeaderAddressEditorState>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saving, 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 staleDistributionListImports = useMemo(
() => distributionListDrift(entries.imports, distributionLists),
[distributionLists, 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(() => {
let cancelled = false;
setDistributionListsLoading(true);
void listCampaignRecipientDistributionLists(settings, campaignId)
.then((response) => {
if (cancelled) return;
setDistributionListsAvailable(response.available && response.expand_available);
setDistributionLists(response.available ? response.sources ?? [] : []);
})
.catch(() => {
if (cancelled) return;
setDistributionListsAvailable(false);
setDistributionLists([]);
})
.finally(() => {
if (!cancelled) setDistributionListsLoading(false);
});
return () => { cancelled = true; };
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (!postboxModuleInstalled) {
setPostboxCatalog({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
return;
}
let cancelled = false;
void getCampaignPostboxCatalog(settings, campaignId)
.then((catalog) => {
if (!cancelled) setPostboxCatalog(catalog);
})
.catch(() => {
if (!cancelled) {
setPostboxCatalog({
available: false,
postboxes: [],
templates: [],
organization_units: []
});
}
});
return () => {
cancelled = true;
};
}, [
campaignId,
postboxModuleInstalled,
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));
});
setPostboxTargetEditorIndex((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 saveEntryAddresses(
index: number,
values: HeaderAddressValues,
merges: EntryAddressMergeValues
) {
updateEntry(index, (entry) => entryWithAddressValues(entry, values, merges));
setRecipientAddressEditorIndex(null);
}
function saveEntryPostboxTargets(
index: number,
targets: ReturnType<typeof normalizePostboxTargets>,
merge: boolean
) {
updateEntry(index, (entry) => ({
...entry,
postbox_targets: targets,
merge_postbox_targets: merge
}));
setPostboxTargetEditorIndex(null);
}
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 requestBulkActivation(active: boolean) {
if (locked) return;
const count = inlineEntries.filter((entry) => (entry.active !== false) !== active).length;
if (count === 0) return;
setBulkActivation({ active, count });
}
function confirmBulkActivation() {
if (!bulkActivation || locked) return;
replaceInlineEntries(inlineEntries.map((entry) => ({ ...entry, active: bulkActivation.active })));
setBulkActivation(null);
}
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 applyDistributionListImport(
snapshot: CampaignDistributionListExpansion,
mode: RecipientImportMode,
routeSelections: DistributionRouteSelections
) {
if (locked || !draft) return;
setDraft(materializeDistributionListExpansion(draft, snapshot, mode, routeSelections));
markDirty();
setDistributionListImportOpen(false);
}
function saveHeaderAddresses(values: HeaderAddressValues) {
const nextRecipients = { ...recipientsSection };
for (const [key, addresses] of Object.entries(values) as Array<[AddressFieldKey, MailboxAddress[]]>) {
nextRecipients[key] = key === "from" ? addresses.slice(0, 1) : addresses;
}
patch(["recipients"], nextRecipients);
setHeaderAddressEditor(null);
}
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}
saving={saving}
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>
}
{(distributionListsAvailable || distributionListsLoading) &&
<Button disabled={locked || distributionListsLoading || !distributionListsAvailable} onClick={() => {setDistributionListImportInitialId("");setDistributionListImportOpen(true);}}>
Import Distribution List
</Button>
}
<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>
<Button
disabled={locked || inlineEntries.every((entry) => entry.active !== false)}
onClick={() => requestBulkActivation(true)}>
<CircleCheck size={16} aria-hidden="true" />
{i18nMessage("i18n:govoplan-campaign.activate_all_value0.7e911e3e", {
value0: inlineEntries.filter((entry) => entry.active === false).length
})}
</Button>
<Button
disabled={locked || inlineEntries.every((entry) => entry.active === false)}
onClick={() => requestBulkActivation(false)}>
<CircleX size={16} aria-hidden="true" />
{i18nMessage("i18n:govoplan-campaign.deactivate_all_value0.87e5d46f", {
value0: inlineEntries.filter((entry) => entry.active !== false).length
})}
</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>
}
{staleDistributionListImports.length > 0 &&
<DismissibleAlert tone="warning" dismissible={false}>
<div className="stale-address-import-warning">
<span>Frozen Distribution List imports have changed or become unavailable. Campaign recipients remain unchanged until an explicit refresh.</span>
<div className="button-row compact-actions">
{staleDistributionListImports.map((item) =>
<span key={`${item.sourceId}:${item.importedRevision ?? "unknown"}`} className="button-row compact-actions">
<span>{item.sourceLabel}: {item.reason}</span>
{item.currentRevision !== null &&
<Button
type="button"
onClick={() => {setDistributionListImportInitialId(item.sourceId);setDistributionListImportOpen(true);}}>
Refresh {item.sourceLabel}
</Button>
}
</span>
)}
</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,
postboxModuleInstalled,
templatesModuleInstalled,
postboxCatalog,
entries: inlineEntries,
fieldDefinitions,
individualAttachmentBasePaths,
zipConfig,
addressFilter: recipientProfilesQuery.filters.recipients ?? "",
translateText,
openAddressEditor: setRecipientAddressEditorIndex,
openPostboxTargetEditor: setPostboxTargetEditorIndex,
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);
}
}}
onQueryChange={setRecipientProfilesQuery} />
</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} />
}
{distributionListImportOpen &&
<DistributionListImportDialog
settings={settings}
campaignId={campaignId}
sources={distributionLists}
initialSourceId={distributionListImportInitialId}
onCancel={() => setDistributionListImportOpen(false)}
onImport={applyDistributionListImport} />
}
{recipientAddressEditorIndex !== null && inlineEntries[recipientAddressEditorIndex] &&
<RecipientAddressEditorDialog
entry={inlineEntries[recipientAddressEditorIndex]}
index={recipientAddressEditorIndex}
locked={locked}
recipientsSection={recipientsSection}
entryDefaults={entryDefaults}
onSave={(values, merges) => saveEntryAddresses(recipientAddressEditorIndex, values, merges)}
onClose={() => setRecipientAddressEditorIndex(null)} />
}
<ConfirmDialog
open={Boolean(bulkActivation)}
title={bulkActivation?.active
? "i18n:govoplan-campaign.activate_all_recipients.6be687f0"
: "i18n:govoplan-campaign.deactivate_all_recipients.5939b005"}
message={bulkActivation ? i18nMessage(
bulkActivation.active
? "i18n:govoplan-campaign.activate_value0_currently_inactive_recipients_the_change_r.53787f50"
: "i18n:govoplan-campaign.deactivate_value0_currently_active_recipients_the_change_r.3bee469f",
{ value0: bulkActivation.count }
) : ""}
confirmLabel={bulkActivation?.active
? "i18n:govoplan-campaign.activate_recipients.bc3a7878"
: "i18n:govoplan-campaign.deactivate_recipients.88d80611"}
tone={bulkActivation?.active ? "default" : "danger"}
onConfirm={confirmBulkActivation}
onCancel={() => setBulkActivation(null)} />
{postboxTargetEditorIndex !== null && inlineEntries[postboxTargetEditorIndex] &&
<PostboxTargetsDialog
open
title={`Recipient ${postboxTargetEditorIndex + 1} - Postbox targets`}
catalog={postboxCatalog}
fields={fieldDefinitions}
targets={normalizePostboxTargets(inlineEntries[postboxTargetEditorIndex].postbox_targets)}
merge={inlineEntries[postboxTargetEditorIndex].merge_postbox_targets !== false}
showMerge
locked={locked}
onSave={(targets, merge) => saveEntryPostboxTargets(postboxTargetEditorIndex, targets, merge)}
onClose={() => setPostboxTargetEditorIndex(null)} />
}
{headerAddressEditor &&
<HeaderAddressEditorDialog
title={headerAddressEditor.title}
columns={headerAddressEditor.columns}
values={headerAddressValues(headerAddressEditor.columns, recipientsSection)}
locked={locked}
onSave={saveHeaderAddresses}
onClose={() => setHeaderAddressEditor(null)} />
}
</>);
}
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;
}