Files
govoplan-postbox/webui/src/features/postbox/PostboxAdminPanel.tsx
T

1630 lines
60 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import {
Archive,
Boxes,
Building2,
Eye,
Inbox,
Pencil,
Plus,
RefreshCw,
Rocket,
Save,
Trash2
} from "lucide-react";
import {
ActionBlockerHint,
AdminPageLayout,
Button,
ConfirmDialog,
Dialog,
DocumentationHelpLink,
FormField,
IconButton,
MetricCard,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
ToggleSwitch,
i18nMessage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings
} from "@govoplan/core-webui";
import {
archivePostbox,
createExactPostbox,
createPostboxTemplate,
listAdminPostboxes,
listPostboxOrganizationTargets,
listPostboxTemplates,
materializePostboxTemplate,
previewPostboxTemplate,
publishPostboxTemplate,
retirePostboxTemplate,
revisePostboxTemplate,
type PostboxDirectoryItem,
type PostboxExactCreatePayload,
type PostboxOrganizationFunction,
type PostboxOrganizationStructure,
type PostboxOrganizationUnit,
type PostboxRoutingPolicy,
type PostboxTemplate,
type PostboxTemplateCreatePayload,
type PostboxTemplatePreview,
type PostboxTemplateRevisionPayload
} from "../../api/postbox";
import {
POSTBOX_ADMIN_DOCUMENTATION,
POSTBOX_FIELD_DOCUMENTATION,
POSTBOX_INTERFACE_I18N,
postboxBusyReason
} from "./interfacePatterns";
type AdminMode = "templates" | "postboxes";
type TemplateDraft = PostboxTemplateCreatePayload & { templateId: string };
type ExactDraft = PostboxExactCreatePayload;
type MaterializeDraft = {
templateId: string;
organization_unit_id: string;
function_id: string;
context_key: string;
};
const routingDefaults = (): PostboxRoutingPolicy => ({
linked_copy: {
enabled: false,
structure_id: null,
relation_type_ids: [],
max_depth: 1,
stop_unit_id: null,
stop_unit_type_id: null,
target_function_type_id: null,
target_template_id: null,
fanout: "nearest",
allowed_classifications: ["internal"],
allowed_producer_modules: [],
require_expiry: false,
max_retention_days: null
},
attention: {
mode: "none",
delay_minutes: null
},
shared_visibility: {
mode: "none"
}
});
const templateDefaults = (): TemplateDraft => ({
templateId: "",
slug: "",
name: "",
description: "",
function_type_id: null,
scope_kind: "tenant",
scope_id: null,
scope_structure_id: null,
scope_relation_type_ids: [],
name_pattern: "{unit_name} / {function_name}",
address_pattern: "{template_slug}.{unit_slug}.{function_slug}",
classification: "internal",
allow_vacant_delivery: true,
portal_visible: false,
routing_policy: routingDefaults()
});
const exactDefaults = (): ExactDraft => ({
name: "",
description: "",
organization_unit_id: "",
function_id: "",
address_key: "",
classification: "internal",
portal_visible: false
});
export default function PostboxAdminPanel({
settings,
canManageBindings,
canManageTemplates
}: {
settings: ApiSettings;
canManageBindings: boolean;
canManageTemplates: boolean;
}) {
const [mode, setMode] = useState<AdminMode>(
canManageTemplates ? "templates" : "postboxes"
);
const [templates, setTemplates] = useState<PostboxTemplate[]>([]);
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
const [units, setUnits] = useState<PostboxOrganizationUnit[]>([]);
const [structures, setStructures] = useState<PostboxOrganizationStructure[]>([]);
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [selectedPostboxId, setSelectedPostboxId] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
const [templateDraft, setTemplateDraft] = useState<TemplateDraft>(templateDefaults);
const [templateBaseline, setTemplateBaseline] = useState<TemplateDraft>(templateDefaults);
const [templatePreview, setTemplatePreview] = useState<PostboxTemplatePreview | null>(null);
const [templatePreviewLoading, setTemplatePreviewLoading] = useState(false);
const [exactDialogOpen, setExactDialogOpen] = useState(false);
const [exactDraft, setExactDraft] = useState<ExactDraft>(exactDefaults);
const [exactBaseline, setExactBaseline] = useState<ExactDraft>(exactDefaults);
const [materializeDialogOpen, setMaterializeDialogOpen] = useState(false);
const [materializeDraft, setMaterializeDraft] = useState<MaterializeDraft>({
templateId: "",
organization_unit_id: "",
function_id: "",
context_key: ""
});
const [materializeBaseline, setMaterializeBaseline] = useState<MaterializeDraft>({
templateId: "",
organization_unit_id: "",
function_id: "",
context_key: ""
});
const [archiveTarget, setArchiveTarget] = useState<PostboxDirectoryItem | null>(null);
const [retireTarget, setRetireTarget] = useState<PostboxTemplate | null>(null);
const { requestDiscard } = useUnsavedChanges();
const selectedTemplate = useMemo(
() => templates.find((template) => template.id === selectedTemplateId) ?? templates[0] ?? null,
[templates, selectedTemplateId]
);
const selectedPostbox = useMemo(
() => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? postboxes[0] ?? null,
[postboxes, selectedPostboxId]
);
const functionTypes = useMemo(() => {
const values = new Map<string, string>();
for (const unit of units) {
for (const fn of unit.functions) {
if (fn.function_type_id && !values.has(fn.function_type_id)) {
values.set(fn.function_type_id, fn.name);
}
}
}
return [...values].map(([id, name]) => ({ id, name }));
}, [units]);
const unitTypes = useMemo(() => {
const values = new Map<string, string>();
for (const unit of units) {
if (unit.unit_type_id && !values.has(unit.unit_type_id)) {
values.set(unit.unit_type_id, unit.name);
}
}
return [...values].map(([id, example]) => ({ id, example }));
}, [units]);
const templateDirty = templateDialogOpen && draftKey(templateDraft) !== draftKey(templateBaseline);
const exactDirty = exactDialogOpen && draftKey(exactDraft) !== draftKey(exactBaseline);
const materializeDirty = materializeDialogOpen && draftKey(materializeDraft) !== draftKey(materializeBaseline);
useUnsavedDraftGuard({
dirty: templateDirty || exactDirty || materializeDirty,
title: "Unsaved Postbox administration draft",
message: "Save or discard the open Postbox administration draft before leaving this surface.",
onSave: saveActiveDraft,
onDiscard: discardAdminDraft
});
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const [nextTemplates, nextPostboxes, organizationTargets] = await Promise.all([
canManageTemplates ? listPostboxTemplates(settings) : Promise.resolve([]),
canManageBindings ? listAdminPostboxes(settings) : Promise.resolve([]),
listPostboxOrganizationTargets(settings)
]);
setTemplates(nextTemplates);
setPostboxes(nextPostboxes);
setUnits(organizationTargets.units);
setStructures(organizationTargets.structures);
setSelectedTemplateId((current) =>
current && nextTemplates.some((template) => template.id === current)
? current
: nextTemplates[0]?.id ?? ""
);
setSelectedPostboxId((current) =>
current && nextPostboxes.some((postbox) => postbox.id === current)
? current
: nextPostboxes[0]?.id ?? ""
);
} catch (loadError) {
setError(errorMessage(loadError));
} finally {
setLoading(false);
}
}, [canManageBindings, canManageTemplates, settings]);
useEffect(() => {
void load();
}, [load]);
function openNewTemplate() {
const next = templateDefaults();
setTemplatePreview(null);
setTemplateDraft(next);
setTemplateBaseline(next);
setTemplateDialogOpen(true);
}
function openTemplateRevision(template: PostboxTemplate) {
const revision = currentRevision(template);
if (!revision) return;
const next = {
templateId: template.id,
slug: template.slug,
name: template.name,
description: template.description || "",
function_type_id: revision.function_type_id ?? null,
scope_kind: revision.scope_kind,
scope_id: revision.scope_id ?? null,
scope_structure_id: revision.scope_structure_id ?? null,
scope_relation_type_ids: revision.scope_relation_type_ids ?? [],
name_pattern: revision.name_pattern,
address_pattern: revision.address_pattern,
classification: revision.classification,
allow_vacant_delivery: revision.allow_vacant_delivery,
portal_visible: revision.portal_visible,
routing_policy: revision.routing_policy ?? routingDefaults()
};
setTemplatePreview(null);
setTemplateDraft(next);
setTemplateBaseline(next);
setTemplateDialogOpen(true);
}
async function saveTemplate(): Promise<boolean> {
setBusy(true);
setError("");
setSuccess("");
try {
if (templateDraft.templateId) {
const currentTemplate = templates.find(
(item) => item.id === templateDraft.templateId
);
if (!currentTemplate) throw new Error("The template is no longer available.");
await revisePostboxTemplate(
settings,
currentTemplate,
revisionPayload(templateDraft)
);
setSuccess("A new immutable template revision was created.");
} else {
await createPostboxTemplate(settings, {
slug: templateDraft.slug,
name: templateDraft.name,
description: templateDraft.description || null,
...revisionPayload(templateDraft)
});
setSuccess("Postbox template created as a draft.");
}
setTemplateBaseline(templateDraft);
setTemplateDialogOpen(false);
await load();
return true;
} catch (actionError) {
setError(errorMessage(actionError));
return false;
} finally {
setBusy(false);
}
}
async function previewTemplate() {
setTemplatePreviewLoading(true);
setError("");
try {
const preview = await previewPostboxTemplate(settings, {
slug: templateDraft.slug,
name: templateDraft.name,
description: templateDraft.description || null,
...revisionPayload(templateDraft),
template_id: templateDraft.templateId || null,
limit: 200
});
setTemplatePreview(preview);
} catch (actionError) {
setTemplatePreview(null);
setError(errorMessage(actionError));
} finally {
setTemplatePreviewLoading(false);
}
}
async function publishSelected() {
if (!selectedTemplate) return;
setBusy(true);
setError("");
setSuccess("");
try {
await publishPostboxTemplate(
settings,
selectedTemplate,
selectedTemplate.current_revision
);
setSuccess(i18nMessage("i18n:govoplan-postbox.published_revision_message", {
revision: selectedTemplate.current_revision
}));
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
function openExact() {
const unit = units.find((item) => item.functions.length);
const next = {
...exactDefaults(),
organization_unit_id: unit?.id ?? "",
function_id: unit?.functions[0]?.id ?? ""
};
setExactDraft(next);
setExactBaseline(next);
setExactDialogOpen(true);
}
async function saveExact(): Promise<boolean> {
setBusy(true);
setError("");
setSuccess("");
try {
await createExactPostbox(settings, {
...exactDraft,
description: exactDraft.description || null,
address_key: exactDraft.address_key || null
});
setExactBaseline(exactDraft);
setExactDialogOpen(false);
setSuccess("Exact function-bound Postbox created.");
await load();
return true;
} catch (actionError) {
setError(errorMessage(actionError));
return false;
} finally {
setBusy(false);
}
}
function openMaterialize(template: PostboxTemplate) {
const revision = currentRevision(template);
const compatible = compatibleTargets(units, revision?.function_type_id);
const unit = compatible[0];
const next = {
templateId: template.id,
organization_unit_id: unit?.id ?? "",
function_id: unit?.functions[0]?.id ?? "",
context_key: ""
};
setMaterializeDraft(next);
setMaterializeBaseline(next);
setMaterializeDialogOpen(true);
}
async function materialize(): Promise<boolean> {
setBusy(true);
setError("");
setSuccess("");
try {
await materializePostboxTemplate(settings, materializeDraft.templateId, {
organization_unit_id: materializeDraft.organization_unit_id,
function_id: materializeDraft.function_id,
context_key: materializeDraft.context_key || null
});
setMaterializeBaseline(materializeDraft);
setMaterializeDialogOpen(false);
setSuccess("Stable Postbox address resolved and materialized.");
await load();
return true;
} catch (actionError) {
setError(errorMessage(actionError));
return false;
} finally {
setBusy(false);
}
}
async function confirmArchive() {
if (!archiveTarget) return;
setBusy(true);
setError("");
setSuccess("");
try {
await archivePostbox(settings, archiveTarget);
setSuccess("Postbox archived. Messages and delivery evidence were retained.");
setArchiveTarget(null);
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
async function confirmRetire() {
if (!retireTarget) return;
setSelectedTemplateId(retireTarget.id);
setBusy(true);
setError("");
setSuccess("");
try {
await retirePostboxTemplate(settings, retireTarget);
setSuccess("Postbox template retired. Existing addresses remain durable.");
setRetireTarget(null);
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
function saveActiveDraft(): Promise<boolean> {
if (templateDirty) return saveTemplate();
if (exactDirty) return saveExact();
if (materializeDirty) return materialize();
return Promise.resolve(true);
}
function discardAdminDraft() {
if (templateDialogOpen) {
setTemplateDraft(templateBaseline);
setTemplateDialogOpen(false);
}
if (exactDialogOpen) {
setExactDraft(exactBaseline);
setExactDialogOpen(false);
}
if (materializeDialogOpen) {
setMaterializeDraft(materializeBaseline);
setMaterializeDialogOpen(false);
}
}
function closeTemplateDialog() {
const close = () => {
setTemplateDraft(templateBaseline);
setTemplateDialogOpen(false);
};
if (templateDirty) requestDiscard(close);
else close();
}
function closeExactDialog() {
const close = () => {
setExactDraft(exactBaseline);
setExactDialogOpen(false);
};
if (exactDirty) requestDiscard(close);
else close();
}
function closeMaterializeDialog() {
const close = () => {
setMaterializeDraft(materializeBaseline);
setMaterializeDialogOpen(false);
};
if (materializeDirty) requestDiscard(close);
else close();
}
return (
<AdminPageLayout
title="Postboxes"
description="Manage durable organization-function addresses and reusable templates."
loading={loading}
error={error}
success={success}
className="postbox-admin-page"
actions={
<>
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
onClick={() => requestDiscard(() => void load())}
disabled={loading || busy}
disabledReason={postboxBusyReason(loading, busy)}
/>
<DocumentationHelpLink reference={POSTBOX_ADMIN_DOCUMENTATION} />
{mode === "templates" && canManageTemplates ? (
<Button variant="primary" onClick={openNewTemplate} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>
<Plus size={16} /> New template
</Button>
) : null}
{mode === "postboxes" && canManageBindings ? (
<Button variant="primary" onClick={openExact} disabled={busy || !units.some((unit) => unit.functions.length)} disabledReason={postboxBusyReason(false, busy) ?? (!units.some((unit) => unit.functions.length) ? POSTBOX_INTERFACE_I18N.organizationTargetSummary : undefined)}>
<Plus size={16} /> Exact postbox
</Button>
) : null}
</>
}
>
<SegmentedControl
value={mode}
onChange={(value) => setMode(value as AdminMode)}
options={[
...(canManageTemplates ? [{ id: "templates", label: "Templates" }] : []),
...(canManageBindings ? [{ id: "postboxes", label: "Postboxes" }] : [])
]}
ariaLabel="Postbox administration section"
/>
{mode === "postboxes" && !units.some((unit) => unit.functions.length) ? (
<ActionBlockerHint
reason={{
summary: POSTBOX_INTERFACE_I18N.organizationTargetSummary,
requiredAction: POSTBOX_INTERFACE_I18N.organizationTargetAction,
actor: POSTBOX_INTERFACE_I18N.organizationTargetActor,
target: POSTBOX_INTERFACE_I18N.organizationTargetDestination
}}
labels={{
requiredAction: POSTBOX_INTERFACE_I18N.requiredAction,
actor: POSTBOX_INTERFACE_I18N.actor,
target: POSTBOX_INTERFACE_I18N.destination
}}
documentation={POSTBOX_ADMIN_DOCUMENTATION}
/>
) : null}
{mode === "templates" ? (
<TemplateWorkspace
templates={templates}
selected={selectedTemplate}
onSelect={setSelectedTemplateId}
onRevise={openTemplateRevision}
onPublish={() => void publishSelected()}
onRetire={() => selectedTemplate && setRetireTarget(selectedTemplate)}
onMaterialize={openMaterialize}
busy={busy}
canManageBindings={canManageBindings}
/>
) : (
<PostboxWorkspace
postboxes={postboxes}
selected={selectedPostbox}
onSelect={setSelectedPostboxId}
onArchive={setArchiveTarget}
busy={busy}
/>
)}
<TemplateDialog
open={templateDialogOpen}
draft={templateDraft}
units={units}
structures={structures}
templates={templates}
functionTypes={functionTypes}
unitTypes={unitTypes}
busy={busy}
preview={templatePreview}
previewLoading={templatePreviewLoading}
onChange={(draft) => {
setTemplateDraft(draft);
setTemplatePreview(null);
}}
onPreview={() => void previewTemplate()}
onClose={closeTemplateDialog}
onSave={() => void saveTemplate()}
/>
<ExactPostboxDialog
open={exactDialogOpen}
draft={exactDraft}
units={units}
busy={busy}
onChange={setExactDraft}
onClose={closeExactDialog}
onSave={() => void saveExact()}
/>
<MaterializeDialog
open={materializeDialogOpen}
draft={materializeDraft}
template={templates.find((item) => item.id === materializeDraft.templateId) ?? null}
units={units}
busy={busy}
onChange={setMaterializeDraft}
onClose={closeMaterializeDialog}
onSave={() => void materialize()}
/>
<ConfirmDialog
open={Boolean(archiveTarget)}
title="Archive Postbox"
message={
archiveTarget
? i18nMessage("i18n:govoplan-postbox.archive_confirmation", { name: archiveTarget.name })
: ""
}
confirmLabel="Archive"
tone="danger"
busy={busy}
onConfirm={() => void confirmArchive()}
onCancel={() => setArchiveTarget(null)}
/>
<ConfirmDialog
open={Boolean(retireTarget)}
title="Retire Postbox template"
message={retireTarget
? i18nMessage("i18n:govoplan-postbox.retire_template_confirmation", { name: retireTarget.name })
: ""}
confirmLabel="Retire"
tone="danger"
busy={busy}
onConfirm={() => void confirmRetire()}
onCancel={() => setRetireTarget(null)}
/>
</AdminPageLayout>
);
}
function TemplateWorkspace({
templates,
selected,
onSelect,
onRevise,
onPublish,
onRetire,
onMaterialize,
busy,
canManageBindings
}: {
templates: PostboxTemplate[];
selected: PostboxTemplate | null;
onSelect: (id: string) => void;
onRevise: (template: PostboxTemplate) => void;
onPublish: () => void;
onRetire: () => void;
onMaterialize: (template: PostboxTemplate) => void;
busy: boolean;
canManageBindings: boolean;
}) {
const revision = selected ? currentRevision(selected) : null;
return (
<div className="postbox-admin-workspace">
<aside className="postbox-admin-list">
{!templates.length ? <p className="postbox-note">No Postbox templates.</p> : null}
{templates.length ? (
<SelectionList label="Postbox templates">
{templates.map((template) => (
<SelectionListItem
key={template.id}
selected={selected?.id === template.id}
onClick={() => onSelect(template.id)}
>
<span className="postbox-item-title">
<strong>{template.name}</strong>
<StatusBadge status={template.status} />
</span>
<span className="postbox-item-context">
{template.slug} · {i18nMessage("i18n:govoplan-postbox.revision_label", { revision: template.current_revision })}
</span>
</SelectionListItem>
))}
</SelectionList>
) : null}
</aside>
<section className="postbox-admin-detail">
{selected && revision ? (
<>
<div className="postbox-admin-detail-heading">
<div>
<span className="postbox-detail-kicker">Template</span>
<h2>{selected.name}</h2>
<p>{selected.description || "No description."}</p>
</div>
<div className="button-row compact-actions">
<Button
onClick={() => onRevise(selected)}
disabled={busy || selected.status === "retired"}
disabledReason={postboxBusyReason(false, busy) ?? (selected.status === "retired" ? POSTBOX_INTERFACE_I18N.retiredTemplate : undefined)}
>
<Pencil size={16} /> New revision
</Button>
<Button
onClick={onPublish}
disabled={busy || selected.status === "retired" || Boolean(revision.published_at)}
disabledReason={postboxBusyReason(false, busy) ?? (selected.status === "retired" ? POSTBOX_INTERFACE_I18N.retiredTemplate : revision.published_at ? POSTBOX_INTERFACE_I18N.publishedRevision : undefined)}
>
<Save size={16} /> Publish
</Button>
{canManageBindings ? (
<Button
onClick={() => onMaterialize(selected)}
disabled={busy || selected.status !== "published"}
disabledReason={postboxBusyReason(false, busy) ?? (selected.status !== "published" ? POSTBOX_INTERFACE_I18N.unpublishedTemplate : undefined)}
>
<Rocket size={16} /> Resolve address
</Button>
) : null}
<Button
variant="danger"
onClick={onRetire}
disabled={busy || selected.status === "retired"}
disabledReason={postboxBusyReason(false, busy) ?? (selected.status === "retired" ? POSTBOX_INTERFACE_I18N.retiredTemplate : undefined)}
>
<Trash2 size={16} /> Retire
</Button>
</div>
</div>
<dl className="postbox-admin-properties">
<div><dt>Revision</dt><dd>{revision.revision}{revision.published_at ? " · Published" : " · Draft"}</dd></div>
<div><dt>Function type</dt><dd>{revision.function_type_id || "Any function type"}</dd></div>
<div><dt>Scope</dt><dd>{revision.scope_kind}{revision.scope_id ? ` · ${revision.scope_id}` : ""}</dd></div>
<div><dt>Classification</dt><dd>{revision.classification}</dd></div>
<div><dt>Vacant delivery</dt><dd>{revision.allow_vacant_delivery ? "Accepted" : "Blocked"}</dd></div>
<div>
<dt>Hierarchy copies</dt>
<dd>
{revision.routing_policy.linked_copy.enabled
? i18nMessage("i18n:govoplan-postbox.depth_label", {
fanout: revision.routing_policy.linked_copy.fanout,
depth: revision.routing_policy.linked_copy.max_depth
})
: "Disabled"}
</dd>
</div>
<div>
<dt>Vacancy escalation</dt>
<dd>
{revision.routing_policy.attention.mode === "vacancy_escalation"
? i18nMessage("i18n:govoplan-postbox.minutes_label", {
minutes: revision.routing_policy.attention.delay_minutes
})
: "Disabled"}
</dd>
</div>
<div><dt>Encryption</dt><dd>{revision.encryption_profile}</dd></div>
<div><dt>Name pattern</dt><dd>{revision.name_pattern}</dd></div>
<div><dt>Address pattern</dt><dd>{revision.address_pattern}</dd></div>
</dl>
<div className="postbox-revision-history">
<h3>Immutable revisions</h3>
{selected.revisions.map((item) => (
<div key={item.id}>
<strong>{i18nMessage("i18n:govoplan-postbox.revision_label", { revision: item.revision })}</strong>
<span>{item.classification} · {item.scope_kind}</span>
<StatusBadge
status={item.published_at ? "published" : "draft"}
label={item.published_at ? "Published" : "Draft"}
/>
</div>
))}
</div>
</>
) : (
<div className="postbox-empty">
<Boxes size={24} />
<strong>Select a template</strong>
<p>Published revisions lazily resolve stable unit-specific addresses.</p>
</div>
)}
</section>
</div>
);
}
function PostboxWorkspace({
postboxes,
selected,
onSelect,
onArchive,
busy
}: {
postboxes: PostboxDirectoryItem[];
selected: PostboxDirectoryItem | null;
onSelect: (id: string) => void;
onArchive: (postbox: PostboxDirectoryItem) => void;
busy: boolean;
}) {
return (
<div className="postbox-admin-workspace">
<aside className="postbox-admin-list">
{!postboxes.length ? <p className="postbox-note">No materialized Postboxes.</p> : null}
{postboxes.length ? (
<SelectionList label="Materialized Postboxes">
{postboxes.map((postbox) => (
<SelectionListItem
key={postbox.id}
selected={selected?.id === postbox.id}
onClick={() => onSelect(postbox.id)}
>
<span className="postbox-item-title">
<strong>{postbox.name}</strong>
<StatusBadge status={postbox.status} />
</span>
<span className="postbox-item-context">
{postbox.organization_unit_name} · {postbox.function_name}
</span>
</SelectionListItem>
))}
</SelectionList>
) : null}
</aside>
<section className="postbox-admin-detail">
{selected ? (
<>
<div className="postbox-admin-detail-heading">
<div>
<span className="postbox-detail-kicker">Postbox</span>
<h2>{selected.name}</h2>
<p>{selected.address}</p>
</div>
<Button
variant="danger"
onClick={() => onArchive(selected)}
disabled={busy || selected.status !== "active"}
disabledReason={postboxBusyReason(false, busy) ?? (selected.status !== "active" ? POSTBOX_INTERFACE_I18N.archivedPostbox : undefined)}
>
<Archive size={16} /> Archive
</Button>
</div>
<dl className="postbox-admin-properties">
<div><dt>Organization unit</dt><dd>{selected.organization_unit_name || "None"}</dd></div>
<div><dt>Function</dt><dd>{selected.function_name || "None"}</dd></div>
<div><dt>Address key</dt><dd>{selected.address_key}</dd></div>
<div><dt>Classification</dt><dd>{selected.classification}</dd></div>
<div><dt>Current holders</dt><dd>{selected.holder_count}</dd></div>
<div><dt>Vacancy</dt><dd>{selected.vacant ? "Vacant" : "Staffed"}</dd></div>
<div><dt>Context</dt><dd>{selected.context_key || "None"}</dd></div>
<div><dt>Template revision</dt><dd>{selected.template_revision_id || "Exact Postbox"}</dd></div>
</dl>
</>
) : (
<div className="postbox-empty">
<Inbox size={24} />
<strong>Select a Postbox</strong>
<p>Materialized Postboxes remain durable through vacancy and reassignment.</p>
</div>
)}
</section>
</div>
);
}
function TemplateDialog({
open,
draft,
units,
structures,
templates,
functionTypes,
unitTypes,
busy,
preview,
previewLoading,
onChange,
onPreview,
onClose,
onSave
}: {
open: boolean;
draft: TemplateDraft;
units: PostboxOrganizationUnit[];
structures: PostboxOrganizationStructure[];
templates: PostboxTemplate[];
functionTypes: Array<{ id: string; name: string }>;
unitTypes: Array<{ id: string; example: string }>;
busy: boolean;
preview: PostboxTemplatePreview | null;
previewLoading: boolean;
onChange: (draft: TemplateDraft) => void;
onPreview: () => void;
onClose: () => void;
onSave: () => void;
}) {
const isRevision = Boolean(draft.templateId);
const scopeOptions = draft.scope_kind === "unit_type"
? unitTypes.map((item) => ({ id: item.id, label: `${item.id} (${item.example})` }))
: units.map((unit) => ({ id: unit.id, label: unit.name }));
const linkedCopy = draft.routing_policy.linked_copy;
const attention = draft.routing_policy.attention;
const selectedScopeStructure = structures.find(
(item) => item.id === draft.scope_structure_id
);
const selectedStructure = structures.find(
(item) => item.id === linkedCopy.structure_id
);
const updateRouting = (routing_policy: PostboxRoutingPolicy) => {
onChange({ ...draft, routing_policy });
};
const updateLinkedCopy = (
next: Partial<PostboxRoutingPolicy["linked_copy"]>
) => {
updateRouting({
...draft.routing_policy,
linked_copy: {
...linkedCopy,
...next
}
});
};
const valid =
draft.name.trim() &&
draft.slug.trim() &&
draft.name_pattern.trim() &&
draft.address_pattern.trim() &&
(draft.scope_kind === "tenant" || Boolean(draft.scope_id)) &&
(draft.scope_kind !== "subtree" || Boolean(draft.scope_structure_id)) &&
(
!linkedCopy.enabled
|| Boolean(
linkedCopy.structure_id
&& linkedCopy.target_function_type_id
&& linkedCopy.target_template_id
&& linkedCopy.allowed_classifications.length
&& linkedCopy.allowed_producer_modules.length
)
);
return (
<Dialog
open={open}
title={isRevision ? "Create template revision" : "New Postbox template"}
className="postbox-dialog postbox-template-dialog"
onClose={onClose}
closeDisabled={busy}
footer={
<div className="button-row compact-actions">
<Button
onClick={onPreview}
disabled={busy || previewLoading || !valid}
disabledReason={postboxBusyReason(false, busy || previewLoading) ?? (!valid ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
>
<Eye size={16} /> {previewLoading ? "Checking impact" : "Preview impact"}
</Button>
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
<Button
variant="primary"
onClick={onSave}
disabled={busy || !valid}
disabledReason={postboxBusyReason(false, busy) ?? (!valid ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
>
{isRevision ? "Create revision" : "Create draft"}
</Button>
</div>
}
>
<div className="postbox-form-grid two-columns">
<FormField label="Name" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={draft.name}
disabled={isRevision}
onChange={(event) => onChange({ ...draft, name: event.target.value })}
/>
</FormField>
<FormField label="Slug" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={draft.slug}
disabled={isRevision}
onChange={(event) => onChange({ ...draft, slug: event.target.value })}
/>
</FormField>
<FormField label="Description" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={draft.description || ""}
disabled={isRevision}
onChange={(event) => onChange({ ...draft, description: event.target.value })}
/>
</FormField>
<FormField label="Function type" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={draft.function_type_id || ""}
onChange={(event) => onChange({
...draft,
function_type_id: event.target.value || null
})}
>
<option value="">Any function type</option>
{functionTypes.map((item) => (
<option key={item.id} value={item.id}>{item.name} · {item.id}</option>
))}
</select>
</FormField>
<FormField label="Scope" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={draft.scope_kind}
onChange={(event) => {
const scope_kind = event.target.value as TemplateDraft["scope_kind"];
onChange({
...draft,
scope_kind,
scope_id: scope_kind === "tenant" ? null : "",
scope_structure_id: null,
scope_relation_type_ids: []
});
}}
>
<option value="tenant">Tenant</option>
<option value="unit">One unit</option>
<option value="subtree">Unit subtree</option>
<option value="unit_type">Unit type</option>
</select>
</FormField>
{draft.scope_kind !== "tenant" ? (
<FormField label="Scope target" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={draft.scope_id || ""}
onChange={(event) => onChange({ ...draft, scope_id: event.target.value || null })}
>
<option value="">Select target</option>
{scopeOptions.map((item) => (
<option key={item.id} value={item.id}>{item.label}</option>
))}
</select>
</FormField>
) : <div />}
{draft.scope_kind === "subtree" ? (
<>
<FormField label="Hierarchy structure" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={draft.scope_structure_id || ""}
onChange={(event) => onChange({
...draft,
scope_structure_id: event.target.value || null,
scope_relation_type_ids: []
})}
>
<option value="">Select structure</option>
{structures.filter((item) => item.status === "active").map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Hierarchy relation types" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<div className="postbox-relation-options">
{(selectedScopeStructure?.relation_types || [])
.filter((item) => item.status === "active" && item.is_hierarchical)
.map((item) => (
<label key={item.id}>
<input
type="checkbox"
checked={draft.scope_relation_type_ids.includes(item.id)}
onChange={(event) => onChange({
...draft,
scope_relation_type_ids: event.target.checked
? [...draft.scope_relation_type_ids, item.id]
: draft.scope_relation_type_ids.filter((id) => id !== item.id)
})}
/>
<span>{item.name}</span>
</label>
))}
{selectedScopeStructure && !selectedScopeStructure.relation_types.some(
(item) => item.status === "active" && item.is_hierarchical
) ? <span className="postbox-note">No active hierarchical relation type.</span> : null}
{!selectedScopeStructure ? <span className="postbox-note">All active hierarchical relations are used unless specific types are selected.</span> : null}
</div>
</FormField>
</>
) : null}
<FormField label="Name pattern" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={draft.name_pattern}
onChange={(event) => onChange({ ...draft, name_pattern: event.target.value })}
/>
</FormField>
<FormField label="Address pattern" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={draft.address_pattern}
onChange={(event) => onChange({ ...draft, address_pattern: event.target.value })}
/>
</FormField>
<FormField label="Classification" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={draft.classification}
onChange={(event) => onChange({ ...draft, classification: event.target.value })}
/>
</FormField>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Accept delivery while vacant"
help="Messages remain institutionally retained even when no incumbent currently has access."
checked={draft.allow_vacant_delivery}
onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })}
/>
</div>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Show in Portal"
help="Portal lists this Postbox only for users whose current function assignment already grants Postbox access."
checked={draft.portal_visible}
onChange={(checked) => onChange({ ...draft, portal_visible: checked })}
/>
</div>
<div className="postbox-routing-section">
<div className="postbox-routing-heading">
<div>
<strong>Hierarchy linked copies</strong>
<span>Copy to explicitly bounded function Postboxes in one selected structure.</span>
<DocumentationHelpLink reference={POSTBOX_FIELD_DOCUMENTATION} />
</div>
<ToggleSwitch
label="Enable hierarchy linked copies"
help="Copies create independent deliveries and evidence at explicitly bounded hierarchy targets."
checked={linkedCopy.enabled}
onChange={(enabled) => updateLinkedCopy({
enabled,
allowed_classifications: linkedCopy.allowed_classifications.length
? linkedCopy.allowed_classifications
: [draft.classification]
})}
/>
</div>
{linkedCopy.enabled ? (
<div className="postbox-form-grid two-columns">
<FormField label="Organization structure" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={linkedCopy.structure_id || ""}
onChange={(event) => updateLinkedCopy({
structure_id: event.target.value || null,
relation_type_ids: []
})}
>
<option value="">Select structure</option>
{structures.filter((item) => item.status === "active").map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Relation type" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={linkedCopy.relation_type_ids[0] || ""}
onChange={(event) => updateLinkedCopy({
relation_type_ids: event.target.value ? [event.target.value] : []
})}
>
<option value="">All hierarchical relations</option>
{(selectedStructure?.relation_types || [])
.filter((item) => item.status === "active" && item.is_hierarchical)
.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Target function type" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={linkedCopy.target_function_type_id || ""}
onChange={(event) => updateLinkedCopy({
target_function_type_id: event.target.value || null
})}
>
<option value="">Select function type</option>
{functionTypes.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Target Postbox template" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={linkedCopy.target_template_id || ""}
onChange={(event) => updateLinkedCopy({
target_template_id: event.target.value || null
})}
>
<option value="">Select published template</option>
{templates
.filter((item) => item.status === "published")
.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Maximum hierarchy depth" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
type="number"
min={1}
max={20}
value={linkedCopy.max_depth}
onChange={(event) => updateLinkedCopy({
max_depth: Math.max(1, Math.min(20, Number(event.target.value) || 1))
})}
/>
</FormField>
<FormField label="Copy behavior" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={linkedCopy.fanout}
onChange={(event) => {
const fanout = event.target.value as "nearest" | "all";
updateRouting({
...draft.routing_policy,
linked_copy: { ...linkedCopy, fanout },
attention: fanout === "all"
? { mode: "none", delay_minutes: null }
: attention
});
}}
>
<option value="nearest">Nearest matching ancestor</option>
<option value="all">All matching ancestors</option>
</select>
</FormField>
<FormField label="Optional stop unit" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={linkedCopy.stop_unit_id || ""}
onChange={(event) => updateLinkedCopy({
stop_unit_id: event.target.value || null
})}
>
<option value="">No unit stop</option>
{units.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Optional stop unit type" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={linkedCopy.stop_unit_type_id || ""}
onChange={(event) => updateLinkedCopy({
stop_unit_type_id: event.target.value || null
})}
>
<option value="">No unit-type stop</option>
{unitTypes.map((item) => (
<option key={item.id} value={item.id}>{item.id}</option>
))}
</select>
</FormField>
<FormField label="Allowed classifications" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={linkedCopy.allowed_classifications.join(", ")}
onChange={(event) => updateLinkedCopy({
allowed_classifications: commaSeparated(event.target.value)
})}
/>
</FormField>
<FormField label="Authorized producer modules" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
placeholder="campaigns, workflow"
value={linkedCopy.allowed_producer_modules.join(", ")}
onChange={(event) => updateLinkedCopy({
allowed_producer_modules: commaSeparated(event.target.value)
})}
/>
</FormField>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Require message expiry"
help="Reject copied deliveries that do not carry an explicit expiry boundary."
checked={linkedCopy.require_expiry}
onChange={(require_expiry) => updateLinkedCopy({ require_expiry })}
/>
</div>
<FormField label="Maximum retention days" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
type="number"
min={1}
max={36500}
value={linkedCopy.max_retention_days ?? ""}
onChange={(event) => updateLinkedCopy({
max_retention_days: event.target.value
? Math.max(1, Math.min(36500, Number(event.target.value)))
: null
})}
/>
</FormField>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Escalate when the nearest target remains vacant"
help="Schedules a separate, auditable delivery only after the configured vacancy delay."
checked={attention.mode === "vacancy_escalation"}
onChange={(checked) => updateRouting({
...draft.routing_policy,
attention: checked
? {
mode: "vacancy_escalation",
delay_minutes: attention.delay_minutes || 1440
}
: { mode: "none", delay_minutes: null }
})}
/>
</div>
{attention.mode === "vacancy_escalation" ? (
<FormField label="Vacancy escalation delay (minutes)" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
type="number"
min={1}
max={43200}
value={attention.delay_minutes ?? 1440}
onChange={(event) => updateRouting({
...draft.routing_policy,
attention: {
mode: "vacancy_escalation",
delay_minutes: Math.max(
1,
Math.min(43200, Number(event.target.value) || 1)
)
}
})}
/>
</FormField>
) : <div />}
</div>
) : null}
</div>
</div>
{preview ? <TemplateImpactPreview preview={preview} /> : null}
<p className="postbox-form-note">
Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.
</p>
</Dialog>
);
}
function TemplateImpactPreview({
preview
}: {
preview: PostboxTemplatePreview;
}) {
return (
<section className="postbox-template-preview" aria-label="Template impact preview">
<div className="postbox-routing-heading">
<div>
<strong>Dry-run impact</strong>
<span>No Postboxes or addresses were created.</span>
</div>
{preview.truncated ? <StatusBadge status="warning" label="List truncated" /> : null}
</div>
<div className="metric-grid compact postbox-preview-metrics">
<MetricCard label="Targets" value={preview.total} tone="info" />
<MetricCard label="Ready" value={preview.ready_count} tone="good" />
<MetricCard label="Existing" value={preview.existing_count} />
<MetricCard label="Vacant" value={preview.vacant_count} tone="warning" />
<MetricCard label="Blocked" value={preview.blocked_count} tone="danger" />
</div>
{preview.diagnostics.length ? (
<p className="postbox-form-note">{preview.diagnostics.join(", ")}</p>
) : null}
<div className="postbox-preview-targets">
{preview.targets.map((target) => (
<div key={`${target.organization_unit_id}:${target.function_id}`}>
<div>
<strong>{target.name}</strong>
<span>{target.organization_unit_name} · {target.function_name}</span>
<code>{target.address}</code>
{target.diagnostics.length ? (
<small>{target.diagnostics.join(", ")}</small>
) : null}
</div>
<div className="postbox-preview-status">
<StatusBadge status={target.status} label={target.status.replaceAll("_", " ")} />
<span>{target.holder_count} holder{target.holder_count === 1 ? "" : "s"}</span>
</div>
</div>
))}
{!preview.targets.length ? (
<p className="postbox-note">The scope contains no matching active function.</p>
) : null}
</div>
</section>
);
}
function ExactPostboxDialog({
open,
draft,
units,
busy,
onChange,
onClose,
onSave
}: {
open: boolean;
draft: ExactDraft;
units: PostboxOrganizationUnit[];
busy: boolean;
onChange: (draft: ExactDraft) => void;
onClose: () => void;
onSave: () => void;
}) {
const unit = units.find((item) => item.id === draft.organization_unit_id);
return (
<Dialog
open={open}
title="New exact Postbox"
className="postbox-dialog"
onClose={onClose}
closeDisabled={busy}
footer={
<div className="button-row compact-actions">
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
<Button
variant="primary"
onClick={onSave}
disabled={busy || !draft.name.trim() || !draft.organization_unit_id || !draft.function_id}
disabledReason={postboxBusyReason(false, busy) ?? ((!draft.name.trim() || !draft.organization_unit_id || !draft.function_id) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
>
Create Postbox
</Button>
</div>
}
>
<div className="postbox-form-grid two-columns">
<FormField label="Name" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input value={draft.name} onChange={(event) => onChange({ ...draft, name: event.target.value })} />
</FormField>
<FormField label="Address key" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input value={draft.address_key || ""} placeholder="Generated when empty" onChange={(event) => onChange({ ...draft, address_key: event.target.value })} />
</FormField>
<FormField label="Organization unit" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={draft.organization_unit_id}
onChange={(event) => {
const nextUnit = units.find((item) => item.id === event.target.value);
onChange({
...draft,
organization_unit_id: event.target.value,
function_id: nextUnit?.functions[0]?.id ?? ""
});
}}
>
<option value="">Select unit</option>
{units.filter((item) => item.functions.length).map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Function" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select value={draft.function_id} onChange={(event) => onChange({ ...draft, function_id: event.target.value })}>
<option value="">Select function</option>
{(unit?.functions || []).map((fn) => (
<option key={fn.id} value={fn.id}>{fn.name}</option>
))}
</select>
</FormField>
<FormField label="Classification" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input value={draft.classification} onChange={(event) => onChange({ ...draft, classification: event.target.value })} />
</FormField>
<FormField label="Description" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input value={draft.description || ""} onChange={(event) => onChange({ ...draft, description: event.target.value })} />
</FormField>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Show in Portal"
help="Portal lists this Postbox only when the current user's function assignment grants access."
checked={draft.portal_visible}
onChange={(checked) => onChange({ ...draft, portal_visible: checked })}
/>
</div>
</div>
</Dialog>
);
}
function MaterializeDialog({
open,
draft,
template,
units,
busy,
onChange,
onClose,
onSave
}: {
open: boolean;
draft: MaterializeDraft;
template: PostboxTemplate | null;
units: PostboxOrganizationUnit[];
busy: boolean;
onChange: (draft: MaterializeDraft) => void;
onClose: () => void;
onSave: () => void;
}) {
const revision = template ? currentRevision(template) : null;
const compatible = compatibleTargets(units, revision?.function_type_id);
const unit = compatible.find((item) => item.id === draft.organization_unit_id);
return (
<Dialog
open={open}
title={template
? i18nMessage("i18n:govoplan-postbox.resolve_address_title", { name: template.name })
: "Resolve address"}
className="postbox-dialog"
onClose={onClose}
closeDisabled={busy}
footer={
<div className="button-row compact-actions">
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
<Button
variant="primary"
onClick={onSave}
disabled={busy || !draft.organization_unit_id || !draft.function_id}
disabledReason={postboxBusyReason(false, busy) ?? ((!draft.organization_unit_id || !draft.function_id) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
>
Resolve address
</Button>
</div>
}
>
<div className="postbox-form-grid">
<FormField label="Organization unit" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={draft.organization_unit_id}
onChange={(event) => {
const nextUnit = compatible.find((item) => item.id === event.target.value);
onChange({
...draft,
organization_unit_id: event.target.value,
function_id: nextUnit?.functions[0]?.id ?? ""
});
}}
>
<option value="">Select unit</option>
{compatible.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Function" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select value={draft.function_id} onChange={(event) => onChange({ ...draft, function_id: event.target.value })}>
<option value="">Select function</option>
{(unit?.functions || []).map((fn) => (
<option key={fn.id} value={fn.id}>{fn.name}</option>
))}
</select>
</FormField>
<FormField label="Optional case or service context" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input value={draft.context_key} onChange={(event) => onChange({ ...draft, context_key: event.target.value })} />
</FormField>
</div>
</Dialog>
);
}
function currentRevision(template: PostboxTemplate) {
return template.revisions.find((revision) => revision.revision === template.current_revision)
?? template.revisions.at(-1)
?? null;
}
function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
return {
function_type_id: draft.function_type_id || null,
scope_kind: draft.scope_kind,
scope_id: draft.scope_kind === "tenant" ? null : draft.scope_id || null,
scope_structure_id: draft.scope_kind === "subtree"
? draft.scope_structure_id || null
: null,
scope_relation_type_ids: draft.scope_kind === "subtree"
? draft.scope_relation_type_ids
: [],
name_pattern: draft.name_pattern,
address_pattern: draft.address_pattern,
classification: draft.classification,
allow_vacant_delivery: draft.allow_vacant_delivery,
portal_visible: draft.portal_visible,
routing_policy: draft.routing_policy
};
}
function compatibleTargets(
units: PostboxOrganizationUnit[],
functionTypeId?: string | null
): PostboxOrganizationUnit[] {
return units
.map((unit) => ({
...unit,
functions: functionTypeId
? unit.functions.filter((fn) => fn.function_type_id === functionTypeId)
: unit.functions
}))
.filter((unit) => unit.functions.length);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Postbox request failed";
}
function commaSeparated(value: string): string[] {
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
}
function draftKey(value: unknown): string {
return JSON.stringify(value);
}