diff --git a/src/govoplan_organizations/backend/api/v1/routes.py b/src/govoplan_organizations/backend/api/v1/routes.py index 55c8d00..ea826ed 100644 --- a/src/govoplan_organizations/backend/api/v1/routes.py +++ b/src/govoplan_organizations/backend/api/v1/routes.py @@ -15,6 +15,7 @@ from govoplan_organizations.backend.db.models import ( OrganizationFunctionType, OrganizationRelation, OrganizationRelationType, + OrganizationTenantSettings, OrganizationStructure, OrganizationUnit, OrganizationUnitType, @@ -33,6 +34,8 @@ from .schemas import ( OrganizationModelResponse, OrganizationRelationItem, OrganizationRelationTypeItem, + OrganizationSettingsItem, + OrganizationSettingsUpdateRequest, OrganizationStructureItem, OrganizationUnitItem, OrganizationUnitTypeItem, @@ -61,6 +64,8 @@ ORG_MODEL_WRITE_SCOPES = ("organizations:model:write",) ORG_UNIT_WRITE_SCOPES = ("organizations:unit:write",) ORG_FUNCTION_WRITE_SCOPES = ("organizations:function:write",) ORG_ASSIGN_SCOPES = ("organizations:function:assign",) +ORG_SETTINGS_READ_SCOPES = ("organizations:settings:read", "organizations:model:read", "admin:settings:read") +ORG_SETTINGS_WRITE_SCOPES = ("organizations:settings:write", "admin:settings:write") SLUG_RE = re.compile(r"[^a-z0-9]+") ModelT = TypeVar("ModelT") @@ -129,6 +134,21 @@ def _item_unit_type(item: OrganizationUnitType) -> OrganizationUnitTypeItem: return OrganizationUnitTypeItem(**_row_fields(item)) +def _item_settings(item: OrganizationTenantSettings) -> OrganizationSettingsItem: + return OrganizationSettingsItem(**_row_fields(item)) + + +def _default_settings(tenant_id: str) -> OrganizationSettingsItem: + return OrganizationSettingsItem( + tenant_id=tenant_id, + allow_tenant_model_customization=True, + require_model_change_requests=False, + audit_detail_level="standard", + change_retention_days=None, + settings={}, + ) + + def _item_structure(item: OrganizationStructure) -> OrganizationStructureItem: return OrganizationStructureItem(**_row_fields(item)) @@ -187,6 +207,48 @@ def _apply_slugged_update(session: Session, item: object, payload: object, tenan setattr(item, "settings", value) +@router.get("/settings", response_model=OrganizationSettingsItem) +def get_organization_settings( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_SETTINGS_READ_SCOPES)), +) -> OrganizationSettingsItem: + tenant_id = _tenant_id(principal) + item = session.query(OrganizationTenantSettings).filter(OrganizationTenantSettings.tenant_id == tenant_id).one_or_none() + return _item_settings(item) if item is not None else _default_settings(tenant_id) + + +@router.patch("/settings", response_model=OrganizationSettingsItem) +def update_organization_settings( + payload: OrganizationSettingsUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_SETTINGS_WRITE_SCOPES)), +) -> OrganizationSettingsItem: + tenant_id = _tenant_id(principal) + item = session.query(OrganizationTenantSettings).filter(OrganizationTenantSettings.tenant_id == tenant_id).one_or_none() + if item is None: + item = OrganizationTenantSettings( + tenant_id=tenant_id, + allow_tenant_model_customization=True, + require_model_change_requests=False, + audit_detail_level="standard", + change_retention_days=None, + settings={}, + ) + session.add(item) + fields = payload.model_fields_set + for field in ("allow_tenant_model_customization", "require_model_change_requests", "audit_detail_level", "change_retention_days"): + if field in fields: + value = getattr(payload, field) + if field != "change_retention_days" and value is None: + raise _invalid(f"{field} cannot be empty.") + setattr(item, field, value) + if "settings" in fields: + if payload.settings is None: + raise _invalid("Settings cannot be empty.") + item.settings = payload.settings + return _item_settings(_commit(session, item)) + + @router.get("/model", response_model=OrganizationModelResponse) def get_organization_model( session: Session = Depends(get_session), @@ -356,6 +418,8 @@ def update_unit( raise _invalid("An organization unit cannot be its own parent.") if payload.parent_id is not None: _get_tenant_row(session, OrganizationUnit, payload.parent_id, tenant_id, "Parent organization unit") + if _would_create_parent_cycle(session, tenant_id, item.id, payload.parent_id): + raise _invalid("This parent assignment would create an organization unit cycle.") item.parent_id = payload.parent_id return _item_unit(_commit(session, item)) @@ -718,3 +782,17 @@ def _would_create_cycle( query = query.filter(OrganizationRelation.id != exclude_relation_id) pending.extend(row[0] for row in query.all()) return False + + +def _would_create_parent_cycle(session: Session, tenant_id: str, unit_id: str, parent_id: str) -> bool: + current: str | None = parent_id + seen: set[str] = set() + while current is not None: + if current == unit_id or current in seen: + return True + seen.add(current) + current = session.query(OrganizationUnit.parent_id).filter( + OrganizationUnit.tenant_id == tenant_id, + OrganizationUnit.id == current, + ).scalar() + return False diff --git a/src/govoplan_organizations/backend/api/v1/schemas.py b/src/govoplan_organizations/backend/api/v1/schemas.py index 1006f72..724625f 100644 --- a/src/govoplan_organizations/backend/api/v1/schemas.py +++ b/src/govoplan_organizations/backend/api/v1/schemas.py @@ -7,6 +7,27 @@ from pydantic import BaseModel, Field StructureKind = Literal["hierarchy", "network", "membership", "classification"] +AuditDetailLevel = Literal["summary", "standard", "full"] + + +class OrganizationSettingsItem(BaseModel): + id: str | None = None + tenant_id: str + allow_tenant_model_customization: bool + require_model_change_requests: bool + audit_detail_level: AuditDetailLevel + change_retention_days: int | None = None + settings: dict[str, Any] + created_at: datetime | None = None + updated_at: datetime | None = None + + +class OrganizationSettingsUpdateRequest(BaseModel): + allow_tenant_model_customization: bool | None = None + require_model_change_requests: bool | None = None + audit_detail_level: AuditDetailLevel | None = None + change_retention_days: int | None = Field(default=None, ge=0) + settings: dict[str, Any] | None = None class OrganizationUnitTypeItem(BaseModel): diff --git a/src/govoplan_organizations/backend/db/models.py b/src/govoplan_organizations/backend/db/models.py index 32aa5bf..bfa463f 100644 --- a/src/govoplan_organizations/backend/db/models.py +++ b/src/govoplan_organizations/backend/db/models.py @@ -4,7 +4,7 @@ import uuid from datetime import datetime from typing import Any -from sqlalchemy import Boolean, DateTime, ForeignKey, Index, JSON, String, Text, UniqueConstraint +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from govoplan_core.db.base import Base, TimestampMixin @@ -42,6 +42,19 @@ class OrganizationUnitType(Base, TimestampMixin): settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) +class OrganizationTenantSettings(Base, TimestampMixin): + __tablename__ = "organizations_tenant_settings" + __table_args__ = (UniqueConstraint("tenant_id", name="uq_organizations_tenant_settings_tenant"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + allow_tenant_model_customization: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + require_model_change_requests: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + audit_detail_level: Mapped[str] = mapped_column(String(30), default="standard", nullable=False) + change_retention_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + class OrganizationStructure(Base, TimestampMixin): __tablename__ = "organizations_structures" __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),) @@ -172,6 +185,7 @@ __all__ = [ "OrganizationRelation", "OrganizationRelationType", "OrganizationStructure", + "OrganizationTenantSettings", "OrganizationUnit", "OrganizationUnitType", "new_uuid", diff --git a/src/govoplan_organizations/backend/manifest.py b/src/govoplan_organizations/backend/manifest.py index 5706c37..9ecd3b9 100644 --- a/src/govoplan_organizations/backend/manifest.py +++ b/src/govoplan_organizations/backend/manifest.py @@ -46,6 +46,8 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio PERMISSIONS = ( _permission("organizations:model:read", "View organization model", "Read organization meta-model definitions such as unit types, structures, and relation types."), _permission("organizations:model:write", "Manage organization model", "Create and edit organization meta-model definitions."), + _permission("organizations:settings:read", "View organization settings", "Read organization governance, audit, and retention settings."), + _permission("organizations:settings:write", "Manage organization settings", "Edit organization governance, audit, and retention settings."), _permission("organizations:unit:read", "View organization units", "Read concrete organization units and relations."), _permission("organizations:unit:write", "Manage organization units", "Create and edit concrete organization units and relations."), _permission("organizations:function:read", "View organization functions", "Read function definitions and identity-held assignments."), @@ -64,7 +66,7 @@ ROLE_TEMPLATES = ( slug="organization_viewer", name="Organization viewer", description="Read organization model, organization units, and function assignments.", - permissions=("organizations:model:read", "organizations:unit:read", "organizations:function:read"), + permissions=("organizations:model:read", "organizations:settings:read", "organizations:unit:read", "organizations:function:read"), ), ) @@ -112,6 +114,7 @@ manifest = ModuleManifest( uninstall_guard_providers=( persistent_table_uninstall_guard( organization_models.OrganizationUnitType, + organization_models.OrganizationTenantSettings, organization_models.OrganizationStructure, organization_models.OrganizationRelationType, organization_models.OrganizationRelation, diff --git a/src/govoplan_organizations/backend/migrations/versions/6d7e8f9a0b1c_organization_meta_model.py b/src/govoplan_organizations/backend/migrations/versions/6d7e8f9a0b1c_organization_meta_model.py index 7691d02..8e19233 100644 --- a/src/govoplan_organizations/backend/migrations/versions/6d7e8f9a0b1c_organization_meta_model.py +++ b/src/govoplan_organizations/backend/migrations/versions/6d7e8f9a0b1c_organization_meta_model.py @@ -71,6 +71,24 @@ def _create_unit_types() -> None: _create_index_if_missing(op.f("ix_organizations_unit_types_tenant_id"), "organizations_unit_types", ["tenant_id"]) +def _create_tenant_settings() -> None: + if "organizations_tenant_settings" not in _tables(): + op.create_table( + "organizations_tenant_settings", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("allow_tenant_model_customization", sa.Boolean(), nullable=False), + sa.Column("require_model_change_requests", sa.Boolean(), nullable=False), + sa.Column("audit_detail_level", sa.String(length=30), nullable=False), + sa.Column("change_retention_days", sa.Integer(), nullable=True), + _json_column("settings"), + *_timestamp_columns(), + sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_tenant_settings")), + sa.UniqueConstraint("tenant_id", name="uq_organizations_tenant_settings_tenant"), + ) + _create_index_if_missing(op.f("ix_organizations_tenant_settings_tenant_id"), "organizations_tenant_settings", ["tenant_id"]) + + def _create_structures() -> None: if "organizations_structures" not in _tables(): op.create_table( @@ -271,6 +289,7 @@ def _create_function_assignments() -> None: def upgrade() -> None: _create_unit_types() + _create_tenant_settings() _create_structures() _create_relation_types() _create_units() @@ -293,6 +312,7 @@ def downgrade() -> None: "organizations_relation_types", "organizations_structures", "organizations_function_types", + "organizations_tenant_settings", "organizations_unit_types", ): if table_name in _tables(): diff --git a/webui/src/api/organizations.ts b/webui/src/api/organizations.ts index 9fe0128..470d25a 100644 --- a/webui/src/api/organizations.ts +++ b/webui/src/api/organizations.ts @@ -13,6 +13,27 @@ export type OrganizationUnitTypeItem = { }; export type OrganizationStructureKind = "hierarchy" | "network" | "membership" | "classification"; +export type OrganizationAuditDetailLevel = "summary" | "standard" | "full"; + +export type OrganizationSettingsItem = { + id?: string | null; + tenant_id: string; + allow_tenant_model_customization: boolean; + require_model_change_requests: boolean; + audit_detail_level: OrganizationAuditDetailLevel; + change_retention_days?: number | null; + settings: Record; + created_at?: string | null; + updated_at?: string | null; +}; + +export type OrganizationSettingsUpdatePayload = { + allow_tenant_model_customization?: boolean; + require_model_change_requests?: boolean; + audit_detail_level?: OrganizationAuditDetailLevel; + change_retention_days?: number | null; + settings?: Record; +}; export type OrganizationStructureItem = { id: string; @@ -205,6 +226,14 @@ export function getOrganizationModel(settings: ApiSettings): Promise(settings, "/api/v1/organizations/model"); } +export function getOrganizationSettings(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/organizations/settings"); +} + +export function patchOrganizationSettings(settings: ApiSettings, payload: OrganizationSettingsUpdatePayload): Promise { + return patch(settings, "/api/v1/organizations/settings", payload); +} + export function createUnitType(settings: ApiSettings, payload: SluggedCreatePayload): Promise { return post(settings, "/api/v1/organizations/unit-types", payload); } diff --git a/webui/src/features/organizations/OrganizationsAdminPanel.tsx b/webui/src/features/organizations/OrganizationsAdminPanel.tsx index e59a3be..773d59d 100644 --- a/webui/src/features/organizations/OrganizationsAdminPanel.tsx +++ b/webui/src/features/organizations/OrganizationsAdminPanel.tsx @@ -1,18 +1,156 @@ -import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; -import OrganizationsPage, { type OrganizationSection } from "./OrganizationsPage"; +import { useEffect, useState } from "react"; +import { + AdminPageLayout, + Button, + Card, + FormField, + ToggleSwitch, + adminErrorMessage, + hasAnyScope, + useUnsavedDraftGuard, + type ApiSettings, + type AuthInfo +} from "@govoplan/core-webui"; +import { + getOrganizationSettings, + patchOrganizationSettings, + type OrganizationAuditDetailLevel, + type OrganizationSettingsItem +} from "../../api/organizations"; -const MODEL_SECTIONS: OrganizationSection[] = ["model"]; +const FALLBACK_SETTINGS: OrganizationSettingsItem = { + tenant_id: "", + allow_tenant_model_customization: true, + require_model_change_requests: false, + audit_detail_level: "standard", + change_retention_days: null, + settings: {} +}; + +const AUDIT_DETAIL_LEVELS: Array<{ value: OrganizationAuditDetailLevel; label: string }> = [ + { value: "summary", label: "i18n:govoplan-organizations.audit_summary.6a7d68a5" }, + { value: "standard", label: "i18n:govoplan-organizations.audit_standard.785d981f" }, + { value: "full", label: "i18n:govoplan-organizations.audit_full.0c83669c" } +]; export default function OrganizationsAdminPanel({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { + const [draft, setDraft] = useState(FALLBACK_SETTINGS); + const [savedDraft, setSavedDraft] = useState(FALLBACK_SETTINGS); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const canWrite = hasAnyScope(auth, ["organizations:settings:write", "admin:settings:write"]); + const dirty = settingsKey(draft) !== settingsKey(savedDraft); + + useUnsavedDraftGuard({ + dirty, + onSave: save, + onDiscard: () => setDraft(savedDraft) + }); + + useEffect(() => { + void load(); + }, [settings.accessToken, settings.apiBaseUrl]); + + async function load() { + setLoading(true); + setError(""); + try { + const next = await getOrganizationSettings(settings); + setDraft(next); + setSavedDraft(next); + } catch (caught) { + setError(adminErrorMessage(caught)); + } finally { + setLoading(false); + } + } + + async function save(): Promise { + setBusy(true); + setError(""); + setSuccess(""); + try { + const saved = await patchOrganizationSettings(settings, { + allow_tenant_model_customization: draft.allow_tenant_model_customization, + require_model_change_requests: draft.require_model_change_requests, + audit_detail_level: draft.audit_detail_level, + change_retention_days: draft.change_retention_days ?? null, + settings: draft.settings + }); + setDraft(saved); + setSavedDraft(saved); + setSuccess("i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa"); + return true; + } catch (caught) { + setError(adminErrorMessage(caught)); + return false; + } finally { + setBusy(false); + } + } + return ( - + } + > +
+ +
+ 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" + /> +
+

i18n:govoplan-organizations.model_governance_help.0fa69021

+
+ + +
+ + + + + setDraft({ ...draft, change_retention_days: event.target.value === "" ? null : Math.max(0, Number(event.target.value)) })} + /> + +
+

i18n:govoplan-organizations.audit_retention_help.42dec57d

+
+
+
); } + +function settingsKey(settings: OrganizationSettingsItem): string { + return JSON.stringify({ + allow_tenant_model_customization: settings.allow_tenant_model_customization, + require_model_change_requests: settings.require_model_change_requests, + audit_detail_level: settings.audit_detail_level, + change_retention_days: settings.change_retention_days ?? null, + settings: settings.settings + }); +} diff --git a/webui/src/features/organizations/OrganizationsPage.tsx b/webui/src/features/organizations/OrganizationsPage.tsx index 480d1d2..e1f6501 100644 --- a/webui/src/features/organizations/OrganizationsPage.tsx +++ b/webui/src/features/organizations/OrganizationsPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; -import { RefreshCw, Save, XCircle } from "lucide-react"; +import { Edit3, Plus, RefreshCw, Save, XCircle } from "lucide-react"; import { ApiError, Button, @@ -226,6 +226,24 @@ function sluggedPayload(draft: SluggedDraft): SluggedCreatePayload { }; } +function sluggedPatchPayload(draft: SluggedDraft): Partial { + return { + name: draft.name.trim(), + slug: textOrNull(draft.slug), + description: textOrNull(draft.description), + is_active: draft.is_active + }; +} + +function sluggedDraftFrom(item: { name: string; slug: string; description?: string | null; is_active: boolean }): SluggedDraft { + return { + name: item.name, + slug: item.slug, + description: item.description ?? "", + is_active: item.is_active + }; +} + function isSluggedDirty(draft: SluggedDraft): boolean { return draft.name.trim() !== "" || draft.slug.trim() !== "" || draft.description.trim() !== "" || !draft.is_active; } @@ -297,6 +315,17 @@ function ActiveToggleButton({ item, disabled, onToggle }: { item: ActiveItem; di ); } +function RowActions({ item, disabled, onEdit, onToggle }: { item: ActiveItem; disabled: boolean; onEdit: () => void; onToggle: () => void }) { + return ( +
+ + +
+ ); +} + function SluggedFields({ draft, onChange, @@ -354,6 +383,15 @@ export default function OrganizationsPage({ const [functionTypeDraft, setFunctionTypeDraft] = useState(() => emptyFunctionTypeDraft()); const [functionDraft, setFunctionDraft] = useState(() => emptyFunctionDraft()); const [assignmentDraft, setAssignmentDraft] = useState(() => emptyAssignmentDraft()); + const [editingUnitTypeId, setEditingUnitTypeId] = useState(null); + const [editingStructureId, setEditingStructureId] = useState(null); + const [editingRelationTypeId, setEditingRelationTypeId] = useState(null); + const [editingUnitId, setEditingUnitId] = useState(null); + const [editingRelationId, setEditingRelationId] = useState(null); + const [editingFunctionTypeId, setEditingFunctionTypeId] = useState(null); + const [editingFunctionId, setEditingFunctionId] = useState(null); + const [editingAssignmentId, setEditingAssignmentId] = useState(null); + const [selectedUnitId, setSelectedUnitId] = useState(""); const canWriteModel = hasScope(auth, "organizations:model:write"); const canWriteUnits = hasScope(auth, "organizations:unit:write"); @@ -366,6 +404,15 @@ export default function OrganizationsPage({ const unitById = useMemo(() => mapById(model.units), [model.units]); const functionTypeById = useMemo(() => mapById(model.function_types), [model.function_types]); const functionById = useMemo(() => mapById(model.functions), [model.functions]); + const unitsByParentId = useMemo(() => { + const mapped = new Map(); + for (const unit of model.units) { + const key = unit.parent_id || ""; + mapped.set(key, [...(mapped.get(key) ?? []), unit]); + } + for (const units of mapped.values()) units.sort((left, right) => left.name.localeCompare(right.name)); + return mapped; + }, [model.units]); const hasDirtyDraft = isSluggedDirty(unitTypeDraft) || isSluggedDirty(structureDraft) || @@ -396,6 +443,10 @@ export default function OrganizationsPage({ if (!sectionSet.has(active)) setActive(firstAvailableSection); }, [active, firstAvailableSection, sectionSet]); + useEffect(() => { + if (selectedUnitId && !unitById.has(selectedUnitId)) setSelectedUnitId(""); + }, [selectedUnitId, unitById]); + const runAction = useCallback(async (action: () => Promise, successMessage = ""): Promise => { setBusy(true); setError(""); @@ -422,6 +473,14 @@ export default function OrganizationsPage({ setFunctionTypeDraft(emptyFunctionTypeDraft()); setFunctionDraft(emptyFunctionDraft()); setAssignmentDraft(emptyAssignmentDraft()); + setEditingUnitTypeId(null); + setEditingStructureId(null); + setEditingRelationTypeId(null); + setEditingUnitId(null); + setEditingRelationId(null); + setEditingFunctionTypeId(null); + setEditingFunctionId(null); + setEditingAssignmentId(null); }, []); function rejectMissingName(): Promise { @@ -438,20 +497,33 @@ export default function OrganizationsPage({ event?.preventDefault(); if (!canWriteModel) return rejectMissingWritePermission(); if (!unitTypeDraft.name.trim()) return rejectMissingName(); - const ok = await runAction(() => createUnitType(settings, sluggedPayload(unitTypeDraft)), "i18n:govoplan-organizations.unit_type_added.e017dc8c"); - if (ok) setUnitTypeDraft(emptySluggedDraft()); + const ok = await runAction( + () => editingUnitTypeId ? patchUnitType(settings, editingUnitTypeId, sluggedPatchPayload(unitTypeDraft)) : createUnitType(settings, sluggedPayload(unitTypeDraft)), + editingUnitTypeId ? "i18n:govoplan-organizations.unit_type_updated.6d8f2a16" : "i18n:govoplan-organizations.unit_type_added.e017dc8c" + ); + if (ok) { + setUnitTypeDraft(emptySluggedDraft()); + setEditingUnitTypeId(null); + } return ok; - }, [canWriteModel, runAction, settings, unitTypeDraft]); + }, [canWriteModel, editingUnitTypeId, runAction, settings, unitTypeDraft]); const submitStructure = useCallback(async (event?: FormEvent): Promise => { event?.preventDefault(); if (!canWriteModel) return rejectMissingWritePermission(); if (!structureDraft.name.trim()) return rejectMissingName(); const payload: StructureCreatePayload = { ...sluggedPayload(structureDraft), structure_kind: structureDraft.structure_kind }; - const ok = await runAction(() => createStructure(settings, payload), "i18n:govoplan-organizations.structure_added.f6cf8e74"); - if (ok) setStructureDraft(emptyStructureDraft()); + const patchPayload: Partial = { ...sluggedPatchPayload(structureDraft), structure_kind: structureDraft.structure_kind }; + const ok = await runAction( + () => editingStructureId ? patchStructure(settings, editingStructureId, patchPayload) : createStructure(settings, payload), + editingStructureId ? "i18n:govoplan-organizations.structure_updated.2591e75f" : "i18n:govoplan-organizations.structure_added.f6cf8e74" + ); + if (ok) { + setStructureDraft(emptyStructureDraft()); + setEditingStructureId(null); + } return ok; - }, [canWriteModel, runAction, settings, structureDraft]); + }, [canWriteModel, editingStructureId, runAction, settings, structureDraft]); const submitRelationType = useCallback(async (event?: FormEvent): Promise => { event?.preventDefault(); @@ -465,10 +537,24 @@ export default function OrganizationsPage({ is_hierarchical: relationTypeDraft.is_hierarchical, allow_cycles: relationTypeDraft.allow_cycles }; - const ok = await runAction(() => createRelationType(settings, payload), "i18n:govoplan-organizations.relation_type_added.6f1edff1"); - if (ok) setRelationTypeDraft(emptyRelationTypeDraft()); + const patchPayload: Partial = { + ...sluggedPatchPayload(relationTypeDraft), + structure_id: textOrNull(relationTypeDraft.structure_id), + source_unit_type_id: textOrNull(relationTypeDraft.source_unit_type_id), + target_unit_type_id: textOrNull(relationTypeDraft.target_unit_type_id), + is_hierarchical: relationTypeDraft.is_hierarchical, + allow_cycles: relationTypeDraft.allow_cycles + }; + const ok = await runAction( + () => editingRelationTypeId ? patchRelationType(settings, editingRelationTypeId, patchPayload) : createRelationType(settings, payload), + editingRelationTypeId ? "i18n:govoplan-organizations.relation_type_updated.5ac57ec7" : "i18n:govoplan-organizations.relation_type_added.6f1edff1" + ); + if (ok) { + setRelationTypeDraft(emptyRelationTypeDraft()); + setEditingRelationTypeId(null); + } return ok; - }, [canWriteModel, relationTypeDraft, runAction, settings]); + }, [canWriteModel, editingRelationTypeId, relationTypeDraft, runAction, settings]); const submitUnit = useCallback(async (event?: FormEvent): Promise => { event?.preventDefault(); @@ -479,10 +565,21 @@ export default function OrganizationsPage({ unit_type_id: textOrNull(unitDraft.unit_type_id), parent_id: textOrNull(unitDraft.parent_id) }; - const ok = await runAction(() => createUnit(settings, payload), "i18n:govoplan-organizations.unit_added.760a8512"); - if (ok) setUnitDraft(emptyUnitDraft()); + const patchPayload: Partial = { + ...sluggedPatchPayload(unitDraft), + unit_type_id: textOrNull(unitDraft.unit_type_id), + parent_id: textOrNull(unitDraft.parent_id) + }; + const ok = await runAction( + () => editingUnitId ? patchUnit(settings, editingUnitId, patchPayload) : createUnit(settings, payload), + editingUnitId ? "i18n:govoplan-organizations.unit_updated.15f02216" : "i18n:govoplan-organizations.unit_added.760a8512" + ); + if (ok) { + setUnitDraft(emptyUnitDraft()); + setEditingUnitId(null); + } return ok; - }, [canWriteUnits, runAction, settings, unitDraft]); + }, [canWriteUnits, editingUnitId, runAction, settings, unitDraft]); const submitRelation = useCallback(async (event?: FormEvent): Promise => { event?.preventDefault(); @@ -492,10 +589,17 @@ export default function OrganizationsPage({ return false; } const payload: RelationCreatePayload = { ...relationDraft, settings: {} }; - const ok = await runAction(() => createRelation(settings, payload), "i18n:govoplan-organizations.relation_added.ca90461a"); - if (ok) setRelationDraft(emptyRelationDraft()); + const patchPayload: Partial = { ...relationDraft }; + const ok = await runAction( + () => editingRelationId ? patchRelation(settings, editingRelationId, patchPayload) : createRelation(settings, payload), + editingRelationId ? "i18n:govoplan-organizations.relation_updated.c6212776" : "i18n:govoplan-organizations.relation_added.ca90461a" + ); + if (ok) { + setRelationDraft(emptyRelationDraft()); + setEditingRelationId(null); + } return ok; - }, [canWriteUnits, relationDraft, runAction, settings]); + }, [canWriteUnits, editingRelationId, relationDraft, runAction, settings]); const submitFunctionType = useCallback(async (event?: FormEvent): Promise => { event?.preventDefault(); @@ -507,10 +611,22 @@ export default function OrganizationsPage({ delegable: functionTypeDraft.delegable, act_in_place_allowed: functionTypeDraft.act_in_place_allowed }; - const ok = await runAction(() => createFunctionType(settings, payload), "i18n:govoplan-organizations.function_type_added.2cd9e899"); - if (ok) setFunctionTypeDraft(emptyFunctionTypeDraft()); + const patchPayload: Partial = { + ...sluggedPatchPayload(functionTypeDraft), + organization_unit_type_id: textOrNull(functionTypeDraft.organization_unit_type_id), + delegable: functionTypeDraft.delegable, + act_in_place_allowed: functionTypeDraft.act_in_place_allowed + }; + const ok = await runAction( + () => editingFunctionTypeId ? patchFunctionType(settings, editingFunctionTypeId, patchPayload) : createFunctionType(settings, payload), + editingFunctionTypeId ? "i18n:govoplan-organizations.function_type_updated.1a4f29f6" : "i18n:govoplan-organizations.function_type_added.2cd9e899" + ); + if (ok) { + setFunctionTypeDraft(emptyFunctionTypeDraft()); + setEditingFunctionTypeId(null); + } return ok; - }, [canWriteModel, functionTypeDraft, runAction, settings]); + }, [canWriteModel, editingFunctionTypeId, functionTypeDraft, runAction, settings]); const submitFunction = useCallback(async (event?: FormEvent): Promise => { event?.preventDefault(); @@ -527,10 +643,23 @@ export default function OrganizationsPage({ delegable: functionDraft.delegable, act_in_place_allowed: functionDraft.act_in_place_allowed }; - const ok = await runAction(() => createFunction(settings, payload), "i18n:govoplan-organizations.function_added.e2266702"); - if (ok) setFunctionDraft(emptyFunctionDraft()); + const patchPayload: Partial = { + ...sluggedPatchPayload(functionDraft), + organization_unit_id: functionDraft.organization_unit_id, + function_type_id: textOrNull(functionDraft.function_type_id), + delegable: functionDraft.delegable, + act_in_place_allowed: functionDraft.act_in_place_allowed + }; + const ok = await runAction( + () => editingFunctionId ? patchFunction(settings, editingFunctionId, patchPayload) : createFunction(settings, payload), + editingFunctionId ? "i18n:govoplan-organizations.function_updated.65016009" : "i18n:govoplan-organizations.function_added.e2266702" + ); + if (ok) { + setFunctionDraft(emptyFunctionDraft()); + setEditingFunctionId(null); + } return ok; - }, [canWriteFunctions, functionDraft, runAction, settings]); + }, [canWriteFunctions, editingFunctionId, functionDraft, runAction, settings]); const submitAssignment = useCallback(async (event?: FormEvent): Promise => { event?.preventDefault(); @@ -550,10 +679,16 @@ export default function OrganizationsPage({ is_active: assignmentDraft.is_active, settings: {} }; - const ok = await runAction(() => createFunctionAssignment(settings, payload), "i18n:govoplan-organizations.assignment_added.94263d1b"); - if (ok) setAssignmentDraft(emptyAssignmentDraft()); + const ok = await runAction( + () => editingAssignmentId ? patchFunctionAssignment(settings, editingAssignmentId, payload) : createFunctionAssignment(settings, payload), + editingAssignmentId ? "i18n:govoplan-organizations.assignment_updated.680b6e33" : "i18n:govoplan-organizations.assignment_added.94263d1b" + ); + if (ok) { + setAssignmentDraft(emptyAssignmentDraft()); + setEditingAssignmentId(null); + } return ok; - }, [assignmentDraft, canAssignFunctions, runAction, settings]); + }, [assignmentDraft, canAssignFunctions, editingAssignmentId, runAction, settings]); const saveDrafts = useCallback(async (): Promise => { if (isSluggedDirty(unitTypeDraft) && !(await submitUnitType())) return false; @@ -624,12 +759,103 @@ export default function OrganizationsPage({ void runAction(() => patchFunctionAssignment(settings, item.id, { is_active: !item.is_active })); }, [runAction, settings]); + const editUnitType = useCallback((item: OrganizationUnitTypeItem) => { + setEditingUnitTypeId(item.id); + setUnitTypeDraft(sluggedDraftFrom(item)); + setActive("model"); + }, []); + + const editStructure = useCallback((item: OrganizationStructureItem) => { + setEditingStructureId(item.id); + setStructureDraft({ ...sluggedDraftFrom(item), structure_kind: item.structure_kind as OrganizationStructureKind }); + setActive("model"); + }, []); + + const editRelationType = useCallback((item: OrganizationRelationTypeItem) => { + setEditingRelationTypeId(item.id); + setRelationTypeDraft({ + ...sluggedDraftFrom(item), + structure_id: item.structure_id ?? "", + source_unit_type_id: item.source_unit_type_id ?? "", + target_unit_type_id: item.target_unit_type_id ?? "", + is_hierarchical: item.is_hierarchical, + allow_cycles: item.allow_cycles + }); + setActive("model"); + }, []); + + const editUnit = useCallback((item: OrganizationUnitItem) => { + setEditingUnitId(item.id); + setSelectedUnitId(item.id); + setUnitDraft({ ...sluggedDraftFrom(item), unit_type_id: item.unit_type_id ?? "", parent_id: item.parent_id ?? "" }); + setActive("units"); + }, []); + + const addSubunit = useCallback((item: OrganizationUnitItem) => { + setSelectedUnitId(item.id); + setEditingUnitId(null); + setUnitDraft({ ...emptyUnitDraft(), parent_id: item.id }); + setActive("units"); + }, []); + + const editRelation = useCallback((item: OrganizationRelationItem) => { + setEditingRelationId(item.id); + setRelationDraft({ + structure_id: item.structure_id, + relation_type_id: item.relation_type_id, + source_unit_id: item.source_unit_id, + target_unit_id: item.target_unit_id, + is_active: item.is_active + }); + setActive("relations"); + }, []); + + const editFunctionType = useCallback((item: OrganizationFunctionTypeItem) => { + setEditingFunctionTypeId(item.id); + setFunctionTypeDraft({ + ...sluggedDraftFrom(item), + organization_unit_type_id: item.organization_unit_type_id ?? "", + delegable: item.delegable, + act_in_place_allowed: item.act_in_place_allowed + }); + setActive("model"); + }, []); + + const editFunction = useCallback((item: OrganizationFunctionItem) => { + setEditingFunctionId(item.id); + setSelectedUnitId(item.organization_unit_id); + setFunctionDraft({ + ...sluggedDraftFrom(item), + organization_unit_id: item.organization_unit_id, + function_type_id: item.function_type_id ?? "", + delegable: item.delegable, + act_in_place_allowed: item.act_in_place_allowed + }); + setActive("functions"); + }, []); + + const editAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => { + setEditingAssignmentId(item.id); + setSelectedUnitId(item.organization_unit_id); + setAssignmentDraft({ + identity_id: item.identity_id, + account_id: item.account_id ?? "", + function_id: item.function_id, + applies_to_subunits: item.applies_to_subunits, + source: item.source, + delegated_from_assignment_id: item.delegated_from_assignment_id ?? "", + acting_for_account_id: item.acting_for_account_id ?? "", + is_active: item.is_active + }); + setActive("assignments"); + }, []); + const unitTypeColumns: DataGridColumn[] = [ { id: "name", header: "i18n:govoplan-organizations.name.709a2322", minWidth: 180, sortable: true, filterable: true, value: (row) => row.name }, { 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: "", width: 130, sticky: "end", render: (row) => toggleUnitType(row)} /> } + { id: "actions", header: "", width: 220, sticky: "end", render: (row) => editUnitType(row)} onToggle={() => toggleUnitType(row)} /> } ]; const structureColumns: DataGridColumn[] = [ @@ -637,7 +863,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: "", width: 130, sticky: "end", render: (row) => toggleStructure(row)} /> } + { id: "actions", header: "", width: 220, sticky: "end", render: (row) => editStructure(row)} onToggle={() => toggleStructure(row)} /> } ]; const relationTypeColumns: DataGridColumn[] = [ @@ -647,7 +873,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: "", width: 130, sticky: "end", render: (row) => toggleRelationType(row)} /> } + { id: "actions", header: "", width: 220, sticky: "end", render: (row) => editRelationType(row)} onToggle={() => toggleRelationType(row)} /> } ]; const unitColumns: DataGridColumn[] = [ @@ -656,7 +882,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: "", width: 130, sticky: "end", render: (row) => toggleUnit(row)} /> } + { id: "actions", header: "", width: 220, sticky: "end", render: (row) => editUnit(row)} onToggle={() => toggleUnit(row)} /> } ]; const relationColumns: DataGridColumn[] = [ @@ -665,7 +891,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: "", width: 130, sticky: "end", render: (row) => toggleRelation(row)} /> } + { id: "actions", header: "", width: 220, sticky: "end", render: (row) => editRelation(row)} onToggle={() => toggleRelation(row)} /> } ]; const functionTypeColumns: DataGridColumn[] = [ @@ -674,7 +900,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: "", width: 130, sticky: "end", render: (row) => toggleFunctionType(row)} /> } + { id: "actions", header: "", width: 220, sticky: "end", render: (row) => editFunctionType(row)} onToggle={() => toggleFunctionType(row)} /> } ]; const functionColumns: DataGridColumn[] = [ @@ -683,7 +909,7 @@ export default function OrganizationsPage({ { id: "type", header: "i18n:govoplan-organizations.function_type.501fe7c0", minWidth: 180, render: (row) => optionalLabel(functionTypeById.get(row.function_type_id || "")?.name) }, { id: "delegable", header: "i18n:govoplan-organizations.delegable.b4f0137d", width: 120, render: (row) => activeStatus(row.delegable) }, { id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) }, - { id: "actions", header: "", width: 130, sticky: "end", render: (row) => toggleFunction(row)} /> } + { id: "actions", header: "", width: 220, sticky: "end", render: (row) => editFunction(row)} onToggle={() => toggleFunction(row)} /> } ]; const assignmentColumns: DataGridColumn[] = [ @@ -693,11 +919,15 @@ export default function OrganizationsPage({ { id: "subunits", header: "i18n:govoplan-organizations.applies_to_subunits.7a15f741", width: 150, render: (row) => activeStatus(row.applies_to_subunits) }, { id: "source", header: "i18n:govoplan-organizations.source.6da13add", width: 130, value: (row) => row.source }, { id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) }, - { id: "actions", header: "", width: 130, sticky: "end", render: (row) => toggleAssignment(row)} /> } + { id: "actions", header: "", width: 220, sticky: "end", render: (row) => editAssignment(row)} onToggle={() => toggleAssignment(row)} /> } ]; const canWriteActive = active === "model" ? canWriteModel : active === "units" || active === "relations" ? canWriteUnits : active === "functions" ? canWriteFunctions : canAssignFunctions; + function cancelEditButton(onCancel: () => void) { + return ; + } + const content = (
@@ -754,7 +984,10 @@ export default function OrganizationsPage({
void submitUnitType(event)}> - +
+ + {editingUnitTypeId && cancelEditButton(() => { setEditingUnitTypeId(null); setUnitTypeDraft(emptySluggedDraft()); })} +
@@ -770,7 +1003,10 @@ export default function OrganizationsPage({ {STRUCTURE_KINDS.map((item) => )} - +
+ + {editingStructureId && cancelEditButton(() => { setEditingStructureId(null); setStructureDraft(emptyStructureDraft()); })} +
@@ -803,7 +1039,10 @@ export default function OrganizationsPage({
- +
+ + {editingRelationTypeId && cancelEditButton(() => { setEditingRelationTypeId(null); setRelationTypeDraft(emptyRelationTypeDraft()); })} +
@@ -824,7 +1063,10 @@ export default function OrganizationsPage({
- +
+ + {editingFunctionTypeId && cancelEditButton(() => { setEditingFunctionTypeId(null); setFunctionTypeDraft(emptyFunctionTypeDraft()); })} +
@@ -835,30 +1077,75 @@ export default function OrganizationsPage({ ); } + function renderUnitTreeNodes(parentId = "", depth = 0, seen: ReadonlySet = new Set()): JSX.Element[] { + return (unitsByParentId.get(parentId) ?? []).flatMap((unit) => { + if (seen.has(unit.id)) return []; + const nextSeen = new Set(seen); + nextSeen.add(unit.id); + return [ +
+ + +
, + ...renderUnitTreeNodes(unit.id, depth + 1, nextSeen) + ]; + }); + } + function renderUnitsSection() { return ( -
- -
void submitUnit(event)}> - - - - - - - - - -
- - row.id} emptyText="i18n:govoplan-organizations.no_units_found.eea2dd0c" initialFit="container" /> +
+ +
+ +
+
+ {model.units.length ? renderUnitTreeNodes() :

i18n:govoplan-organizations.no_units_found.eea2dd0c

} +
+
+ +
void submitUnit(event)}> + + + + + + + + {unitDraft.parent_id && ( +

+ i18n:govoplan-organizations.selected_parent.0e013d44 {unitById.get(unitDraft.parent_id)?.name ?? unitDraft.parent_id} +

+ )} +
+ + {editingUnitId && cancelEditButton(() => { setEditingUnitId(null); setUnitDraft(emptyUnitDraft()); })} +
+ +
+ + row.id} emptyText="i18n:govoplan-organizations.no_units_found.eea2dd0c" initialFit="container" /> + +
); } @@ -896,7 +1183,10 @@ export default function OrganizationsPage({ setRelationDraft({ ...relationDraft, is_active: event.target.checked })} /> i18n:govoplan-organizations.active.a733b809 - +
+ + {editingRelationId && cancelEditButton(() => { setEditingRelationId(null); setRelationDraft(emptyRelationDraft()); })} +
@@ -928,7 +1218,10 @@ export default function OrganizationsPage({
- +
+ + {editingFunctionId && cancelEditButton(() => { setEditingFunctionId(null); setFunctionDraft(emptyFunctionDraft()); })} +
@@ -962,7 +1255,10 @@ export default function OrganizationsPage({ - +
+ + {editingAssignmentId && cancelEditButton(() => { setEditingAssignmentId(null); setAssignmentDraft(emptyAssignmentDraft()); })} +
diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 89f42ec..15bb273 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -9,26 +9,41 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-organizations.add_function_type.90d793f1": "Add function type", "i18n:govoplan-organizations.add_relation.6b6e67ea": "Add relation", "i18n:govoplan-organizations.add_relation_type.2ad19d03": "Add relation type", + "i18n:govoplan-organizations.add_root_unit.1ef8a9f5": "Add root unit", "i18n:govoplan-organizations.add_structure.b722042a": "Add structure", + "i18n:govoplan-organizations.add_subunit.8256a8f7": "Add subunit", "i18n:govoplan-organizations.add_unit.8fa12fb1": "Add unit", "i18n:govoplan-organizations.add_unit_type.58f2c05a": "Add unit type", "i18n:govoplan-organizations.allow_cycles.31327578": "Allow cycles", "i18n:govoplan-organizations.applies_to_subunits.7a15f741": "Applies to subunits", "i18n:govoplan-organizations.assignment_added.94263d1b": "Function assignment added.", + "i18n:govoplan-organizations.assignment_updated.680b6e33": "Function assignment updated.", "i18n:govoplan-organizations.assignments.278f513e": "Assignments", + "i18n:govoplan-organizations.audit_and_retention.3ba1d2fc": "Audit and retention", + "i18n:govoplan-organizations.audit_detail_level.7397355d": "Audit detail level", + "i18n:govoplan-organizations.audit_full.0c83669c": "Full", + "i18n:govoplan-organizations.audit_retention_help.42dec57d": "Controls how much organization-change detail is retained for review, troubleshooting, and evidence.", + "i18n:govoplan-organizations.audit_standard.785d981f": "Standard", + "i18n:govoplan-organizations.audit_summary.6a7d68a5": "Summary", "i18n:govoplan-organizations.classification.3e9f6c3a": "Classification", + "i18n:govoplan-organizations.allow_tenant_model_customization.2425d751": "Tenant admins may customize the organization meta-model", + "i18n:govoplan-organizations.change_retention_days.71bbd140": "Retain organization change detail for days", + "i18n:govoplan-organizations.cancel_edit.309c2a6f": "Cancel edit", "i18n:govoplan-organizations.concrete_model.4e9c531a": "Concrete model", "i18n:govoplan-organizations.deactivate.46ab070d": "Deactivate", "i18n:govoplan-organizations.delegable.b4f0137d": "Delegable", "i18n:govoplan-organizations.description.55f8ebc8": "Description", "i18n:govoplan-organizations.direct_assignment.2a068aac": "direct", "i18n:govoplan-organizations.discard_organization_drafts.766d5f46": "Discard organization drafts", + "i18n:govoplan-organizations.edit.7dce1220": "Edit", "i18n:govoplan-organizations.function.28822f3a": "Function", "i18n:govoplan-organizations.function_added.e2266702": "Function added.", "i18n:govoplan-organizations.function_assignments.4c5c6dae": "Function assignments", "i18n:govoplan-organizations.function_type.501fe7c0": "Function type", "i18n:govoplan-organizations.function_type_added.2cd9e899": "Function type added.", + "i18n:govoplan-organizations.function_type_updated.1a4f29f6": "Function type updated.", "i18n:govoplan-organizations.function_types.172c01fe": "Function types", + "i18n:govoplan-organizations.function_updated.65016009": "Function updated.", "i18n:govoplan-organizations.functions.805dc49b": "Functions", "i18n:govoplan-organizations.hierarchical.8964f313": "Hierarchical", "i18n:govoplan-organizations.hierarchy.74797f37": "Hierarchy", @@ -37,9 +52,12 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-organizations.inactive.09af574c": "Inactive", "i18n:govoplan-organizations.kind.794c9d9c": "Kind", "i18n:govoplan-organizations.loading_organization_model.846aa317": "Loading organization model...", + "i18n:govoplan-organizations.loading_organization_settings.c6008db8": "Loading organization settings...", "i18n:govoplan-organizations.membership.4531d86d": "Membership", "i18n:govoplan-organizations.meta_model.7398487c": "Meta-model", "i18n:govoplan-organizations.model.20f35e63": "Model", + "i18n:govoplan-organizations.model_governance.6aa18fd0": "Model governance", + "i18n:govoplan-organizations.model_governance_help.0fa69021": "These switches define whether the tenant can change its own organization model directly or must record governed change requests.", "i18n:govoplan-organizations.name.709a2322": "Name", "i18n:govoplan-organizations.name_is_required.27cd3782": "Name is required.", "i18n:govoplan-organizations.network.a24d31c3": "Network", @@ -49,23 +67,33 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-organizations.no_relation_types_found.6f90bb81": "No relation types found.", "i18n:govoplan-organizations.no_relations_found.4e75b11d": "No relations found.", "i18n:govoplan-organizations.no_structures_found.20b382d0": "No structures found.", + "i18n:govoplan-organizations.no_unit_type.73e799f5": "No unit type", "i18n:govoplan-organizations.no_unit_types_found.c81fb2a7": "No unit types found.", "i18n:govoplan-organizations.no_units_found.eea2dd0c": "No units found.", "i18n:govoplan-organizations.none.334c4a4c": "None", "i18n:govoplan-organizations.optional_account_id.5fcf495c": "Optional account ID", "i18n:govoplan-organizations.organization_model.4f924c0e": "Organization model", "i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Configure tenant-local unit types, parallel structures, relation types, and function types used by the organization module.", + "i18n:govoplan-organizations.organization_settings.c9ab9829": "Organization settings", + "i18n:govoplan-organizations.organization_settings_description.35dc9f10": "Configure tenant-level governance, audit, and retention behavior for organization changes.", + "i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organization settings saved.", + "i18n:govoplan-organizations.organization_tree.e5bfb195": "Organization tree", "i18n:govoplan-organizations.organizations.220edf64": "Organizations", "i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Model organization types, concrete units, structures, functions, and identity-held function assignments.", "i18n:govoplan-organizations.parent_unit.1986c35e": "Parent unit", "i18n:govoplan-organizations.reactivate.58f2855d": "Reactivate", "i18n:govoplan-organizations.relation_added.ca90461a": "Relation added.", + "i18n:govoplan-organizations.relation_updated.c6212776": "Relation updated.", "i18n:govoplan-organizations.relation_type.d0aee2e7": "Relation type", "i18n:govoplan-organizations.relation_type_added.6f1edff1": "Relation type added.", + "i18n:govoplan-organizations.relation_type_updated.5ac57ec7": "Relation type updated.", "i18n:govoplan-organizations.relation_types.e5890528": "Relation types", "i18n:govoplan-organizations.relations.1c796711": "Relations", "i18n:govoplan-organizations.reload.cce71553": "Reload", + "i18n:govoplan-organizations.require_model_change_requests.83454cad": "Organization model changes require recorded change requests", + "i18n:govoplan-organizations.save_settings.913aba9f": "Save settings", "i18n:govoplan-organizations.save_drafts.606f9401": "Save drafts", + "i18n:govoplan-organizations.saving.56a2285c": "Saving...", "i18n:govoplan-organizations.select_function.4895a67d": "Select function", "i18n:govoplan-organizations.select_function_type.2f426c43": "Select function type", "i18n:govoplan-organizations.select_relation_type.73f92478": "Select relation type", @@ -74,21 +102,34 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-organizations.select_target_unit.8d607541": "Select target unit", "i18n:govoplan-organizations.select_unit.013bf13a": "Select unit", "i18n:govoplan-organizations.select_unit_type.2c71d14f": "Select unit type", + "i18n:govoplan-organizations.selected_parent.0e013d44": "Selected parent:", "i18n:govoplan-organizations.slug.094da9b9": "Slug", "i18n:govoplan-organizations.source.6da13add": "Source", "i18n:govoplan-organizations.source_unit.2fbd8baa": "Source unit", "i18n:govoplan-organizations.status.bae7d5be": "Status", "i18n:govoplan-organizations.structure.7732fb0b": "Structure", "i18n:govoplan-organizations.structure_added.f6cf8e74": "Structure added.", + "i18n:govoplan-organizations.structure_updated.2591e75f": "Structure updated.", "i18n:govoplan-organizations.structures.f9b7f3b4": "Structures", "i18n:govoplan-organizations.target_unit.a1507d86": "Target unit", "i18n:govoplan-organizations.type.599dcce2": "Type", "i18n:govoplan-organizations.unit.8fe4d595": "Unit", "i18n:govoplan-organizations.unit_added.760a8512": "Unit added.", + "i18n:govoplan-organizations.unit_updated.15f02216": "Unit updated.", "i18n:govoplan-organizations.unit_type.9e62810d": "Unit type", "i18n:govoplan-organizations.unit_type_added.e017dc8c": "Unit type added.", + "i18n:govoplan-organizations.unit_type_updated.6d8f2a16": "Unit type updated.", "i18n:govoplan-organizations.unit_types.c7afc174": "Unit types", "i18n:govoplan-organizations.units.e14d0d92": "Units", + "i18n:govoplan-organizations.unlimited.35569464": "Unlimited", + "i18n:govoplan-organizations.update_assignment.bf130723": "Update assignment", + "i18n:govoplan-organizations.update_function.504f03e5": "Update function", + "i18n:govoplan-organizations.update_function_type.0b7e07d3": "Update function type", + "i18n:govoplan-organizations.update_relation.4c44ce83": "Update relation", + "i18n:govoplan-organizations.update_relation_type.e6e7f30c": "Update relation type", + "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." }, de: { @@ -99,26 +140,41 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-organizations.add_function_type.90d793f1": "Funktionstyp hinzufügen", "i18n:govoplan-organizations.add_relation.6b6e67ea": "Beziehung hinzufügen", "i18n:govoplan-organizations.add_relation_type.2ad19d03": "Beziehungstyp hinzufügen", + "i18n:govoplan-organizations.add_root_unit.1ef8a9f5": "Wurzeleinheit hinzufügen", "i18n:govoplan-organizations.add_structure.b722042a": "Struktur hinzufügen", + "i18n:govoplan-organizations.add_subunit.8256a8f7": "Untereinheit hinzufügen", "i18n:govoplan-organizations.add_unit.8fa12fb1": "Einheit hinzufügen", "i18n:govoplan-organizations.add_unit_type.58f2c05a": "Einheitstyp hinzufügen", "i18n:govoplan-organizations.allow_cycles.31327578": "Zyklen erlauben", "i18n:govoplan-organizations.applies_to_subunits.7a15f741": "Gilt für Untereinheiten", "i18n:govoplan-organizations.assignment_added.94263d1b": "Funktionszuordnung hinzugefügt.", + "i18n:govoplan-organizations.assignment_updated.680b6e33": "Funktionszuordnung aktualisiert.", "i18n:govoplan-organizations.assignments.278f513e": "Zuordnungen", + "i18n:govoplan-organizations.audit_and_retention.3ba1d2fc": "Audit und Aufbewahrung", + "i18n:govoplan-organizations.audit_detail_level.7397355d": "Audit-Detailgrad", + "i18n:govoplan-organizations.audit_full.0c83669c": "Vollständig", + "i18n:govoplan-organizations.audit_retention_help.42dec57d": "Steuert, wie viele Details zu Organisationsänderungen für Prüfung, Fehlersuche und Nachweise aufbewahrt werden.", + "i18n:govoplan-organizations.audit_standard.785d981f": "Standard", + "i18n:govoplan-organizations.audit_summary.6a7d68a5": "Zusammenfassung", "i18n:govoplan-organizations.classification.3e9f6c3a": "Klassifikation", + "i18n:govoplan-organizations.allow_tenant_model_customization.2425d751": "Mandantenadmins dürfen das Organisations-Metamodell anpassen", + "i18n:govoplan-organizations.change_retention_days.71bbd140": "Änderungsdetails zur Organisation in Tagen aufbewahren", + "i18n:govoplan-organizations.cancel_edit.309c2a6f": "Bearbeitung abbrechen", "i18n:govoplan-organizations.concrete_model.4e9c531a": "Konkretes Modell", "i18n:govoplan-organizations.deactivate.46ab070d": "Deaktivieren", "i18n:govoplan-organizations.delegable.b4f0137d": "Delegierbar", "i18n:govoplan-organizations.description.55f8ebc8": "Beschreibung", "i18n:govoplan-organizations.direct_assignment.2a068aac": "direkt", "i18n:govoplan-organizations.discard_organization_drafts.766d5f46": "Organisationsentwürfe verwerfen", + "i18n:govoplan-organizations.edit.7dce1220": "Bearbeiten", "i18n:govoplan-organizations.function.28822f3a": "Funktion", "i18n:govoplan-organizations.function_added.e2266702": "Funktion hinzugefügt.", "i18n:govoplan-organizations.function_assignments.4c5c6dae": "Funktionszuordnungen", "i18n:govoplan-organizations.function_type.501fe7c0": "Funktionstyp", "i18n:govoplan-organizations.function_type_added.2cd9e899": "Funktionstyp hinzugefügt.", + "i18n:govoplan-organizations.function_type_updated.1a4f29f6": "Funktionstyp aktualisiert.", "i18n:govoplan-organizations.function_types.172c01fe": "Funktionstypen", + "i18n:govoplan-organizations.function_updated.65016009": "Funktion aktualisiert.", "i18n:govoplan-organizations.functions.805dc49b": "Funktionen", "i18n:govoplan-organizations.hierarchical.8964f313": "Hierarchisch", "i18n:govoplan-organizations.hierarchy.74797f37": "Hierarchie", @@ -127,9 +183,12 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-organizations.inactive.09af574c": "Inaktiv", "i18n:govoplan-organizations.kind.794c9d9c": "Art", "i18n:govoplan-organizations.loading_organization_model.846aa317": "Organisationsmodell wird geladen...", + "i18n:govoplan-organizations.loading_organization_settings.c6008db8": "Organisationseinstellungen werden geladen...", "i18n:govoplan-organizations.membership.4531d86d": "Mitgliedschaft", "i18n:govoplan-organizations.meta_model.7398487c": "Metamodell", "i18n:govoplan-organizations.model.20f35e63": "Modell", + "i18n:govoplan-organizations.model_governance.6aa18fd0": "Modell-Governance", + "i18n:govoplan-organizations.model_governance_help.0fa69021": "Diese Schalter legen fest, ob der Mandant sein Organisationsmodell direkt ändern darf oder geregelte Änderungsanträge erfassen muss.", "i18n:govoplan-organizations.name.709a2322": "Name", "i18n:govoplan-organizations.name_is_required.27cd3782": "Name ist erforderlich.", "i18n:govoplan-organizations.network.a24d31c3": "Netzwerk", @@ -139,23 +198,33 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-organizations.no_relation_types_found.6f90bb81": "Keine Beziehungstypen gefunden.", "i18n:govoplan-organizations.no_relations_found.4e75b11d": "Keine Beziehungen gefunden.", "i18n:govoplan-organizations.no_structures_found.20b382d0": "Keine Strukturen gefunden.", + "i18n:govoplan-organizations.no_unit_type.73e799f5": "Kein Einheitstyp", "i18n:govoplan-organizations.no_unit_types_found.c81fb2a7": "Keine Einheitstypen gefunden.", "i18n:govoplan-organizations.no_units_found.eea2dd0c": "Keine Einheiten gefunden.", "i18n:govoplan-organizations.none.334c4a4c": "Keine", "i18n:govoplan-organizations.optional_account_id.5fcf495c": "Optionale Konto-ID", "i18n:govoplan-organizations.organization_model.4f924c0e": "Organisationsmodell", "i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Konfiguriere mandantenbezogene Einheitstypen, parallele Strukturen, Beziehungstypen und Funktionstypen des Organisationsmoduls.", + "i18n:govoplan-organizations.organization_settings.c9ab9829": "Organisationseinstellungen", + "i18n:govoplan-organizations.organization_settings_description.35dc9f10": "Konfiguriere Governance, Audit und Aufbewahrung für Organisationsänderungen auf Mandantenebene.", + "i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organisationseinstellungen gespeichert.", + "i18n:govoplan-organizations.organization_tree.e5bfb195": "Organisationsbaum", "i18n:govoplan-organizations.organizations.220edf64": "Organisationen", "i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Modelliere Organisationstypen, konkrete Einheiten, Strukturen, Funktionen und identitätsbezogene Funktionszuordnungen.", "i18n:govoplan-organizations.parent_unit.1986c35e": "Übergeordnete Einheit", "i18n:govoplan-organizations.reactivate.58f2855d": "Reaktivieren", "i18n:govoplan-organizations.relation_added.ca90461a": "Beziehung hinzugefügt.", + "i18n:govoplan-organizations.relation_updated.c6212776": "Beziehung aktualisiert.", "i18n:govoplan-organizations.relation_type.d0aee2e7": "Beziehungstyp", "i18n:govoplan-organizations.relation_type_added.6f1edff1": "Beziehungstyp hinzugefügt.", + "i18n:govoplan-organizations.relation_type_updated.5ac57ec7": "Beziehungstyp aktualisiert.", "i18n:govoplan-organizations.relation_types.e5890528": "Beziehungstypen", "i18n:govoplan-organizations.relations.1c796711": "Beziehungen", "i18n:govoplan-organizations.reload.cce71553": "Neu laden", + "i18n:govoplan-organizations.require_model_change_requests.83454cad": "Änderungen am Organisationsmodell erfordern erfasste Änderungsanträge", + "i18n:govoplan-organizations.save_settings.913aba9f": "Einstellungen speichern", "i18n:govoplan-organizations.save_drafts.606f9401": "Entwürfe speichern", + "i18n:govoplan-organizations.saving.56a2285c": "Speichern...", "i18n:govoplan-organizations.select_function.4895a67d": "Funktion auswählen", "i18n:govoplan-organizations.select_function_type.2f426c43": "Funktionstyp auswählen", "i18n:govoplan-organizations.select_relation_type.73f92478": "Beziehungstyp auswählen", @@ -164,21 +233,34 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-organizations.select_target_unit.8d607541": "Ziel-Einheit auswählen", "i18n:govoplan-organizations.select_unit.013bf13a": "Einheit auswählen", "i18n:govoplan-organizations.select_unit_type.2c71d14f": "Einheitstyp auswählen", + "i18n:govoplan-organizations.selected_parent.0e013d44": "Ausgewählte übergeordnete Einheit:", "i18n:govoplan-organizations.slug.094da9b9": "Slug", "i18n:govoplan-organizations.source.6da13add": "Quelle", "i18n:govoplan-organizations.source_unit.2fbd8baa": "Quell-Einheit", "i18n:govoplan-organizations.status.bae7d5be": "Status", "i18n:govoplan-organizations.structure.7732fb0b": "Struktur", "i18n:govoplan-organizations.structure_added.f6cf8e74": "Struktur hinzugefügt.", + "i18n:govoplan-organizations.structure_updated.2591e75f": "Struktur aktualisiert.", "i18n:govoplan-organizations.structures.f9b7f3b4": "Strukturen", "i18n:govoplan-organizations.target_unit.a1507d86": "Ziel-Einheit", "i18n:govoplan-organizations.type.599dcce2": "Typ", "i18n:govoplan-organizations.unit.8fe4d595": "Einheit", "i18n:govoplan-organizations.unit_added.760a8512": "Einheit hinzugefügt.", + "i18n:govoplan-organizations.unit_updated.15f02216": "Einheit aktualisiert.", "i18n:govoplan-organizations.unit_type.9e62810d": "Einheitstyp", "i18n:govoplan-organizations.unit_type_added.e017dc8c": "Einheitstyp hinzugefügt.", + "i18n:govoplan-organizations.unit_type_updated.6d8f2a16": "Einheitstyp aktualisiert.", "i18n:govoplan-organizations.unit_types.c7afc174": "Einheitstypen", "i18n:govoplan-organizations.units.e14d0d92": "Einheiten", + "i18n:govoplan-organizations.unlimited.35569464": "Unbegrenzt", + "i18n:govoplan-organizations.update_assignment.bf130723": "Zuordnung aktualisieren", + "i18n:govoplan-organizations.update_function.504f03e5": "Funktion aktualisieren", + "i18n:govoplan-organizations.update_function_type.0b7e07d3": "Funktionstyp aktualisieren", + "i18n:govoplan-organizations.update_relation.4c44ce83": "Beziehung aktualisieren", + "i18n:govoplan-organizations.update_relation_type.e6e7f30c": "Beziehungstyp aktualisieren", + "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." } }; diff --git a/webui/src/module.ts b/webui/src/module.ts index 4d23898..831c376 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -21,11 +21,11 @@ const translations = { const organizationAdminSections: AdminSectionsUiCapability = { sections: [ { - id: "tenant-organization-model", - label: "i18n:govoplan-organizations.organization_model.4f924c0e", + id: "tenant-organization-settings", + label: "i18n:govoplan-organizations.organization_settings.c9ab9829", group: "TENANT", order: 85, - anyOf: ["organizations:model:read", "admin:settings:read"], + anyOf: ["organizations:settings:read", "admin:settings:read"], render: ({ settings, auth }) => createElement(OrganizationsAdminPanel, { settings, auth }) } ] diff --git a/webui/src/styles/organizations.css b/webui/src/styles/organizations.css index 39cf0ad..61caa86 100644 --- a/webui/src/styles/organizations.css +++ b/webui/src/styles/organizations.css @@ -14,6 +14,11 @@ max-width: 100%; } +.organizations-settings-grid { + display: grid; + gap: 18px; +} + .organizations-heading { margin-bottom: 4px; } @@ -33,6 +38,13 @@ align-items: start; } +.organizations-editor-grid { + display: grid; + grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); + gap: 18px; + align-items: start; +} + .organizations-form-panel, .organizations-table-panel { min-width: 0; @@ -53,6 +65,18 @@ grid-column: 1 / -1; } +.organizations-form-actions, +.organizations-row-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.organizations-row-actions { + justify-content: flex-end; +} + .organizations-check-list { display: grid; gap: 10px; @@ -73,6 +97,51 @@ gap: 18px; } +.organizations-tree-toolbar { + display: flex; + justify-content: flex-start; + margin-bottom: 12px; +} + +.organizations-tree-list { + display: grid; + gap: 4px; + max-height: 640px; + overflow: auto; +} + +.organizations-tree-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 6px; +} + +.organizations-tree-node { + display: grid; + gap: 2px; + width: 100%; + min-width: 0; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: var(--text); + text-align: left; + cursor: pointer; +} + +.organizations-tree-node:hover, +.organizations-tree-node.active { + border-color: var(--line); + background: var(--surface-muted, #f6f7f9); +} + +.organizations-tree-node span { + color: var(--muted); + font-size: 12px; +} + .organizations-field-note { margin: 8px 0 0; color: var(--muted); @@ -90,7 +159,8 @@ } @media (max-width: 1100px) { - .organizations-section-grid { + .organizations-section-grid, + .organizations-editor-grid { grid-template-columns: 1fr; } }