Migrate Organizations interface patterns

This commit is contained in:
2026-08-03 12:09:33 +02:00
parent 3f0f38d226
commit 97acfcba7d
11 changed files with 533 additions and 74 deletions
+4
View File
@@ -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
+51
View File
@@ -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.
+84 -2
View File
@@ -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",
@@ -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()
+2 -1
View File
@@ -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": {
".": {
@@ -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.");
@@ -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={<><Button onClick={() => void load()} disabled={loading || busy}>i18n:govoplan-organizations.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !dirty}>{busy ? "i18n:govoplan-organizations.saving.56a2285c" : "i18n:govoplan-organizations.save_settings.913aba9f"}</Button></>}
actions={(
<>
<DocumentationHelpLink reference={ORGANIZATIONS_DOCUMENTATION} />
<Button
onClick={() => requestDiscard(() => void load())}
disabled={Boolean(reloadDisabledReason)}
disabledReason={reloadDisabledReason}
>
i18n:govoplan-organizations.reload.cce71553
</Button>
<Button
variant="primary"
onClick={() => void save()}
disabled={Boolean(saveDisabledReason)}
disabledReason={saveDisabledReason}
>
{busy ? "i18n:govoplan-organizations.saving.56a2285c" : "i18n:govoplan-organizations.save_settings.913aba9f"}
</Button>
</>
)}
>
{!canWrite && (
<ActionBlockerHint
reason={{
summary: ORGANIZATIONS_INTERFACE_I18N.readOnlySummary,
requiredAction: ORGANIZATIONS_INTERFACE_I18N.permissionGuidance,
actor: ORGANIZATIONS_INTERFACE_I18N.administrator,
target: ORGANIZATIONS_INTERFACE_I18N.administrationTarget
}}
labels={{
requiredAction: ORGANIZATIONS_INTERFACE_I18N.requiredAction,
actor: ORGANIZATIONS_INTERFACE_I18N.actor,
target: ORGANIZATIONS_INTERFACE_I18N.destination
}}
documentation={ORGANIZATIONS_DOCUMENTATION}
/>
)}
<div className="organizations-settings-grid">
<Card title="i18n:govoplan-organizations.model_governance.6aa18fd0">
<div className="settings-list">
<ToggleSwitch
checked={draft.allow_tenant_model_customization}
disabled={!canWrite || busy}
help={organizationWriteReason(canWrite, busy, "settings")}
onChange={(checked) => setDraft({ ...draft, allow_tenant_model_customization: checked })}
label="i18n:govoplan-organizations.allow_tenant_model_customization.2425d751"
/>
<ToggleSwitch
checked={draft.require_model_change_requests}
disabled={!canWrite || busy}
help={organizationWriteReason(canWrite, busy, "settings")}
onChange={(checked) => 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:
<Card title="i18n:govoplan-organizations.audit_and_retention.3ba1d2fc">
<div className="admin-form-grid two-columns">
<FormField label="i18n:govoplan-organizations.audit_detail_level.7397355d">
<FormField
label="i18n:govoplan-organizations.audit_detail_level.7397355d"
help={organizationWriteReason(canWrite, busy, "settings")}
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
>
<select value={draft.audit_detail_level} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, audit_detail_level: event.target.value as OrganizationAuditDetailLevel })}>
{AUDIT_DETAIL_LEVELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.change_retention_days.71bbd140">
<FormField
label="i18n:govoplan-organizations.change_retention_days.71bbd140"
help={organizationWriteReason(canWrite, busy, "settings")}
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
>
<input
type="number"
min={0}
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { Edit3, Plus, RefreshCw } from "lucide-react";
import {
ActionBlockerHint,
AdminIconButton,
ApiError,
Button,
@@ -8,6 +9,7 @@ import {
DataGrid,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
ExplorerTree,
FormField,
IconButton,
@@ -20,6 +22,7 @@ import {
hasScope,
isViewSurfaceVisible,
useEffectiveView,
useUnsavedChanges,
useUnsavedDraftGuard,
usePlatformUiCapabilities,
useViewSurfaces,
@@ -62,6 +65,12 @@ import {
type StructureCreatePayload,
type UnitCreatePayload
} from "../../api/organizations";
import {
ORGANIZATIONS_DOCUMENTATION,
ORGANIZATIONS_FIELD_DOCUMENTATION,
ORGANIZATIONS_INTERFACE_I18N,
organizationWriteReason
} from "./interfacePatterns";
export type OrganizationSection = "model" | "units" | "relations" | "functions";
type OrganizationsPageMode = "workspace" | "admin";
@@ -302,22 +311,36 @@ function activeStatus(active: boolean): JSX.Element {
function SluggedFields({
draft,
onChange,
disabled
disabled,
disabledReason
}: {
draft: SluggedDraft;
onChange: (next: SluggedDraft) => void;
disabled: boolean;
disabledReason?: string;
}) {
return (
<>
<FormField label="i18n:govoplan-organizations.name.709a2322">
<FormField
label="i18n:govoplan-organizations.name.709a2322"
help={disabledReason}
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
>
<input value={draft.name} disabled={disabled} onChange={(event) => onChange({ ...draft, name: event.target.value })} />
</FormField>
<FormField label="i18n:govoplan-organizations.slug.094da9b9">
<FormField
label="i18n:govoplan-organizations.slug.094da9b9"
help={disabledReason}
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
>
<input value={draft.slug} disabled={disabled} onChange={(event) => onChange({ ...draft, slug: event.target.value })} />
</FormField>
<div className="wide">
<FormField label="i18n:govoplan-organizations.description.55f8ebc8">
<FormField
label="i18n:govoplan-organizations.description.55f8ebc8"
help={disabledReason}
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
>
<textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => onChange({ ...draft, description: event.target.value })} />
</FormField>
</div>
@@ -325,6 +348,7 @@ function SluggedFields({
<ToggleSwitch
checked={draft.is_active}
disabled={disabled}
help={disabledReason}
onChange={(checked) => 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<SluggedDraft>(() => emptySluggedDraft());
const [structureDraft, setStructureDraft] = useState<StructureDraft>(() => 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) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editUnitType(row) }]} /> }
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, disabledReason: organizationWriteReason(canWriteModel, busy, "model"), onClick: () => editUnitType(row) }]} /> }
];
const structureColumns: DataGridColumn<OrganizationStructureItem>[] = [
@@ -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) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editStructure(row) }]} /> }
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, disabledReason: organizationWriteReason(canWriteModel, busy, "model"), onClick: () => editStructure(row) }]} /> }
];
const relationTypeColumns: DataGridColumn<OrganizationRelationTypeItem>[] = [
@@ -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) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editRelationType(row) }]} /> }
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, disabledReason: organizationWriteReason(canWriteModel, busy, "model"), onClick: () => editRelationType(row) }]} /> }
];
const unitColumns: DataGridColumn<OrganizationUnitItem>[] = [
@@ -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) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, onClick: () => editUnit(row) }]} /> }
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, disabledReason: organizationWriteReason(canWriteUnits, busy, "unit"), onClick: () => editUnit(row) }]} /> }
];
const relationColumns: DataGridColumn<OrganizationRelationItem>[] = [
@@ -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) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, onClick: () => editRelation(row) }]} /> }
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, disabledReason: organizationWriteReason(canWriteUnits, busy, "unit"), onClick: () => editRelation(row) }]} /> }
];
const functionTypeColumns: DataGridColumn<OrganizationFunctionTypeItem>[] = [
@@ -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) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editFunctionType(row) }]} /> }
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, disabledReason: organizationWriteReason(canWriteModel, busy, "model"), onClick: () => editFunctionType(row) }]} /> }
];
const functionColumns: DataGridColumn<OrganizationFunctionItem>[] = [
@@ -938,6 +963,7 @@ export default function OrganizationsPage({
label: "i18n:govoplan-organizations.edit.7dce1220",
icon: <Edit3 size={16} aria-hidden="true" />,
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 = (
<div className={`${mode === "workspace" ? "content-pad " : ""}organizations-page ${mode === "admin" ? "organizations-admin-page" : ""}`.trim()}>
@@ -960,7 +1005,14 @@ export default function OrganizationsPage({
<p>{description}</p>
</div>
<div className="organizations-toolbar">
<Button type="button" onClick={() => void loadModel()} disabled={loading || busy} title="i18n:govoplan-organizations.reload.cce71553">
<DocumentationHelpLink reference={ORGANIZATIONS_DOCUMENTATION} />
<Button
type="button"
onClick={() => requestDiscard(() => void loadModel())}
disabled={Boolean(reloadDisabledReason)}
disabledReason={reloadDisabledReason}
title="i18n:govoplan-organizations.reload.cce71553"
>
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-organizations.reload.cce71553
</Button>
</div>
@@ -968,7 +1020,23 @@ export default function OrganizationsPage({
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
{!canWriteActive && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-organizations.write_permission_required.8b09fd67</DismissibleAlert>}
{!canWriteActive && (
<ActionBlockerHint
reason={{
summary: ORGANIZATIONS_INTERFACE_I18N.readOnlySummary,
details: activeWriteReason,
requiredAction: ORGANIZATIONS_INTERFACE_I18N.permissionGuidance,
actor: ORGANIZATIONS_INTERFACE_I18N.administrator,
target: ORGANIZATIONS_INTERFACE_I18N.administrationTarget
}}
labels={{
requiredAction: ORGANIZATIONS_INTERFACE_I18N.requiredAction,
actor: ORGANIZATIONS_INTERFACE_I18N.actor,
target: ORGANIZATIONS_INTERFACE_I18N.destination
}}
documentation={ORGANIZATIONS_DOCUMENTATION}
/>
)}
<LoadingFrame loading={loading || busy} label="i18n:govoplan-organizations.loading_organization_model.846aa317">
{active === "model" && renderModelSection()}
@@ -984,7 +1052,11 @@ export default function OrganizationsPage({
return (
<div className="workspace organizations-workspace">
<ModuleSubnav active={active} groups={visibleSectionGroups} onSelect={setActive} />
<ModuleSubnav
active={active}
groups={visibleSectionGroups}
onSelect={(section) => requestDiscard(() => setActive(section))}
/>
<main className="workspace-content">
{content}
</main>
@@ -994,16 +1066,16 @@ export default function OrganizationsPage({
function renderModelSection() {
return (
<div className="organizations-table-stack">
<Card title="i18n:govoplan-organizations.unit_types.c7afc174" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit_type.58f2c05a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} onClick={openUnitTypeCreate} />}>
<Card title="i18n:govoplan-organizations.unit_types.c7afc174" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit_type.58f2c05a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openUnitTypeCreate} />}>
<DataGrid id="organizations-unit-types" rows={model.unit_types} columns={unitTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_unit_types_found.c81fb2a7" initialFit="container" />
</Card>
<Card title="i18n:govoplan-organizations.structures.f9b7f3b4" actions={<AdminIconButton label="i18n:govoplan-organizations.add_structure.b722042a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} onClick={openStructureCreate} />}>
<Card title="i18n:govoplan-organizations.structures.f9b7f3b4" actions={<AdminIconButton label="i18n:govoplan-organizations.add_structure.b722042a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openStructureCreate} />}>
<DataGrid id="organizations-structures" rows={model.structures} columns={structureColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_structures_found.20b382d0" initialFit="container" />
</Card>
<Card title="i18n:govoplan-organizations.relation_types.e5890528" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation_type.2ad19d03" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} onClick={openRelationTypeCreate} />}>
<Card title="i18n:govoplan-organizations.relation_types.e5890528" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation_type.2ad19d03" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openRelationTypeCreate} />}>
<DataGrid id="organizations-relation-types" rows={model.relation_types} columns={relationTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_relation_types_found.6f90bb81" initialFit="container" />
</Card>
<Card title="i18n:govoplan-organizations.function_types.172c01fe" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function_type.90d793f1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} onClick={openFunctionTypeCreate} />}>
<Card title="i18n:govoplan-organizations.function_types.172c01fe" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function_type.90d793f1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openFunctionTypeCreate} />}>
<DataGrid id="organizations-function-types" rows={model.function_types} columns={functionTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_function_types_found.5eef31b1" initialFit="container" />
</Card>
</div>
@@ -1013,9 +1085,9 @@ export default function OrganizationsPage({
function renderUnitsSection() {
return (
<div className="organizations-table-stack">
<Card title="i18n:govoplan-organizations.organization_tree.e5bfb195" actions={<AdminIconButton label="i18n:govoplan-organizations.add_root_unit.1ef8a9f5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} onClick={() => openUnitCreate()} />}>
<Card title="i18n:govoplan-organizations.organization_tree.e5bfb195" actions={<AdminIconButton label="i18n:govoplan-organizations.add_root_unit.1ef8a9f5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={() => openUnitCreate()} />}>
<div className="explorer-tree-toolbar">
{selectedUnitId && <Button type="button" variant="ghost" disabled={busy} onClick={() => setSelectedUnitId("")}>i18n:govoplan-organizations.none.334c4a4c</Button>}
{selectedUnitId && <Button type="button" variant="ghost" disabled={busy} disabledReason={busy ? ORGANIZATIONS_INTERFACE_I18N.busy : undefined} onClick={() => setSelectedUnitId("")}>i18n:govoplan-organizations.none.334c4a4c</Button>}
</div>
{model.units.length ? (
<ExplorerTree
@@ -1041,13 +1113,14 @@ export default function OrganizationsPage({
icon={<Plus size={16} aria-hidden="true" />}
variant="ghost"
disabled={!canWriteUnits || busy}
disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")}
onClick={() => addSubunit(unit)}
/>
)}
/>
) : <p className="muted small-note">i18n:govoplan-organizations.no_units_found.eea2dd0c</p>}
</Card>
<Card title="i18n:govoplan-organizations.units.e14d0d92" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit.8fa12fb1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} onClick={() => openUnitCreate()} />}>
<Card title="i18n:govoplan-organizations.units.e14d0d92" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit.8fa12fb1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={() => openUnitCreate()} />}>
<DataGrid id="organizations-units" rows={model.units} columns={unitColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_units_found.eea2dd0c" initialFit="container" />
</Card>
</div>
@@ -1057,7 +1130,7 @@ export default function OrganizationsPage({
function renderRelationsSection() {
return (
<div className="organizations-table-stack">
<Card title="i18n:govoplan-organizations.relations.1c796711" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation.6b6e67ea" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} onClick={openRelationCreate} />}>
<Card title="i18n:govoplan-organizations.relations.1c796711" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation.6b6e67ea" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={openRelationCreate} />}>
<DataGrid id="organizations-relations" rows={model.relations} columns={relationColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_relations_found.4e75b11d" initialFit="container" />
</Card>
</div>
@@ -1067,7 +1140,7 @@ export default function OrganizationsPage({
function renderFunctionsSection() {
return (
<div className="organizations-table-stack">
<Card title="i18n:govoplan-organizations.functions.805dc49b" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function.6abafee0" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteFunctions || busy} onClick={() => openFunctionCreate()} />}>
<Card title="i18n:govoplan-organizations.functions.805dc49b" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function.6abafee0" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteFunctions || busy} disabledReason={organizationWriteReason(canWriteFunctions, busy, "function")} onClick={() => openFunctionCreate()} />}>
<DataGrid id="organizations-functions" rows={model.functions} columns={functionColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_functions_found.54e759a0" initialFit="container" />
</Card>
</div>
@@ -1077,7 +1150,11 @@ export default function OrganizationsPage({
function renderChangeRequestField() {
return (
<div className="wide organizations-dialog-change-request">
<FormField label="i18n:govoplan-organizations.change_request_id.a6e7fd6b">
<FormField
label="i18n:govoplan-organizations.change_request_id.a6e7fd6b"
help={busy ? ORGANIZATIONS_INTERFACE_I18N.busy : undefined}
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
>
<input value={changeRequestId} onChange={(event) => setChangeRequestId(event.target.value)} placeholder="cfgreq-..." disabled={busy} />
</FormField>
<p className="organizations-field-note">i18n:govoplan-organizations.change_request_id_help.0c119a5d</p>
@@ -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({
<Dialog
open={Boolean(activeEditor)}
title={editorTitle()}
onClose={() => !busy && discardDrafts()}
onClose={() => {
if (busy) return;
if (hasDirtyDraft) requestDiscard(discardDrafts);
else discardDrafts();
}}
closeDisabled={busy}
className="admin-dialog admin-dialog-wide organizations-editor-dialog"
footer={(
<>
<Button type="button" onClick={discardDrafts} disabled={busy}>i18n:govoplan-organizations.cancel_edit.309c2a6f</Button>
<Button type="submit" form={formId} variant="primary" disabled={editorSubmitDisabled()}>{editorTitle()}</Button>
<Button
type="button"
onClick={() => hasDirtyDraft ? requestDiscard(discardDrafts) : discardDrafts()}
disabled={busy}
disabledReason={busy ? ORGANIZATIONS_INTERFACE_I18N.busy : undefined}
>
i18n:govoplan-organizations.cancel_edit.309c2a6f
</Button>
<Button
type="submit"
form={formId}
variant="primary"
disabled={editorSubmitDisabled()}
disabledReason={editorSubmitDisabledReason()}
>
{editorTitle()}
</Button>
</>
)}
>
<div className="button-row organizations-dialog-help">
<DocumentationHelpLink reference={ORGANIZATIONS_FIELD_DOCUMENTATION} />
</div>
<form id={formId} className="admin-form-grid two-columns" onSubmit={(event) => void submit(event)}>
{renderEditorFields()}
</form>
@@ -1148,7 +1261,7 @@ export default function OrganizationsPage({
if (activeEditor === "unitType") {
return (
<>
<SluggedFields draft={unitTypeDraft} onChange={setUnitTypeDraft} disabled={!canWriteModel || busy} />
<SluggedFields draft={unitTypeDraft} onChange={setUnitTypeDraft} disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} />
{renderChangeRequestField()}
</>
);
@@ -1156,8 +1269,8 @@ export default function OrganizationsPage({
if (activeEditor === "structure") {
return (
<>
<SluggedFields draft={structureDraft} onChange={setStructureDraft} disabled={!canWriteModel || busy} />
<FormField label="i18n:govoplan-organizations.kind.794c9d9c">
<SluggedFields draft={structureDraft} onChange={setStructureDraft} disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} />
<FormField label="i18n:govoplan-organizations.kind.794c9d9c" help={organizationWriteReason(canWriteModel, busy, "model")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
<select value={structureDraft.structure_kind} disabled={!canWriteModel || busy} onChange={(event) => setStructureDraft({ ...structureDraft, structure_kind: event.target.value as OrganizationStructureKind })}>
{STRUCTURE_KINDS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
@@ -1169,28 +1282,28 @@ export default function OrganizationsPage({
if (activeEditor === "relationType") {
return (
<>
<SluggedFields draft={relationTypeDraft} onChange={setRelationTypeDraft} disabled={!canWriteModel || busy} />
<FormField label="i18n:govoplan-organizations.structure.7732fb0b">
<SluggedFields draft={relationTypeDraft} onChange={setRelationTypeDraft} disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} />
<FormField label="i18n:govoplan-organizations.structure.7732fb0b" help={organizationWriteReason(canWriteModel, busy, "model")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
<select value={relationTypeDraft.structure_id} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, structure_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{model.structures.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.source_unit.2fbd8baa">
<FormField label="i18n:govoplan-organizations.source_unit.2fbd8baa" help={organizationWriteReason(canWriteModel, busy, "model")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
<select value={relationTypeDraft.source_unit_type_id} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, source_unit_type_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.target_unit.a1507d86">
<FormField label="i18n:govoplan-organizations.target_unit.a1507d86" help={organizationWriteReason(canWriteModel, busy, "model")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
<select value={relationTypeDraft.target_unit_type_id} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, target_unit_type_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<div className="organizations-check-list">
<ToggleSwitch checked={relationTypeDraft.is_hierarchical} disabled={!canWriteModel || busy} onChange={(checked) => setRelationTypeDraft({ ...relationTypeDraft, is_hierarchical: checked })} label="i18n:govoplan-organizations.hierarchical.8964f313" />
<ToggleSwitch checked={relationTypeDraft.allow_cycles} disabled={!canWriteModel || busy} onChange={(checked) => setRelationTypeDraft({ ...relationTypeDraft, allow_cycles: checked })} label="i18n:govoplan-organizations.allow_cycles.31327578" />
<ToggleSwitch checked={relationTypeDraft.is_hierarchical} disabled={!canWriteModel || busy} help={organizationWriteReason(canWriteModel, busy, "model")} onChange={(checked) => setRelationTypeDraft({ ...relationTypeDraft, is_hierarchical: checked })} label="i18n:govoplan-organizations.hierarchical.8964f313" />
<ToggleSwitch checked={relationTypeDraft.allow_cycles} disabled={!canWriteModel || busy} help={organizationWriteReason(canWriteModel, busy, "model")} onChange={(checked) => setRelationTypeDraft({ ...relationTypeDraft, allow_cycles: checked })} label="i18n:govoplan-organizations.allow_cycles.31327578" />
</div>
{renderChangeRequestField()}
</>
@@ -1199,14 +1312,14 @@ export default function OrganizationsPage({
if (activeEditor === "unit") {
return (
<>
<SluggedFields draft={unitDraft} onChange={setUnitDraft} disabled={!canWriteUnits || busy} />
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d">
<SluggedFields draft={unitDraft} onChange={setUnitDraft} disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} />
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d" help={organizationWriteReason(canWriteUnits, busy, "unit")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
<select value={unitDraft.unit_type_id} disabled={!canWriteUnits || busy} onChange={(event) => setUnitDraft({ ...unitDraft, unit_type_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.parent_unit.1986c35e">
<FormField label="i18n:govoplan-organizations.parent_unit.1986c35e" help={organizationWriteReason(canWriteUnits, busy, "unit")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
<select value={unitDraft.parent_id} disabled={!canWriteUnits || busy} onChange={(event) => setUnitDraft({ ...unitDraft, parent_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{model.units.filter((item) => item.id !== editingUnitId).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
@@ -1224,25 +1337,25 @@ export default function OrganizationsPage({
if (activeEditor === "relation") {
return (
<>
<FormField label="i18n:govoplan-organizations.structure.7732fb0b">
<FormField label="i18n:govoplan-organizations.structure.7732fb0b" help={organizationWriteReason(canWriteUnits, busy, "unit")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
<select value={relationDraft.structure_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, structure_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.select_structure.c10f551c</option>
{model.structures.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.relation_type.d0aee2e7">
<FormField label="i18n:govoplan-organizations.relation_type.d0aee2e7" help={organizationWriteReason(canWriteUnits, busy, "unit")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
<select value={relationDraft.relation_type_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, relation_type_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.select_relation_type.73f92478</option>
{model.relation_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.source_unit.2fbd8baa">
<FormField label="i18n:govoplan-organizations.source_unit.2fbd8baa" help={organizationWriteReason(canWriteUnits, busy, "unit")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
<select value={relationDraft.source_unit_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, source_unit_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.select_source_unit.9b5a29c8</option>
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.target_unit.a1507d86">
<FormField label="i18n:govoplan-organizations.target_unit.a1507d86" help={organizationWriteReason(canWriteUnits, busy, "unit")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
<select value={relationDraft.target_unit_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, target_unit_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.select_target_unit.8d607541</option>
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
@@ -1252,6 +1365,7 @@ export default function OrganizationsPage({
<ToggleSwitch
checked={relationDraft.is_active}
disabled={!canWriteUnits || busy}
help={organizationWriteReason(canWriteUnits, busy, "unit")}
onChange={(checked) => setRelationDraft({ ...relationDraft, is_active: checked })}
label="i18n:govoplan-organizations.active.a733b809"
/>
@@ -1263,16 +1377,16 @@ export default function OrganizationsPage({
if (activeEditor === "functionType") {
return (
<>
<SluggedFields draft={functionTypeDraft} onChange={setFunctionTypeDraft} disabled={!canWriteModel || busy} />
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d">
<SluggedFields draft={functionTypeDraft} onChange={setFunctionTypeDraft} disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} />
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d" help={organizationWriteReason(canWriteModel, busy, "model")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
<select value={functionTypeDraft.organization_unit_type_id} disabled={!canWriteModel || busy} onChange={(event) => setFunctionTypeDraft({ ...functionTypeDraft, organization_unit_type_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<div className="organizations-check-list">
<ToggleSwitch checked={functionTypeDraft.delegable} disabled={!canWriteModel || busy} onChange={(checked) => setFunctionTypeDraft({ ...functionTypeDraft, delegable: checked })} label="i18n:govoplan-organizations.delegable.b4f0137d" />
<ToggleSwitch checked={functionTypeDraft.act_in_place_allowed} disabled={!canWriteModel || busy} onChange={(checked) => setFunctionTypeDraft({ ...functionTypeDraft, act_in_place_allowed: checked })} label="i18n:govoplan-organizations.act_in_place.49b942bd" />
<ToggleSwitch checked={functionTypeDraft.delegable} disabled={!canWriteModel || busy} help={organizationWriteReason(canWriteModel, busy, "model")} onChange={(checked) => setFunctionTypeDraft({ ...functionTypeDraft, delegable: checked })} label="i18n:govoplan-organizations.delegable.b4f0137d" />
<ToggleSwitch checked={functionTypeDraft.act_in_place_allowed} disabled={!canWriteModel || busy} help={organizationWriteReason(canWriteModel, busy, "model")} onChange={(checked) => setFunctionTypeDraft({ ...functionTypeDraft, act_in_place_allowed: checked })} label="i18n:govoplan-organizations.act_in_place.49b942bd" />
</div>
{renderChangeRequestField()}
</>
@@ -1280,22 +1394,22 @@ export default function OrganizationsPage({
}
return (
<>
<SluggedFields draft={functionDraft} onChange={setFunctionDraft} disabled={!canWriteFunctions || busy} />
<FormField label="i18n:govoplan-organizations.unit.8fe4d595">
<SluggedFields draft={functionDraft} onChange={setFunctionDraft} disabled={!canWriteFunctions || busy} disabledReason={organizationWriteReason(canWriteFunctions, busy, "function")} />
<FormField label="i18n:govoplan-organizations.unit.8fe4d595" help={organizationWriteReason(canWriteFunctions, busy, "function")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
<select value={functionDraft.organization_unit_id} disabled={!canWriteFunctions || busy} onChange={(event) => setFunctionDraft({ ...functionDraft, organization_unit_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.select_unit.013bf13a</option>
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.function_type.501fe7c0">
<FormField label="i18n:govoplan-organizations.function_type.501fe7c0" help={organizationWriteReason(canWriteFunctions, busy, "function")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
<select value={functionDraft.function_type_id} disabled={!canWriteFunctions || busy} onChange={(event) => setFunctionDraft({ ...functionDraft, function_type_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{model.function_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<div className="organizations-check-list">
<ToggleSwitch checked={functionDraft.delegable} disabled={!canWriteFunctions || busy} onChange={(checked) => setFunctionDraft({ ...functionDraft, delegable: checked })} label="i18n:govoplan-organizations.delegable.b4f0137d" />
<ToggleSwitch checked={functionDraft.act_in_place_allowed} disabled={!canWriteFunctions || busy} onChange={(checked) => setFunctionDraft({ ...functionDraft, act_in_place_allowed: checked })} label="i18n:govoplan-organizations.act_in_place.49b942bd" />
<ToggleSwitch checked={functionDraft.delegable} disabled={!canWriteFunctions || busy} help={organizationWriteReason(canWriteFunctions, busy, "function")} onChange={(checked) => setFunctionDraft({ ...functionDraft, delegable: checked })} label="i18n:govoplan-organizations.delegable.b4f0137d" />
<ToggleSwitch checked={functionDraft.act_in_place_allowed} disabled={!canWriteFunctions || busy} help={organizationWriteReason(canWriteFunctions, busy, "function")} onChange={(checked) => setFunctionDraft({ ...functionDraft, act_in_place_allowed: checked })} label="i18n:govoplan-organizations.act_in_place.49b942bd" />
</div>
{renderChangeRequestField()}
</>
@@ -0,0 +1,44 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const ORGANIZATIONS_DOCUMENTATION = {
topicId: "organizations.model",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const ORGANIZATIONS_FIELD_DOCUMENTATION = {
topicId: "organizations.reference.fields-and-consequences",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const ORGANIZATIONS_INTERFACE_I18N = {
loading: "i18n:govoplan-organizations.loading_reason",
busy: "i18n:govoplan-organizations.busy_reason",
noChanges: "i18n:govoplan-organizations.no_changes_reason",
modelWrite: "i18n:govoplan-organizations.model_write_reason",
unitWrite: "i18n:govoplan-organizations.unit_write_reason",
functionWrite: "i18n:govoplan-organizations.function_write_reason",
settingsWrite: "i18n:govoplan-organizations.settings_write_reason",
incomplete: "i18n:govoplan-organizations.incomplete_editor_reason",
readOnlySummary: "i18n:govoplan-organizations.read_only_summary",
requiredAction: "i18n:govoplan-organizations.required_action",
actor: "i18n:govoplan-organizations.responsible_actor",
destination: "i18n:govoplan-organizations.destination",
permissionGuidance: "i18n:govoplan-organizations.request_write_permission",
administrator: "i18n:govoplan-organizations.organization_or_access_administrator",
administrationTarget: "i18n:govoplan-organizations.administration_roles_target"
} as const;
export function organizationWriteReason(
permitted: boolean,
busy: boolean,
kind: "model" | "unit" | "function" | "settings"
): string | undefined {
if (busy) return ORGANIZATIONS_INTERFACE_I18N.busy;
if (permitted) return undefined;
return {
model: ORGANIZATIONS_INTERFACE_I18N.modelWrite,
unit: ORGANIZATIONS_INTERFACE_I18N.unitWrite,
function: ORGANIZATIONS_INTERFACE_I18N.functionWrite,
settings: ORGANIZATIONS_INTERFACE_I18N.settingsWrite
}[kind];
}
+34 -2
View File
@@ -122,7 +122,23 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.update_structure.b2e25446": "Update structure",
"i18n:govoplan-organizations.update_unit.67e2500f": "Update unit",
"i18n:govoplan-organizations.update_unit_type.b96fc0ad": "Update unit type",
"i18n:govoplan-organizations.write_permission_required.8b09fd67": "You do not have write permission for this organization section."
"i18n:govoplan-organizations.write_permission_required.8b09fd67": "You do not have write permission for this organization section.",
"i18n:govoplan-organizations.organizations_administration": "Organizations administration",
"i18n:govoplan-organizations.loading_reason": "Organization data is loading.",
"i18n:govoplan-organizations.busy_reason": "An organization change is in progress.",
"i18n:govoplan-organizations.no_changes_reason": "Make a change before saving.",
"i18n:govoplan-organizations.model_write_reason": "Organization model write permission is required.",
"i18n:govoplan-organizations.unit_write_reason": "Organization unit write permission is required.",
"i18n:govoplan-organizations.function_write_reason": "Organization function write permission is required.",
"i18n:govoplan-organizations.settings_write_reason": "Organization settings write permission is required.",
"i18n:govoplan-organizations.incomplete_editor_reason": "Complete the required fields before saving.",
"i18n:govoplan-organizations.read_only_summary": "This organization section is read-only.",
"i18n:govoplan-organizations.required_action": "Required action",
"i18n:govoplan-organizations.responsible_actor": "Responsible actor",
"i18n:govoplan-organizations.destination": "Destination",
"i18n:govoplan-organizations.request_write_permission": "Request the matching organization model, unit, function, or settings write permission.",
"i18n:govoplan-organizations.organization_or_access_administrator": "An organization or Access administrator",
"i18n:govoplan-organizations.administration_roles_target": "Administration > Role templates and function mappings"
},
de: {
"i18n:govoplan-organizations.act_in_place.49b942bd": "In Vertretung handeln",
@@ -245,6 +261,22 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.update_structure.b2e25446": "Struktur aktualisieren",
"i18n:govoplan-organizations.update_unit.67e2500f": "Einheit aktualisieren",
"i18n:govoplan-organizations.update_unit_type.b96fc0ad": "Einheitstyp aktualisieren",
"i18n:govoplan-organizations.write_permission_required.8b09fd67": "Du hast keine Schreibberechtigung für diesen Organisationsbereich."
"i18n:govoplan-organizations.write_permission_required.8b09fd67": "Du hast keine Schreibberechtigung für diesen Organisationsbereich.",
"i18n:govoplan-organizations.organizations_administration": "Organisationsverwaltung",
"i18n:govoplan-organizations.loading_reason": "Organisationsdaten werden geladen.",
"i18n:govoplan-organizations.busy_reason": "Eine Organisationsänderung wird verarbeitet.",
"i18n:govoplan-organizations.no_changes_reason": "Nehmen Sie vor dem Speichern eine Änderung vor.",
"i18n:govoplan-organizations.model_write_reason": "Eine Schreibberechtigung für das Organisationsmodell ist erforderlich.",
"i18n:govoplan-organizations.unit_write_reason": "Eine Schreibberechtigung für Organisationseinheiten ist erforderlich.",
"i18n:govoplan-organizations.function_write_reason": "Eine Schreibberechtigung für Organisationsfunktionen ist erforderlich.",
"i18n:govoplan-organizations.settings_write_reason": "Eine Schreibberechtigung für Organisationseinstellungen ist erforderlich.",
"i18n:govoplan-organizations.incomplete_editor_reason": "Füllen Sie vor dem Speichern die Pflichtfelder aus.",
"i18n:govoplan-organizations.read_only_summary": "Dieser Organisationsbereich ist schreibgeschützt.",
"i18n:govoplan-organizations.required_action": "Erforderliche Aktion",
"i18n:govoplan-organizations.responsible_actor": "Verantwortliche Stelle",
"i18n:govoplan-organizations.destination": "Ziel",
"i18n:govoplan-organizations.request_write_permission": "Fordern Sie die passende Schreibberechtigung für Organisationsmodell, Einheiten, Funktionen oder Einstellungen an.",
"i18n:govoplan-organizations.organization_or_access_administrator": "Eine Organisations- oder Access-Administration",
"i18n:govoplan-organizations.administration_roles_target": "Administration > Rollenvorlagen und Funktionszuordnungen"
}
};
+2 -2
View File
@@ -44,7 +44,7 @@ const organizationFunctionPicker: OrganizationFunctionPickerUiCapability = {
export const organizationsModule: PlatformWebModule = {
id: "organizations",
label: "i18n:govoplan-organizations.organizations.220edf64",
version: "1.0.0",
version: "0.1.8",
dependencies: ["access"],
optionalDependencies: ["admin"],
translations,
@@ -53,7 +53,7 @@ export const organizationsModule: PlatformWebModule = {
id: "organizations.admin.tenant",
moduleId: "organizations",
kind: "section",
label: "Organizations administration",
label: "i18n:govoplan-organizations.organizations_administration",
order: 85
}
],