feat: add governed postbox delivery and report hardening
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Dialog, DismissibleAlert, FormField, TableActionGroup, ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
CampaignPostboxCatalog,
|
||||
CampaignPostboxOrganizationUnit
|
||||
} from "../../../api/campaigns";
|
||||
import type { CampaignFieldDefinition } from "../utils/fieldDefinitions";
|
||||
import { asRecord } from "../utils/campaignView";
|
||||
|
||||
export type CampaignPostboxTarget = {
|
||||
id: string;
|
||||
mode: "direct" | "derived";
|
||||
label?: string;
|
||||
postbox_id?: string;
|
||||
address_key?: string;
|
||||
template_id?: string;
|
||||
organization_unit_id?: string;
|
||||
organization_unit_field?: string;
|
||||
organization_unit_match?: "id" | "slug";
|
||||
function_id?: string;
|
||||
function_field?: string;
|
||||
function_match?: "id" | "slug";
|
||||
context_key?: string;
|
||||
context_field?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
catalog: CampaignPostboxCatalog;
|
||||
fields: CampaignFieldDefinition[];
|
||||
targets: CampaignPostboxTarget[];
|
||||
locked?: boolean;
|
||||
merge?: boolean;
|
||||
showMerge?: boolean;
|
||||
onSave: (targets: CampaignPostboxTarget[], merge: boolean) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function PostboxTargetsDialog({
|
||||
open,
|
||||
title,
|
||||
catalog,
|
||||
fields,
|
||||
targets,
|
||||
locked = false,
|
||||
merge = true,
|
||||
showMerge = false,
|
||||
onSave,
|
||||
onClose
|
||||
}: Props) {
|
||||
const [draftTargets, setDraftTargets] = useState<CampaignPostboxTarget[]>(
|
||||
() => targets.map(normalizeTarget)
|
||||
);
|
||||
const [draftMerge, setDraftMerge] = useState(merge);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function updateTarget(index: number, patch: Partial<CampaignPostboxTarget>) {
|
||||
setDraftTargets((current) =>
|
||||
current.map((target, currentIndex) =>
|
||||
currentIndex === index ? normalizeTarget({ ...target, ...patch }) : target
|
||||
)
|
||||
);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function addTarget() {
|
||||
const firstPostbox = catalog.postboxes[0];
|
||||
setDraftTargets((current) => [
|
||||
...current,
|
||||
{
|
||||
id: newTargetId(),
|
||||
mode: "direct",
|
||||
postbox_id: firstPostbox?.id ?? ""
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
function save() {
|
||||
const prepared = draftTargets.map(normalizeTarget);
|
||||
const invalidIndex = prepared.findIndex((target) => !targetIsComplete(target));
|
||||
if (invalidIndex >= 0) {
|
||||
setError(`Target ${invalidIndex + 1} is incomplete.`);
|
||||
return;
|
||||
}
|
||||
onSave(prepared, draftMerge);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={title}
|
||||
className="campaign-postbox-target-modal"
|
||||
bodyClassName="campaign-postbox-target-body"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" disabled={locked} onClick={save}>Save</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="campaign-postbox-target-toolbar">
|
||||
{showMerge &&
|
||||
<ToggleSwitch
|
||||
label="Postbox target defaults"
|
||||
inactiveLabel="Replace defaults"
|
||||
activeLabel="Add to defaults"
|
||||
checked={draftMerge}
|
||||
disabled={locked}
|
||||
onChange={setDraftMerge}
|
||||
/>
|
||||
}
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={locked || catalog.postboxes.length + catalog.templates.length === 0}
|
||||
onClick={addTarget}
|
||||
>
|
||||
<Plus aria-hidden="true" />
|
||||
Add target
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" dismissible={false}>{error}</DismissibleAlert>}
|
||||
{draftTargets.length === 0 &&
|
||||
<div className="data-grid-empty">No Postbox targets configured.</div>
|
||||
}
|
||||
<div className="campaign-postbox-target-list">
|
||||
{draftTargets.map((target, index) =>
|
||||
<PostboxTargetRow
|
||||
key={target.id}
|
||||
target={target}
|
||||
index={index}
|
||||
count={draftTargets.length}
|
||||
catalog={catalog}
|
||||
fields={fields}
|
||||
locked={locked}
|
||||
onChange={(patch) => updateTarget(index, patch)}
|
||||
onMove={(offset) => setDraftTargets((current) => moveTarget(current, index, index + offset))}
|
||||
onRemove={() => setDraftTargets((current) => current.filter((_, currentIndex) => currentIndex !== index))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PostboxTargetRow({
|
||||
target,
|
||||
index,
|
||||
count,
|
||||
catalog,
|
||||
fields,
|
||||
locked,
|
||||
onChange,
|
||||
onMove,
|
||||
onRemove
|
||||
}: {
|
||||
target: CampaignPostboxTarget;
|
||||
index: number;
|
||||
count: number;
|
||||
catalog: CampaignPostboxCatalog;
|
||||
fields: CampaignFieldDefinition[];
|
||||
locked: boolean;
|
||||
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
|
||||
onMove: (offset: number) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const selectedUnit = catalog.organization_units.find(
|
||||
(unit) => unit.id === target.organization_unit_id
|
||||
);
|
||||
return (
|
||||
<section className="campaign-postbox-target-row">
|
||||
<div className="campaign-postbox-target-row-heading">
|
||||
<strong>Target {index + 1}</strong>
|
||||
<TableActionGroup actions={[
|
||||
{ id: "up", label: "Move target up", icon: <ArrowUp aria-hidden="true" />, disabled: locked || index === 0, onClick: () => onMove(-1) },
|
||||
{ id: "down", label: "Move target down", icon: <ArrowDown aria-hidden="true" />, disabled: locked || index === count - 1, onClick: () => onMove(1) },
|
||||
{ id: "remove", label: "Remove target", icon: <Trash2 aria-hidden="true" />, disabled: locked, variant: "danger", onClick: onRemove }
|
||||
]} />
|
||||
</div>
|
||||
<div className="form-grid compact responsive-form-grid campaign-postbox-target-grid">
|
||||
<FormField label="Target type">
|
||||
<select
|
||||
value={target.mode}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange(
|
||||
event.target.value === "derived"
|
||||
? derivedTargetDefaults(target.id, catalog, fields)
|
||||
: directTargetDefaults(target.id, catalog)
|
||||
)}
|
||||
>
|
||||
<option value="direct">Direct Postbox</option>
|
||||
<option value="derived">Derived from fields</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Label">
|
||||
<input
|
||||
value={target.label ?? ""}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ label: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
{target.mode === "direct" ?
|
||||
<DirectTargetFields
|
||||
target={target}
|
||||
catalog={catalog}
|
||||
locked={locked}
|
||||
onChange={onChange}
|
||||
/> :
|
||||
<DerivedTargetFields
|
||||
target={target}
|
||||
catalog={catalog}
|
||||
fields={fields}
|
||||
selectedUnit={selectedUnit}
|
||||
locked={locked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectTargetFields({
|
||||
target,
|
||||
catalog,
|
||||
locked,
|
||||
onChange
|
||||
}: {
|
||||
target: CampaignPostboxTarget;
|
||||
catalog: CampaignPostboxCatalog;
|
||||
locked: boolean;
|
||||
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
|
||||
}) {
|
||||
return (
|
||||
<FormField label="Postbox">
|
||||
<select
|
||||
value={target.postbox_id ?? ""}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ postbox_id: event.target.value })}
|
||||
>
|
||||
<option value="">Select a Postbox</option>
|
||||
{catalog.postboxes.map((postbox) =>
|
||||
<option key={postbox.id} value={postbox.id}>
|
||||
{postbox.name} ({postbox.address}){postbox.vacant ? " - vacant" : ""}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
function DerivedTargetFields({
|
||||
target,
|
||||
catalog,
|
||||
fields,
|
||||
selectedUnit,
|
||||
locked,
|
||||
onChange
|
||||
}: {
|
||||
target: CampaignPostboxTarget;
|
||||
catalog: CampaignPostboxCatalog;
|
||||
fields: CampaignFieldDefinition[];
|
||||
selectedUnit?: CampaignPostboxOrganizationUnit;
|
||||
locked: boolean;
|
||||
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
|
||||
}) {
|
||||
const organizationFields = useMemo(
|
||||
() => fields.filter((field) => field.type === "organization_unit" || field.type === "string"),
|
||||
[fields]
|
||||
);
|
||||
const functionFields = useMemo(
|
||||
() => fields.filter((field) => field.type === "organization_function" || field.type === "string"),
|
||||
[fields]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FormField label="Postbox template">
|
||||
<select
|
||||
value={target.template_id ?? ""}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ template_id: event.target.value })}
|
||||
>
|
||||
<option value="">Select a template</option>
|
||||
{catalog.templates.map((template) =>
|
||||
<option key={template.id} value={template.id}>{template.name}</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
<DerivedReferenceField
|
||||
label="Organization unit"
|
||||
fixedValue={target.organization_unit_id}
|
||||
fieldValue={target.organization_unit_field}
|
||||
match={target.organization_unit_match}
|
||||
fixedOptions={catalog.organization_units}
|
||||
fields={organizationFields}
|
||||
locked={locked}
|
||||
onFixed={(value) => onChange({
|
||||
organization_unit_id: value,
|
||||
organization_unit_field: undefined,
|
||||
function_id: undefined
|
||||
})}
|
||||
onField={(value) => onChange({
|
||||
organization_unit_id: undefined,
|
||||
organization_unit_field: value
|
||||
})}
|
||||
onMatch={(value) => onChange({ organization_unit_match: value })}
|
||||
/>
|
||||
<DerivedReferenceField
|
||||
label="Function"
|
||||
fixedValue={target.function_id}
|
||||
fieldValue={target.function_field}
|
||||
match={target.function_match}
|
||||
fixedOptions={selectedUnit?.functions ?? []}
|
||||
fields={functionFields}
|
||||
locked={locked}
|
||||
fixedDisabled={!target.organization_unit_id}
|
||||
onFixed={(value) => onChange({
|
||||
function_id: value,
|
||||
function_field: undefined
|
||||
})}
|
||||
onField={(value) => onChange({
|
||||
function_id: undefined,
|
||||
function_field: value
|
||||
})}
|
||||
onMatch={(value) => onChange({ function_match: value })}
|
||||
/>
|
||||
<FormField label="Context source">
|
||||
<select
|
||||
value={target.context_field ? "field" : target.context_key ? "fixed" : "none"}
|
||||
disabled={locked}
|
||||
onChange={(event) => {
|
||||
const mode = event.target.value;
|
||||
onChange({
|
||||
context_key: mode === "fixed" ? target.context_key ?? "" : undefined,
|
||||
context_field: mode === "field" ? target.context_field ?? fields[0]?.name ?? "" : undefined
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="none">No context</option>
|
||||
<option value="fixed">Fixed value</option>
|
||||
<option value="field">Campaign field</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{target.context_field !== undefined ?
|
||||
<FormField label="Context field">
|
||||
<select
|
||||
value={target.context_field}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ context_field: event.target.value })}
|
||||
>
|
||||
<option value="">Select a field</option>
|
||||
{fields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
|
||||
</select>
|
||||
</FormField> :
|
||||
target.context_key !== undefined &&
|
||||
<FormField label="Context value">
|
||||
<input
|
||||
value={target.context_key}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ context_key: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DerivedReferenceField({
|
||||
label,
|
||||
fixedValue,
|
||||
fieldValue,
|
||||
match,
|
||||
fixedOptions,
|
||||
fields,
|
||||
locked,
|
||||
fixedDisabled = false,
|
||||
onFixed,
|
||||
onField,
|
||||
onMatch
|
||||
}: {
|
||||
label: string;
|
||||
fixedValue?: string;
|
||||
fieldValue?: string;
|
||||
match?: "id" | "slug";
|
||||
fixedOptions: Array<{ id: string; name: string; slug: string }>;
|
||||
fields: CampaignFieldDefinition[];
|
||||
locked: boolean;
|
||||
fixedDisabled?: boolean;
|
||||
onFixed: (value: string) => void;
|
||||
onField: (value: string) => void;
|
||||
onMatch: (value: "id" | "slug") => void;
|
||||
}) {
|
||||
const usesField = fieldValue !== undefined;
|
||||
return (
|
||||
<>
|
||||
<FormField label={`${label} source`}>
|
||||
<select
|
||||
value={usesField ? "field" : "fixed"}
|
||||
disabled={locked}
|
||||
onChange={(event) =>
|
||||
event.target.value === "field"
|
||||
? onField(fieldValue ?? fields[0]?.name ?? "")
|
||||
: onFixed(fixedValue ?? fixedOptions[0]?.id ?? "")
|
||||
}
|
||||
>
|
||||
<option value="fixed">Fixed value</option>
|
||||
<option value="field">Campaign field</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{usesField ?
|
||||
<>
|
||||
<FormField label={`${label} field`}>
|
||||
<select value={fieldValue ?? ""} disabled={locked} onChange={(event) => onField(event.target.value)}>
|
||||
<option value="">Select a field</option>
|
||||
{fields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Field contains">
|
||||
<select value={match ?? "id"} disabled={locked} onChange={(event) => onMatch(event.target.value as "id" | "slug")}>
|
||||
<option value="id">Stable ID</option>
|
||||
<option value="slug">Key / slug</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</> :
|
||||
<FormField label={label}>
|
||||
<select value={fixedValue ?? ""} disabled={locked || fixedDisabled} onChange={(event) => onFixed(event.target.value)}>
|
||||
<option value="">Select {label.toLowerCase()}</option>
|
||||
{fixedOptions.map((option) => <option key={option.id} value={option.id}>{option.name} ({option.slug})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizePostboxTargets(value: unknown): CampaignPostboxTarget[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map(asRecord)
|
||||
.map((target) => normalizeTarget({
|
||||
id: String(target.id ?? ""),
|
||||
mode: target.mode === "derived" ? "derived" : "direct",
|
||||
label: optionalText(target.label),
|
||||
postbox_id: optionalText(target.postbox_id),
|
||||
address_key: optionalText(target.address_key),
|
||||
template_id: optionalText(target.template_id),
|
||||
organization_unit_id: optionalText(target.organization_unit_id),
|
||||
organization_unit_field: optionalText(target.organization_unit_field),
|
||||
organization_unit_match: target.organization_unit_match === "slug" ? "slug" : "id",
|
||||
function_id: optionalText(target.function_id),
|
||||
function_field: optionalText(target.function_field),
|
||||
function_match: target.function_match === "slug" ? "slug" : "id",
|
||||
context_key: optionalText(target.context_key),
|
||||
context_field: optionalText(target.context_field)
|
||||
}))
|
||||
.filter((target) => Boolean(target.id));
|
||||
}
|
||||
|
||||
function normalizeTarget(target: CampaignPostboxTarget): CampaignPostboxTarget {
|
||||
const common = {
|
||||
id: target.id || newTargetId(),
|
||||
mode: target.mode,
|
||||
...(target.label?.trim() ? { label: target.label.trim() } : {})
|
||||
};
|
||||
if (target.mode === "direct") {
|
||||
return {
|
||||
...common,
|
||||
mode: "direct",
|
||||
...(target.postbox_id ? { postbox_id: target.postbox_id } : {}),
|
||||
...(!target.postbox_id && target.address_key ? { address_key: target.address_key } : {})
|
||||
};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
mode: "derived",
|
||||
template_id: target.template_id ?? "",
|
||||
...(target.organization_unit_field !== undefined
|
||||
? {
|
||||
organization_unit_field: target.organization_unit_field,
|
||||
organization_unit_match: target.organization_unit_match ?? "id"
|
||||
}
|
||||
: { organization_unit_id: target.organization_unit_id ?? "" }),
|
||||
...(target.function_field !== undefined
|
||||
? {
|
||||
function_field: target.function_field,
|
||||
function_match: target.function_match ?? "id"
|
||||
}
|
||||
: { function_id: target.function_id ?? "" }),
|
||||
...(target.context_field !== undefined
|
||||
? { context_field: target.context_field }
|
||||
: target.context_key !== undefined
|
||||
? { context_key: target.context_key }
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
function targetIsComplete(target: CampaignPostboxTarget): boolean {
|
||||
if (!target.id) return false;
|
||||
if (target.mode === "direct") {
|
||||
return Boolean(target.postbox_id || target.address_key);
|
||||
}
|
||||
return Boolean(
|
||||
target.template_id
|
||||
&& (target.organization_unit_id || target.organization_unit_field)
|
||||
&& (target.function_id || target.function_field)
|
||||
);
|
||||
}
|
||||
|
||||
function directTargetDefaults(id: string, catalog: CampaignPostboxCatalog): CampaignPostboxTarget {
|
||||
return {
|
||||
id,
|
||||
mode: "direct",
|
||||
postbox_id: catalog.postboxes[0]?.id ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
function derivedTargetDefaults(
|
||||
id: string,
|
||||
catalog: CampaignPostboxCatalog,
|
||||
fields: CampaignFieldDefinition[]
|
||||
): CampaignPostboxTarget {
|
||||
const unit = catalog.organization_units[0];
|
||||
return {
|
||||
id,
|
||||
mode: "derived",
|
||||
template_id: catalog.templates[0]?.id ?? "",
|
||||
...(unit
|
||||
? {
|
||||
organization_unit_id: unit.id,
|
||||
function_id: unit.functions[0]?.id ?? ""
|
||||
}
|
||||
: {
|
||||
organization_unit_field: fields[0]?.name ?? "",
|
||||
organization_unit_match: "id",
|
||||
function_field: fields[0]?.name ?? "",
|
||||
function_match: "id"
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function moveTarget(
|
||||
targets: CampaignPostboxTarget[],
|
||||
source: number,
|
||||
destination: number
|
||||
): CampaignPostboxTarget[] {
|
||||
if (destination < 0 || destination >= targets.length) return targets;
|
||||
const next = [...targets];
|
||||
const [target] = next.splice(source, 1);
|
||||
next.splice(destination, 0, target);
|
||||
return next;
|
||||
}
|
||||
|
||||
function optionalText(value: unknown): string | undefined {
|
||||
const text = String(value ?? "").trim();
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
function newTargetId(): string {
|
||||
const suffix = typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `postbox-${suffix}`;
|
||||
}
|
||||
Reference in New Issue
Block a user