567 lines
21 KiB
TypeScript
567 lines
21 KiB
TypeScript
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<RecipientImportMode>("append");
|
|
const [requestedChannels, setRequestedChannels] = useState<RequestedChannel[]>(channelOptions.map((item) => item.id));
|
|
const [parameters, setParameters] = useState<Record<string, unknown>>({});
|
|
const [preview, setPreview] = useState<CampaignDistributionListExpansion | null>(null);
|
|
const [routeSelections, setRouteSelections] = useState<DistributionRouteSelections>({});
|
|
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<PreviewRow[]>(() => [
|
|
...(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 (
|
|
<Dialog
|
|
open
|
|
title="Import a Distribution List"
|
|
className="recipient-import-modal"
|
|
bodyClassName="recipient-import-body"
|
|
closeDisabled={loading}
|
|
closeOnBackdrop={!loading}
|
|
onClose={onCancel}
|
|
footer={
|
|
<>
|
|
<Button onClick={onCancel} disabled={loading}>Cancel</Button>
|
|
<Button
|
|
variant="primary"
|
|
disabled={loading || !preview || preview.recipients.length === 0 || preview.truncated || unresolvedRoutes.length > 0}
|
|
onClick={() => void freezeAndImport()}
|
|
>
|
|
Freeze and import
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="address-source-import-controls">
|
|
<input
|
|
type="search"
|
|
value={sourceQuery}
|
|
disabled={loading || sources.length === 0}
|
|
placeholder="Search Distribution Lists"
|
|
aria-label="Search Distribution Lists"
|
|
onChange={(event) => setSourceQuery(event.target.value)}
|
|
/>
|
|
<FormField label="Import mode">
|
|
<SegmentedControl
|
|
ariaLabel="Distribution List import mode"
|
|
value={mode}
|
|
onChange={setMode}
|
|
size="content"
|
|
width="inline"
|
|
disabled={loading}
|
|
options={[
|
|
{ id: "append", label: "Append" },
|
|
{ id: "replace", label: "Replace" }
|
|
]}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
|
|
<div className="campaign-header-grid recipient-import-upload-grid">
|
|
<div className="address-source-picker" role="radiogroup" aria-label="Distribution List">
|
|
{filteredSources.map((source) => (
|
|
<button
|
|
type="button"
|
|
key={source.id}
|
|
className={`address-source-option ${source.id === selectedSourceId ? "is-selected" : ""}`}
|
|
disabled={loading}
|
|
role="radio"
|
|
aria-checked={source.id === selectedSourceId}
|
|
onClick={() => setSelectedSourceId(source.id)}
|
|
>
|
|
<span className="address-source-option-main">
|
|
<strong>{source.name}</strong>
|
|
<span>{source.definition_kind} · revision {source.revision}</span>
|
|
</span>
|
|
<span className="address-source-option-meta">
|
|
<span>{source.entry_count} entries</span>
|
|
{source.stale && <span>Stale source</span>}
|
|
</span>
|
|
</button>
|
|
))}
|
|
{sources.length > 0 && filteredSources.length === 0 && (
|
|
<div className="empty-state compact-empty">No Distribution Lists match the search.</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="distribution-list-import-settings">
|
|
<fieldset className="form-section compact-form-section">
|
|
<legend>Requested channels</legend>
|
|
{channelOptions.map((channel) => (
|
|
<ToggleSwitch
|
|
key={channel.id}
|
|
label={channel.label}
|
|
checked={requestedChannels.includes(channel.id)}
|
|
disabled={loading}
|
|
onChange={(checked) => toggleChannel(channel.id, checked)}
|
|
/>
|
|
))}
|
|
</fieldset>
|
|
{selectedSource?.parameters.map((parameter) => (
|
|
<DistributionParameterField
|
|
key={parameter.key}
|
|
parameter={parameter}
|
|
value={parameters[parameter.key]}
|
|
disabled={loading}
|
|
onChange={(value) => updateParameter(parameter, value)}
|
|
/>
|
|
))}
|
|
<Button
|
|
variant="primary"
|
|
disabled={loading || !selectedSourceId || requestedChannels.length === 0}
|
|
onClick={() => void loadPreview()}
|
|
>
|
|
{preview ? "Refresh preview" : "Preview expansion"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert>}
|
|
{loading && <DismissibleAlert tone="info" compact dismissible={false}>Resolving Distribution List...</DismissibleAlert>}
|
|
{!loading && sources.length === 0 && (
|
|
<DismissibleAlert tone="info" dismissible={false}>
|
|
No Distribution Lists are available to this Campaign.
|
|
</DismissibleAlert>
|
|
)}
|
|
{preview && (
|
|
<>
|
|
<DescriptionList columns={5} collapseAt="never" className="recipient-import-summary">
|
|
<div><dt>List</dt><dd>{preview.source.name}</dd></div>
|
|
<div><dt>Revision</dt><dd>{preview.source.revision}</dd></div>
|
|
<div><dt>Included</dt><dd>{preview.recipients.length}</dd></div>
|
|
<div><dt>Excluded</dt><dd>{preview.excluded.length}</dd></div>
|
|
<div><dt>Providers</dt><dd>{preview.provider_evidence.length}</dd></div>
|
|
<div><dt>Route decisions</dt><dd>{unresolvedRoutes.length ? `${unresolvedRoutes.length} required` : "Complete"}</dd></div>
|
|
<div><dt>State</dt><dd>{preview.stale ? "Stale" : preview.truncated ? "Truncated" : "Current"}</dd></div>
|
|
</DescriptionList>
|
|
{preview.stale && (
|
|
<DismissibleAlert tone="warning" compact dismissible={false}>
|
|
At least one provider result is stale. Review its diagnostics before freezing this expansion.
|
|
</DismissibleAlert>
|
|
)}
|
|
{preview.truncated && (
|
|
<DismissibleAlert tone="danger" compact dismissible={false}>
|
|
The expansion reached a safety limit and cannot be frozen from this dialog.
|
|
</DismissibleAlert>
|
|
)}
|
|
{unresolvedRoutes.length > 0 && (
|
|
<DismissibleAlert tone="warning" compact dismissible={false}>
|
|
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.
|
|
</DismissibleAlert>
|
|
)}
|
|
{preview.diagnostics.map((diagnostic) => (
|
|
<DismissibleAlert
|
|
key={`${diagnostic.code}:${diagnostic.message}`}
|
|
tone={diagnostic.severity === "error" ? "danger" : diagnostic.severity === "warning" ? "warning" : "info"}
|
|
compact
|
|
dismissible={false}
|
|
>
|
|
{diagnostic.message}
|
|
</DismissibleAlert>
|
|
))}
|
|
<DistributionPreviewGrid
|
|
rows={previewRows}
|
|
routeSelections={routeSelections}
|
|
onRouteChange={(recipientKey, selection) => setRouteSelections((current) => ({
|
|
...current,
|
|
[recipientKey]: selection
|
|
}))}
|
|
page={previewPage}
|
|
pageSize={previewPageSize}
|
|
onPageChange={setPreviewPage}
|
|
onPageSizeChange={(pageSize) => {
|
|
setPreviewPageSize(pageSize);
|
|
setPreviewPage(1);
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
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 <ToggleSwitch label={label} checked={Boolean(value)} disabled={disabled} onChange={onChange} />;
|
|
}
|
|
if (parameter.allowed_values.length > 0) {
|
|
return (
|
|
<FormField label={parameter.required ? `${label} *` : label} help={parameter.description || undefined}>
|
|
<select value={scalarInputValue(value)} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
|
|
{!parameter.required && <option value="">Any</option>}
|
|
{parameter.allowed_values.map((option) => (
|
|
<option key={String(option)} value={String(option)}>{String(option)}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
);
|
|
}
|
|
const inputType = parameter.value_type === "date"
|
|
? "date"
|
|
: parameter.value_type === "datetime"
|
|
? "datetime-local"
|
|
: ["integer", "number"].includes(parameter.value_type)
|
|
? "number"
|
|
: "text";
|
|
return (
|
|
<FormField label={parameter.required ? `${label} *` : label} help={parameter.description || undefined}>
|
|
<input
|
|
type={inputType}
|
|
value={scalarInputValue(value)}
|
|
disabled={disabled}
|
|
min={parameter.minimum ?? undefined}
|
|
max={parameter.maximum ?? undefined}
|
|
pattern={parameter.pattern ?? undefined}
|
|
placeholder={parameter.value_type === "string_list" ? "Value 1, Value 2" : undefined}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
/>
|
|
</FormField>
|
|
);
|
|
}
|
|
|
|
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<PreviewRow>[] = [
|
|
{ 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) => (
|
|
<RouteSelect
|
|
row={row}
|
|
value={routeSelections[row.recipient_key]?.primaryTargetKey ?? ""}
|
|
onChange={(value) => 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 (
|
|
<RouteSelect
|
|
row={row}
|
|
value={selection?.fallbackTargetKey ?? ""}
|
|
primaryTargetKey={selection?.primaryTargetKey ?? ""}
|
|
fallback
|
|
onChange={(value) => 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 (
|
|
<DataGrid
|
|
id="campaign-distribution-list-preview"
|
|
rows={rows}
|
|
columns={columns}
|
|
getRowKey={(row) => `${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 (
|
|
<select
|
|
value={value}
|
|
disabled={!row.included || (fallback && !primary)}
|
|
aria-label={`${fallback ? "Fallback" : "Primary route"} for ${row.display_name || row.recipient_key}`}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
>
|
|
<option value="">{fallback ? "No fallback" : "Select route"}</option>
|
|
{options.map((candidate) => (
|
|
<option key={candidate.target_key} value={candidate.target_key}>
|
|
{routeLabel(candidate.channel)}: {candidate.target}{candidate.preferred ? " (preferred)" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
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)}`;
|
|
}
|