import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { usePlatformModuleInstalled } from "@govoplan/core-webui"; import type { ApiSettings } from "../../types"; import { Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import FieldValueInput from "./components/FieldValueInput"; import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay"; import { getDraftFields } from "./utils/fieldDefinitions"; import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments"; import { importedRowsNeedAttachmentSource, materializeRecipientImport, type RecipientImportMode, type RecipientImportPreview } from "./utils/bulkImport"; import { buildTemplatePreviewContext } from "./utils/templatePlaceholders"; import { RecipientImportDialog } from "./RecipientDataPage"; import { addressesFromValue } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const filesModuleInstalled = usePlatformModuleInstalled("files"); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const [importOpen, setImportOpen] = useState(false); const [recipientDataPage, setRecipientDataPage] = useState(1); const [recipientDataPageSize, setRecipientDataPageSize] = useState(10); const version = data.currentVersion; const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({ settings, campaignId, version, locked, reload, setError, currentStep: "recipient-data", unsavedTitle: "Unsaved recipient data changes", unsavedMessage: "Recipient field values or attachments have unsaved changes. Save them before leaving, or discard them and continue." }); const entries = asRecord(displayDraft.entries); const inlineEntries = asArray(entries.inline).map(asRecord); const source = asRecord(entries.source); const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]); const attachmentSection = asRecord(displayDraft.attachments); const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]); const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]); const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection, true), [attachmentSection]); const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]); const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachmentSection.zip), [attachmentSection.zip]); function replaceInlineEntries(nextEntries: Record[]) { patch(["entries", "inline"], nextEntries); } function updateEntry(index: number, updater: (entry: Record) => Record) { const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry); replaceInlineEntries(nextEntries); } 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 applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) { if (locked || !draft) return; const needsAttachmentSource = importedRowsNeedAttachmentSource(preview); const currentAttachments = asRecord(draft.attachments); let nextBasePaths = normalizeAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true); let attachmentBasePath: AttachmentBasePath | null = null; if (needsAttachmentSource) { if (nextBasePaths.length === 0) { nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }]; } const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual)); nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath); attachmentBasePath = nextBasePaths[selectedIndex] ?? null; } const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath }); const nextDraft = needsAttachmentSource ? { ...importedDraft, attachments: { ...asRecord(importedDraft.attachments), base_paths: nextBasePaths, base_path: nextBasePaths[0]?.path || "." } } : importedDraft; setDraft(nextDraft); markDirty(); setImportOpen(false); } return (
Recipient data
{error && {error}} {localError && {localError}} {locked && } <> setImportOpen(true)}>Import}> {inlineEntries.length === 0 && !source.type &&

No recipient profiles are stored in the current version yet. Add recipients first, then maintain their data here.

} {inlineEntries.length === 0 && Boolean(source.type) && ( This campaign references an external recipient source. A parsed data preview will be added when file/source preview support is implemented. )} {inlineEntries.length > 0 && (
String(entry.id || index)} emptyText="No recipient data found." className="recipient-table-wrap recipient-data-table-wrap recipient-data-table" pagination={{ page: recipientDataPage, pageSize: recipientDataPageSize, pageSizeOptions: [10, 25, 50, 100, 250], onPageChange: setRecipientDataPage, onPageSizeChange: (pageSize) => { setRecipientDataPageSize(pageSize); setRecipientDataPage(1); } }} />
)}
{importOpen && ( setImportOpen(false)} onImport={applyRecipientImport} /> )}
); } type RecipientDataColumnContext = { settings: ApiSettings; campaignId: string; draft: Record; locked: boolean; filesModuleInstalled: boolean; fieldDefinitions: ReturnType; individualAttachmentBasePaths: ReturnType; zipConfig: AttachmentZipCollection; updateEntryAttachments: (index: number, attachments: AttachmentRule[]) => void; updateEntryField: (index: number, field: string, value: unknown) => void; }; function recipientDataColumns({ settings, campaignId, draft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn>[] { return [ { id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => {index + 1}, value: (_entry, index) => index + 1 }, { id: "recipient", header: "Recipient", width: 260, resizable: true, sortable: true, filterable: true, sticky: "start", render: (entry) => ( {firstRecipientEmail(entry) || "No To address"} {extraRecipientCount(entry) > 0 && +{extraRecipientCount(entry)}} ), value: firstRecipientEmail }, { id: "attachments", header: "Attachments", width: 180, filterable: true, render: (entry, index) => { const attachments = normalizeAttachmentRules(entry.attachments); return ( updateEntryAttachments(index, rules)} /> ); }, value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ") }, ...fieldDefinitions.map((field): DataGridColumn> => ({ 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 ( updateEntryField(index, field.name, value)} /> ); }, value: (entry) => String(asRecord(entry.fields)[field.name] ?? "") })) ]; } function firstRecipientEmail(entry: Record): string { return (addressesFromValue(entry.to)[0] ?? addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0])?.email ?? ""; } function extraRecipientCount(entry: Record): number { const count = addressesFromValue(entry.to).length; return Math.max(0, count - 1); }