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