import { DescriptionList } from "@govoplan/core-webui"; import { useEffect, useMemo, useState } from "react"; import { Button, DataGrid, Dialog, DismissibleAlert, FormField, SegmentedControl, ToggleSwitch, type DataGridColumn } from "@govoplan/core-webui"; import type { ApiSettings } from "../../../types"; import { previewCampaignRecipientDistributionList, snapshotCampaignRecipientDistributionList, type CampaignDistributionListExpansion, type CampaignDistributionListExpansionInput, type CampaignDistributionListParameter, type CampaignDistributionListSource, type CampaignDistributionRecipient } from "../../../api/campaigns"; import type { RecipientImportMode } from "../utils/bulkImport"; import { usableChannelSummary, type DistributionRouteSelection, type DistributionRouteSelections } from "../utils/distributionListImport"; type RequestedChannel = "email" | "postal" | "internal_mail" | "portal"; type PreviewRow = CampaignDistributionRecipient & { included: boolean }; const channelOptions: Array<{id: RequestedChannel;label: string;}> = [ { id: "email", label: "Email" }, { id: "postal", label: "Postal" }, { id: "internal_mail", label: "Internal mail" }, { id: "portal", label: "Portal" } ]; export default function DistributionListImportDialog({ settings, campaignId, sources, initialSourceId = "", onCancel, onImport }: { settings: ApiSettings; campaignId: string; sources: CampaignDistributionListSource[]; initialSourceId?: string; onCancel: () => void; onImport: ( snapshot: CampaignDistributionListExpansion, mode: RecipientImportMode, routeSelections: DistributionRouteSelections ) => void; }) { const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.id || ""); const [sourceQuery, setSourceQuery] = useState(""); const [mode, setMode] = useState("append"); const [requestedChannels, setRequestedChannels] = useState(channelOptions.map((item) => item.id)); const [parameters, setParameters] = useState>({}); const [preview, setPreview] = useState(null); const [routeSelections, setRouteSelections] = useState({}); const [previewPage, setPreviewPage] = useState(1); const [previewPageSize, setPreviewPageSize] = useState(50); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const selectedSource = useMemo( () => sources.find((source) => source.id === selectedSourceId) ?? null, [selectedSourceId, sources] ); const filteredSources = useMemo(() => { const query = sourceQuery.trim().toLowerCase(); if (!query) return sources; return sources.filter((source) => [source.name, source.description, source.definition_kind] .filter(Boolean) .some((value) => String(value).toLowerCase().includes(query))); }, [sourceQuery, sources]); const previewRows = useMemo(() => [ ...(preview?.recipients ?? []).map((recipient) => ({ ...recipient, included: true })), ...(preview?.excluded ?? []).map((recipient) => ({ ...recipient, included: false })) ], [preview]); const unresolvedRoutes = useMemo( () => preview?.recipients.filter((recipient) => !validRouteSelection(recipient, routeSelections[recipient.recipient_key])) ?? [], [preview, routeSelections] ); useEffect(() => { if (filteredSources.some((source) => source.id === selectedSourceId)) return; setSelectedSourceId(filteredSources[0]?.id ?? ""); }, [filteredSources, selectedSourceId]); useEffect(() => { if (!selectedSource) { setParameters({}); setPreview(null); setRouteSelections({}); return; } setParameters(Object.fromEntries( selectedSource.parameters .filter((parameter) => parameter.default !== null && parameter.default !== undefined) .map((parameter) => [parameter.key, parameter.default]) )); setPreview(null); setRouteSelections({}); setPreviewPage(1); setError(""); }, [selectedSource?.id, selectedSource?.revision_id]); function toggleChannel(channel: RequestedChannel, checked: boolean) { setRequestedChannels((current) => checked ? [...new Set([...current, channel])] : current.filter((item) => item !== channel)); setPreview(null); setRouteSelections({}); } function updateParameter(parameter: CampaignDistributionListParameter, value: unknown) { setParameters((current) => ({ ...current, [parameter.key]: normalizeParameterValue(parameter, value) })); setPreview(null); setRouteSelections({}); } function requestPayload(idempotencyKey?: string): CampaignDistributionListExpansionInput { return { list_id: selectedSourceId, revision: selectedSource?.revision ?? null, purpose: "campaign_delivery", requested_channels: requestedChannels, parameters, idempotency_key: idempotencyKey ?? null }; } async function loadPreview() { if (!selectedSourceId || requestedChannels.length === 0) return; setLoading(true); setError(""); try { const result = await previewCampaignRecipientDistributionList(settings, campaignId, requestPayload()); setPreview(result); setRouteSelections(defaultRouteSelections(result.recipients)); setPreviewPage(1); } catch (reason) { setPreview(null); setError(reason instanceof Error ? reason.message : String(reason)); } finally { setLoading(false); } } async function freezeAndImport() { if (!preview || !selectedSourceId) return; setLoading(true); setError(""); try { const idempotencyKey = `campaign:${campaignId}:distribution:${selectedSourceId}:${randomId()}`; const snapshot = await snapshotCampaignRecipientDistributionList( settings, campaignId, requestPayload(idempotencyKey) ); const unresolved = snapshot.recipients.filter( (recipient) => !validRouteSelection(recipient, routeSelections[recipient.recipient_key]) ); if (unresolved.length > 0) { setPreview(snapshot); setError(`${unresolved.length} recipient route${unresolved.length === 1 ? "" : "s"} must be selected again because the frozen expansion changed.`); return; } onImport(snapshot, mode, routeSelections); } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); } finally { setLoading(false); } } return ( } >
setSourceQuery(event.target.value)} />
{filteredSources.map((source) => ( ))} {sources.length > 0 && filteredSources.length === 0 && (
No Distribution Lists match the search.
)}
Requested channels {channelOptions.map((channel) => ( toggleChannel(channel.id, checked)} /> ))}
{selectedSource?.parameters.map((parameter) => ( updateParameter(parameter, value)} /> ))}
{error && {error}} {loading && Resolving Distribution List...} {!loading && sources.length === 0 && ( No Distribution Lists are available to this Campaign. )} {preview && ( <>
List
{preview.source.name}
Revision
{preview.source.revision}
Included
{preview.recipients.length}
Excluded
{preview.excluded.length}
Providers
{preview.provider_evidence.length}
Route decisions
{unresolvedRoutes.length ? `${unresolvedRoutes.length} required` : "Complete"}
State
{preview.stale ? "Stale" : preview.truncated ? "Truncated" : "Current"}
{preview.stale && ( At least one provider result is stale. Review its diagnostics before freezing this expansion. )} {preview.truncated && ( The expansion reached a safety limit and cannot be frozen from this dialog. )} {unresolvedRoutes.length > 0 && ( Select one delivery route for every included recipient. A fallback is optional and is only used when the primary channel rejects before accepting the delivery. )} {preview.diagnostics.map((diagnostic) => ( {diagnostic.message} ))} setRouteSelections((current) => ({ ...current, [recipientKey]: selection }))} page={previewPage} pageSize={previewPageSize} onPageChange={setPreviewPage} onPageSizeChange={(pageSize) => { setPreviewPageSize(pageSize); setPreviewPage(1); }} /> )}
); } function DistributionParameterField({ parameter, value, disabled, onChange }: { parameter: CampaignDistributionListParameter; value: unknown; disabled: boolean; onChange: (value: unknown) => void; }) { const label = parameter.label || parameter.key; if (parameter.value_type === "boolean") { return ; } if (parameter.allowed_values.length > 0) { return ( ); } const inputType = parameter.value_type === "date" ? "date" : parameter.value_type === "datetime" ? "datetime-local" : ["integer", "number"].includes(parameter.value_type) ? "number" : "text"; return ( onChange(event.target.value)} /> ); } function DistributionPreviewGrid({ rows, routeSelections, onRouteChange, page, pageSize, onPageChange, onPageSizeChange }: { rows: PreviewRow[]; routeSelections: DistributionRouteSelections; onRouteChange: (recipientKey: string, selection: DistributionRouteSelection) => void; page: number; pageSize: number; onPageChange: (page: number) => void; onPageSizeChange: (pageSize: number) => void; }) { const columns: DataGridColumn[] = [ { id: "name", header: "Recipient", width: "minmax(180px, 1fr)", value: (row) => row.display_name || row.recipient_key }, { id: "result", header: "Decision", width: 120, value: (row) => row.included ? "Included" : row.status }, { id: "primary", header: "Primary route", width: "minmax(210px, 1fr)", render: (row) => ( onRouteChange(row.recipient_key, { primaryTargetKey: value, fallbackTargetKey: "" })} /> ), filterValue: (row) => usableChannelSummary(row.channels) }, { id: "fallback", header: "Fallback", width: "minmax(210px, 1fr)", render: (row) => { const selection = routeSelections[row.recipient_key]; return ( onRouteChange(row.recipient_key, { primaryTargetKey: selection?.primaryTargetKey ?? "", fallbackTargetKey: value })} /> ); } }, { id: "source", header: "Source entries", width: "minmax(160px, 0.8fr)", value: (row) => row.source_entry_ids.join(", ") }, { id: "reason", header: "Explanation", width: "minmax(220px, 1.2fr)", value: (row) => row.explanations.map((item) => item.message).join(" · ") } ]; return ( `${row.included ? "included" : "excluded"}:${row.recipient_key}`} emptyText="No recipients resolved from this Distribution List." className="recipient-table-wrap" pagination={{ mode: "client", page, pageSize, pageSizeOptions: [25, 50, 100, 250], onPageChange, onPageSizeChange }} /> ); } function RouteSelect({ row, value, primaryTargetKey = "", fallback = false, onChange }: { row: PreviewRow; value: string; primaryTargetKey?: string; fallback?: boolean; onChange: (value: string) => void; }) { const usable = row.channels.filter((candidate) => candidate.status === "usable"); const primary = usable.find((candidate) => candidate.target_key === primaryTargetKey) ?? null; const options = fallback ? usable.filter((candidate) => fallbackAllowed(primary?.channel, candidate.channel)) : usable; return ( ); } function defaultRouteSelections(recipients: CampaignDistributionRecipient[]): DistributionRouteSelections { return Object.fromEntries(recipients.map((recipient) => { const usable = recipient.channels.filter((candidate) => candidate.status === "usable"); const preferred = usable.filter((candidate) => candidate.preferred); const primary = preferred.length === 1 ? preferred[0] : usable.length === 1 ? usable[0] : null; return [recipient.recipient_key, { primaryTargetKey: primary?.target_key ?? "", fallbackTargetKey: "" }]; })); } function validRouteSelection( recipient: CampaignDistributionRecipient, selection: DistributionRouteSelection | undefined ): boolean { if (!selection?.primaryTargetKey) return false; const usable = recipient.channels.filter((candidate) => candidate.status === "usable"); const primary = usable.find((candidate) => candidate.target_key === selection.primaryTargetKey); if (!primary) return false; if (!selection.fallbackTargetKey) return true; const fallback = usable.find((candidate) => candidate.target_key === selection.fallbackTargetKey); return Boolean(fallback && fallbackAllowed(primary.channel, fallback.channel)); } function fallbackAllowed(primary: string | undefined, fallback: string): boolean { if (primary === "email") return fallback === "portal" || fallback === "postal" || fallback === "internal_mail"; if (primary === "portal") return fallback === "email" || fallback === "postal" || fallback === "internal_mail"; return false; } function routeLabel(channel: string): string { if (channel === "email") return "Mail"; if (channel === "portal") return "Postbox"; if (channel === "internal_mail") return "Internal mail"; if (channel === "postal") return "Postal"; return channel; } function normalizeParameterValue(parameter: CampaignDistributionListParameter, value: unknown): unknown { if (value === "" || value === null || value === undefined) return null; if (parameter.value_type === "integer") return Number.parseInt(String(value), 10); if (parameter.value_type === "number") return Number(String(value)); if (parameter.value_type === "string_list") return String(value).split(",").map((item) => item.trim()).filter(Boolean); return value; } function scalarInputValue(value: unknown): string { if (Array.isArray(value)) return value.join(", "); return value === null || value === undefined ? "" : String(value); } function randomId(): string { return globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; }