diff --git a/README.md b/README.md
index 0f8660a..7a7b5d0 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,10 @@ organizations module is active. Those controls are limited to governance and
policy settings such as tenant model customization, change-request
requirements, audit detail, and retention behavior.
+The reviewed surface inventory, consequence classes, availability rules, and
+accessibility evidence are recorded in
+[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
+
## Module Contract
The module registers two capabilities from
diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md
new file mode 100644
index 0000000..39584db
--- /dev/null
+++ b/docs/INTERFACE_PATTERN_MIGRATION.md
@@ -0,0 +1,51 @@
+# Organizations Interface Pattern Migration
+
+This document records the bounded migration of Organizations-owned WebUI
+surfaces to the GovOPlaN interface pattern language. Core owns shared controls
+and host shells. Organizations owns tenant-local model definitions, concrete
+units, relations, functions, settings, and their mutation consequences.
+
+## Surface Inventory
+
+| Surface | Archetype | Consequence class | Contract |
+| --- | --- | --- | --- |
+| `/organizations` model section | Repeated administration and definition library | Change model vocabulary | Shared DataGrid/cards/dialogs, stable row actions, governed change reference, contextual help |
+| `/organizations` units section | Hierarchy explorer and directory | Change hierarchy and routing context | Shared ExplorerTree/DataGrid, parent selection, explicit write blockers, guarded drafts |
+| `/organizations` relations section | Repeated administration | Change structure traversal | Typed source/target editor, lifecycle state, contextual field help |
+| `/organizations` functions section | Repeated administration | Change institutional responsibility vocabulary | Stable function rows, optional capability actions, explicit Access boundary |
+| `organizations.admin.tenant` | Effective tenant configuration | Change governance, audit, and retention behavior | Shared admin layout, permission blocker, dirty-state guard, contextual help |
+| `organizations.functionPicker` | Governed reference selector | Select an active function | Tenant-safe labels, bounded loading/error state, no sibling-private import |
+
+## Consequence And Availability Rules
+
+- Organization hierarchies and settings are tenant-owned. A tenant may start
+ from a versioned system template, but it does not inherit one mutable global
+ hierarchy.
+- Unit types, structures, relation types, and function types define the model;
+ units, relations, and functions are concrete facts within it.
+- Parent and relation changes can affect downstream routing, Postbox
+ resolution, reporting, and policy context. Slugs are stable integration
+ references.
+- Deactivation preserves the record and audit evidence while removing it from
+ active selection. The UI does not represent deactivation as deletion.
+- Delegation and act-in-place flags describe organizational semantics only.
+ Access and governed workflows still decide effective authority.
+- When tenant governance requires it, model mutations need an approved
+ change-request reference. Dirty dialogs, section changes, and reloads use the
+ shared unsaved-change guard.
+- Unavailable actions remain visible and identify the missing permission,
+ responsible administrator, and administration destination.
+
+## State And Accessibility Evidence
+
+The module uses Core page, subnavigation, grid, tree, card, dialog, loading,
+alert, status, blocker, field-help, and disabled-action controls. Stable table
+action slots remain keyboard reachable; dialogs retain shared focus containment
+and return. Responsive behavior stays owned by the existing workspace and admin
+shell CSS. Labels and accessible properties use the English/German module
+catalogue, and API errors are bounded to the current tenant operation.
+
+The manifest contributes stable help contexts for the workspace, sections,
+settings, fields, lifecycle changes, and governed change references. Backend
+and WebUI contract tests prevent those boundaries and explanations from
+silently regressing.
diff --git a/src/govoplan_organizations/backend/manifest.py b/src/govoplan_organizations/backend/manifest.py
index 0259c86..e38bf90 100644
--- a/src/govoplan_organizations/backend/manifest.py
+++ b/src/govoplan_organizations/backend/manifest.py
@@ -8,6 +8,7 @@ from govoplan_core.core.access import (
)
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import (
+ DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
@@ -155,7 +156,7 @@ manifest = ModuleManifest(
NavItem(
path="/organizations",
label="Organizations",
- icon="users",
+ icon="building-2",
required_any=ORGANIZATIONS_READ_SCOPES,
order=70,
),
@@ -175,7 +176,7 @@ manifest = ModuleManifest(
NavItem(
path="/organizations",
label="Organizations",
- icon="users",
+ icon="building-2",
required_any=ORGANIZATIONS_READ_SCOPES,
order=70,
),
@@ -229,8 +230,89 @@ manifest = ModuleManifest(
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "access_admin", "operator"),
+ related_modules=("tenancy", "access", "idm", "policy", "audit"),
+ links=(
+ DocumentationLink(
+ label="Organizations workspace",
+ href="/organizations",
+ kind="runtime",
+ ),
+ DocumentationLink(
+ label="Organization model API",
+ href="/api/v1/organizations/model",
+ kind="api",
+ ),
+ ),
+ metadata={
+ "kind": "guide",
+ "help_contexts": [
+ "organizations.workspace",
+ "organizations.model",
+ "organizations.units",
+ "organizations.relations",
+ "organizations.functions",
+ "organizations.admin.tenant",
+ "organizations.blocked",
+ ],
+ },
order=25,
),
+ DocumentationTopic(
+ id="organizations.reference.fields-and-consequences",
+ title="Organization model fields and consequences",
+ summary=(
+ "Distinguish tenant-owned model definitions, concrete units, "
+ "relations, functions, governance references, and lifecycle state."
+ ),
+ body=(
+ "Unit types, structures, relation types, and function types define "
+ "the tenant-owned model. Units, relations, and functions are concrete "
+ "institutional facts within that model. Slugs are stable references for "
+ "integrations; parent and relation changes affect hierarchy traversal and "
+ "downstream routing. Deactivation retains the record and evidence but "
+ "removes it from active selection. Delegation and act-in-place flags only "
+ "describe permitted organizational semantics; Access and governed workflows "
+ "still decide effective authority. When configured, a recorded change-request "
+ "ID is required before model mutations. Organization settings are tenant-owned "
+ "and do not inherit a live global hierarchy."
+ ),
+ layer="configured",
+ documentation_types=("admin",),
+ audience=("tenant_admin", "access_admin", "operator"),
+ related_modules=("tenancy", "access", "idm", "policy", "audit"),
+ links=(
+ DocumentationLink(
+ label="Organizations workspace",
+ href="/organizations",
+ kind="runtime",
+ ),
+ DocumentationLink(
+ label="Organization settings API",
+ href="/api/v1/organizations/settings",
+ kind="api",
+ ),
+ ),
+ metadata={
+ "kind": "reference",
+ "help_contexts": [
+ "organizations.field.name",
+ "organizations.field.slug",
+ "organizations.field.parent",
+ "organizations.field.relation",
+ "organizations.field.function",
+ "organizations.field.change-request",
+ "organizations.field.audit-retention",
+ "organizations.action.deactivate",
+ ],
+ "consequence_classes": {
+ "model_change": "Changes the valid vocabulary and constraints for tenant-owned organization facts.",
+ "hierarchy_change": "Changes traversal, routing, and inherited institutional context for downstream modules.",
+ "deactivate": "Retains the fact and evidence while removing it from active selection.",
+ "settings": "Changes tenant-owned governance, audit detail, and retention behavior.",
+ },
+ },
+ order=26,
+ ),
),
architecture=declared_module_architecture(
layer="institutional_foundation",
diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py
new file mode 100644
index 0000000..7b42934
--- /dev/null
+++ b/tests/test_interface_documentation_contract.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+import unittest
+
+from govoplan_organizations.backend.manifest import manifest
+
+
+class OrganizationsInterfaceDocumentationContractTests(unittest.TestCase):
+ def test_route_and_admin_surface_remain_declared(self) -> None:
+ frontend = manifest.frontend
+ self.assertIsNotNone(frontend)
+ self.assertEqual(
+ {"/organizations"},
+ {route.path for route in frontend.routes}, # type: ignore[union-attr]
+ )
+ self.assertEqual(
+ {"organizations.admin.tenant"},
+ {surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr]
+ )
+ self.assertTrue(
+ all(item.icon == "building-2" for item in manifest.nav_items)
+ )
+
+ def test_topics_publish_stable_help_and_consequence_metadata(self) -> None:
+ topics = {topic.id: topic for topic in manifest.documentation}
+ self.assertIn("organizations.model", topics)
+ self.assertIn("organizations.reference.fields-and-consequences", topics)
+ self.assertIn(
+ "organizations.admin.tenant",
+ topics["organizations.model"].metadata["help_contexts"],
+ )
+ reference = topics["organizations.reference.fields-and-consequences"]
+ self.assertIn(
+ "organizations.field.change-request",
+ reference.metadata["help_contexts"],
+ )
+ self.assertIn("hierarchy_change", reference.metadata["consequence_classes"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/webui/package.json b/webui/package.json
index ba5dd02..c25872c 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -7,7 +7,8 @@
"module": "src/index.ts",
"types": "src/index.ts",
"scripts": {
- "test:organizations-tree": "node scripts/test-organizations-tree-structure.mjs"
+ "test:organizations-tree": "node scripts/test-organizations-tree-structure.mjs",
+ "test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
},
"exports": {
".": {
diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs
new file mode 100644
index 0000000..c9720ca
--- /dev/null
+++ b/webui/scripts/test-interface-pattern-language.mjs
@@ -0,0 +1,28 @@
+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/organizations/OrganizationsPage.tsx");
+const settings = source("../src/features/organizations/OrganizationsAdminPanel.tsx");
+const patterns = source("../src/features/organizations/interfacePatterns.ts");
+const moduleSource = source("../src/module.ts");
+const translations = source("../src/i18n/generatedTranslations.ts");
+
+assert(page.includes("DocumentationHelpLink") && settings.includes("DocumentationHelpLink"), "Workspace and tenant settings expose contextual documentation");
+assert(page.includes("ActionBlockerHint") && settings.includes("ActionBlockerHint"), "Read-only states identify action, actor, and destination");
+assert(page.includes("disabledReason") && settings.includes("disabledReason"), "Unavailable organization actions explain their state");
+assert(page.includes("requestDiscard(() => void loadModel())") && settings.includes("requestDiscard(() => void load())"), "Reload preserves dirty organization drafts and settings");
+assert(page.includes("hasDirtyDraft ? requestDiscard(discardDrafts)"), "Editor close uses the shared unsaved-change guard");
+assert(page.includes("ORGANIZATIONS_FIELD_DOCUMENTATION"), "Model fields link to stable consequence documentation");
+assert(patterns.includes('topicId: "organizations.model"') && patterns.includes('topicId: "organizations.reference.fields-and-consequences"'), "Organizations uses manifest-backed help references");
+assert(moduleSource.includes('version: "0.1.8"') && moduleSource.includes('label: "i18n:govoplan-organizations.organizations_administration"'), "WebUI metadata matches the module release and localizes its composed surface");
+assert(translations.includes('"i18n:govoplan-organizations.read_only_summary"'), "Blocker explanations are present in the translation catalogue");
+assert(!page.includes("window.confirm"), "Organizations does not use browser-native consequential confirmation");
+
+console.log("Organizations surfaces satisfy the recorded interface pattern-language contract.");
diff --git a/webui/src/features/organizations/OrganizationsAdminPanel.tsx b/webui/src/features/organizations/OrganizationsAdminPanel.tsx
index 1821f1c..0193439 100644
--- a/webui/src/features/organizations/OrganizationsAdminPanel.tsx
+++ b/webui/src/features/organizations/OrganizationsAdminPanel.tsx
@@ -1,12 +1,15 @@
import { useEffect, useState } from "react";
import {
+ ActionBlockerHint,
AdminPageLayout,
Button,
Card,
+ DocumentationHelpLink,
FormField,
ToggleSwitch,
adminErrorMessage,
hasAnyScope,
+ useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo
@@ -17,6 +20,12 @@ import {
type OrganizationAuditDetailLevel,
type OrganizationSettingsItem
} from "../../api/organizations";
+import {
+ ORGANIZATIONS_DOCUMENTATION,
+ ORGANIZATIONS_FIELD_DOCUMENTATION,
+ ORGANIZATIONS_INTERFACE_I18N,
+ organizationWriteReason
+} from "./interfacePatterns";
const FALLBACK_SETTINGS: OrganizationSettingsItem = {
tenant_id: "",
@@ -40,8 +49,16 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
+ const { requestDiscard } = useUnsavedChanges();
const canWrite = hasAnyScope(auth, ["organizations:settings:write", "admin:settings:write"]);
const dirty = settingsKey(draft) !== settingsKey(savedDraft);
+ const reloadDisabledReason = loading
+ ? ORGANIZATIONS_INTERFACE_I18N.loading
+ : busy
+ ? ORGANIZATIONS_INTERFACE_I18N.busy
+ : undefined;
+ const saveDisabledReason = organizationWriteReason(canWrite, busy, "settings")
+ ?? (!dirty ? ORGANIZATIONS_INTERFACE_I18N.noChanges : undefined);
useUnsavedDraftGuard({
dirty,
@@ -99,20 +116,57 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
loadingLabel="i18n:govoplan-organizations.loading_organization_settings.c6008db8"
error={error}
success={success}
- actions={<>>}
+ actions={(
+ <>
+
+
+
+ >
+ )}
>
+ {!canWrite && (
+
+ )}
setDraft({ ...draft, allow_tenant_model_customization: checked })}
label="i18n:govoplan-organizations.allow_tenant_model_customization.2425d751"
/>
setDraft({ ...draft, require_model_change_requests: checked })}
label="i18n:govoplan-organizations.require_model_change_requests.83454cad"
/>
@@ -122,12 +176,20 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
-
+
-
+
void;
disabled: boolean;
+ disabledReason?: string;
}) {
return (
<>
-
+
onChange({ ...draft, name: event.target.value })} />
-
+
onChange({ ...draft, slug: event.target.value })} />
-
+
@@ -325,6 +348,7 @@ function SluggedFields({
onChange({ ...draft, is_active: checked })}
label="i18n:govoplan-organizations.active.a733b809"
/>
@@ -362,6 +386,7 @@ export default function OrganizationsPage({
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
+ const { requestDiscard } = useUnsavedChanges();
const [unitTypeDraft, setUnitTypeDraft] = useState(() => emptySluggedDraft());
const [structureDraft, setStructureDraft] = useState(() => emptyStructureDraft());
@@ -871,7 +896,7 @@ export default function OrganizationsPage({
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, sortable: true, filterable: true, value: (row) => row.slug },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
{ id: "description", header: "i18n:govoplan-organizations.description.55f8ebc8", minWidth: 220, value: (row) => row.description ?? "" },
- { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, onClick: () => editUnitType(row) }]} /> }
+ { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, disabledReason: organizationWriteReason(canWriteModel, busy, "model"), onClick: () => editUnitType(row) }]} /> }
];
const structureColumns: DataGridColumn[] = [
@@ -879,7 +904,7 @@ export default function OrganizationsPage({
{ id: "kind", header: "i18n:govoplan-organizations.kind.794c9d9c", width: 140, sortable: true, filterable: true, value: (row) => row.structure_kind },
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
- { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, onClick: () => editStructure(row) }]} /> }
+ { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, disabledReason: organizationWriteReason(canWriteModel, busy, "model"), onClick: () => editStructure(row) }]} /> }
];
const relationTypeColumns: DataGridColumn[] = [
@@ -889,7 +914,7 @@ export default function OrganizationsPage({
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 170, render: (row) => optionalLabel(unitTypeById.get(row.target_unit_type_id || "")?.name) },
{ id: "hierarchical", header: "i18n:govoplan-organizations.hierarchical.8964f313", width: 130, render: (row) => activeStatus(row.is_hierarchical) },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
- { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, onClick: () => editRelationType(row) }]} /> }
+ { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, disabledReason: organizationWriteReason(canWriteModel, busy, "model"), onClick: () => editRelationType(row) }]} /> }
];
const unitColumns: DataGridColumn[] = [
@@ -898,7 +923,7 @@ export default function OrganizationsPage({
{ id: "parent", header: "i18n:govoplan-organizations.parent_unit.1986c35e", minWidth: 180, render: (row) => optionalLabel(unitById.get(row.parent_id || "")?.name) },
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
- { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteUnits || busy, onClick: () => editUnit(row) }]} /> }
+ { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteUnits || busy, disabledReason: organizationWriteReason(canWriteUnits, busy, "unit"), onClick: () => editUnit(row) }]} /> }
];
const relationColumns: DataGridColumn[] = [
@@ -907,7 +932,7 @@ export default function OrganizationsPage({
{ id: "source", header: "i18n:govoplan-organizations.source_unit.2fbd8baa", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.source_unit_id)?.name) },
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.target_unit_id)?.name) },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
- { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteUnits || busy, onClick: () => editRelation(row) }]} /> }
+ { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteUnits || busy, disabledReason: organizationWriteReason(canWriteUnits, busy, "unit"), onClick: () => editRelation(row) }]} /> }
];
const functionTypeColumns: DataGridColumn[] = [
@@ -916,7 +941,7 @@ export default function OrganizationsPage({
{ id: "delegable", header: "i18n:govoplan-organizations.delegable.b4f0137d", width: 120, render: (row) => activeStatus(row.delegable) },
{ id: "actInPlace", header: "i18n:govoplan-organizations.act_in_place.49b942bd", width: 140, render: (row) => activeStatus(row.act_in_place_allowed) },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
- { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, onClick: () => editFunctionType(row) }]} /> }
+ { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, disabledReason: organizationWriteReason(canWriteModel, busy, "model"), onClick: () => editFunctionType(row) }]} /> }
];
const functionColumns: DataGridColumn[] = [
@@ -938,6 +963,7 @@ export default function OrganizationsPage({
label: "i18n:govoplan-organizations.edit.7dce1220",
icon: ,
disabled: !canWriteFunctions || busy,
+ disabledReason: organizationWriteReason(canWriteFunctions, busy, "function"),
onClick: () => editFunction(row)
},
...functionActionContributions.map((contribution) => ({
@@ -950,7 +976,26 @@ export default function OrganizationsPage({
}
];
- const canWriteActive = active === "model" ? canWriteModel : active === "units" || active === "relations" ? canWriteUnits : canWriteFunctions;
+ const activeWriteKind = active === "model"
+ ? "model"
+ : active === "units" || active === "relations"
+ ? "unit"
+ : "function";
+ const canWriteActive = activeWriteKind === "model"
+ ? canWriteModel
+ : activeWriteKind === "unit"
+ ? canWriteUnits
+ : canWriteFunctions;
+ const activeWriteReason = organizationWriteReason(
+ canWriteActive,
+ busy,
+ activeWriteKind
+ );
+ const reloadDisabledReason = loading
+ ? ORGANIZATIONS_INTERFACE_I18N.loading
+ : busy
+ ? ORGANIZATIONS_INTERFACE_I18N.busy
+ : undefined;
const content = (
@@ -960,7 +1005,14 @@ export default function OrganizationsPage({
{description}
-
@@ -968,7 +1020,23 @@ export default function OrganizationsPage({
{error && {error}}
{success && !error && {success}}
- {!canWriteActive && i18n:govoplan-organizations.write_permission_required.8b09fd67}
+ {!canWriteActive && (
+
+ )}
{active === "model" && renderModelSection()}
@@ -984,7 +1052,11 @@ export default function OrganizationsPage({
return (
-
+
requestDiscard(() => setActive(section))}
+ />
{content}
@@ -994,16 +1066,16 @@ export default function OrganizationsPage({
function renderModelSection() {
return (
- } variant="primary" disabled={!canWriteModel || busy} onClick={openUnitTypeCreate} />}>
+ } variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openUnitTypeCreate} />}>
row.id} emptyText="i18n:govoplan-organizations.no_unit_types_found.c81fb2a7" initialFit="container" />
- } variant="primary" disabled={!canWriteModel || busy} onClick={openStructureCreate} />}>
+ } variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openStructureCreate} />}>
row.id} emptyText="i18n:govoplan-organizations.no_structures_found.20b382d0" initialFit="container" />
- } variant="primary" disabled={!canWriteModel || busy} onClick={openRelationTypeCreate} />}>
+ } variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openRelationTypeCreate} />}>
row.id} emptyText="i18n:govoplan-organizations.no_relation_types_found.6f90bb81" initialFit="container" />
- } variant="primary" disabled={!canWriteModel || busy} onClick={openFunctionTypeCreate} />}>
+ } variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openFunctionTypeCreate} />}>
row.id} emptyText="i18n:govoplan-organizations.no_function_types_found.5eef31b1" initialFit="container" />
@@ -1013,9 +1085,9 @@ export default function OrganizationsPage({
function renderUnitsSection() {
return (
-
} variant="primary" disabled={!canWriteUnits || busy} onClick={() => openUnitCreate()} />}>
+
} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={() => openUnitCreate()} />}>
- {selectedUnitId && setSelectedUnitId("")}>i18n:govoplan-organizations.none.334c4a4c}
+ {selectedUnitId && setSelectedUnitId("")}>i18n:govoplan-organizations.none.334c4a4c}
{model.units.length ? (
}
variant="ghost"
disabled={!canWriteUnits || busy}
+ disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")}
onClick={() => addSubunit(unit)}
/>
)}
/>
) :
i18n:govoplan-organizations.no_units_found.eea2dd0c
}
-
} variant="primary" disabled={!canWriteUnits || busy} onClick={() => openUnitCreate()} />}>
+
} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={() => openUnitCreate()} />}>
row.id} emptyText="i18n:govoplan-organizations.no_units_found.eea2dd0c" initialFit="container" />
@@ -1057,7 +1130,7 @@ export default function OrganizationsPage({
function renderRelationsSection() {
return (
- } variant="primary" disabled={!canWriteUnits || busy} onClick={openRelationCreate} />}>
+ } variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={openRelationCreate} />}>
row.id} emptyText="i18n:govoplan-organizations.no_relations_found.4e75b11d" initialFit="container" />
@@ -1067,7 +1140,7 @@ export default function OrganizationsPage({
function renderFunctionsSection() {
return (
- } variant="primary" disabled={!canWriteFunctions || busy} onClick={() => openFunctionCreate()} />}>
+ } variant="primary" disabled={!canWriteFunctions || busy} disabledReason={organizationWriteReason(canWriteFunctions, busy, "function")} onClick={() => openFunctionCreate()} />}>
row.id} emptyText="i18n:govoplan-organizations.no_functions_found.54e759a0" initialFit="container" />
@@ -1077,7 +1150,11 @@ export default function OrganizationsPage({
function renderChangeRequestField() {
return (
-
+
setChangeRequestId(event.target.value)} placeholder="cfgreq-..." disabled={busy} />
i18n:govoplan-organizations.change_request_id_help.0c119a5d
@@ -1099,18 +1176,32 @@ export default function OrganizationsPage({
}
function editorSubmitDisabled(): boolean {
- if (busy || !activeEditor) return true;
- if ((activeEditor === "unitType" || activeEditor === "structure" || activeEditor === "relationType" || activeEditor === "functionType") && !canWriteModel) return true;
- if ((activeEditor === "unit" || activeEditor === "relation") && !canWriteUnits) return true;
- if (activeEditor === "function" && !canWriteFunctions) return true;
- if (activeEditor === "unitType") return !unitTypeDraft.name.trim();
- if (activeEditor === "structure") return !structureDraft.name.trim();
- if (activeEditor === "relationType") return !relationTypeDraft.name.trim();
- if (activeEditor === "unit") return !unitDraft.name.trim();
- if (activeEditor === "relation") return !relationDraft.structure_id || !relationDraft.relation_type_id || !relationDraft.source_unit_id || !relationDraft.target_unit_id;
- if (activeEditor === "functionType") return !functionTypeDraft.name.trim();
- if (activeEditor === "function") return !functionDraft.name.trim() || !functionDraft.organization_unit_id;
- return true;
+ return Boolean(editorSubmitDisabledReason());
+ }
+
+ function editorSubmitDisabledReason(): string | undefined {
+ if (busy) return ORGANIZATIONS_INTERFACE_I18N.busy;
+ if (!activeEditor) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
+ if (activeEditor === "unitType" || activeEditor === "structure" || activeEditor === "relationType" || activeEditor === "functionType") {
+ const reason = organizationWriteReason(canWriteModel, busy, "model");
+ if (reason) return reason;
+ }
+ if (activeEditor === "unit" || activeEditor === "relation") {
+ const reason = organizationWriteReason(canWriteUnits, busy, "unit");
+ if (reason) return reason;
+ }
+ if (activeEditor === "function") {
+ const reason = organizationWriteReason(canWriteFunctions, busy, "function");
+ if (reason) return reason;
+ }
+ if (activeEditor === "unitType" && !unitTypeDraft.name.trim()) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
+ if (activeEditor === "structure" && !structureDraft.name.trim()) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
+ if (activeEditor === "relationType" && !relationTypeDraft.name.trim()) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
+ if (activeEditor === "unit" && !unitDraft.name.trim()) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
+ if (activeEditor === "relation" && (!relationDraft.structure_id || !relationDraft.relation_type_id || !relationDraft.source_unit_id || !relationDraft.target_unit_id)) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
+ if (activeEditor === "functionType" && !functionTypeDraft.name.trim()) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
+ if (activeEditor === "function" && (!functionDraft.name.trim() || !functionDraft.organization_unit_id)) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
+ return undefined;
}
function renderEditorDialog() {
@@ -1127,16 +1218,38 @@ export default function OrganizationsPage({