978 lines
32 KiB
TypeScript
978 lines
32 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Archive,
|
|
Boxes,
|
|
Building2,
|
|
Inbox,
|
|
Pencil,
|
|
Plus,
|
|
RefreshCw,
|
|
Rocket,
|
|
Save,
|
|
Trash2
|
|
} from "lucide-react";
|
|
import {
|
|
AdminPageLayout,
|
|
Button,
|
|
ConfirmDialog,
|
|
Dialog,
|
|
FormField,
|
|
IconButton,
|
|
SegmentedControl,
|
|
SelectionList,
|
|
SelectionListItem,
|
|
StatusBadge,
|
|
ToggleSwitch,
|
|
type ApiSettings
|
|
} from "@govoplan/core-webui";
|
|
import {
|
|
archivePostbox,
|
|
createExactPostbox,
|
|
createPostboxTemplate,
|
|
listAdminPostboxes,
|
|
listPostboxOrganizationTargets,
|
|
listPostboxTemplates,
|
|
materializePostboxTemplate,
|
|
publishPostboxTemplate,
|
|
retirePostboxTemplate,
|
|
revisePostboxTemplate,
|
|
type PostboxDirectoryItem,
|
|
type PostboxExactCreatePayload,
|
|
type PostboxOrganizationFunction,
|
|
type PostboxOrganizationUnit,
|
|
type PostboxTemplate,
|
|
type PostboxTemplateCreatePayload,
|
|
type PostboxTemplateRevisionPayload
|
|
} from "../../api/postbox";
|
|
|
|
|
|
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 templateDefaults = (): TemplateDraft => ({
|
|
templateId: "",
|
|
slug: "",
|
|
name: "",
|
|
description: "",
|
|
function_type_id: null,
|
|
scope_kind: "tenant",
|
|
scope_id: null,
|
|
name_pattern: "{unit_name} / {function_name}",
|
|
address_pattern: "{template_slug}.{unit_slug}.{function_slug}",
|
|
classification: "internal",
|
|
allow_vacant_delivery: true
|
|
});
|
|
|
|
const exactDefaults = (): ExactDraft => ({
|
|
name: "",
|
|
description: "",
|
|
organization_unit_id: "",
|
|
function_id: "",
|
|
address_key: "",
|
|
classification: "internal"
|
|
});
|
|
|
|
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 [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 [exactDialogOpen, setExactDialogOpen] = useState(false);
|
|
const [exactDraft, setExactDraft] = useState<ExactDraft>(exactDefaults);
|
|
const [materializeDialogOpen, setMaterializeDialogOpen] = useState(false);
|
|
const [materializeDraft, setMaterializeDraft] = useState<MaterializeDraft>({
|
|
templateId: "",
|
|
organization_unit_id: "",
|
|
function_id: "",
|
|
context_key: ""
|
|
});
|
|
const [archiveTarget, setArchiveTarget] = useState<PostboxDirectoryItem | null>(null);
|
|
|
|
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 load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const [nextTemplates, nextPostboxes, nextUnits] = await Promise.all([
|
|
canManageTemplates ? listPostboxTemplates(settings) : Promise.resolve([]),
|
|
canManageBindings ? listAdminPostboxes(settings) : Promise.resolve([]),
|
|
listPostboxOrganizationTargets(settings)
|
|
]);
|
|
setTemplates(nextTemplates);
|
|
setPostboxes(nextPostboxes);
|
|
setUnits(nextUnits);
|
|
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() {
|
|
setTemplateDraft(templateDefaults());
|
|
setTemplateDialogOpen(true);
|
|
}
|
|
|
|
function openTemplateRevision(template: PostboxTemplate) {
|
|
const revision = currentRevision(template);
|
|
if (!revision) return;
|
|
setTemplateDraft({
|
|
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,
|
|
name_pattern: revision.name_pattern,
|
|
address_pattern: revision.address_pattern,
|
|
classification: revision.classification,
|
|
allow_vacant_delivery: revision.allow_vacant_delivery
|
|
});
|
|
setTemplateDialogOpen(true);
|
|
}
|
|
|
|
async function saveTemplate() {
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
if (templateDraft.templateId) {
|
|
await revisePostboxTemplate(
|
|
settings,
|
|
templateDraft.templateId,
|
|
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.");
|
|
}
|
|
setTemplateDialogOpen(false);
|
|
await load();
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function publishSelected() {
|
|
if (!selectedTemplate) return;
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
await publishPostboxTemplate(
|
|
settings,
|
|
selectedTemplate.id,
|
|
selectedTemplate.current_revision
|
|
);
|
|
setSuccess(`Published revision ${selectedTemplate.current_revision}.`);
|
|
await load();
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function retireSelected() {
|
|
if (!selectedTemplate) return;
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
await retirePostboxTemplate(settings, selectedTemplate.id);
|
|
setSuccess("Postbox template retired. Existing addresses remain durable.");
|
|
await load();
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function openExact() {
|
|
const unit = units.find((item) => item.functions.length);
|
|
setExactDraft({
|
|
...exactDefaults(),
|
|
organization_unit_id: unit?.id ?? "",
|
|
function_id: unit?.functions[0]?.id ?? ""
|
|
});
|
|
setExactDialogOpen(true);
|
|
}
|
|
|
|
async function saveExact() {
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
await createExactPostbox(settings, {
|
|
...exactDraft,
|
|
description: exactDraft.description || null,
|
|
address_key: exactDraft.address_key || null
|
|
});
|
|
setExactDialogOpen(false);
|
|
setSuccess("Exact function-bound Postbox created.");
|
|
await load();
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function openMaterialize(template: PostboxTemplate) {
|
|
const revision = currentRevision(template);
|
|
const compatible = compatibleTargets(units, revision?.function_type_id);
|
|
const unit = compatible[0];
|
|
setMaterializeDraft({
|
|
templateId: template.id,
|
|
organization_unit_id: unit?.id ?? "",
|
|
function_id: unit?.functions[0]?.id ?? "",
|
|
context_key: ""
|
|
});
|
|
setMaterializeDialogOpen(true);
|
|
}
|
|
|
|
async function materialize() {
|
|
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
|
|
});
|
|
setMaterializeDialogOpen(false);
|
|
setSuccess("Stable Postbox address resolved and materialized.");
|
|
await load();
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function confirmArchive() {
|
|
if (!archiveTarget) return;
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
await archivePostbox(settings, archiveTarget.id);
|
|
setSuccess("Postbox archived. Messages and delivery evidence were retained.");
|
|
setArchiveTarget(null);
|
|
await load();
|
|
} catch (actionError) {
|
|
setError(errorMessage(actionError));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
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={() => void load()}
|
|
disabled={busy}
|
|
/>
|
|
{mode === "templates" && canManageTemplates ? (
|
|
<Button variant="primary" onClick={openNewTemplate}>
|
|
<Plus size={16} /> New template
|
|
</Button>
|
|
) : null}
|
|
{mode === "postboxes" && canManageBindings ? (
|
|
<Button variant="primary" onClick={openExact}>
|
|
<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 === "templates" ? (
|
|
<TemplateWorkspace
|
|
templates={templates}
|
|
selected={selectedTemplate}
|
|
onSelect={setSelectedTemplateId}
|
|
onRevise={openTemplateRevision}
|
|
onPublish={() => void publishSelected()}
|
|
onRetire={() => void retireSelected()}
|
|
onMaterialize={openMaterialize}
|
|
busy={busy}
|
|
canManageBindings={canManageBindings}
|
|
/>
|
|
) : (
|
|
<PostboxWorkspace
|
|
postboxes={postboxes}
|
|
selected={selectedPostbox}
|
|
onSelect={setSelectedPostboxId}
|
|
onArchive={setArchiveTarget}
|
|
busy={busy}
|
|
/>
|
|
)}
|
|
|
|
<TemplateDialog
|
|
open={templateDialogOpen}
|
|
draft={templateDraft}
|
|
units={units}
|
|
functionTypes={functionTypes}
|
|
unitTypes={unitTypes}
|
|
busy={busy}
|
|
onChange={setTemplateDraft}
|
|
onClose={() => setTemplateDialogOpen(false)}
|
|
onSave={() => void saveTemplate()}
|
|
/>
|
|
<ExactPostboxDialog
|
|
open={exactDialogOpen}
|
|
draft={exactDraft}
|
|
units={units}
|
|
busy={busy}
|
|
onChange={setExactDraft}
|
|
onClose={() => setExactDialogOpen(false)}
|
|
onSave={() => void saveExact()}
|
|
/>
|
|
<MaterializeDialog
|
|
open={materializeDialogOpen}
|
|
draft={materializeDraft}
|
|
template={templates.find((item) => item.id === materializeDraft.templateId) ?? null}
|
|
units={units}
|
|
busy={busy}
|
|
onChange={setMaterializeDraft}
|
|
onClose={() => setMaterializeDialogOpen(false)}
|
|
onSave={() => void materialize()}
|
|
/>
|
|
<ConfirmDialog
|
|
open={Boolean(archiveTarget)}
|
|
title="Archive Postbox"
|
|
message={
|
|
archiveTarget
|
|
? `Archive "${archiveTarget.name}"? Its messages and delivery evidence remain retained, but the address stops accepting new delivery.`
|
|
: ""
|
|
}
|
|
confirmLabel="Archive"
|
|
tone="danger"
|
|
busy={busy}
|
|
onConfirm={() => void confirmArchive()}
|
|
onCancel={() => setArchiveTarget(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} · 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"}>
|
|
<Pencil size={16} /> New revision
|
|
</Button>
|
|
<Button onClick={onPublish} disabled={busy || selected.status === "retired" || Boolean(revision.published_at)}>
|
|
<Save size={16} /> Publish
|
|
</Button>
|
|
{canManageBindings ? (
|
|
<Button onClick={() => onMaterialize(selected)} disabled={busy || selected.status !== "published"}>
|
|
<Rocket size={16} /> Resolve address
|
|
</Button>
|
|
) : null}
|
|
<Button variant="danger" onClick={onRetire} disabled={busy || selected.status === "retired"}>
|
|
<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>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>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"}
|
|
>
|
|
<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,
|
|
functionTypes,
|
|
unitTypes,
|
|
busy,
|
|
onChange,
|
|
onClose,
|
|
onSave
|
|
}: {
|
|
open: boolean;
|
|
draft: TemplateDraft;
|
|
units: PostboxOrganizationUnit[];
|
|
functionTypes: Array<{ id: string; name: string }>;
|
|
unitTypes: Array<{ id: string; example: string }>;
|
|
busy: boolean;
|
|
onChange: (draft: TemplateDraft) => 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 valid =
|
|
draft.name.trim() &&
|
|
draft.slug.trim() &&
|
|
draft.name_pattern.trim() &&
|
|
draft.address_pattern.trim() &&
|
|
(draft.scope_kind === "tenant" || Boolean(draft.scope_id));
|
|
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={onClose} disabled={busy}>Cancel</Button>
|
|
<Button variant="primary" onClick={onSave} disabled={busy || !valid}>
|
|
{isRevision ? "Create revision" : "Create draft"}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="postbox-form-grid two-columns">
|
|
<FormField label="Name">
|
|
<input
|
|
value={draft.name}
|
|
disabled={isRevision}
|
|
onChange={(event) => onChange({ ...draft, name: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Slug">
|
|
<input
|
|
value={draft.slug}
|
|
disabled={isRevision}
|
|
onChange={(event) => onChange({ ...draft, slug: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Description">
|
|
<input
|
|
value={draft.description || ""}
|
|
disabled={isRevision}
|
|
onChange={(event) => onChange({ ...draft, description: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Function type">
|
|
<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">
|
|
<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 : ""
|
|
});
|
|
}}
|
|
>
|
|
<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">
|
|
<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 />}
|
|
<FormField label="Name pattern">
|
|
<input
|
|
value={draft.name_pattern}
|
|
onChange={(event) => onChange({ ...draft, name_pattern: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Address pattern">
|
|
<input
|
|
value={draft.address_pattern}
|
|
onChange={(event) => onChange({ ...draft, address_pattern: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Classification">
|
|
<input
|
|
value={draft.classification}
|
|
onChange={(event) => onChange({ ...draft, classification: event.target.value })}
|
|
/>
|
|
</FormField>
|
|
<div className="postbox-toggle-field">
|
|
<ToggleSwitch
|
|
label="Accept delivery while vacant"
|
|
checked={draft.allow_vacant_delivery}
|
|
onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<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 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}>Cancel</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={onSave}
|
|
disabled={busy || !draft.name.trim() || !draft.organization_unit_id || !draft.function_id}
|
|
>
|
|
Create Postbox
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="postbox-form-grid two-columns">
|
|
<FormField label="Name">
|
|
<input value={draft.name} onChange={(event) => onChange({ ...draft, name: event.target.value })} />
|
|
</FormField>
|
|
<FormField label="Address key">
|
|
<input value={draft.address_key || ""} placeholder="Generated when empty" onChange={(event) => onChange({ ...draft, address_key: event.target.value })} />
|
|
</FormField>
|
|
<FormField label="Organization unit">
|
|
<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">
|
|
<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">
|
|
<input value={draft.classification} onChange={(event) => onChange({ ...draft, classification: event.target.value })} />
|
|
</FormField>
|
|
<FormField label="Description">
|
|
<input value={draft.description || ""} onChange={(event) => onChange({ ...draft, description: event.target.value })} />
|
|
</FormField>
|
|
</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={`Resolve address${template ? ` · ${template.name}` : ""}`}
|
|
className="postbox-dialog"
|
|
onClose={onClose}
|
|
closeDisabled={busy}
|
|
footer={
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={onClose} disabled={busy}>Cancel</Button>
|
|
<Button variant="primary" onClick={onSave} disabled={busy || !draft.organization_unit_id || !draft.function_id}>
|
|
Resolve address
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="postbox-form-grid">
|
|
<FormField label="Organization unit">
|
|
<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">
|
|
<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">
|
|
<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,
|
|
name_pattern: draft.name_pattern,
|
|
address_pattern: draft.address_pattern,
|
|
classification: draft.classification,
|
|
allow_vacant_delivery: draft.allow_vacant_delivery
|
|
};
|
|
}
|
|
|
|
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";
|
|
}
|