Add scoped postbox template previews
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user