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
+46
View File
@@ -114,6 +114,8 @@ export type PostboxGrouping = {
name: string;
is_default: boolean;
postbox_ids: string[];
total_count: number;
unread_count: number;
resource_revision: number;
etag: string;
created_at: string;
@@ -192,6 +194,8 @@ export type PostboxTemplateRevision = {
function_type_id?: string | null;
scope_kind: "tenant" | "unit" | "subtree" | "unit_type";
scope_id?: string | null;
scope_structure_id?: string | null;
scope_relation_type_ids: string[];
name_pattern: string;
address_pattern: string;
classification: string;
@@ -225,6 +229,8 @@ export type PostboxTemplateRevisionPayload = Pick<
| "function_type_id"
| "scope_kind"
| "scope_id"
| "scope_structure_id"
| "scope_relation_type_ids"
| "name_pattern"
| "address_pattern"
| "classification"
@@ -238,6 +244,31 @@ export type PostboxTemplateCreatePayload = PostboxTemplateRevisionPayload & {
description?: string | null;
};
export type PostboxTemplatePreviewTarget = {
organization_unit_id: string;
organization_unit_name: string;
function_id: string;
function_name: string;
address: string;
name: string;
holder_count: number;
vacant: boolean;
status: string;
existing_postbox_id?: string | null;
diagnostics: string[];
};
export type PostboxTemplatePreview = {
targets: PostboxTemplatePreviewTarget[];
total: number;
ready_count: number;
existing_count: number;
vacant_count: number;
blocked_count: number;
truncated: boolean;
diagnostics: string[];
};
export type PostboxExactCreatePayload = {
name: string;
description?: string | null;
@@ -435,6 +466,21 @@ export function createPostboxTemplate(
return apiPostJson(settings, "/api/v1/postbox/admin/templates", payload);
}
export function previewPostboxTemplate(
settings: ApiSettings,
payload: PostboxTemplateCreatePayload & {
template_id?: string | null;
context_key?: string | null;
limit?: number;
}
): Promise<PostboxTemplatePreview> {
return apiPostJson(
settings,
"/api/v1/postbox/admin/templates/preview",
payload
);
}
export function revisePostboxTemplate(
settings: ApiSettings,
template: PostboxTemplate,
@@ -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,
+3 -1
View File
@@ -605,7 +605,9 @@ export default function PostboxPage({
<option value="all">All postboxes</option>
{groupings.map((grouping) => (
<option key={grouping.id} value={grouping.id}>
{grouping.name}{grouping.is_default ? " (default)" : ""}
{grouping.name}
{grouping.unread_count ? ` (${grouping.unread_count})` : ""}
{grouping.is_default ? " · default" : ""}
</option>
))}
</select>
+77
View File
@@ -466,6 +466,83 @@
font-size: 12px;
}
.postbox-relation-options {
min-height: 38px;
display: flex;
flex-wrap: wrap;
align-content: center;
gap: 8px 14px;
}
.postbox-relation-options label {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--text-strong);
font-size: 13px;
}
.postbox-relation-options input {
width: auto;
}
.postbox-template-preview {
display: grid;
gap: 12px;
border-top: var(--border-line);
margin-top: 18px;
padding-top: 16px;
}
.postbox-preview-metrics.metric-grid {
margin: 0;
}
.postbox-preview-targets {
max-height: 280px;
display: grid;
gap: 1px;
overflow: auto;
background: var(--border-color);
border: var(--border-line);
}
.postbox-preview-targets > div {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: var(--surface);
padding: 10px 12px;
}
.postbox-preview-targets > div > div:first-child {
min-width: 0;
display: grid;
gap: 2px;
}
.postbox-preview-targets span,
.postbox-preview-targets small {
color: var(--muted);
font-size: 12px;
}
.postbox-preview-targets code {
overflow: hidden;
color: var(--text-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-preview-status {
flex: 0 0 auto;
display: grid;
justify-items: end;
gap: 4px;
}
.postbox-form-note {
margin: 15px 0 0;
color: var(--muted);