diff --git a/README.md b/README.md index 92bc488..2fe96dd 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ Frontend package: Platform RBAC, module capability contracts, and governance rules are documented in `govoplan-core/docs/`. +The module's interface archetypes, consequence classes, contextual-help +contracts, and accessibility evidence are recorded in +[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md). + ## Current implementation The first usable slice includes immutable template revisions, stable lazy diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..d8cd8a6 --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,54 @@ +# Postbox Interface Pattern Migration + +This document records the bounded migration of Postbox-owned WebUI surfaces to +the GovOPlaN interface pattern language. Core owns shared controls and host +shells. Postbox owns function-bound addresses, messages, receipts, delivery +evidence, personal inbox projections, and reusable address templates. + +## Surface Inventory + +| Surface | Archetype | Consequence class | Contract | +| --- | --- | --- | --- | +| `/postbox` directory | Directory and hierarchy-context selector | Change query projection | Shared selection list, explicit assignment blocker, contextual help, guarded reload | +| `/postbox` messages | Searchable work queue | Read, acknowledge, reply, or author | Shared filters/pagination/status/alerts, explained unavailable actions, privacy-safe retained states | +| Message detail and composer | Record detail and consequential editor | Deliver institutional message | Source/function provenance, classification boundary, guarded draft, stable field help | +| Unified-view dialog | Personal configuration editor | Change or delete query projection | Shared dialog/toggle/confirmation; deletion never changes source messages or evidence | +| `postbox.admin.templates` | Versioned definition library | Publish or retire immutable template revision | Shared admin layout, durable consequence explanations, guarded editor, contextual help | +| Materialized Postbox administration | Repeated administration | Create or archive durable address | Organization/function selector, prerequisite blocker, destructive confirmation, retained-evidence explanation | +| `postbox.widget.inbox` | Dashboard summary | Navigate to unread work | Shared widget loading/error/list contract and localized accessible attachment metadata | + +## Consequence And Availability Rules + +- A Postbox address belongs to an organization function, not to an account. + Current effective IDM assignments determine access without changing the + durable message or address record. +- Publishing freezes an immutable template revision. Retiring a template stops + future revision and materialization work but leaves existing addresses + intact. +- Archiving an address stops new delivery while preserving messages, receipts, + access events, and delivery evidence. +- Unified inbox views are personal projections. Deleting one does not move or + delete source Postboxes, messages, acknowledgements, or evidence. +- Hierarchy copies are separate, bounded deliveries. Vacancy escalation is a + separately scheduled and auditable route, not an implicit personal grant. +- Withdrawal and expiry prevent future content access while retaining only the + metadata the current actor may inspect. Already exported or printed plaintext + cannot be retracted. +- Disabled actions state whether the blocker is loading, another operation, a + missing permission, a missing assignment, an unavailable message, or a + lifecycle constraint. + +## State And Accessibility Evidence + +The module uses Core admin/page shells, selection lists, pagination, dialogs, +confirmations, alerts, status badges, action blockers, contextual field help, +disabled reasons, and unsaved-change guards. Shared dialogs retain keyboard +focus and return behavior. Existing responsive workspace CSS keeps directory, +message list, and detail regions bounded and independently scrollable. + +English and German catalogues cover module metadata, accessible attributes, +workspaces, dialogs, fields, lifecycle states, and dashboard output. Dates use +the selected platform locale. Manifest topics publish stable route, surface, +field, blocker, privacy, and consequence references. Focused backend and WebUI +tests pin these contracts without importing optional sibling modules. + diff --git a/src/govoplan_postbox/backend/manifest.py b/src/govoplan_postbox/backend/manifest.py index 72b1a6a..b003588 100644 --- a/src/govoplan_postbox/backend/manifest.py +++ b/src/govoplan_postbox/backend/manifest.py @@ -17,6 +17,7 @@ from govoplan_core.core.module_guards import ( persistent_table_uninstall_guard, ) from govoplan_core.core.modules import ( + DocumentationLink, DocumentationTopic, FrontendModule, FrontendRoute, @@ -375,8 +376,8 @@ manifest = ModuleManifest( "reassignment. Current access combines a generic Postbox " "permission with effective IDM assignment context. Templates " "can lazily materialize unit-specific addresses, while exact " - "postboxes cover exceptional responsibilities. The first " - "Plaintext Postboxes remain available without Encryption. A " + "postboxes cover exceptional responsibilities. Plaintext " + "Postboxes remain available without Encryption. A " "server-envelope profile stores message bodies as ciphertext and " "uses the optional Encryption capability for authorized reads. " "External ciphertext profiles retain producer-managed references " @@ -393,8 +394,102 @@ manifest = ModuleManifest( "files", "notifications", ), + links=( + DocumentationLink( + label="Postbox inbox", + href="/postbox", + kind="runtime", + ), + DocumentationLink( + label="Postbox directory API", + href="/api/v1/postbox/directory", + kind="api", + ), + ), + metadata={ + "kind": "guide", + "help_contexts": [ + "postbox.inbox.directory", + "postbox.inbox.messages", + "postbox.widget.inbox", + "postbox.admin.templates", + "postbox.blocker.assignment", + "postbox.state.unavailable", + ], + "privacy_notes": [ + "Directory access is derived from current effective function assignments.", + "Unavailable message states reveal only retained audit metadata permitted to the current actor.", + "Subjects, participants, routing facts, and attachment references remain observable metadata.", + ], + }, order=35, ), + DocumentationTopic( + id="postbox.reference.fields-and-consequences", + title="Postbox fields and consequences", + summary=( + "Reference for address, template, routing, message, grouping, " + "classification, retention, and lifecycle fields." + ), + body=( + "A Postbox address is durable and bound to an organization function. " + "Template revisions are immutable after publication; retiring a template " + "does not remove materialized addresses. Archiving an address stops new " + "delivery while retaining messages and evidence. Unified inbox views are " + "personal projections only and never move or delete source messages. " + "Classification limits eligible delivery and hierarchy-copy targets. " + "Hierarchy copies are independent deliveries with their own evidence; " + "vacancy escalation is delayed and separately auditable. Message expiry " + "or withdrawal blocks future content access but cannot retract plaintext " + "already copied, exported, or printed." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("administrator", "user", "campaign_manager"), + related_modules=( + "organizations", + "idm", + "policy", + "audit", + "encryption", + ), + links=( + DocumentationLink( + label="Postbox administration", + href="/admin?section=postbox", + kind="runtime", + ), + DocumentationLink( + label="Postbox concept", + href="docs/POSTBOX_CONCEPT.md", + kind="source", + ), + ), + metadata={ + "kind": "reference", + "help_contexts": [ + "postbox.field.address", + "postbox.field.function", + "postbox.field.scope", + "postbox.field.classification", + "postbox.field.retention", + "postbox.field.hierarchy-routing", + "postbox.field.recipients", + "postbox.action.archive", + "postbox.action.retire-template", + "postbox.action.delete-grouping", + ], + "consequence_classes": { + "publish_template": "Freezes an immutable address and routing revision for future materialization.", + "retire_template": "Stops new revisions and materialization while retaining existing addresses.", + "archive_postbox": "Stops new delivery while retaining messages, receipts, and evidence.", + "delete_grouping": "Deletes only the personal projection; source Postboxes and messages remain unchanged.", + "route_copy": "Creates a separately retained delivery and evidence record at each bounded target.", + "withdraw_or_expire": "Blocks future content access while retaining permitted audit metadata.", + }, + }, + order=36, + ), ), architecture=declared_module_architecture( layer="communication_participation", diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py new file mode 100644 index 0000000..7efcd35 --- /dev/null +++ b/tests/test_interface_documentation_contract.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import unittest + +from govoplan_postbox.backend.manifest import manifest + + +class PostboxInterfaceDocumentationContractTests(unittest.TestCase): + def test_route_and_contributed_surfaces_remain_declared(self) -> None: + frontend = manifest.frontend + self.assertIsNotNone(frontend) + self.assertEqual( + {"/postbox"}, + {route.path for route in frontend.routes}, # type: ignore[union-attr] + ) + self.assertEqual( + { + "postbox.admin.templates", + "postbox.inbox.directory", + "postbox.inbox.messages", + "postbox.widget.inbox", + }, + {surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr] + ) + self.assertTrue(all(item.icon == "inbox" for item in manifest.nav_items)) + + def test_topics_publish_stable_help_privacy_and_consequence_metadata(self) -> None: + topics = {topic.id: topic for topic in manifest.documentation} + self.assertIn("postbox.function-bound-containers", topics) + self.assertIn("postbox.reference.fields-and-consequences", topics) + + guide = topics["postbox.function-bound-containers"] + self.assertIn("postbox.inbox.messages", guide.metadata["help_contexts"]) + self.assertIn("postbox.state.unavailable", guide.metadata["help_contexts"]) + self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3) + + reference = topics["postbox.reference.fields-and-consequences"] + self.assertIn("postbox.field.classification", reference.metadata["help_contexts"]) + self.assertIn("postbox.action.delete-grouping", reference.metadata["help_contexts"]) + self.assertIn("archive_postbox", reference.metadata["consequence_classes"]) + self.assertIn("withdraw_or_expire", reference.metadata["consequence_classes"]) + + +if __name__ == "__main__": + unittest.main() + diff --git a/webui/package.json b/webui/package.json index edb3d6f..fa2557d 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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", diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs new file mode 100644 index 0000000..be7cf6b --- /dev/null +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -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."); + diff --git a/webui/src/features/postbox/PostboxAdminPanel.tsx b/webui/src/features/postbox/PostboxAdminPanel.tsx index 86f03a7..27371ed 100644 --- a/webui/src/features/postbox/PostboxAdminPanel.tsx +++ b/webui/src/features/postbox/PostboxAdminPanel.tsx @@ -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(templateDefaults); + const [templateBaseline, setTemplateBaseline] = useState(templateDefaults); const [exactDialogOpen, setExactDialogOpen] = useState(false); const [exactDraft, setExactDraft] = useState(exactDefaults); + const [exactBaseline, setExactBaseline] = useState(exactDefaults); const [materializeDialogOpen, setMaterializeDialogOpen] = useState(false); const [materializeDraft, setMaterializeDraft] = useState({ templateId: "", @@ -140,7 +153,15 @@ export default function PostboxAdminPanel({ function_id: "", context_key: "" }); + const [materializeBaseline, setMaterializeBaseline] = useState({ + templateId: "", + organization_unit_id: "", + function_id: "", + context_key: "" + }); const [archiveTarget, setArchiveTarget] = useState(null); + const [retireTarget, setRetireTarget] = useState(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 { 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 { 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 { 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 { + 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 ( } - onClick={() => void load()} - disabled={busy} + onClick={() => requestDiscard(() => void load())} + disabled={loading || busy} + disabledReason={postboxBusyReason(loading, busy)} /> + {mode === "templates" && canManageTemplates ? ( - ) : null} {mode === "postboxes" && canManageBindings ? ( - ) : null} @@ -418,6 +522,22 @@ export default function PostboxAdminPanel({ ]} ariaLabel="Postbox administration section" /> + {mode === "postboxes" && !units.some((unit) => unit.functions.length) ? ( + + ) : null} {mode === "templates" ? ( 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()} /> setExactDialogOpen(false)} + onClose={closeExactDialog} onSave={() => void saveExact()} /> setMaterializeDialogOpen(false)} + onClose={closeMaterializeDialog} onSave={() => void materialize()} /> void confirmArchive()} onCancel={() => setArchiveTarget(null)} /> + void confirmRetire()} + onCancel={() => setRetireTarget(null)} + /> ); } @@ -529,7 +661,7 @@ function TemplateWorkspace({ - {template.slug} · revision {template.current_revision} + {template.slug} · {i18nMessage("i18n:govoplan-postbox.revision_label", { revision: template.current_revision })} ))} @@ -546,24 +678,41 @@ function TemplateWorkspace({

