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([]); const [distributionListImportOpen, setDistributionListImportOpen] = useState(false); const [distributionListImportInitialId, setDistributionListImportInitialId] = useState(""); const [distributionListsAvailable, setDistributionListsAvailable] = useState(false); const [distributionListsLoading, setDistributionListsLoading] = useState(false); const [distributionLists, setDistributionLists] = useState([]); const [recipientProfilesPage, setRecipientProfilesPage] = useState(1); const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10); const [recipientProfilesQuery, setRecipientProfilesQuery] = useState({ sort: null, filters: {} }); const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState(null); const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState(null); const [postboxCatalog, setPostboxCatalog] = useState({ available: false, postboxes: [], templates: [], organization_units: [] }); const [headerAddressEditor, setHeaderAddressEditor] = useState(null); const version = data.currentVersion; const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({ settings, campaignId, version, locked, reload, setError, currentStep: "recipients", unsavedTitle: "i18n:govoplan-campaign.unsaved_recipient_changes.4184b4ed", unsavedMessage: "i18n:govoplan-campaign.recipient_address_data_has_unsaved_changes_save_.563c3b94" }); const recipientsSection = asRecord(displayDraft.recipients); const entries = asRecord(displayDraft.entries); const attachments = asRecord(displayDraft.attachments); const entryDefaults = asRecord(entries.defaults); const inlineEntries = asArray(entries.inline).map(asRecord); const source = asRecord(entries.source); const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]); const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]); const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]); const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments, true), [attachments]); const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]); const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]); const addressSourceRevisionById = useMemo( () => new Map(addressSources.map((addressSource) => [addressSource.source_id, addressSource.source_revision])), [addressSources] ); const staleAddressImports = useMemo(() => { return asArray(entries.imports). map(asRecord). filter((record) => record.source_type === "addresses"). map((record) => { const sourceId = typeof record.source_id === "string" ? record.source_id : ""; const importedRevision = typeof record.source_revision === "string" ? record.source_revision : ""; const currentRevision = sourceId ? addressSourceRevisionById.get(sourceId) ?? "" : ""; return { sourceId, sourceLabel: typeof record.source_label === "string" ? record.source_label : sourceId, importedRevision, currentRevision }; }). filter((record) => record.sourceId && record.currentRevision && record.importedRevision && record.currentRevision !== record.importedRevision); }, [addressSourceRevisionById, entries.imports]); const 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 = { 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[]) { patch(["entries", "inline"], nextEntries); } function addRecipient(afterIndex = inlineEntries.length - 1) { const nextIndex = inlineEntries.length + 1; const insertIndex = Math.max(0, Math.min(afterIndex + 1, inlineEntries.length)); const newEntry = { id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`), active: true, to: [], cc: [], bcc: [], name: "", email: "", from: [], reply_to: [], merge_to: false, merge_cc: true, merge_bcc: true, merge_reply_to: true }; replaceInlineEntries(insertAfter(inlineEntries, afterIndex, newEntry)); setRecipientAddressEditorIndex(insertIndex); } function updateEntry(index: number, updater: (entry: Record) => Record) { const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry); replaceInlineEntries(nextEntries); } function 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, 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 ( <> saveDraft("manual")} dirty={dirty} locked={locked} draft={draft} error={error} localError={localError} currentVersionId={data.campaign?.current_version_id}>
{ if (!locked) setHeaderAddressEditor({ title: "Default sender", columns: [fromAddressColumn] }); }} onCopy={() => void copyHeaderAddresses([fromAddressColumn])} />
patch(["recipients", "allow_individual_from"], checked)} />
{ if (!locked) setHeaderAddressEditor({ title: "Global Reply-To", columns: [replyToAddressColumn] }); }} onCopy={() => void copyHeaderAddresses([replyToAddressColumn])} />
patch(["recipients", "allow_individual_reply_to"], checked)} />
{recipientHeaderRows.map((row) => { const column = getAddressColumn(row.key); return (
{ if (!locked) setHeaderAddressEditor({ title: row.label, columns: [column] }); }} onCopy={() => void copyHeaderAddresses([column])} />
patch(["recipients", row.toggleKey], checked)} />
); })}
{(addressSourcesAvailable || addressSourcesLoading) && } {(distributionListsAvailable || distributionListsLoading) && } }> {inlineEntries.length === 0 && Boolean(source.type) && i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.bdae9fb4 } {staleAddressImports.length > 0 &&
Address imports are older than their source: {staleAddressImports.map((item) => item.sourceLabel || item.sourceId).join(", ")}.
{staleAddressImports.map((item) => )}
} {staleDistributionListImports.length > 0 &&
Frozen Distribution List imports have changed or become unavailable. Campaign recipients remain unchanged until an explicit refresh.
{staleDistributionListImports.map((item) => {item.sourceLabel}: {item.reason} {item.currentRevision !== null && } )}
} {!source.type &&
String(entry.id || index)} emptyText="i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.478b8026" emptyAction={ addRecipient(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_recipient.e540d52a" />} className="recipient-table-wrap recipient-address-table recipient-merged-data-table" pagination={{ page: recipientProfilesPage, pageSize: recipientProfilesPageSize, pageSizeOptions: [10, 25, 50, 100, 250], onPageChange: setRecipientProfilesPage, onPageSizeChange: (pageSize) => { setRecipientProfilesPageSize(pageSize); setRecipientProfilesPage(1); } }} onQueryChange={setRecipientProfilesQuery} />
}
{importOpen && setImportOpen(false)} onImport={applyRecipientImport} /> } {addressSourceImportOpen && setAddressSourceImportOpen(false)} onImport={applyAddressSourceImport} /> } {distributionListImportOpen && setDistributionListImportOpen(false)} onImport={applyDistributionListImport} /> } {recipientAddressEditorIndex !== null && inlineEntries[recipientAddressEditorIndex] && saveEntryAddresses(recipientAddressEditorIndex, values, merges)} onClose={() => setRecipientAddressEditorIndex(null)} /> } {postboxTargetEditorIndex !== null && inlineEntries[postboxTargetEditorIndex] && saveEntryPostboxTargets(postboxTargetEditorIndex, targets, merge)} onClose={() => setPostboxTargetEditorIndex(null)} /> } {headerAddressEditor && setHeaderAddressEditor(null)} /> } ); } function uniqueEntryId(entries: Record[], 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; }