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
+2 -1
View File
@@ -14,7 +14,8 @@
"./styles/postbox.css": "./src/styles/postbox.css"
},
"scripts": {
"test:ui-structure": "node scripts/test-postbox-page-structure.mjs"
"test:ui-structure": "node scripts/test-postbox-page-structure.mjs",
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
function source(path) {
return readFileSync(new URL(path, import.meta.url), "utf8");
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const page = source("../src/features/postbox/PostboxPage.tsx");
const admin = source("../src/features/postbox/PostboxAdminPanel.tsx");
const widget = source("../src/features/postbox/PostboxInboxWidget.tsx");
const patterns = source("../src/features/postbox/interfacePatterns.ts");
const moduleSource = source("../src/module.ts");
const translations = source("../src/i18n/generatedTranslations.ts");
assert(page.includes("DocumentationHelpLink") && admin.includes("DocumentationHelpLink"), "Inbox and administration expose contextual documentation");
assert(page.includes("ActionBlockerHint") && admin.includes("ActionBlockerHint"), "Assignment and organization prerequisites identify action, actor, and destination");
assert(page.includes("disabledReason") && admin.includes("disabledReason"), "Unavailable Postbox actions explain their state");
assert(page.includes("useUnsavedDraftGuard") && admin.includes("useUnsavedDraftGuard"), "Message, grouping, template, and address drafts use the shared guard");
assert(page.includes("delete_grouping_confirmation") && page.includes("ConfirmDialog"), "Deleting a unified view confirms that source records remain unchanged");
assert(admin.includes("archive_confirmation") && admin.includes("retire_template_confirmation"), "Address and template lifecycle actions use shared destructive confirmation");
assert(patterns.includes('topicId: "postbox.function-bound-containers"') && patterns.includes('topicId: "postbox.reference.fields-and-consequences"'), "Postbox uses manifest-backed help references");
assert(moduleSource.includes('version: "0.1.2"') && moduleSource.includes("generatedTranslations"), "WebUI metadata matches the module release and registers translations");
assert(translations.includes('"i18n:govoplan-postbox.unavailable_message_reason"'), "Access-sensitive unavailable states are localized");
assert(widget.includes("usePlatformLanguage") && widget.includes("i18nMessage"), "Widget dates and dynamic accessible labels follow the platform locale");
assert(!page.includes("window.confirm") && !admin.includes("window.confirm"), "Postbox does not use browser-native consequential confirmation");
console.log("Postbox surfaces satisfy the recorded interface pattern-language contract.");
+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;
}
+468
View File
@@ -0,0 +1,468 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-postbox.postbox": "Postbox",
"i18n:govoplan-postbox.postboxes": "Postboxes",
"i18n:govoplan-postbox.postbox_inbox": "Postbox inbox",
"i18n:govoplan-postbox.postbox_inbox_description": "Unread messages across accessible Postboxes.",
"i18n:govoplan-postbox.communication": "Communication",
"i18n:govoplan-postbox.maximum_messages": "Maximum messages",
"i18n:govoplan-postbox.postbox_directory": "Postbox directory",
"i18n:govoplan-postbox.postbox_messages": "Postbox messages",
"i18n:govoplan-postbox.postbox_inbox_widget": "Postbox inbox widget",
"i18n:govoplan-postbox.postbox_templates_bindings": "Postbox templates and bindings",
"i18n:govoplan-postbox.loading_reason": "The Postbox data is still loading.",
"i18n:govoplan-postbox.busy_reason": "Another Postbox action is still running.",
"i18n:govoplan-postbox.no_postbox_reason": "No accessible Postbox is available for this action.",
"i18n:govoplan-postbox.no_message_reason": "Select a message before using this action.",
"i18n:govoplan-postbox.no_send_permission_reason": "Your account may not create Postbox messages.",
"i18n:govoplan-postbox.no_reply_permission_reason": "Your account may not reply to Postbox messages.",
"i18n:govoplan-postbox.no_acknowledge_permission_reason": "Your account may not acknowledge Postbox messages.",
"i18n:govoplan-postbox.unavailable_message_reason": "Withdrawn or expired messages cannot be changed.",
"i18n:govoplan-postbox.already_acknowledged_reason": "This message is already acknowledged.",
"i18n:govoplan-postbox.retired_template_reason": "Retired templates cannot receive new revisions or addresses.",
"i18n:govoplan-postbox.published_revision_reason": "This immutable revision is already published.",
"i18n:govoplan-postbox.unpublished_template_reason": "Publish the template before resolving an address.",
"i18n:govoplan-postbox.archived_postbox_reason": "Only active Postboxes can be archived.",
"i18n:govoplan-postbox.incomplete_draft_reason": "Complete all required fields before saving.",
"i18n:govoplan-postbox.required_action": "Required action",
"i18n:govoplan-postbox.responsible_actor": "Responsible actor",
"i18n:govoplan-postbox.destination": "Destination",
"i18n:govoplan-postbox.assignment_required_action": "Obtain an effective assignment to a function with a Postbox.",
"i18n:govoplan-postbox.assignment_responsible_actor": "An IDM or organization administrator",
"i18n:govoplan-postbox.assignment_destination": "IDM function assignments or Organizations",
"i18n:govoplan-postbox.organization_target_summary": "No organization function is available for a concrete Postbox address.",
"i18n:govoplan-postbox.organization_target_action": "Create or activate an organization unit and function first.",
"i18n:govoplan-postbox.organization_target_actor": "An organization administrator",
"i18n:govoplan-postbox.organization_target_destination": "Organizations",
"i18n:govoplan-postbox.archive_confirmation": "Archive \"{name}\"? Its messages and delivery evidence remain retained, but the address stops accepting new delivery.",
"i18n:govoplan-postbox.delete_grouping_confirmation": "Delete the unified view \"{name}\"? Only this personal projection is removed. Source Postboxes, messages, receipts, and evidence remain unchanged.",
"i18n:govoplan-postbox.retire_template_confirmation": "Retire \"{name}\"? Published addresses remain durable, but this template can no longer create new revisions or addresses.",
"i18n:govoplan-postbox.published_revision_message": "Published revision {revision}.",
"i18n:govoplan-postbox.resolve_address_title": "Resolve address · {name}",
"i18n:govoplan-postbox.revision_label": "Revision {revision}",
"i18n:govoplan-postbox.depth_label": "{fanout} · depth {depth}",
"i18n:govoplan-postbox.minutes_label": "{minutes} minutes",
"i18n:govoplan-postbox.attachments_label": "{count} attachments",
"i18n:govoplan-postbox.open_unread": "Open Postbox ({count} unread)",
"Refresh": "Refresh",
"New template": "New template",
"Exact postbox": "Exact postbox",
"Templates": "Templates",
"Postboxes": "Postboxes",
"Postbox administration section": "Postbox administration section",
"Archive Postbox": "Archive Postbox",
"Archive": "Archive",
"No Postbox templates.": "No Postbox templates.",
"Postbox templates": "Postbox templates",
"Template": "Template",
"No description.": "No description.",
"New revision": "New revision",
"Publish": "Publish",
"Resolve address": "Resolve address",
"Retire": "Retire",
"Retire Postbox template": "Retire Postbox template",
"Revision": "Revision",
"Function type": "Function type",
"Any function type": "Any function type",
"Scope": "Scope",
"Classification": "Classification",
"Vacant delivery": "Vacant delivery",
"Accepted": "Accepted",
"Blocked": "Blocked",
"Hierarchy copies": "Hierarchy copies",
"Disabled": "Disabled",
"Vacancy escalation": "Vacancy escalation",
"Encryption": "Encryption",
"Name pattern": "Name pattern",
"Address pattern": "Address pattern",
"Immutable revisions": "Immutable revisions",
"Published": "Published",
"Draft": "Draft",
"Select a template": "Select a template",
"Published revisions lazily resolve stable unit-specific addresses.": "Published revisions lazily resolve stable unit-specific addresses.",
"No materialized Postboxes.": "No materialized Postboxes.",
"Materialized Postboxes": "Materialized Postboxes",
"Organization unit": "Organization unit",
"Function": "Function",
"Address key": "Address key",
"Current holders": "Current holders",
"Vacancy": "Vacancy",
"Vacant": "Vacant",
"Staffed": "Staffed",
"Context": "Context",
"None": "None",
"Template revision": "Template revision",
"Exact Postbox": "Exact Postbox",
"Select a Postbox": "Select a Postbox",
"Materialized Postboxes remain durable through vacancy and reassignment.": "Materialized Postboxes remain durable through vacancy and reassignment.",
"Create template revision": "Create template revision",
"New Postbox template": "New Postbox template",
"Cancel": "Cancel",
"Create revision": "Create revision",
"Create draft": "Create draft",
"Name": "Name",
"Slug": "Slug",
"Description": "Description",
"Tenant": "Tenant",
"One unit": "One unit",
"Unit subtree": "Unit subtree",
"Unit type": "Unit type",
"Scope target": "Scope target",
"Select target": "Select target",
"Accept delivery while vacant": "Accept delivery while vacant",
"Hierarchy linked copies": "Hierarchy linked copies",
"Copy to explicitly bounded function Postboxes in one selected structure.": "Copy to explicitly bounded function Postboxes in one selected structure.",
"Enable hierarchy linked copies": "Enable hierarchy linked copies",
"Organization structure": "Organization structure",
"Select structure": "Select structure",
"Relation type": "Relation type",
"All hierarchical relations": "All hierarchical relations",
"Target function type": "Target function type",
"Select function type": "Select function type",
"Target Postbox template": "Target Postbox template",
"Select published template": "Select published template",
"Maximum hierarchy depth": "Maximum hierarchy depth",
"Copy behavior": "Copy behavior",
"Nearest matching ancestor": "Nearest matching ancestor",
"All matching ancestors": "All matching ancestors",
"Optional stop unit": "Optional stop unit",
"No unit stop": "No unit stop",
"Optional stop unit type": "Optional stop unit type",
"No unit-type stop": "No unit-type stop",
"Allowed classifications": "Allowed classifications",
"Authorized producer modules": "Authorized producer modules",
"Require message expiry": "Require message expiry",
"Maximum retention days": "Maximum retention days",
"Escalate when the nearest target remains vacant": "Escalate when the nearest target remains vacant",
"Vacancy escalation delay (minutes)": "Vacancy escalation delay (minutes)",
"Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.": "Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.",
"New exact Postbox": "New exact Postbox",
"Create Postbox": "Create Postbox",
"Generated when empty": "Generated when empty",
"Select unit": "Select unit",
"Select function": "Select function",
"Optional case or service context": "Optional case or service context",
"Manage durable organization-function addresses and reusable templates.": "Manage durable organization-function addresses and reusable templates.",
"A new immutable template revision was created.": "A new immutable template revision was created.",
"Postbox template created as a draft.": "Postbox template created as a draft.",
"Postbox template retired. Existing addresses remain durable.": "Postbox template retired. Existing addresses remain durable.",
"Exact function-bound Postbox created.": "Exact function-bound Postbox created.",
"Stable Postbox address resolved and materialized.": "Stable Postbox address resolved and materialized.",
"Postbox archived. Messages and delivery evidence were retained.": "Postbox archived. Messages and delivery evidence were retained.",
"The template is no longer available.": "The template is no longer available.",
"Postbox request failed": "Postbox request failed",
"Unsaved Postbox administration draft": "Unsaved Postbox administration draft",
"Save or discard the open Postbox administration draft before leaving this surface.": "Save or discard the open Postbox administration draft before leaving this surface.",
"Messages remain institutionally retained even when no incumbent currently has access.": "Messages remain institutionally retained even when no incumbent currently has access.",
"Copies create independent deliveries and evidence at explicitly bounded hierarchy targets.": "Copies create independent deliveries and evidence at explicitly bounded hierarchy targets.",
"Reject copied deliveries that do not carry an explicit expiry boundary.": "Reject copied deliveries that do not carry an explicit expiry boundary.",
"Schedules a separate, auditable delivery only after the configured vacancy delay.": "Schedules a separate, auditable delivery only after the configured vacancy delay.",
"New unified view": "New unified view",
"Delete unified view": "Delete unified view",
"Unsaved Postbox draft": "Unsaved Postbox draft",
"Save or discard the open Postbox draft before leaving this surface.": "Save or discard the open Postbox draft before leaving this surface.",
"The grouping is no longer available.": "The grouping is no longer available.",
"Inbox view": "Inbox view",
"All postboxes": "All postboxes",
"Edit unified view": "Edit unified view",
"Loading postboxes": "Loading postboxes",
"No assigned postboxes": "No assigned postboxes",
"Postboxes appear when your account has a current matching function assignment.": "Postboxes appear when your account has a current matching function assignment.",
"Assigned postboxes": "Assigned postboxes",
"No organization": "No organization",
"No function": "No function",
"New message": "New message",
"Refresh messages": "Refresh messages",
"Search messages": "Search messages",
"Search Postbox messages": "Search Postbox messages",
"Clear message search": "Clear message search",
"Message state": "Message state",
"All": "All",
"Unread": "Unread",
"Read": "Read",
"Acknowledged": "Acknowledged",
"Loading messages": "Loading messages",
"No messages": "No messages",
"This view has no delivered Postbox messages.": "This view has no delivered Postbox messages.",
"Postbox messages": "Postbox messages",
"Withdrawn": "Withdrawn",
"Expired": "Expired",
"Postbox message pagination": "Postbox message pagination",
"Message": "Message",
"Reply": "Reply",
"Acknowledge": "Acknowledge",
"Message unavailable": "Message unavailable",
"Select a message": "Select a message",
"Source, function context, content, and evidence remain attached to the originating postbox.": "Source, function context, content, and evidence remain attached to the originating postbox.",
"Delete": "Delete",
"Save": "Save",
"Default unified view": "Default unified view",
"Source postboxes": "Source postboxes",
"Recipients": "Recipients",
"Separate addresses with commas": "Separate addresses with commas",
"Subject": "Subject",
"Send": "Send",
"This message was withdrawn. Future access is blocked and audit metadata remains visible. Plaintext already decrypted, copied, exported, or printed cannot be retracted.": "This message was withdrawn. Future access is blocked and audit metadata remains visible. Plaintext already decrypted, copied, exported, or printed cannot be retracted.",
"This message has expired. Its audit metadata remains visible, but its content and actions are unavailable.": "This message has expired. Its audit metadata remains visible, but its content and actions are unavailable.",
"Platform": "Platform",
"Source and responsibility": "Source and responsibility",
"Organization": "Organization",
"Address": "Address",
"Producer": "Producer",
"Encryption profile": "Encryption profile",
"Key envelopes": "Key envelopes",
"External grants": "External grants",
"Not recorded": "Not recorded",
"Not loaded": "Not loaded",
"No plaintext body is available for this message.": "No plaintext body is available for this message.",
"Participants": "Participants",
"Evidence and attachments": "Evidence and attachments",
"No attachment references.": "No attachment references.",
"Current access": "Current access",
"Platform-native message": "Platform-native message",
"Public": "Public",
"Internal": "Internal",
"Confidential": "Confidential",
"Restricted": "Restricted",
"No unread Postbox messages.": "No unread Postbox messages.",
"Loading unread Postbox messages": "Loading unread Postbox messages",
"Open Postbox": "Open Postbox"
} satisfies Record<string, string>;
const de = {
...en,
"i18n:govoplan-postbox.postbox": "Postfach",
"i18n:govoplan-postbox.postboxes": "Postfächer",
"i18n:govoplan-postbox.postbox_inbox": "Postfach-Eingang",
"i18n:govoplan-postbox.postbox_inbox_description": "Ungelesene Nachrichten aus zugänglichen Postfächern.",
"i18n:govoplan-postbox.communication": "Kommunikation",
"i18n:govoplan-postbox.maximum_messages": "Maximale Nachrichtenanzahl",
"i18n:govoplan-postbox.postbox_directory": "Postfachverzeichnis",
"i18n:govoplan-postbox.postbox_messages": "Postfachnachrichten",
"i18n:govoplan-postbox.postbox_inbox_widget": "Postfach-Eingangs-Widget",
"i18n:govoplan-postbox.postbox_templates_bindings": "Postfachvorlagen und Zuordnungen",
"i18n:govoplan-postbox.loading_reason": "Die Postfachdaten werden noch geladen.",
"i18n:govoplan-postbox.busy_reason": "Eine andere Postfachaktion läuft noch.",
"i18n:govoplan-postbox.no_postbox_reason": "Für diese Aktion ist kein zugängliches Postfach verfügbar.",
"i18n:govoplan-postbox.no_message_reason": "Wählen Sie zuerst eine Nachricht aus.",
"i18n:govoplan-postbox.no_send_permission_reason": "Ihr Konto darf keine Postfachnachrichten erstellen.",
"i18n:govoplan-postbox.no_reply_permission_reason": "Ihr Konto darf nicht auf Postfachnachrichten antworten.",
"i18n:govoplan-postbox.no_acknowledge_permission_reason": "Ihr Konto darf Postfachnachrichten nicht bestätigen.",
"i18n:govoplan-postbox.unavailable_message_reason": "Zurückgezogene oder abgelaufene Nachrichten können nicht geändert werden.",
"i18n:govoplan-postbox.already_acknowledged_reason": "Diese Nachricht wurde bereits bestätigt.",
"i18n:govoplan-postbox.retired_template_reason": "Stillgelegte Vorlagen können keine neuen Revisionen oder Adressen erhalten.",
"i18n:govoplan-postbox.published_revision_reason": "Diese unveränderliche Revision ist bereits veröffentlicht.",
"i18n:govoplan-postbox.unpublished_template_reason": "Veröffentlichen Sie die Vorlage, bevor Sie eine Adresse auflösen.",
"i18n:govoplan-postbox.archived_postbox_reason": "Nur aktive Postfächer können archiviert werden.",
"i18n:govoplan-postbox.incomplete_draft_reason": "Füllen Sie vor dem Speichern alle Pflichtfelder aus.",
"i18n:govoplan-postbox.required_action": "Erforderliche Aktion",
"i18n:govoplan-postbox.responsible_actor": "Verantwortliche Stelle",
"i18n:govoplan-postbox.destination": "Ziel",
"i18n:govoplan-postbox.assignment_required_action": "Eine wirksame Zuordnung zu einer Funktion mit Postfach erhalten.",
"i18n:govoplan-postbox.assignment_responsible_actor": "IDM- oder Organisationsadministration",
"i18n:govoplan-postbox.assignment_destination": "IDM-Funktionszuordnungen oder Organisationen",
"i18n:govoplan-postbox.organization_target_summary": "Für eine konkrete Postfachadresse ist keine Organisationsfunktion verfügbar.",
"i18n:govoplan-postbox.organization_target_action": "Erstellen oder aktivieren Sie zuerst eine Organisationseinheit und Funktion.",
"i18n:govoplan-postbox.organization_target_actor": "Organisationsadministration",
"i18n:govoplan-postbox.organization_target_destination": "Organisationen",
"i18n:govoplan-postbox.archive_confirmation": "\"{name}\" archivieren? Nachrichten und Zustellnachweise bleiben erhalten, die Adresse nimmt jedoch keine neuen Zustellungen an.",
"i18n:govoplan-postbox.delete_grouping_confirmation": "Die zusammengeführte Ansicht \"{name}\" löschen? Nur diese persönliche Projektion wird entfernt. Quellpostfächer, Nachrichten, Bestätigungen und Nachweise bleiben unverändert.",
"i18n:govoplan-postbox.retire_template_confirmation": "\"{name}\" stilllegen? Veröffentlichte Adressen bleiben dauerhaft, diese Vorlage kann jedoch keine neuen Revisionen oder Adressen mehr erstellen.",
"i18n:govoplan-postbox.published_revision_message": "Revision {revision} veröffentlicht.",
"i18n:govoplan-postbox.resolve_address_title": "Adresse auflösen · {name}",
"i18n:govoplan-postbox.revision_label": "Revision {revision}",
"i18n:govoplan-postbox.depth_label": "{fanout} · Tiefe {depth}",
"i18n:govoplan-postbox.minutes_label": "{minutes} Minuten",
"i18n:govoplan-postbox.attachments_label": "{count} Anhänge",
"i18n:govoplan-postbox.open_unread": "Postfach öffnen ({count} ungelesen)",
"Refresh": "Aktualisieren",
"New template": "Neue Vorlage",
"Exact postbox": "Konkretes Postfach",
"Templates": "Vorlagen",
"Postboxes": "Postfächer",
"Postbox administration section": "Postfach-Verwaltungsbereich",
"Archive Postbox": "Postfach archivieren",
"Archive": "Archivieren",
"No Postbox templates.": "Keine Postfachvorlagen vorhanden.",
"Postbox templates": "Postfachvorlagen",
"Template": "Vorlage",
"No description.": "Keine Beschreibung.",
"New revision": "Neue Revision",
"Publish": "Veröffentlichen",
"Resolve address": "Adresse auflösen",
"Retire": "Stilllegen",
"Retire Postbox template": "Postfachvorlage stilllegen",
"Revision": "Revision",
"Function type": "Funktionstyp",
"Any function type": "Beliebiger Funktionstyp",
"Scope": "Geltungsbereich",
"Classification": "Klassifizierung",
"Vacant delivery": "Zustellung bei Vakanz",
"Accepted": "Angenommen",
"Blocked": "Blockiert",
"Hierarchy copies": "Hierarchiekopien",
"Disabled": "Deaktiviert",
"Vacancy escalation": "Vakanzeskalation",
"Encryption": "Verschlüsselung",
"Name pattern": "Namensmuster",
"Address pattern": "Adressmuster",
"Immutable revisions": "Unveränderliche Revisionen",
"Published": "Veröffentlicht",
"Draft": "Entwurf",
"Select a template": "Vorlage auswählen",
"Published revisions lazily resolve stable unit-specific addresses.": "Veröffentlichte Revisionen lösen stabile einheitsspezifische Adressen bei Bedarf auf.",
"No materialized Postboxes.": "Keine materialisierten Postfächer.",
"Materialized Postboxes": "Materialisierte Postfächer",
"Organization unit": "Organisationseinheit",
"Function": "Funktion",
"Address key": "Adressschlüssel",
"Current holders": "Aktuelle Inhaber",
"Vacancy": "Vakanz",
"Vacant": "Unbesetzt",
"Staffed": "Besetzt",
"Context": "Kontext",
"None": "Keiner",
"Template revision": "Vorlagenrevision",
"Exact Postbox": "Konkretes Postfach",
"Select a Postbox": "Postfach auswählen",
"Materialized Postboxes remain durable through vacancy and reassignment.": "Materialisierte Postfächer bleiben bei Vakanz und Neuzuordnung dauerhaft bestehen.",
"Create template revision": "Vorlagenrevision erstellen",
"New Postbox template": "Neue Postfachvorlage",
"Cancel": "Abbrechen",
"Create revision": "Revision erstellen",
"Create draft": "Entwurf erstellen",
"Name": "Name",
"Slug": "Kurzname",
"Description": "Beschreibung",
"Tenant": "Mandant",
"One unit": "Eine Einheit",
"Unit subtree": "Teilbaum einer Einheit",
"Unit type": "Einheitstyp",
"Scope target": "Ziel des Geltungsbereichs",
"Select target": "Ziel auswählen",
"Accept delivery while vacant": "Zustellung bei Vakanz annehmen",
"Hierarchy linked copies": "Verknüpfte Hierarchiekopien",
"Copy to explicitly bounded function Postboxes in one selected structure.": "In ausdrücklich begrenzte Funktionspostfächer einer ausgewählten Struktur kopieren.",
"Enable hierarchy linked copies": "Verknüpfte Hierarchiekopien aktivieren",
"Organization structure": "Organisationsstruktur",
"Select structure": "Struktur auswählen",
"Relation type": "Beziehungstyp",
"All hierarchical relations": "Alle hierarchischen Beziehungen",
"Target function type": "Zielfunktionstyp",
"Select function type": "Funktionstyp auswählen",
"Target Postbox template": "Ziel-Postfachvorlage",
"Select published template": "Veröffentlichte Vorlage auswählen",
"Maximum hierarchy depth": "Maximale Hierarchietiefe",
"Copy behavior": "Kopierverhalten",
"Nearest matching ancestor": "Nächster passender Vorfahr",
"All matching ancestors": "Alle passenden Vorfahren",
"Optional stop unit": "Optionale Stoppeinheit",
"No unit stop": "Keine Stoppeinheit",
"Optional stop unit type": "Optionaler Stoppeinheitstyp",
"No unit-type stop": "Kein Einheitstyp-Stopp",
"Allowed classifications": "Zulässige Klassifizierungen",
"Authorized producer modules": "Autorisierte Erzeugermodule",
"Require message expiry": "Ablauf der Nachricht verlangen",
"Maximum retention days": "Maximale Aufbewahrungstage",
"Escalate when the nearest target remains vacant": "Eskalieren, wenn das nächste Ziel unbesetzt bleibt",
"Vacancy escalation delay (minutes)": "Verzögerung der Vakanzeskalation (Minuten)",
"Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.": "Verfügbare Mustervariablen umfassen Vorlage, Einheit, Funktion und optionale Kontextnamen oder Kurznamen. Veröffentlichte Revisionen sind unveränderlich.",
"New exact Postbox": "Neues konkretes Postfach",
"Create Postbox": "Postfach erstellen",
"Generated when empty": "Wird leer automatisch erzeugt",
"Select unit": "Einheit auswählen",
"Select function": "Funktion auswählen",
"Optional case or service context": "Optionaler Fall- oder Dienstkontext",
"Manage durable organization-function addresses and reusable templates.": "Dauerhafte Organisationsfunktionsadressen und wiederverwendbare Vorlagen verwalten.",
"A new immutable template revision was created.": "Eine neue unveränderliche Vorlagenrevision wurde erstellt.",
"Postbox template created as a draft.": "Postfachvorlage wurde als Entwurf erstellt.",
"Postbox template retired. Existing addresses remain durable.": "Postfachvorlage wurde stillgelegt. Bestehende Adressen bleiben dauerhaft.",
"Exact function-bound Postbox created.": "Konkretes funktionsgebundenes Postfach wurde erstellt.",
"Stable Postbox address resolved and materialized.": "Stabile Postfachadresse wurde aufgelöst und materialisiert.",
"Postbox archived. Messages and delivery evidence were retained.": "Postfach wurde archiviert. Nachrichten und Zustellnachweise blieben erhalten.",
"The template is no longer available.": "Die Vorlage ist nicht mehr verfügbar.",
"Postbox request failed": "Postfachanfrage fehlgeschlagen",
"Unsaved Postbox administration draft": "Ungespeicherter Postfach-Verwaltungsentwurf",
"Save or discard the open Postbox administration draft before leaving this surface.": "Speichern oder verwerfen Sie den offenen Postfach-Verwaltungsentwurf, bevor Sie diesen Bereich verlassen.",
"Messages remain institutionally retained even when no incumbent currently has access.": "Nachrichten bleiben institutionell erhalten, auch wenn aktuell kein Funktionsinhaber Zugriff hat.",
"Copies create independent deliveries and evidence at explicitly bounded hierarchy targets.": "Kopien erzeugen eigenständige Zustellungen und Nachweise an ausdrücklich begrenzten Hierarchiezielen.",
"Reject copied deliveries that do not carry an explicit expiry boundary.": "Kopierte Zustellungen ohne ausdrückliche Ablaufgrenze ablehnen.",
"Schedules a separate, auditable delivery only after the configured vacancy delay.": "Plant erst nach der konfigurierten Vakanzverzögerung eine separate, prüfbare Zustellung.",
"New unified view": "Neue zusammengeführte Ansicht",
"Delete unified view": "Zusammengeführte Ansicht löschen",
"Unsaved Postbox draft": "Ungespeicherter Postfachentwurf",
"Save or discard the open Postbox draft before leaving this surface.": "Speichern oder verwerfen Sie den offenen Postfachentwurf, bevor Sie diesen Bereich verlassen.",
"The grouping is no longer available.": "Die zusammengeführte Ansicht ist nicht mehr verfügbar.",
"Inbox view": "Eingangsansicht",
"All postboxes": "Alle Postfächer",
"Edit unified view": "Zusammengeführte Ansicht bearbeiten",
"Loading postboxes": "Postfächer werden geladen",
"No assigned postboxes": "Keine zugewiesenen Postfächer",
"Postboxes appear when your account has a current matching function assignment.": "Postfächer erscheinen, wenn Ihr Konto eine aktuelle passende Funktionszuordnung hat.",
"Assigned postboxes": "Zugewiesene Postfächer",
"No organization": "Keine Organisation",
"No function": "Keine Funktion",
"New message": "Neue Nachricht",
"Refresh messages": "Nachrichten aktualisieren",
"Search messages": "Nachrichten suchen",
"Search Postbox messages": "Postfachnachrichten suchen",
"Clear message search": "Nachrichtensuche leeren",
"Message state": "Nachrichtenstatus",
"All": "Alle",
"Unread": "Ungelesen",
"Read": "Gelesen",
"Acknowledged": "Bestätigt",
"Loading messages": "Nachrichten werden geladen",
"No messages": "Keine Nachrichten",
"This view has no delivered Postbox messages.": "Diese Ansicht enthält keine zugestellten Postfachnachrichten.",
"Postbox messages": "Postfachnachrichten",
"Withdrawn": "Zurückgezogen",
"Expired": "Abgelaufen",
"Postbox message pagination": "Seitennavigation der Postfachnachrichten",
"Message": "Nachricht",
"Reply": "Antworten",
"Acknowledge": "Bestätigen",
"Message unavailable": "Nachricht nicht verfügbar",
"Select a message": "Nachricht auswählen",
"Source, function context, content, and evidence remain attached to the originating postbox.": "Quelle, Funktionskontext, Inhalt und Nachweise bleiben dem Ursprungspostfach zugeordnet.",
"Delete": "Löschen",
"Save": "Speichern",
"Default unified view": "Standardansicht",
"Source postboxes": "Quellpostfächer",
"Recipients": "Empfänger",
"Separate addresses with commas": "Adressen durch Kommas trennen",
"Subject": "Betreff",
"Send": "Senden",
"This message was withdrawn. Future access is blocked and audit metadata remains visible. Plaintext already decrypted, copied, exported, or printed cannot be retracted.": "Diese Nachricht wurde zurückgezogen. Künftiger Zugriff ist gesperrt und Prüfmetadaten bleiben sichtbar. Bereits entschlüsselter, kopierter, exportierter oder gedruckter Klartext kann nicht zurückgerufen werden.",
"This message has expired. Its audit metadata remains visible, but its content and actions are unavailable.": "Diese Nachricht ist abgelaufen. Ihre Prüfmetadaten bleiben sichtbar, Inhalt und Aktionen sind jedoch nicht verfügbar.",
"Platform": "Plattform",
"Source and responsibility": "Quelle und Verantwortung",
"Organization": "Organisation",
"Address": "Adresse",
"Producer": "Erzeuger",
"Encryption profile": "Verschlüsselungsprofil",
"Key envelopes": "Schlüsselumschläge",
"External grants": "Externe Freigaben",
"Not recorded": "Nicht erfasst",
"Not loaded": "Nicht geladen",
"No plaintext body is available for this message.": "Für diese Nachricht ist kein Klartextinhalt verfügbar.",
"Participants": "Beteiligte",
"Evidence and attachments": "Nachweise und Anhänge",
"No attachment references.": "Keine Anhangsreferenzen.",
"Current access": "Aktueller Zugriff",
"Platform-native message": "Plattformeigene Nachricht",
"Public": "Öffentlich",
"Internal": "Intern",
"Confidential": "Vertraulich",
"Restricted": "Beschränkt",
"No unread Postbox messages.": "Keine ungelesenen Postfachnachrichten.",
"Loading unread Postbox messages": "Ungelesene Postfachnachrichten werden geladen",
"Open Postbox": "Postfach öffnen"
} satisfies Record<keyof typeof en, string>;
export const generatedTranslations: PlatformTranslations = { en, de };
+19 -12
View File
@@ -5,6 +5,7 @@ import {
type DashboardWidgetsUiCapability,
type PlatformWebModule
} from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import PostboxInboxWidget from "./features/postbox/PostboxInboxWidget";
import "./styles/postbox.css";
@@ -14,16 +15,21 @@ const PostboxAdminPanel = lazy(
() => import("./features/postbox/PostboxAdminPanel")
);
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
const readScope = ["postbox:postbox:read"];
const postboxDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "postbox.inbox",
surfaceId: "postbox.widget.inbox",
title: "Postbox inbox",
description: "Unread messages across accessible Postboxes.",
title: "i18n:govoplan-postbox.postbox_inbox",
description: "i18n:govoplan-postbox.postbox_inbox_description",
moduleId: "postbox",
category: "Communication",
category: "i18n:govoplan-postbox.communication",
order: 55,
defaultVisible: false,
defaultSize: "medium",
@@ -36,7 +42,7 @@ const postboxDashboardWidgets: DashboardWidgetsUiCapability = {
configurationFields: [
{
id: "maxItems",
label: "Maximum messages",
label: "i18n:govoplan-postbox.maximum_messages",
kind: "number",
min: 1,
max: 12,
@@ -60,7 +66,7 @@ const postboxAdminSections: AdminSectionsUiCapability = {
id: "postbox",
moduleId: "postbox",
kind: "management",
label: "Postboxes",
label: "i18n:govoplan-postbox.postboxes",
group: "TENANT",
order: 45,
surfaceId: "postbox.admin.templates",
@@ -80,8 +86,8 @@ const postboxAdminSections: AdminSectionsUiCapability = {
export const postboxModule: PlatformWebModule = {
id: "postbox",
label: "Postbox",
version: "0.1.0",
label: "i18n:govoplan-postbox.postbox",
version: "0.1.2",
dependencies: ["identity", "organizations", "idm"],
optionalDependencies: [
"access",
@@ -95,10 +101,11 @@ export const postboxModule: PlatformWebModule = {
"views",
"workflow"
],
translations,
navItems: [
{
to: "/postbox",
label: "Postbox",
label: "i18n:govoplan-postbox.postbox",
iconName: "inbox",
anyOf: readScope,
order: 58
@@ -119,28 +126,28 @@ export const postboxModule: PlatformWebModule = {
id: "postbox.inbox.directory",
moduleId: "postbox",
kind: "section",
label: "Postbox directory",
label: "i18n:govoplan-postbox.postbox_directory",
order: 10
},
{
id: "postbox.inbox.messages",
moduleId: "postbox",
kind: "section",
label: "Postbox messages",
label: "i18n:govoplan-postbox.postbox_messages",
order: 20
},
{
id: "postbox.widget.inbox",
moduleId: "postbox",
kind: "section",
label: "Postbox inbox widget",
label: "i18n:govoplan-postbox.postbox_inbox_widget",
order: 25
},
{
id: "postbox.admin.templates",
moduleId: "postbox",
kind: "section",
label: "Postbox templates and bindings",
label: "i18n:govoplan-postbox.postbox_templates_bindings",
order: 30
}
],