668 lines
29 KiB
TypeScript
668 lines
29 KiB
TypeScript
import { useEffect, useMemo, useState } from "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 { 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 { 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,
|
|
entryWithAddressList,
|
|
formatAddressCollectionForClipboard,
|
|
getAddressColumn,
|
|
getEntryAddresses,
|
|
headerAddressValues,
|
|
hiddenRecipientAddressMatch,
|
|
recipientAddressFilterValue,
|
|
recipientAddressOverlayColumns,
|
|
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";
|
|
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 [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, 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) => {
|
|
let nextEntry = entry;
|
|
for (const column of recipientAddressOverlayColumns) {
|
|
if (!(column.key in values)) continue;
|
|
nextEntry = entryWithAddressList(nextEntry, column.key, values[column.key] ?? []);
|
|
if (!column.mergeKey || !(column.mergeKey in merges)) continue;
|
|
nextEntry = { ...nextEntry, [column.mergeKey]: Boolean(merges[column.mergeKey]) };
|
|
delete nextEntry[column.mergeKey.replace("merge_", "combine_")];
|
|
}
|
|
return nextEntry;
|
|
});
|
|
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 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}
|
|
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>
|
|
</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)} />
|
|
|
|
}
|
|
{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;
|
|
}
|