Add scoped postbox template previews

This commit is contained in:
2026-08-06 12:42:20 +02:00
parent c53646a8aa
commit d107c09f74
15 changed files with 1300 additions and 56 deletions
@@ -3,6 +3,7 @@ import {
Archive,
Boxes,
Building2,
Eye,
Inbox,
Pencil,
Plus,
@@ -20,6 +21,7 @@ import {
DocumentationHelpLink,
FormField,
IconButton,
MetricCard,
SegmentedControl,
SelectionList,
SelectionListItem,
@@ -38,6 +40,7 @@ import {
listPostboxOrganizationTargets,
listPostboxTemplates,
materializePostboxTemplate,
previewPostboxTemplate,
publishPostboxTemplate,
retirePostboxTemplate,
revisePostboxTemplate,
@@ -49,6 +52,7 @@ import {
type PostboxRoutingPolicy,
type PostboxTemplate,
type PostboxTemplateCreatePayload,
type PostboxTemplatePreview,
type PostboxTemplateRevisionPayload
} from "../../api/postbox";
import {
@@ -102,6 +106,8 @@ const templateDefaults = (): TemplateDraft => ({
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",
@@ -143,6 +149,8 @@ export default function PostboxAdminPanel({
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);
@@ -239,6 +247,7 @@ export default function PostboxAdminPanel({
function openNewTemplate() {
const next = templateDefaults();
setTemplatePreview(null);
setTemplateDraft(next);
setTemplateBaseline(next);
setTemplateDialogOpen(true);
@@ -255,12 +264,15 @@ export default function PostboxAdminPanel({
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,
routing_policy: revision.routing_policy ?? routingDefaults()
};
setTemplatePreview(null);
setTemplateDraft(next);
setTemplateBaseline(next);
setTemplateDialogOpen(true);
@@ -303,6 +315,27 @@ export default function PostboxAdminPanel({
}
}
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);
@@ -569,7 +602,13 @@ export default function PostboxAdminPanel({
functionTypes={functionTypes}
unitTypes={unitTypes}
busy={busy}
onChange={setTemplateDraft}
preview={templatePreview}
previewLoading={templatePreviewLoading}
onChange={(draft) => {
setTemplateDraft(draft);
setTemplatePreview(null);
}}
onPreview={() => void previewTemplate()}
onClose={closeTemplateDialog}
onSave={() => void saveTemplate()}
/>
@@ -855,7 +894,10 @@ function TemplateDialog({
functionTypes,
unitTypes,
busy,
preview,
previewLoading,
onChange,
onPreview,
onClose,
onSave
}: {
@@ -867,7 +909,10 @@ function TemplateDialog({
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;
}) {
@@ -877,6 +922,9 @@ function TemplateDialog({
: 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
);
@@ -900,6 +948,7 @@ function TemplateDialog({
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(
@@ -919,6 +968,13 @@ function TemplateDialog({
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"
@@ -975,7 +1031,9 @@ function TemplateDialog({
onChange({
...draft,
scope_kind,
scope_id: scope_kind === "tenant" ? null : ""
scope_id: scope_kind === "tenant" ? null : "",
scope_structure_id: null,
scope_relation_type_ids: []
});
}}
>
@@ -998,6 +1056,50 @@ function TemplateDialog({
</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}
@@ -1235,6 +1337,7 @@ function TemplateDialog({
) : 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>
@@ -1242,6 +1345,55 @@ function TemplateDialog({
);
}
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,
@@ -1416,6 +1568,12 @@ function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
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,