{selected.description || "No description."}

- - {canManageBindings ? ( - ) : null} -
-
Revision
{revision.revision}{revision.published_at ? " · published" : " · draft"}
+
Revision
{revision.revision}{revision.published_at ? " · Published" : " · Draft"}
Function type
{revision.function_type_id || "Any function type"}
Scope
{revision.scope_kind}{revision.scope_id ? ` · ${revision.scope_id}` : ""}
Classification
{revision.classification}
@@ -572,7 +721,10 @@ function TemplateWorkspace({
Hierarchy copies
{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"}
@@ -580,7 +732,9 @@ function TemplateWorkspace({
Vacancy escalation
{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"}
@@ -592,7 +746,7 @@ function TemplateWorkspace({

Immutable revisions

{selected.revisions.map((item) => (
- Revision {item.revision} + {i18nMessage("i18n:govoplan-postbox.revision_label", { revision: item.revision })} {item.classification} · {item.scope_kind} onArchive(selected)} disabled={busy || selected.status !== "active"} + disabledReason={postboxBusyReason(false, busy) ?? (selected.status !== "active" ? POSTBOX_INTERFACE_I18N.archivedPostbox : undefined)} > Archive @@ -764,36 +919,41 @@ function TemplateDialog({ closeDisabled={busy} footer={
- - +
} >
- + onChange({ ...draft, name: event.target.value })} /> - + onChange({ ...draft, slug: event.target.value })} /> - + onChange({ ...draft, description: event.target.value })} /> - + - + {draft.scope_kind !== "tenant" ? ( - + ) :
} - + onChange({ ...draft, name_pattern: event.target.value })} /> - + onChange({ ...draft, address_pattern: event.target.value })} /> - + onChange({ ...draft, classification: event.target.value })} @@ -859,6 +1019,7 @@ function TemplateDialog({
onChange({ ...draft, allow_vacant_delivery: checked })} /> @@ -868,9 +1029,11 @@ function TemplateDialog({
Hierarchy linked copies Copy to explicitly bounded function Postboxes in one selected structure. +
updateLinkedCopy({ enabled, @@ -882,7 +1045,7 @@ function TemplateDialog({
{linkedCopy.enabled ? (
- + - + - + - + - + - + - + - + - + updateLinkedCopy({ @@ -1002,7 +1165,7 @@ function TemplateDialog({ })} /> - + updateLinkedCopy({ require_expiry })} />
- + updateRouting({ ...draft.routing_policy, @@ -1047,7 +1212,7 @@ function TemplateDialog({ />
{attention.mode === "vacancy_escalation" ? ( - + - + @@ -1116,13 +1282,13 @@ function ExactPostboxDialog({ } >
- + onChange({ ...draft, name: event.target.value })} /> - + onChange({ ...draft, address_key: event.target.value })} /> - + - + - + onChange({ ...draft, classification: event.target.value })} /> - + onChange({ ...draft, description: event.target.value })} />
@@ -1184,21 +1350,28 @@ function MaterializeDialog({ return ( - - +
} >
- + - + - + onChange({ ...draft, context_key: event.target.value })} />
@@ -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); +} diff --git a/webui/src/features/postbox/PostboxInboxWidget.tsx b/webui/src/features/postbox/PostboxInboxWidget.tsx index 0f1e5f2..9689020 100644 --- a/webui/src/features/postbox/PostboxInboxWidget.tsx +++ b/webui/src/features/postbox/PostboxInboxWidget.tsx @@ -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:
) : null} {postboxes.length ? ( @@ -585,13 +695,15 @@ export default function PostboxPage({ label="New message" icon={} onClick={openCompose} - disabled={!canSend || !postboxes.length || busy} + disabled={Boolean(composeDisabledReason)} + disabledReason={composeDisabledReason} /> } - onClick={() => void loadMessages()} + onClick={() => requestDiscard(() => void loadMessages())} disabled={loadingMessages || busy} + disabledReason={postboxBusyReason(loadingMessages, busy)} /> @@ -679,7 +791,7 @@ export default function PostboxPage({ > {message.subject} - + {message.sender_label || message.producer_module || "Platform"} @@ -734,31 +846,15 @@ export default function PostboxPage({
@@ -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={
{groupingDraft.id ? ( ) : }
- @@ -818,7 +916,7 @@ export default function PostboxPage({ } >
- + @@ -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={
- @@ -893,7 +992,7 @@ export default function PostboxPage({ } >
- + @@ -937,7 +1036,7 @@ export default function PostboxPage({
- +
- + @@ -964,7 +1063,7 @@ export default function PostboxPage({
- +