Migrate Postbox interface patterns

This commit is contained in:
2026-08-03 12:26:20 +02:00
parent 8f8d259a76
commit a97eb3bcef
12 changed files with 1209 additions and 152 deletions
+258 -81
View File
@@ -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);
}
@@ -5,7 +5,9 @@ import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
i18nMessage,
useDashboardWidgetData,
usePlatformLanguage,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
@@ -23,6 +25,7 @@ export default function PostboxInboxWidget({
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const { language } = usePlatformLanguage();
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
const load = useCallback(async () => {
const postboxes = await listPostboxes(settings);
@@ -53,7 +56,7 @@ export default function PostboxInboxWidget({
id: message.id,
title: message.subject,
detail: message.sender_label || message.producer_module || "Postbox",
meta: new Intl.DateTimeFormat(undefined, {
meta: new Intl.DateTimeFormat(language, {
day: "2-digit",
month: "short",
hour: "2-digit",
@@ -61,7 +64,7 @@ export default function PostboxInboxWidget({
}).format(new Date(message.delivered_at)),
leading: <Inbox size={17} aria-hidden="true" />,
trailing: message.attachments.length ? (
<span title={`${message.attachments.length} attachments`}>
<span title={i18nMessage("i18n:govoplan-postbox.attachments_label", { count: message.attachments.length })}>
<Paperclip size={15} aria-hidden="true" />
</span>
) : undefined,
@@ -70,7 +73,9 @@ export default function PostboxInboxWidget({
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/postbox">
{data?.total ? `Open Postbox (${data.total} unread)` : "Open Postbox"}
{data?.total
? i18nMessage("i18n:govoplan-postbox.open_unread", { count: data.total })
: "Open Postbox"}
</Link>
</div>
</LoadingFrame>
+170 -53
View File
@@ -18,10 +18,13 @@ import {
X
} from "lucide-react";
import {
ActionBlockerHint,
Button,
ConfirmDialog,
DataGridPaginationBar,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
IconButton,
SegmentedControl,
@@ -30,7 +33,11 @@ import {
StatusBadge,
ToggleSwitch,
hasScope,
i18nMessage,
isApiError,
usePlatformLanguage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
@@ -49,6 +56,12 @@ import {
type PostboxGrouping,
type PostboxMessage
} from "../../api/postbox";
import {
POSTBOX_DOCUMENTATION,
POSTBOX_FIELD_DOCUMENTATION,
POSTBOX_INTERFACE_I18N,
postboxBusyReason
} from "./interfacePatterns";
type GroupingDraft = {
@@ -114,13 +127,20 @@ export default function PostboxPage({
const [error, setError] = useState("");
const [groupingDialogOpen, setGroupingDialogOpen] = useState(false);
const [groupingDraft, setGroupingDraft] = useState<GroupingDraft>(emptyGrouping);
const [groupingBaseline, setGroupingBaseline] = useState<GroupingDraft>(emptyGrouping);
const [deleteGroupingTarget, setDeleteGroupingTarget] = useState<GroupingDraft | null>(null);
const [messageDialogOpen, setMessageDialogOpen] = useState(false);
const [replyParent, setReplyParent] = useState<PostboxMessage | null>(null);
const [messageDraft, setMessageDraft] = useState<MessageDraft>(emptyMessageDraft);
const [messageBaseline, setMessageBaseline] = useState<MessageDraft>(emptyMessageDraft);
const { language } = usePlatformLanguage();
const { requestDiscard } = useUnsavedChanges();
const canAcknowledge = hasScope(auth, "postbox:message:acknowledge");
const canSend = hasScope(auth, "postbox:message:write");
const canReply = hasScope(auth, "postbox:message:reply");
const groupingDirty = groupingDialogOpen && draftKey(groupingDraft) !== draftKey(groupingBaseline);
const messageDirty = messageDialogOpen && draftKey(messageDraft) !== draftKey(messageBaseline);
const selectedPostbox = useMemo(
() => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? null,
[postboxes, selectedPostboxId]
@@ -138,6 +158,32 @@ export default function PostboxPage({
return postboxes.map((postbox) => postbox.id);
}, [postboxes, selectedGrouping, selectedPostboxId]);
const scopeKey = scopePostboxIds.join("|");
const composeDisabledReason = postboxBusyReason(false, busy)
?? (!canSend ? POSTBOX_INTERFACE_I18N.noSendReason : undefined)
?? (!postboxes.length ? POSTBOX_INTERFACE_I18N.noPostbox : undefined);
const replyDisabledReason = postboxBusyReason(false, busy)
?? (!canReply ? POSTBOX_INTERFACE_I18N.noReplyReason : undefined)
?? (!selectedMessage ? POSTBOX_INTERFACE_I18N.noMessage : undefined)
?? (selectedMessage?.availability !== "available"
? POSTBOX_INTERFACE_I18N.unavailableMessage
: undefined);
const acknowledgeDisabledReason = postboxBusyReason(false, busy)
?? (!canAcknowledge ? POSTBOX_INTERFACE_I18N.noAcknowledgeReason : undefined)
?? (!selectedMessage ? POSTBOX_INTERFACE_I18N.noMessage : undefined)
?? (selectedMessage?.availability !== "available"
? POSTBOX_INTERFACE_I18N.unavailableMessage
: undefined)
?? (selectedMessage?.acknowledged_at
? POSTBOX_INTERFACE_I18N.alreadyAcknowledged
: undefined);
useUnsavedDraftGuard({
dirty: groupingDirty || messageDirty,
title: "Unsaved Postbox draft",
message: "Save or discard the open Postbox draft before leaving this surface.",
onSave: () => groupingDirty ? saveGrouping() : submitMessage(),
onDiscard: discardOpenDraft
});
const loadDirectory = useCallback(async () => {
setLoadingDirectory(true);
@@ -336,22 +382,26 @@ export default function PostboxPage({
}
function openNewGrouping() {
setGroupingDraft(emptyGrouping());
const next = emptyGrouping();
setGroupingDraft(next);
setGroupingBaseline(next);
setGroupingDialogOpen(true);
}
function openGrouping(grouping: PostboxGrouping) {
setGroupingDraft({
const next = {
id: grouping.id,
name: grouping.name,
is_default: grouping.is_default,
postbox_ids: [...grouping.postbox_ids]
});
};
setGroupingDraft(next);
setGroupingBaseline(next);
setGroupingDialogOpen(true);
}
async function saveGrouping() {
if (!groupingDraft.name.trim()) return;
async function saveGrouping(): Promise<boolean> {
if (!groupingDraft.name.trim()) return false;
setBusy(true);
setError("");
const payload = {
@@ -368,24 +418,28 @@ export default function PostboxPage({
setSelectedScope(saved.id);
setSelectedPostboxId("");
setGroupingDialogOpen(false);
setGroupingBaseline(groupingDraft);
return true;
} catch (actionError) {
setError(errorMessage(actionError));
return false;
} finally {
setBusy(false);
}
}
async function removeGrouping() {
if (!groupingDraft.id) return;
if (!deleteGroupingTarget?.id) return;
setBusy(true);
setError("");
try {
const existing = groupings.find((item) => item.id === groupingDraft.id);
const existing = groupings.find((item) => item.id === deleteGroupingTarget.id);
if (!existing) throw new Error("The grouping is no longer available.");
await deletePostboxGrouping(settings, existing);
setSelectedScope("all");
setSelectedPostboxId("");
setGroupingDialogOpen(false);
setDeleteGroupingTarget(null);
await loadDirectory();
} catch (actionError) {
setError(errorMessage(actionError));
@@ -398,30 +452,34 @@ export default function PostboxPage({
const postbox = selectedPostbox ?? postboxes[0] ?? null;
if (!postbox) return;
setReplyParent(null);
setMessageDraft({
const next = {
...emptyMessageDraft(),
postbox_id: postbox.id,
classification: postbox.classification
});
};
setMessageDraft(next);
setMessageBaseline(next);
setMessageDialogOpen(true);
}
function openReply() {
if (!selectedMessage) return;
setReplyParent(selectedMessage);
setMessageDraft({
const next = {
...emptyMessageDraft(),
postbox_id: selectedMessage.postbox_id,
subject: selectedMessage.subject.toLowerCase().startsWith("re:")
? selectedMessage.subject
: `Re: ${selectedMessage.subject}`,
classification: selectedMessage.classification
});
};
setMessageDraft(next);
setMessageBaseline(next);
setMessageDialogOpen(true);
}
async function submitMessage() {
if (!messageDraft.postbox_id || !messageDraft.subject.trim()) return;
async function submitMessage(): Promise<boolean> {
if (!messageDraft.postbox_id || !messageDraft.subject.trim()) return false;
setBusy(true);
setError("");
const participants = messageDraft.recipients
@@ -467,13 +525,47 @@ export default function PostboxPage({
);
setMessages(refreshed.messages);
setTotal(refreshed.total);
setMessageBaseline(messageDraft);
return true;
} catch (actionError) {
setError(errorMessage(actionError));
return false;
} finally {
setBusy(false);
}
}
function discardOpenDraft() {
if (groupingDialogOpen) {
setGroupingDraft(groupingBaseline);
setGroupingDialogOpen(false);
}
if (messageDialogOpen) {
setMessageDraft(messageBaseline);
setMessageDialogOpen(false);
setReplyParent(null);
}
}
function closeGroupingDialog() {
const close = () => {
setGroupingDraft(groupingBaseline);
setGroupingDialogOpen(false);
};
if (groupingDirty) requestDiscard(close);
else close();
}
function closeMessageDialog() {
const close = () => {
setMessageDraft(messageBaseline);
setMessageDialogOpen(false);
setReplyParent(null);
};
if (messageDirty) requestDiscard(close);
else close();
}
return (
<main className="workspace-data-page module-entry-page postbox-page">
<div className="postbox-shell">
@@ -482,18 +574,22 @@ export default function PostboxPage({
<div className="postbox-bar-title">
<Inbox size={17} aria-hidden="true" />
<strong>Postbox</strong>
<DocumentationHelpLink reference={POSTBOX_DOCUMENTATION} />
</div>
<div className="postbox-icon-actions">
<IconButton
label="New unified view"
icon={<Plus size={16} />}
onClick={openNewGrouping}
disabled={busy}
disabledReason={postboxBusyReason(false, busy)}
/>
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
onClick={() => void loadDirectory()}
onClick={() => requestDiscard(() => void loadDirectory())}
disabled={loadingDirectory || busy}
disabledReason={postboxBusyReason(loadingDirectory, busy)}
/>
</div>
</div>
@@ -530,6 +626,20 @@ export default function PostboxPage({
<Archive size={20} />
<strong>No assigned postboxes</strong>
<p>Postboxes appear when your account has a current matching function assignment.</p>
<ActionBlockerHint
reason={{
summary: POSTBOX_INTERFACE_I18N.noPostbox,
requiredAction: POSTBOX_INTERFACE_I18N.assignmentAction,
actor: POSTBOX_INTERFACE_I18N.assignmentActor,
target: POSTBOX_INTERFACE_I18N.assignmentDestination
}}
labels={{
requiredAction: POSTBOX_INTERFACE_I18N.requiredAction,
actor: POSTBOX_INTERFACE_I18N.actor,
target: POSTBOX_INTERFACE_I18N.destination
}}
documentation={POSTBOX_DOCUMENTATION}
/>
</div>
) : null}
{postboxes.length ? (
@@ -585,13 +695,15 @@ export default function PostboxPage({
label="New message"
icon={<Send size={16} />}
onClick={openCompose}
disabled={!canSend || !postboxes.length || busy}
disabled={Boolean(composeDisabledReason)}
disabledReason={composeDisabledReason}
/>
<IconButton
label="Refresh messages"
icon={<RefreshCw size={16} />}
onClick={() => void loadMessages()}
onClick={() => requestDiscard(() => void loadMessages())}
disabled={loadingMessages || busy}
disabledReason={postboxBusyReason(loadingMessages, busy)}
/>
</div>
</div>
@@ -679,7 +791,7 @@ export default function PostboxPage({
>
<span className="postbox-message-heading">
<strong>{message.subject}</strong>
<time>{formatDate(message.delivered_at)}</time>
<time>{formatDate(message.delivered_at, language)}</time>
</span>
<span className="postbox-message-preview">
{message.sender_label || message.producer_module || "Platform"}
@@ -734,31 +846,15 @@ export default function PostboxPage({
<div className="button-row compact-actions">
<Button
onClick={openReply}
disabled={
!canReply ||
!selectedMessage ||
selectedMessage.availability !== "available" ||
busy
}
disabled={Boolean(replyDisabledReason)}
disabledReason={replyDisabledReason}
>
<Reply size={16} /> Reply
</Button>
<Button
onClick={() => void acknowledgeSelected()}
disabled={
!selectedMessage ||
selectedMessage.availability !== "available" ||
Boolean(selectedMessage.acknowledged_at) ||
busy
}
disabledReason={
!canAcknowledge
? "You cannot acknowledge Postbox messages."
: selectedMessage &&
selectedMessage.availability !== "available"
? "Withdrawn or expired messages cannot be acknowledged."
: undefined
}
disabled={Boolean(acknowledgeDisabledReason)}
disabledReason={acknowledgeDisabledReason}
>
<CheckCheck size={16} /> Acknowledge
</Button>
@@ -789,27 +885,29 @@ export default function PostboxPage({
open={groupingDialogOpen}
title={groupingDraft.id ? "Edit unified view" : "New unified view"}
className="postbox-dialog"
onClose={() => setGroupingDialogOpen(false)}
onClose={closeGroupingDialog}
closeDisabled={busy}
footer={
<div className="postbox-dialog-actions">
{groupingDraft.id ? (
<Button
variant="danger"
onClick={() => void removeGrouping()}
onClick={() => setDeleteGroupingTarget(groupingDraft)}
disabled={busy}
disabledReason={postboxBusyReason(false, busy)}
>
<Trash2 size={16} /> Delete
</Button>
) : <span />}
<div className="button-row compact-actions">
<Button onClick={() => setGroupingDialogOpen(false)} disabled={busy}>
<Button onClick={closeGroupingDialog} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void saveGrouping()}
disabled={busy || !groupingDraft.name.trim()}
disabledReason={postboxBusyReason(false, busy) ?? (!groupingDraft.name.trim() ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
>
Save
</Button>
@@ -818,7 +916,7 @@ export default function PostboxPage({
}
>
<div className="postbox-form-grid">
<FormField label="Name">
<FormField label="Name" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={groupingDraft.name}
onChange={(event) =>
@@ -871,11 +969,11 @@ export default function PostboxPage({
open={messageDialogOpen}
title={replyParent ? "Reply" : "New message"}
className="postbox-dialog postbox-message-dialog"
onClose={() => setMessageDialogOpen(false)}
onClose={closeMessageDialog}
closeDisabled={busy}
footer={
<div className="postbox-dialog-actions end">
<Button onClick={() => setMessageDialogOpen(false)} disabled={busy}>
<Button onClick={closeMessageDialog} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>
Cancel
</Button>
<Button
@@ -886,6 +984,7 @@ export default function PostboxPage({
!messageDraft.postbox_id ||
!messageDraft.subject.trim()
}
disabledReason={postboxBusyReason(false, busy) ?? ((!messageDraft.postbox_id || !messageDraft.subject.trim()) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
>
<Send size={16} /> Send
</Button>
@@ -893,7 +992,7 @@ export default function PostboxPage({
}
>
<div className="postbox-compose-grid">
<FormField label="Postbox">
<FormField label="Postbox" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={messageDraft.postbox_id}
disabled={Boolean(replyParent)}
@@ -915,7 +1014,7 @@ export default function PostboxPage({
))}
</select>
</FormField>
<FormField label="Classification">
<FormField label="Classification" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={messageDraft.classification}
onChange={(event) =>
@@ -937,7 +1036,7 @@ export default function PostboxPage({
</select>
</FormField>
<div className="postbox-compose-wide">
<FormField label="Recipients">
<FormField label="Recipients" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={messageDraft.recipients}
placeholder="Separate addresses with commas"
@@ -951,7 +1050,7 @@ export default function PostboxPage({
</FormField>
</div>
<div className="postbox-compose-wide">
<FormField label="Subject">
<FormField label="Subject" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={messageDraft.subject}
onChange={(event) =>
@@ -964,7 +1063,7 @@ export default function PostboxPage({
</FormField>
</div>
<div className="postbox-compose-wide">
<FormField label="Message">
<FormField label="Message" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<textarea
rows={10}
value={messageDraft.body_text}
@@ -979,6 +1078,19 @@ export default function PostboxPage({
</div>
</div>
</Dialog>
<ConfirmDialog
open={Boolean(deleteGroupingTarget)}
title="Delete unified view"
message={deleteGroupingTarget
? i18nMessage("i18n:govoplan-postbox.delete_grouping_confirmation", { name: deleteGroupingTarget.name })
: ""}
confirmLabel="Delete"
tone="danger"
busy={busy}
onConfirm={() => void removeGrouping()}
onCancel={() => setDeleteGroupingTarget(null)}
/>
</main>
);
}
@@ -990,6 +1102,7 @@ function MessageDetail({
message: PostboxMessage;
postbox?: PostboxDirectoryItem;
}) {
const { language } = usePlatformLanguage();
return (
<div className="postbox-message-detail">
{message.availability !== "available" ? (
@@ -1014,7 +1127,7 @@ function MessageDetail({
<h1>{message.subject}</h1>
<div className="postbox-detail-byline">
<span>{message.sender_label || message.producer_module || "Platform"}</span>
<time>{formatLongDate(message.delivered_at)}</time>
<time>{formatLongDate(message.delivered_at, language)}</time>
</div>
</header>
<section className="postbox-provenance">
@@ -1122,10 +1235,10 @@ function classificationLabel(value: string): string {
return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
}
function formatDate(value: string): string {
function formatDate(value: string, language: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
return new Intl.DateTimeFormat(language, {
month: "short",
day: "numeric",
hour: "2-digit",
@@ -1133,10 +1246,10 @@ function formatDate(value: string): string {
}).format(date);
}
function formatLongDate(value: string): string {
function formatLongDate(value: string, language: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
return new Intl.DateTimeFormat(language, {
dateStyle: "long",
timeStyle: "short"
}).format(date);
@@ -1145,3 +1258,7 @@ function formatLongDate(value: string): string {
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Postbox request failed";
}
function draftKey(value: unknown): string {
return JSON.stringify(value);
}
@@ -0,0 +1,52 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const POSTBOX_DOCUMENTATION = {
topicId: "postbox.function-bound-containers",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const POSTBOX_ADMIN_DOCUMENTATION = {
topicId: "postbox.function-bound-containers",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const POSTBOX_FIELD_DOCUMENTATION = {
topicId: "postbox.reference.fields-and-consequences",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const POSTBOX_INTERFACE_I18N = {
loading: "i18n:govoplan-postbox.loading_reason",
busy: "i18n:govoplan-postbox.busy_reason",
noPostbox: "i18n:govoplan-postbox.no_postbox_reason",
noMessage: "i18n:govoplan-postbox.no_message_reason",
noSendReason: "i18n:govoplan-postbox.no_send_permission_reason",
noReplyReason: "i18n:govoplan-postbox.no_reply_permission_reason",
noAcknowledgeReason: "i18n:govoplan-postbox.no_acknowledge_permission_reason",
unavailableMessage: "i18n:govoplan-postbox.unavailable_message_reason",
alreadyAcknowledged: "i18n:govoplan-postbox.already_acknowledged_reason",
retiredTemplate: "i18n:govoplan-postbox.retired_template_reason",
publishedRevision: "i18n:govoplan-postbox.published_revision_reason",
unpublishedTemplate: "i18n:govoplan-postbox.unpublished_template_reason",
archivedPostbox: "i18n:govoplan-postbox.archived_postbox_reason",
incompleteDraft: "i18n:govoplan-postbox.incomplete_draft_reason",
requiredAction: "i18n:govoplan-postbox.required_action",
actor: "i18n:govoplan-postbox.responsible_actor",
destination: "i18n:govoplan-postbox.destination",
assignmentAction: "i18n:govoplan-postbox.assignment_required_action",
assignmentActor: "i18n:govoplan-postbox.assignment_responsible_actor",
assignmentDestination: "i18n:govoplan-postbox.assignment_destination",
organizationTargetSummary: "i18n:govoplan-postbox.organization_target_summary",
organizationTargetAction: "i18n:govoplan-postbox.organization_target_action",
organizationTargetActor: "i18n:govoplan-postbox.organization_target_actor",
organizationTargetDestination: "i18n:govoplan-postbox.organization_target_destination"
} as const;
export function postboxBusyReason(
loading: boolean,
busy: boolean
): string | undefined {
if (loading) return POSTBOX_INTERFACE_I18N.loading;
if (busy) return POSTBOX_INTERFACE_I18N.busy;
return undefined;
}