Migrate Postbox interface patterns
This commit is contained in:
@@ -12,10 +12,12 @@ import {
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
AdminPageLayout,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
@@ -23,6 +25,9 @@ import {
|
||||
SelectionListItem,
|
||||
StatusBadge,
|
||||
ToggleSwitch,
|
||||
i18nMessage,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
@@ -46,6 +51,12 @@ import {
|
||||
type PostboxTemplateCreatePayload,
|
||||
type PostboxTemplateRevisionPayload
|
||||
} from "../../api/postbox";
|
||||
import {
|
||||
POSTBOX_ADMIN_DOCUMENTATION,
|
||||
POSTBOX_FIELD_DOCUMENTATION,
|
||||
POSTBOX_INTERFACE_I18N,
|
||||
postboxBusyReason
|
||||
} from "./interfacePatterns";
|
||||
|
||||
|
||||
type AdminMode = "templates" | "postboxes";
|
||||
@@ -131,8 +142,10 @@ export default function PostboxAdminPanel({
|
||||
const [success, setSuccess] = useState("");
|
||||
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
|
||||
const [templateDraft, setTemplateDraft] = useState<TemplateDraft>(templateDefaults);
|
||||
const [templateBaseline, setTemplateBaseline] = useState<TemplateDraft>(templateDefaults);
|
||||
const [exactDialogOpen, setExactDialogOpen] = useState(false);
|
||||
const [exactDraft, setExactDraft] = useState<ExactDraft>(exactDefaults);
|
||||
const [exactBaseline, setExactBaseline] = useState<ExactDraft>(exactDefaults);
|
||||
const [materializeDialogOpen, setMaterializeDialogOpen] = useState(false);
|
||||
const [materializeDraft, setMaterializeDraft] = useState<MaterializeDraft>({
|
||||
templateId: "",
|
||||
@@ -140,7 +153,15 @@ export default function PostboxAdminPanel({
|
||||
function_id: "",
|
||||
context_key: ""
|
||||
});
|
||||
const [materializeBaseline, setMaterializeBaseline] = useState<MaterializeDraft>({
|
||||
templateId: "",
|
||||
organization_unit_id: "",
|
||||
function_id: "",
|
||||
context_key: ""
|
||||
});
|
||||
const [archiveTarget, setArchiveTarget] = useState<PostboxDirectoryItem | null>(null);
|
||||
const [retireTarget, setRetireTarget] = useState<PostboxTemplate | null>(null);
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const selectedTemplate = useMemo(
|
||||
() => templates.find((template) => template.id === selectedTemplateId) ?? templates[0] ?? null,
|
||||
@@ -170,6 +191,17 @@ export default function PostboxAdminPanel({
|
||||
}
|
||||
return [...values].map(([id, example]) => ({ id, example }));
|
||||
}, [units]);
|
||||
const templateDirty = templateDialogOpen && draftKey(templateDraft) !== draftKey(templateBaseline);
|
||||
const exactDirty = exactDialogOpen && draftKey(exactDraft) !== draftKey(exactBaseline);
|
||||
const materializeDirty = materializeDialogOpen && draftKey(materializeDraft) !== draftKey(materializeBaseline);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: templateDirty || exactDirty || materializeDirty,
|
||||
title: "Unsaved Postbox administration draft",
|
||||
message: "Save or discard the open Postbox administration draft before leaving this surface.",
|
||||
onSave: saveActiveDraft,
|
||||
onDiscard: discardAdminDraft
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -206,14 +238,16 @@ export default function PostboxAdminPanel({
|
||||
}, [load]);
|
||||
|
||||
function openNewTemplate() {
|
||||
setTemplateDraft(templateDefaults());
|
||||
const next = templateDefaults();
|
||||
setTemplateDraft(next);
|
||||
setTemplateBaseline(next);
|
||||
setTemplateDialogOpen(true);
|
||||
}
|
||||
|
||||
function openTemplateRevision(template: PostboxTemplate) {
|
||||
const revision = currentRevision(template);
|
||||
if (!revision) return;
|
||||
setTemplateDraft({
|
||||
const next = {
|
||||
templateId: template.id,
|
||||
slug: template.slug,
|
||||
name: template.name,
|
||||
@@ -226,11 +260,13 @@ export default function PostboxAdminPanel({
|
||||
classification: revision.classification,
|
||||
allow_vacant_delivery: revision.allow_vacant_delivery,
|
||||
routing_policy: revision.routing_policy ?? routingDefaults()
|
||||
});
|
||||
};
|
||||
setTemplateDraft(next);
|
||||
setTemplateBaseline(next);
|
||||
setTemplateDialogOpen(true);
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
async function saveTemplate(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
@@ -255,10 +291,13 @@ export default function PostboxAdminPanel({
|
||||
});
|
||||
setSuccess("Postbox template created as a draft.");
|
||||
}
|
||||
setTemplateBaseline(templateDraft);
|
||||
setTemplateDialogOpen(false);
|
||||
await load();
|
||||
return true;
|
||||
} catch (actionError) {
|
||||
setError(errorMessage(actionError));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -275,23 +314,9 @@ export default function PostboxAdminPanel({
|
||||
selectedTemplate,
|
||||
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);
|
||||
setSuccess("Postbox template retired. Existing addresses remain durable.");
|
||||
setSuccess(i18nMessage("i18n:govoplan-postbox.published_revision_message", {
|
||||
revision: selectedTemplate.current_revision
|
||||
}));
|
||||
await load();
|
||||
} catch (actionError) {
|
||||
setError(errorMessage(actionError));
|
||||
@@ -302,15 +327,17 @@ export default function PostboxAdminPanel({
|
||||
|
||||
function openExact() {
|
||||
const unit = units.find((item) => item.functions.length);
|
||||
setExactDraft({
|
||||
const next = {
|
||||
...exactDefaults(),
|
||||
organization_unit_id: unit?.id ?? "",
|
||||
function_id: unit?.functions[0]?.id ?? ""
|
||||
});
|
||||
};
|
||||
setExactDraft(next);
|
||||
setExactBaseline(next);
|
||||
setExactDialogOpen(true);
|
||||
}
|
||||
|
||||
async function saveExact() {
|
||||
async function saveExact(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
@@ -320,11 +347,14 @@ export default function PostboxAdminPanel({
|
||||
description: exactDraft.description || null,
|
||||
address_key: exactDraft.address_key || null
|
||||
});
|
||||
setExactBaseline(exactDraft);
|
||||
setExactDialogOpen(false);
|
||||
setSuccess("Exact function-bound Postbox created.");
|
||||
await load();
|
||||
return true;
|
||||
} catch (actionError) {
|
||||
setError(errorMessage(actionError));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -334,16 +364,18 @@ export default function PostboxAdminPanel({
|
||||
const revision = currentRevision(template);
|
||||
const compatible = compatibleTargets(units, revision?.function_type_id);
|
||||
const unit = compatible[0];
|
||||
setMaterializeDraft({
|
||||
const next = {
|
||||
templateId: template.id,
|
||||
organization_unit_id: unit?.id ?? "",
|
||||
function_id: unit?.functions[0]?.id ?? "",
|
||||
context_key: ""
|
||||
});
|
||||
};
|
||||
setMaterializeDraft(next);
|
||||
setMaterializeBaseline(next);
|
||||
setMaterializeDialogOpen(true);
|
||||
}
|
||||
|
||||
async function materialize() {
|
||||
async function materialize(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
@@ -353,11 +385,14 @@ export default function PostboxAdminPanel({
|
||||
function_id: materializeDraft.function_id,
|
||||
context_key: materializeDraft.context_key || null
|
||||
});
|
||||
setMaterializeBaseline(materializeDraft);
|
||||
setMaterializeDialogOpen(false);
|
||||
setSuccess("Stable Postbox address resolved and materialized.");
|
||||
await load();
|
||||
return true;
|
||||
} catch (actionError) {
|
||||
setError(errorMessage(actionError));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -380,6 +415,73 @@ export default function PostboxAdminPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRetire() {
|
||||
if (!retireTarget) return;
|
||||
setSelectedTemplateId(retireTarget.id);
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
await retirePostboxTemplate(settings, retireTarget);
|
||||
setSuccess("Postbox template retired. Existing addresses remain durable.");
|
||||
setRetireTarget(null);
|
||||
await load();
|
||||
} catch (actionError) {
|
||||
setError(errorMessage(actionError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function saveActiveDraft(): Promise<boolean> {
|
||||
if (templateDirty) return saveTemplate();
|
||||
if (exactDirty) return saveExact();
|
||||
if (materializeDirty) return materialize();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
function discardAdminDraft() {
|
||||
if (templateDialogOpen) {
|
||||
setTemplateDraft(templateBaseline);
|
||||
setTemplateDialogOpen(false);
|
||||
}
|
||||
if (exactDialogOpen) {
|
||||
setExactDraft(exactBaseline);
|
||||
setExactDialogOpen(false);
|
||||
}
|
||||
if (materializeDialogOpen) {
|
||||
setMaterializeDraft(materializeBaseline);
|
||||
setMaterializeDialogOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeTemplateDialog() {
|
||||
const close = () => {
|
||||
setTemplateDraft(templateBaseline);
|
||||
setTemplateDialogOpen(false);
|
||||
};
|
||||
if (templateDirty) requestDiscard(close);
|
||||
else close();
|
||||
}
|
||||
|
||||
function closeExactDialog() {
|
||||
const close = () => {
|
||||
setExactDraft(exactBaseline);
|
||||
setExactDialogOpen(false);
|
||||
};
|
||||
if (exactDirty) requestDiscard(close);
|
||||
else close();
|
||||
}
|
||||
|
||||
function closeMaterializeDialog() {
|
||||
const close = () => {
|
||||
setMaterializeDraft(materializeBaseline);
|
||||
setMaterializeDialogOpen(false);
|
||||
};
|
||||
if (materializeDirty) requestDiscard(close);
|
||||
else close();
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageLayout
|
||||
title="Postboxes"
|
||||
@@ -393,16 +495,18 @@ export default function PostboxAdminPanel({
|
||||
<IconButton
|
||||
label="Refresh"
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={() => void load()}
|
||||
disabled={busy}
|
||||
onClick={() => requestDiscard(() => void load())}
|
||||
disabled={loading || busy}
|
||||
disabledReason={postboxBusyReason(loading, busy)}
|
||||
/>
|
||||
<DocumentationHelpLink reference={POSTBOX_ADMIN_DOCUMENTATION} />
|
||||
{mode === "templates" && canManageTemplates ? (
|
||||
<Button variant="primary" onClick={openNewTemplate}>
|
||||
<Button variant="primary" onClick={openNewTemplate} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>
|
||||
<Plus size={16} /> New template
|
||||
</Button>
|
||||
) : null}
|
||||
{mode === "postboxes" && canManageBindings ? (
|
||||
<Button variant="primary" onClick={openExact}>
|
||||
<Button variant="primary" onClick={openExact} disabled={busy || !units.some((unit) => unit.functions.length)} disabledReason={postboxBusyReason(false, busy) ?? (!units.some((unit) => unit.functions.length) ? POSTBOX_INTERFACE_I18N.organizationTargetSummary : undefined)}>
|
||||
<Plus size={16} /> Exact postbox
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -418,6 +522,22 @@ export default function PostboxAdminPanel({
|
||||
]}
|
||||
ariaLabel="Postbox administration section"
|
||||
/>
|
||||
{mode === "postboxes" && !units.some((unit) => unit.functions.length) ? (
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: POSTBOX_INTERFACE_I18N.organizationTargetSummary,
|
||||
requiredAction: POSTBOX_INTERFACE_I18N.organizationTargetAction,
|
||||
actor: POSTBOX_INTERFACE_I18N.organizationTargetActor,
|
||||
target: POSTBOX_INTERFACE_I18N.organizationTargetDestination
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: POSTBOX_INTERFACE_I18N.requiredAction,
|
||||
actor: POSTBOX_INTERFACE_I18N.actor,
|
||||
target: POSTBOX_INTERFACE_I18N.destination
|
||||
}}
|
||||
documentation={POSTBOX_ADMIN_DOCUMENTATION}
|
||||
/>
|
||||
) : null}
|
||||
{mode === "templates" ? (
|
||||
<TemplateWorkspace
|
||||
templates={templates}
|
||||
@@ -425,7 +545,7 @@ export default function PostboxAdminPanel({
|
||||
onSelect={setSelectedTemplateId}
|
||||
onRevise={openTemplateRevision}
|
||||
onPublish={() => void publishSelected()}
|
||||
onRetire={() => void retireSelected()}
|
||||
onRetire={() => selectedTemplate && setRetireTarget(selectedTemplate)}
|
||||
onMaterialize={openMaterialize}
|
||||
busy={busy}
|
||||
canManageBindings={canManageBindings}
|
||||
@@ -450,7 +570,7 @@ export default function PostboxAdminPanel({
|
||||
unitTypes={unitTypes}
|
||||
busy={busy}
|
||||
onChange={setTemplateDraft}
|
||||
onClose={() => setTemplateDialogOpen(false)}
|
||||
onClose={closeTemplateDialog}
|
||||
onSave={() => void saveTemplate()}
|
||||
/>
|
||||
<ExactPostboxDialog
|
||||
@@ -459,7 +579,7 @@ export default function PostboxAdminPanel({
|
||||
units={units}
|
||||
busy={busy}
|
||||
onChange={setExactDraft}
|
||||
onClose={() => setExactDialogOpen(false)}
|
||||
onClose={closeExactDialog}
|
||||
onSave={() => void saveExact()}
|
||||
/>
|
||||
<MaterializeDialog
|
||||
@@ -469,7 +589,7 @@ export default function PostboxAdminPanel({
|
||||
units={units}
|
||||
busy={busy}
|
||||
onChange={setMaterializeDraft}
|
||||
onClose={() => setMaterializeDialogOpen(false)}
|
||||
onClose={closeMaterializeDialog}
|
||||
onSave={() => void materialize()}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
@@ -477,7 +597,7 @@ export default function PostboxAdminPanel({
|
||||
title="Archive Postbox"
|
||||
message={
|
||||
archiveTarget
|
||||
? `Archive "${archiveTarget.name}"? Its messages and delivery evidence remain retained, but the address stops accepting new delivery.`
|
||||
? i18nMessage("i18n:govoplan-postbox.archive_confirmation", { name: archiveTarget.name })
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Archive"
|
||||
@@ -486,6 +606,18 @@ export default function PostboxAdminPanel({
|
||||
onConfirm={() => void confirmArchive()}
|
||||
onCancel={() => setArchiveTarget(null)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(retireTarget)}
|
||||
title="Retire Postbox template"
|
||||
message={retireTarget
|
||||
? i18nMessage("i18n:govoplan-postbox.retire_template_confirmation", { name: retireTarget.name })
|
||||
: ""}
|
||||
confirmLabel="Retire"
|
||||
tone="danger"
|
||||
busy={busy}
|
||||
onConfirm={() => void confirmRetire()}
|
||||
onCancel={() => setRetireTarget(null)}
|
||||
/>
|
||||
</AdminPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -529,7 +661,7 @@ function TemplateWorkspace({
|
||||
<StatusBadge status={template.status} />
|
||||
</span>
|
||||
<span className="postbox-item-context">
|
||||
{template.slug} · revision {template.current_revision}
|
||||
{template.slug} · {i18nMessage("i18n:govoplan-postbox.revision_label", { revision: template.current_revision })}
|
||||
</span>
|
||||
</SelectionListItem>
|
||||
))}
|
||||
@@ -546,24 +678,41 @@ function TemplateWorkspace({
|
||||
<p>{selected.description || "No description."}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => onRevise(selected)} disabled={busy || selected.status === "retired"}>
|
||||
<Button
|
||||
onClick={() => onRevise(selected)}
|
||||
disabled={busy || selected.status === "retired"}
|
||||
disabledReason={postboxBusyReason(false, busy) ?? (selected.status === "retired" ? POSTBOX_INTERFACE_I18N.retiredTemplate : undefined)}
|
||||
>
|
||||
<Pencil size={16} /> New revision
|
||||
</Button>
|
||||
<Button onClick={onPublish} disabled={busy || selected.status === "retired" || Boolean(revision.published_at)}>
|
||||
<Button
|
||||
onClick={onPublish}
|
||||
disabled={busy || selected.status === "retired" || Boolean(revision.published_at)}
|
||||
disabledReason={postboxBusyReason(false, busy) ?? (selected.status === "retired" ? POSTBOX_INTERFACE_I18N.retiredTemplate : revision.published_at ? POSTBOX_INTERFACE_I18N.publishedRevision : undefined)}
|
||||
>
|
||||
<Save size={16} /> Publish
|
||||
</Button>
|
||||
{canManageBindings ? (
|
||||
<Button onClick={() => onMaterialize(selected)} disabled={busy || selected.status !== "published"}>
|
||||
<Button
|
||||
onClick={() => onMaterialize(selected)}
|
||||
disabled={busy || selected.status !== "published"}
|
||||
disabledReason={postboxBusyReason(false, busy) ?? (selected.status !== "published" ? POSTBOX_INTERFACE_I18N.unpublishedTemplate : undefined)}
|
||||
>
|
||||
<Rocket size={16} /> Resolve address
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="danger" onClick={onRetire} disabled={busy || selected.status === "retired"}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onRetire}
|
||||
disabled={busy || selected.status === "retired"}
|
||||
disabledReason={postboxBusyReason(false, busy) ?? (selected.status === "retired" ? POSTBOX_INTERFACE_I18N.retiredTemplate : undefined)}
|
||||
>
|
||||
<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>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>
|
||||
@@ -572,7 +721,10 @@ function TemplateWorkspace({
|
||||
<dt>Hierarchy copies</dt>
|
||||
<dd>
|
||||
{revision.routing_policy.linked_copy.enabled
|
||||
? `${revision.routing_policy.linked_copy.fanout} · depth ${revision.routing_policy.linked_copy.max_depth}`
|
||||
? i18nMessage("i18n:govoplan-postbox.depth_label", {
|
||||
fanout: revision.routing_policy.linked_copy.fanout,
|
||||
depth: revision.routing_policy.linked_copy.max_depth
|
||||
})
|
||||
: "Disabled"}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -580,7 +732,9 @@ function TemplateWorkspace({
|
||||
<dt>Vacancy escalation</dt>
|
||||
<dd>
|
||||
{revision.routing_policy.attention.mode === "vacancy_escalation"
|
||||
? `${revision.routing_policy.attention.delay_minutes} minutes`
|
||||
? i18nMessage("i18n:govoplan-postbox.minutes_label", {
|
||||
minutes: revision.routing_policy.attention.delay_minutes
|
||||
})
|
||||
: "Disabled"}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -592,7 +746,7 @@ function TemplateWorkspace({
|
||||
<h3>Immutable revisions</h3>
|
||||
{selected.revisions.map((item) => (
|
||||
<div key={item.id}>
|
||||
<strong>Revision {item.revision}</strong>
|
||||
<strong>{i18nMessage("i18n:govoplan-postbox.revision_label", { revision: item.revision })}</strong>
|
||||
<span>{item.classification} · {item.scope_kind}</span>
|
||||
<StatusBadge
|
||||
status={item.published_at ? "published" : "draft"}
|
||||
@@ -664,6 +818,7 @@ function PostboxWorkspace({
|
||||
variant="danger"
|
||||
onClick={() => onArchive(selected)}
|
||||
disabled={busy || selected.status !== "active"}
|
||||
disabledReason={postboxBusyReason(false, busy) ?? (selected.status !== "active" ? POSTBOX_INTERFACE_I18N.archivedPostbox : undefined)}
|
||||
>
|
||||
<Archive size={16} /> Archive
|
||||
</Button>
|
||||
@@ -764,36 +919,41 @@ function TemplateDialog({
|
||||
closeDisabled={busy}
|
||||
footer={
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button variant="primary" onClick={onSave} disabled={busy || !valid}>
|
||||
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onSave}
|
||||
disabled={busy || !valid}
|
||||
disabledReason={postboxBusyReason(false, busy) ?? (!valid ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
|
||||
>
|
||||
{isRevision ? "Create revision" : "Create draft"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="postbox-form-grid two-columns">
|
||||
<FormField label="Name">
|
||||
<FormField label="Name" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={draft.name}
|
||||
disabled={isRevision}
|
||||
onChange={(event) => onChange({ ...draft, name: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Slug">
|
||||
<FormField label="Slug" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={draft.slug}
|
||||
disabled={isRevision}
|
||||
onChange={(event) => onChange({ ...draft, slug: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Description">
|
||||
<FormField label="Description" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={draft.description || ""}
|
||||
disabled={isRevision}
|
||||
onChange={(event) => onChange({ ...draft, description: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Function type">
|
||||
<FormField label="Function type" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={draft.function_type_id || ""}
|
||||
onChange={(event) => onChange({
|
||||
@@ -807,7 +967,7 @@ function TemplateDialog({
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Scope">
|
||||
<FormField label="Scope" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={draft.scope_kind}
|
||||
onChange={(event) => {
|
||||
@@ -826,7 +986,7 @@ function TemplateDialog({
|
||||
</select>
|
||||
</FormField>
|
||||
{draft.scope_kind !== "tenant" ? (
|
||||
<FormField label="Scope target">
|
||||
<FormField label="Scope target" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={draft.scope_id || ""}
|
||||
onChange={(event) => onChange({ ...draft, scope_id: event.target.value || null })}
|
||||
@@ -838,19 +998,19 @@ function TemplateDialog({
|
||||
</select>
|
||||
</FormField>
|
||||
) : <div />}
|
||||
<FormField label="Name pattern">
|
||||
<FormField label="Name pattern" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={draft.name_pattern}
|
||||
onChange={(event) => onChange({ ...draft, name_pattern: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Address pattern">
|
||||
<FormField label="Address pattern" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={draft.address_pattern}
|
||||
onChange={(event) => onChange({ ...draft, address_pattern: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Classification">
|
||||
<FormField label="Classification" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={draft.classification}
|
||||
onChange={(event) => onChange({ ...draft, classification: event.target.value })}
|
||||
@@ -859,6 +1019,7 @@ function TemplateDialog({
|
||||
<div className="postbox-toggle-field">
|
||||
<ToggleSwitch
|
||||
label="Accept delivery while vacant"
|
||||
help="Messages remain institutionally retained even when no incumbent currently has access."
|
||||
checked={draft.allow_vacant_delivery}
|
||||
onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })}
|
||||
/>
|
||||
@@ -868,9 +1029,11 @@ function TemplateDialog({
|
||||
<div>
|
||||
<strong>Hierarchy linked copies</strong>
|
||||
<span>Copy to explicitly bounded function Postboxes in one selected structure.</span>
|
||||
<DocumentationHelpLink reference={POSTBOX_FIELD_DOCUMENTATION} />
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
label="Enable hierarchy linked copies"
|
||||
help="Copies create independent deliveries and evidence at explicitly bounded hierarchy targets."
|
||||
checked={linkedCopy.enabled}
|
||||
onChange={(enabled) => updateLinkedCopy({
|
||||
enabled,
|
||||
@@ -882,7 +1045,7 @@ function TemplateDialog({
|
||||
</div>
|
||||
{linkedCopy.enabled ? (
|
||||
<div className="postbox-form-grid two-columns">
|
||||
<FormField label="Organization structure">
|
||||
<FormField label="Organization structure" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={linkedCopy.structure_id || ""}
|
||||
onChange={(event) => updateLinkedCopy({
|
||||
@@ -896,7 +1059,7 @@ function TemplateDialog({
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Relation type">
|
||||
<FormField label="Relation type" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={linkedCopy.relation_type_ids[0] || ""}
|
||||
onChange={(event) => updateLinkedCopy({
|
||||
@@ -911,7 +1074,7 @@ function TemplateDialog({
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Target function type">
|
||||
<FormField label="Target function type" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={linkedCopy.target_function_type_id || ""}
|
||||
onChange={(event) => updateLinkedCopy({
|
||||
@@ -924,7 +1087,7 @@ function TemplateDialog({
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Target Postbox template">
|
||||
<FormField label="Target Postbox template" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={linkedCopy.target_template_id || ""}
|
||||
onChange={(event) => updateLinkedCopy({
|
||||
@@ -939,7 +1102,7 @@ function TemplateDialog({
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Maximum hierarchy depth">
|
||||
<FormField label="Maximum hierarchy depth" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -950,7 +1113,7 @@ function TemplateDialog({
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Copy behavior">
|
||||
<FormField label="Copy behavior" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={linkedCopy.fanout}
|
||||
onChange={(event) => {
|
||||
@@ -968,7 +1131,7 @@ function TemplateDialog({
|
||||
<option value="all">All matching ancestors</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Optional stop unit">
|
||||
<FormField label="Optional stop unit" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={linkedCopy.stop_unit_id || ""}
|
||||
onChange={(event) => updateLinkedCopy({
|
||||
@@ -981,7 +1144,7 @@ function TemplateDialog({
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Optional stop unit type">
|
||||
<FormField label="Optional stop unit type" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={linkedCopy.stop_unit_type_id || ""}
|
||||
onChange={(event) => updateLinkedCopy({
|
||||
@@ -994,7 +1157,7 @@ function TemplateDialog({
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Allowed classifications">
|
||||
<FormField label="Allowed classifications" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={linkedCopy.allowed_classifications.join(", ")}
|
||||
onChange={(event) => updateLinkedCopy({
|
||||
@@ -1002,7 +1165,7 @@ function TemplateDialog({
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Authorized producer modules">
|
||||
<FormField label="Authorized producer modules" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
placeholder="campaigns, workflow"
|
||||
value={linkedCopy.allowed_producer_modules.join(", ")}
|
||||
@@ -1014,11 +1177,12 @@ function TemplateDialog({
|
||||
<div className="postbox-toggle-field">
|
||||
<ToggleSwitch
|
||||
label="Require message expiry"
|
||||
help="Reject copied deliveries that do not carry an explicit expiry boundary."
|
||||
checked={linkedCopy.require_expiry}
|
||||
onChange={(require_expiry) => updateLinkedCopy({ require_expiry })}
|
||||
/>
|
||||
</div>
|
||||
<FormField label="Maximum retention days">
|
||||
<FormField label="Maximum retention days" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -1034,6 +1198,7 @@ function TemplateDialog({
|
||||
<div className="postbox-toggle-field">
|
||||
<ToggleSwitch
|
||||
label="Escalate when the nearest target remains vacant"
|
||||
help="Schedules a separate, auditable delivery only after the configured vacancy delay."
|
||||
checked={attention.mode === "vacancy_escalation"}
|
||||
onChange={(checked) => updateRouting({
|
||||
...draft.routing_policy,
|
||||
@@ -1047,7 +1212,7 @@ function TemplateDialog({
|
||||
/>
|
||||
</div>
|
||||
{attention.mode === "vacancy_escalation" ? (
|
||||
<FormField label="Vacancy escalation delay (minutes)">
|
||||
<FormField label="Vacancy escalation delay (minutes)" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -1104,11 +1269,12 @@ function ExactPostboxDialog({
|
||||
closeDisabled={busy}
|
||||
footer={
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onSave}
|
||||
disabled={busy || !draft.name.trim() || !draft.organization_unit_id || !draft.function_id}
|
||||
disabledReason={postboxBusyReason(false, busy) ?? ((!draft.name.trim() || !draft.organization_unit_id || !draft.function_id) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
|
||||
>
|
||||
Create Postbox
|
||||
</Button>
|
||||
@@ -1116,13 +1282,13 @@ function ExactPostboxDialog({
|
||||
}
|
||||
>
|
||||
<div className="postbox-form-grid two-columns">
|
||||
<FormField label="Name">
|
||||
<FormField label="Name" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input value={draft.name} onChange={(event) => onChange({ ...draft, name: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Address key">
|
||||
<FormField label="Address key" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input value={draft.address_key || ""} placeholder="Generated when empty" onChange={(event) => onChange({ ...draft, address_key: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Organization unit">
|
||||
<FormField label="Organization unit" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={draft.organization_unit_id}
|
||||
onChange={(event) => {
|
||||
@@ -1140,7 +1306,7 @@ function ExactPostboxDialog({
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Function">
|
||||
<FormField label="Function" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select value={draft.function_id} onChange={(event) => onChange({ ...draft, function_id: event.target.value })}>
|
||||
<option value="">Select function</option>
|
||||
{(unit?.functions || []).map((fn) => (
|
||||
@@ -1148,10 +1314,10 @@ function ExactPostboxDialog({
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Classification">
|
||||
<FormField label="Classification" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input value={draft.classification} onChange={(event) => onChange({ ...draft, classification: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Description">
|
||||
<FormField label="Description" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input value={draft.description || ""} onChange={(event) => onChange({ ...draft, description: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -1184,21 +1350,28 @@ function MaterializeDialog({
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={`Resolve address${template ? ` · ${template.name}` : ""}`}
|
||||
title={template
|
||||
? i18nMessage("i18n:govoplan-postbox.resolve_address_title", { name: template.name })
|
||||
: "Resolve address"}
|
||||
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}>
|
||||
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onSave}
|
||||
disabled={busy || !draft.organization_unit_id || !draft.function_id}
|
||||
disabledReason={postboxBusyReason(false, busy) ?? ((!draft.organization_unit_id || !draft.function_id) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
|
||||
>
|
||||
Resolve address
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="postbox-form-grid">
|
||||
<FormField label="Organization unit">
|
||||
<FormField label="Organization unit" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={draft.organization_unit_id}
|
||||
onChange={(event) => {
|
||||
@@ -1216,7 +1389,7 @@ function MaterializeDialog({
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Function">
|
||||
<FormField label="Function" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<select value={draft.function_id} onChange={(event) => onChange({ ...draft, function_id: event.target.value })}>
|
||||
<option value="">Select function</option>
|
||||
{(unit?.functions || []).map((fn) => (
|
||||
@@ -1224,7 +1397,7 @@ function MaterializeDialog({
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Optional case or service context">
|
||||
<FormField label="Optional case or service context" documentation={POSTBOX_FIELD_DOCUMENTATION}>
|
||||
<input value={draft.context_key} onChange={(event) => onChange({ ...draft, context_key: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -1272,3 +1445,7 @@ function errorMessage(error: unknown): string {
|
||||
function commaSeparated(value: string): string[] {
|
||||
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function draftKey(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user