Add organizations module surface

This commit is contained in:
2026-07-10 17:33:53 +02:00
parent 34133ba9cd
commit ad2561b50f
22 changed files with 3284 additions and 35 deletions

View File

@@ -3,7 +3,7 @@
## Scope ## Scope
This repository owns the canonical GovOPlaN organizational model: tenant-local This repository owns the canonical GovOPlaN organizational model: tenant-local
organization units, hierarchy, functions, and account-held function organization units, parallel structures, functions, and identity-held function
assignments. assignments.
The module describes responsibility in the institution. It does not decide The module describes responsibility in the institution. It does not decide
@@ -14,11 +14,16 @@ facts to roles, rights, permission decisions, and explainability.
- Depend on kernel contracts from `govoplan-core`. - Depend on kernel contracts from `govoplan-core`.
- Depend on `govoplan-tenancy` for tenant ownership. - Depend on `govoplan-tenancy` for tenant ownership.
- Do not import access, identity, postbox, workflow, or portal internals. - Depend on identity only through kernel capabilities when resolving account to
identity relationships.
- Do not import access, admin, identity, postbox, workflow, or portal internals.
- Expose integration through manifests, capabilities, API routes, events, and - Expose integration through manifests, capabilities, API routes, events, and
typed DTOs. typed DTOs.
- Treat function assignment as organizational responsibility, not as a login - Treat function assignment as organizational responsibility, not as a login
permission by itself. permission by itself.
- Keep the organization modelling UI as a standalone module route. Admin-only
organization controls should be contributed by this module through the
platform `admin.sections` WebUI capability.
## Local Workflow ## Local Workflow

View File

@@ -3,29 +3,35 @@
`govoplan-organizations` is the canonical organizational model module for `govoplan-organizations` is the canonical organizational model module for
GovOPlaN. GovOPlaN.
It owns organization units, organizational hierarchy, functions, and It owns organization unit types, organization structures, concrete units,
account-held function assignments. These facts exist independently of functions, and identity-held function assignments. These facts exist
authentication and authorization. IDM can import them, Identity can link them independently of authentication and authorization. Identity links people to
to people/accounts, and Access can project them into roles and rights. accounts, and Access projects organization facts into roles and rights.
## Boundary ## Boundary
Organizations owns: Organizations owns:
- tenant-local organization units - tenant-local organization unit types
- parent/child organizational hierarchy - tenant-local concrete organization units
- parallel organization structures and relation types
- functions in organization units - functions in organization units
- whether a function may be delegated or used for acting in place - whether a function may be delegated or used for acting in place
- account-held function assignments - identity-held function assignments, optionally constrained to one account
- whether an assignment applies only to one unit or also to subunits - whether an assignment applies only to one unit or also to subunits
Organizations does not own: Organizations does not own:
- login accounts or identity lifecycle - login accounts, identity lifecycle, or account linking
- role and permission evaluation - role and permission evaluation
- postboxes, workflows, portals, files, or cases - postboxes, workflows, portals, files, or cases
- external IDM connector internals - external IDM connector internals
The WebUI exposed by this repository is a normal module UI at
`/organizations`. Admin-only controls are contributed by this module through the
platform `admin.sections` WebUI capability, so the admin shell can show them
when the organizations module is active.
## Module Contract ## Module Contract
The module registers the `organizations.directory` capability from The module registers the `organizations.directory` capability from

View File

@@ -5,19 +5,28 @@ The organization model answers where responsibility lives.
## Layers ## Layers
- Tenant: the administrative space. - Tenant: the administrative space.
- Organization unit: a department, office, committee, service desk, school, - Organization unit type: an abstract type such as faculty, institute,
institute, or other structural unit. committee, department, team, service desk, or legal entity.
- Organization structure: a named way to look at the institution. Several
structures may exist in parallel, for example employer hierarchy, academic
structure, committees, memberships, or classifications.
- Relation type: the permitted edge between units inside a structure.
- Organization unit: a concrete department, office, committee, service desk,
school, institute, or other unit.
- Function: a named responsibility in an organization unit, such as clerk, - Function: a named responsibility in an organization unit, such as clerk,
reviewer, approver, committee secretary, intake desk, or resource manager. reviewer, approver, committee secretary, intake desk, or resource manager.
- Function assignment: an account holds a function in an organization unit, - Function assignment: an identity holds a function in an organization unit,
optionally for that unit and all subunits. optionally for that unit and all subunits. The assignment may be constrained
to a specific account, but the real-world responsibility belongs to the
identity.
## Boundary With Identity ## Boundary With Identity
Organization assignments refer to account and identity IDs, but this module Organization assignments refer to identity IDs and may optionally carry account
does not own login accounts or identity lifecycle. `govoplan-identity` owns the IDs for account-specific exercise of a function. This module does not own login
identity/account directory. The organization module only records that an accounts, identity lifecycle, or account linking. `govoplan-identity` owns the
account or identity currently holds an organizational function. identity/account directory. The organization module records that an identity
currently holds an organizational function.
## Boundary With Access ## Boundary With Access
@@ -28,6 +37,13 @@ enforcement, and explain responses.
This separation keeps the institution model stable even when authorization This separation keeps the institution model stable even when authorization
policy changes. policy changes.
## UI Boundary
The organization workspace at `/organizations` is the primary module UI for
modelling concrete units, structures, functions, and assignments. Admin-only
organization controls are still owned by this module, but are contributed to the
platform admin shell through the `admin.sections` WebUI capability.
## Boundary With IDM ## Boundary With IDM
`govoplan-idm` may import organization units, functions, and assignments from `govoplan-idm` may import organization units, functions, and assignments from

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "@govoplan/organizations-webui",
"version": "0.1.6",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/organizations.css": "./webui/src/styles/organizations.css"
},
"files": [
"webui/src",
"README.md",
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

View File

@@ -0,0 +1 @@
"""Organizations API package."""

View File

@@ -0,0 +1 @@
"""Organizations API v1 package."""

View File

@@ -0,0 +1,720 @@
from __future__ import annotations
import re
from typing import Any, TypeVar
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, require_any_scope
from govoplan_core.db.session import get_session
from govoplan_organizations.backend.db.models import (
OrganizationFunction,
OrganizationFunctionAssignment,
OrganizationFunctionType,
OrganizationRelation,
OrganizationRelationType,
OrganizationStructure,
OrganizationUnit,
OrganizationUnitType,
)
from .schemas import (
FunctionAssignmentCreateRequest,
FunctionAssignmentUpdateRequest,
FunctionCreateRequest,
FunctionTypeCreateRequest,
FunctionTypeUpdateRequest,
FunctionUpdateRequest,
OrganizationFunctionAssignmentItem,
OrganizationFunctionItem,
OrganizationFunctionTypeItem,
OrganizationModelResponse,
OrganizationRelationItem,
OrganizationRelationTypeItem,
OrganizationStructureItem,
OrganizationUnitItem,
OrganizationUnitTypeItem,
RelationCreateRequest,
RelationTypeCreateRequest,
RelationTypeUpdateRequest,
RelationUpdateRequest,
StructureCreateRequest,
StructureUpdateRequest,
UnitCreateRequest,
UnitTypeCreateRequest,
UnitTypeUpdateRequest,
UnitUpdateRequest,
)
router = APIRouter(prefix="/organizations", tags=["organizations"])
ORG_READ_SCOPES = (
"organizations:model:read",
"organizations:unit:read",
"organizations:function:read",
"admin:settings:read",
)
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",)
SLUG_RE = re.compile(r"[^a-z0-9]+")
ModelT = TypeVar("ModelT")
def _slug(value: str | None, fallback: str) -> str:
source = value or fallback
normalized = SLUG_RE.sub("-", source.strip().casefold()).strip("-")
return normalized[:100] or "item"
def _tenant_id(principal: ApiPrincipal) -> str:
return principal.tenant_id
def _not_found(label: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{label} not found")
def _conflict(message: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=message)
def _invalid(message: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=message)
def _get_tenant_row(session: Session, model: type[ModelT], item_id: str, tenant_id: str, label: str) -> ModelT:
item = session.get(model, item_id)
if item is None or getattr(item, "tenant_id") != tenant_id:
raise _not_found(label)
return item
def _ensure_optional_unit_type(session: Session, tenant_id: str, unit_type_id: str | None) -> None:
if unit_type_id is None:
return
_get_tenant_row(session, OrganizationUnitType, unit_type_id, tenant_id, "Organization unit type")
def _ensure_optional_structure(session: Session, tenant_id: str, structure_id: str | None) -> None:
if structure_id is None:
return
_get_tenant_row(session, OrganizationStructure, structure_id, tenant_id, "Organization structure")
def _ensure_unique_slug(session: Session, model: type, tenant_id: str, slug: str, *, exclude_id: str | None = None) -> None:
query = session.query(model).filter(model.tenant_id == tenant_id, model.slug == slug)
if exclude_id is not None:
query = query.filter(model.id != exclude_id)
if query.count():
raise _conflict(f"Slug already exists in this tenant: {slug}")
def _commit(session: Session, item: ModelT) -> ModelT:
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise _conflict("The organization model change conflicts with existing data.") from exc
session.refresh(item)
return item
def _item_unit_type(item: OrganizationUnitType) -> OrganizationUnitTypeItem:
return OrganizationUnitTypeItem(**_row_fields(item))
def _item_structure(item: OrganizationStructure) -> OrganizationStructureItem:
return OrganizationStructureItem(**_row_fields(item))
def _item_relation_type(item: OrganizationRelationType) -> OrganizationRelationTypeItem:
return OrganizationRelationTypeItem(**_row_fields(item))
def _item_unit(item: OrganizationUnit) -> OrganizationUnitItem:
return OrganizationUnitItem(**_row_fields(item))
def _item_relation(item: OrganizationRelation) -> OrganizationRelationItem:
return OrganizationRelationItem(**_row_fields(item))
def _item_function_type(item: OrganizationFunctionType) -> OrganizationFunctionTypeItem:
return OrganizationFunctionTypeItem(**_row_fields(item))
def _item_function(item: OrganizationFunction) -> OrganizationFunctionItem:
return OrganizationFunctionItem(**_row_fields(item))
def _item_assignment(item: OrganizationFunctionAssignment) -> OrganizationFunctionAssignmentItem:
return OrganizationFunctionAssignmentItem(**_row_fields(item))
def _row_fields(item: object) -> dict[str, Any]:
keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined]
return {key: getattr(item, key) for key in keys}
def _apply_slugged_update(session: Session, item: object, payload: object, tenant_id: str, model: type) -> None:
fields = payload.model_fields_set # type: ignore[attr-defined]
if "slug" in fields:
slug = _slug(getattr(payload, "slug"), getattr(item, "name", "item"))
_ensure_unique_slug(session, model, tenant_id, slug, exclude_id=getattr(item, "id"))
setattr(item, "slug", slug)
if "name" in fields:
value = getattr(payload, "name")
if value is None:
raise _invalid("Name cannot be empty.")
setattr(item, "name", value.strip())
if "description" in fields:
setattr(item, "description", getattr(payload, "description"))
if "is_active" in fields:
value = getattr(payload, "is_active")
if value is None:
raise _invalid("Active state cannot be empty.")
setattr(item, "is_active", value)
if "settings" in fields:
value = getattr(payload, "settings")
if value is None:
raise _invalid("Settings cannot be empty.")
setattr(item, "settings", value)
@router.get("/model", response_model=OrganizationModelResponse)
def get_organization_model(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)),
) -> OrganizationModelResponse:
tenant_id = _tenant_id(principal)
return OrganizationModelResponse(
unit_types=[_item_unit_type(item) for item in session.query(OrganizationUnitType).filter(OrganizationUnitType.tenant_id == tenant_id).order_by(OrganizationUnitType.name.asc()).all()],
structures=[_item_structure(item) for item in session.query(OrganizationStructure).filter(OrganizationStructure.tenant_id == tenant_id).order_by(OrganizationStructure.name.asc()).all()],
relation_types=[_item_relation_type(item) for item in session.query(OrganizationRelationType).filter(OrganizationRelationType.tenant_id == tenant_id).order_by(OrganizationRelationType.name.asc()).all()],
units=[_item_unit(item) for item in session.query(OrganizationUnit).filter(OrganizationUnit.tenant_id == tenant_id).order_by(OrganizationUnit.name.asc()).all()],
relations=[_item_relation(item) for item in session.query(OrganizationRelation).filter(OrganizationRelation.tenant_id == tenant_id).order_by(OrganizationRelation.created_at.asc()).all()],
function_types=[_item_function_type(item) for item in session.query(OrganizationFunctionType).filter(OrganizationFunctionType.tenant_id == tenant_id).order_by(OrganizationFunctionType.name.asc()).all()],
functions=[_item_function(item) for item in session.query(OrganizationFunction).filter(OrganizationFunction.tenant_id == tenant_id).order_by(OrganizationFunction.name.asc()).all()],
function_assignments=[_item_assignment(item) for item in session.query(OrganizationFunctionAssignment).filter(OrganizationFunctionAssignment.tenant_id == tenant_id).order_by(OrganizationFunctionAssignment.created_at.asc()).all()],
)
@router.post("/unit-types", response_model=OrganizationUnitTypeItem, status_code=status.HTTP_201_CREATED)
def create_unit_type(
payload: UnitTypeCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)),
) -> OrganizationUnitTypeItem:
tenant_id = _tenant_id(principal)
slug = _slug(payload.slug, payload.name)
_ensure_unique_slug(session, OrganizationUnitType, tenant_id, slug)
item = OrganizationUnitType(tenant_id=tenant_id, slug=slug, name=payload.name.strip(), description=payload.description, is_active=payload.is_active, settings=payload.settings)
session.add(item)
return _item_unit_type(_commit(session, item))
@router.patch("/unit-types/{item_id}", response_model=OrganizationUnitTypeItem)
def update_unit_type(
item_id: str,
payload: UnitTypeUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)),
) -> OrganizationUnitTypeItem:
tenant_id = _tenant_id(principal)
item = _get_tenant_row(session, OrganizationUnitType, item_id, tenant_id, "Organization unit type")
_apply_slugged_update(session, item, payload, tenant_id, OrganizationUnitType)
return _item_unit_type(_commit(session, item))
@router.post("/structures", response_model=OrganizationStructureItem, status_code=status.HTTP_201_CREATED)
def create_structure(
payload: StructureCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)),
) -> OrganizationStructureItem:
tenant_id = _tenant_id(principal)
slug = _slug(payload.slug, payload.name)
_ensure_unique_slug(session, OrganizationStructure, tenant_id, slug)
item = OrganizationStructure(tenant_id=tenant_id, slug=slug, name=payload.name.strip(), description=payload.description, structure_kind=payload.structure_kind, is_active=payload.is_active, settings=payload.settings)
session.add(item)
return _item_structure(_commit(session, item))
@router.patch("/structures/{item_id}", response_model=OrganizationStructureItem)
def update_structure(
item_id: str,
payload: StructureUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)),
) -> OrganizationStructureItem:
tenant_id = _tenant_id(principal)
item = _get_tenant_row(session, OrganizationStructure, item_id, tenant_id, "Organization structure")
_apply_slugged_update(session, item, payload, tenant_id, OrganizationStructure)
if "structure_kind" in payload.model_fields_set:
if payload.structure_kind is None:
raise _invalid("Structure kind cannot be empty.")
item.structure_kind = payload.structure_kind
return _item_structure(_commit(session, item))
@router.post("/relation-types", response_model=OrganizationRelationTypeItem, status_code=status.HTTP_201_CREATED)
def create_relation_type(
payload: RelationTypeCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)),
) -> OrganizationRelationTypeItem:
tenant_id = _tenant_id(principal)
_ensure_optional_structure(session, tenant_id, payload.structure_id)
_ensure_optional_unit_type(session, tenant_id, payload.source_unit_type_id)
_ensure_optional_unit_type(session, tenant_id, payload.target_unit_type_id)
slug = _slug(payload.slug, payload.name)
_ensure_unique_slug(session, OrganizationRelationType, tenant_id, slug)
item = OrganizationRelationType(
tenant_id=tenant_id,
structure_id=payload.structure_id,
slug=slug,
name=payload.name.strip(),
description=payload.description,
source_unit_type_id=payload.source_unit_type_id,
target_unit_type_id=payload.target_unit_type_id,
is_hierarchical=payload.is_hierarchical,
allow_cycles=payload.allow_cycles,
is_active=payload.is_active,
settings=payload.settings,
)
session.add(item)
return _item_relation_type(_commit(session, item))
@router.patch("/relation-types/{item_id}", response_model=OrganizationRelationTypeItem)
def update_relation_type(
item_id: str,
payload: RelationTypeUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)),
) -> OrganizationRelationTypeItem:
tenant_id = _tenant_id(principal)
item = _get_tenant_row(session, OrganizationRelationType, item_id, tenant_id, "Organization relation type")
_apply_slugged_update(session, item, payload, tenant_id, OrganizationRelationType)
fields = payload.model_fields_set
if "structure_id" in fields:
_ensure_optional_structure(session, tenant_id, payload.structure_id)
item.structure_id = payload.structure_id
if "source_unit_type_id" in fields:
_ensure_optional_unit_type(session, tenant_id, payload.source_unit_type_id)
item.source_unit_type_id = payload.source_unit_type_id
if "target_unit_type_id" in fields:
_ensure_optional_unit_type(session, tenant_id, payload.target_unit_type_id)
item.target_unit_type_id = payload.target_unit_type_id
for field in ("is_hierarchical", "allow_cycles"):
if field in fields:
value = getattr(payload, field)
if value is None:
raise _invalid(f"{field} cannot be empty.")
setattr(item, field, value)
return _item_relation_type(_commit(session, item))
@router.post("/units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED)
def create_unit(
payload: UnitCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_UNIT_WRITE_SCOPES)),
) -> OrganizationUnitItem:
tenant_id = _tenant_id(principal)
_ensure_optional_unit_type(session, tenant_id, payload.unit_type_id)
if payload.parent_id is not None:
_get_tenant_row(session, OrganizationUnit, payload.parent_id, tenant_id, "Parent organization unit")
slug = _slug(payload.slug, payload.name)
_ensure_unique_slug(session, OrganizationUnit, tenant_id, slug)
item = OrganizationUnit(tenant_id=tenant_id, unit_type_id=payload.unit_type_id, parent_id=payload.parent_id, slug=slug, name=payload.name.strip(), description=payload.description, is_active=payload.is_active, settings=payload.settings)
session.add(item)
return _item_unit(_commit(session, item))
@router.patch("/units/{item_id}", response_model=OrganizationUnitItem)
def update_unit(
item_id: str,
payload: UnitUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_UNIT_WRITE_SCOPES)),
) -> OrganizationUnitItem:
tenant_id = _tenant_id(principal)
item = _get_tenant_row(session, OrganizationUnit, item_id, tenant_id, "Organization unit")
_apply_slugged_update(session, item, payload, tenant_id, OrganizationUnit)
if "unit_type_id" in payload.model_fields_set:
_ensure_optional_unit_type(session, tenant_id, payload.unit_type_id)
item.unit_type_id = payload.unit_type_id
if "parent_id" in payload.model_fields_set:
if payload.parent_id == item.id:
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")
item.parent_id = payload.parent_id
return _item_unit(_commit(session, item))
@router.post("/relations", response_model=OrganizationRelationItem, status_code=status.HTTP_201_CREATED)
def create_relation(
payload: RelationCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_UNIT_WRITE_SCOPES)),
) -> OrganizationRelationItem:
tenant_id = _tenant_id(principal)
structure, relation_type, source, target = _validated_relation_parts(
session,
tenant_id,
structure_id=payload.structure_id,
relation_type_id=payload.relation_type_id,
source_unit_id=payload.source_unit_id,
target_unit_id=payload.target_unit_id,
)
_validate_relation_edge(session, tenant_id, structure, relation_type, source, target)
item = OrganizationRelation(
tenant_id=tenant_id,
structure_id=structure.id,
relation_type_id=relation_type.id,
source_unit_id=source.id,
target_unit_id=target.id,
valid_from=payload.valid_from,
valid_until=payload.valid_until,
is_active=payload.is_active,
settings=payload.settings,
)
session.add(item)
return _item_relation(_commit(session, item))
@router.patch("/relations/{item_id}", response_model=OrganizationRelationItem)
def update_relation(
item_id: str,
payload: RelationUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_UNIT_WRITE_SCOPES)),
) -> OrganizationRelationItem:
tenant_id = _tenant_id(principal)
item = _get_tenant_row(session, OrganizationRelation, item_id, tenant_id, "Organization relation")
structure_id = payload.structure_id if "structure_id" in payload.model_fields_set else item.structure_id
relation_type_id = payload.relation_type_id if "relation_type_id" in payload.model_fields_set else item.relation_type_id
source_unit_id = payload.source_unit_id if "source_unit_id" in payload.model_fields_set else item.source_unit_id
target_unit_id = payload.target_unit_id if "target_unit_id" in payload.model_fields_set else item.target_unit_id
if structure_id is None or relation_type_id is None or source_unit_id is None or target_unit_id is None:
raise _invalid("Relation structure, type, source, and target are required.")
structure, relation_type, source, target = _validated_relation_parts(
session,
tenant_id,
structure_id=structure_id,
relation_type_id=relation_type_id,
source_unit_id=source_unit_id,
target_unit_id=target_unit_id,
)
_validate_relation_edge(session, tenant_id, structure, relation_type, source, target, exclude_relation_id=item.id)
item.structure_id = structure.id
item.relation_type_id = relation_type.id
item.source_unit_id = source.id
item.target_unit_id = target.id
for field in ("valid_from", "valid_until", "is_active", "settings"):
if field in payload.model_fields_set:
setattr(item, field, getattr(payload, field))
return _item_relation(_commit(session, item))
@router.post("/function-types", response_model=OrganizationFunctionTypeItem, status_code=status.HTTP_201_CREATED)
def create_function_type(
payload: FunctionTypeCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)),
) -> OrganizationFunctionTypeItem:
tenant_id = _tenant_id(principal)
_ensure_optional_unit_type(session, tenant_id, payload.organization_unit_type_id)
slug = _slug(payload.slug, payload.name)
_ensure_unique_slug(session, OrganizationFunctionType, tenant_id, slug)
item = OrganizationFunctionType(
tenant_id=tenant_id,
slug=slug,
name=payload.name.strip(),
description=payload.description,
organization_unit_type_id=payload.organization_unit_type_id,
delegable=payload.delegable,
act_in_place_allowed=payload.act_in_place_allowed,
is_active=payload.is_active,
settings=payload.settings,
)
session.add(item)
return _item_function_type(_commit(session, item))
@router.patch("/function-types/{item_id}", response_model=OrganizationFunctionTypeItem)
def update_function_type(
item_id: str,
payload: FunctionTypeUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)),
) -> OrganizationFunctionTypeItem:
tenant_id = _tenant_id(principal)
item = _get_tenant_row(session, OrganizationFunctionType, item_id, tenant_id, "Organization function type")
_apply_slugged_update(session, item, payload, tenant_id, OrganizationFunctionType)
if "organization_unit_type_id" in payload.model_fields_set:
_ensure_optional_unit_type(session, tenant_id, payload.organization_unit_type_id)
item.organization_unit_type_id = payload.organization_unit_type_id
for field in ("delegable", "act_in_place_allowed"):
if field in payload.model_fields_set:
value = getattr(payload, field)
if value is None:
raise _invalid(f"{field} cannot be empty.")
setattr(item, field, value)
return _item_function_type(_commit(session, item))
@router.post("/functions", response_model=OrganizationFunctionItem, status_code=status.HTTP_201_CREATED)
def create_function(
payload: FunctionCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_FUNCTION_WRITE_SCOPES)),
) -> OrganizationFunctionItem:
tenant_id = _tenant_id(principal)
unit = _get_tenant_row(session, OrganizationUnit, payload.organization_unit_id, tenant_id, "Organization unit")
function_type = _optional_function_type(session, tenant_id, payload.function_type_id)
slug = _slug(payload.slug, payload.name)
_ensure_function_slug(session, tenant_id, unit.id, slug)
delegable = payload.delegable if payload.delegable is not None else (function_type.delegable if function_type else False)
act_in_place_allowed = payload.act_in_place_allowed if payload.act_in_place_allowed is not None else (function_type.act_in_place_allowed if function_type else False)
item = OrganizationFunction(
tenant_id=tenant_id,
function_type_id=payload.function_type_id,
organization_unit_id=unit.id,
slug=slug,
name=payload.name.strip(),
description=payload.description,
delegable=delegable,
act_in_place_allowed=act_in_place_allowed,
is_active=payload.is_active,
settings=payload.settings,
)
session.add(item)
return _item_function(_commit(session, item))
@router.patch("/functions/{item_id}", response_model=OrganizationFunctionItem)
def update_function(
item_id: str,
payload: FunctionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_FUNCTION_WRITE_SCOPES)),
) -> OrganizationFunctionItem:
tenant_id = _tenant_id(principal)
item = _get_tenant_row(session, OrganizationFunction, item_id, tenant_id, "Organization function")
_apply_slugged_update_for_function(session, item, payload, tenant_id)
if "organization_unit_id" in payload.model_fields_set:
if payload.organization_unit_id is None:
raise _invalid("Organization unit is required.")
unit = _get_tenant_row(session, OrganizationUnit, payload.organization_unit_id, tenant_id, "Organization unit")
_ensure_function_slug(session, tenant_id, unit.id, item.slug, exclude_id=item.id)
item.organization_unit_id = unit.id
if "function_type_id" in payload.model_fields_set:
_optional_function_type(session, tenant_id, payload.function_type_id)
item.function_type_id = payload.function_type_id
for field in ("delegable", "act_in_place_allowed"):
if field in payload.model_fields_set:
value = getattr(payload, field)
if value is None:
raise _invalid(f"{field} cannot be empty.")
setattr(item, field, value)
return _item_function(_commit(session, item))
@router.post("/function-assignments", response_model=OrganizationFunctionAssignmentItem, status_code=status.HTTP_201_CREATED)
def create_function_assignment(
payload: FunctionAssignmentCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_ASSIGN_SCOPES)),
) -> OrganizationFunctionAssignmentItem:
tenant_id = _tenant_id(principal)
function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function")
item = OrganizationFunctionAssignment(
tenant_id=tenant_id,
identity_id=payload.identity_id,
account_id=payload.account_id,
function_id=function.id,
organization_unit_id=function.organization_unit_id,
applies_to_subunits=payload.applies_to_subunits,
source=payload.source,
delegated_from_assignment_id=payload.delegated_from_assignment_id,
acting_for_account_id=payload.acting_for_account_id,
valid_from=payload.valid_from,
valid_until=payload.valid_until,
is_active=payload.is_active,
settings=payload.settings,
)
if item.delegated_from_assignment_id is not None:
_get_tenant_row(session, OrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
session.add(item)
return _item_assignment(_commit(session, item))
@router.patch("/function-assignments/{item_id}", response_model=OrganizationFunctionAssignmentItem)
def update_function_assignment(
item_id: str,
payload: FunctionAssignmentUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_ASSIGN_SCOPES)),
) -> OrganizationFunctionAssignmentItem:
tenant_id = _tenant_id(principal)
item = _get_tenant_row(session, OrganizationFunctionAssignment, item_id, tenant_id, "Organization function assignment")
if "function_id" in payload.model_fields_set:
if payload.function_id is None:
raise _invalid("Function is required.")
function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function")
item.function_id = function.id
item.organization_unit_id = function.organization_unit_id
if "identity_id" in payload.model_fields_set and payload.identity_id is None:
raise _invalid("Identity is required.")
if "source" in payload.model_fields_set and payload.source is None:
raise _invalid("Assignment source is required.")
if "applies_to_subunits" in payload.model_fields_set and payload.applies_to_subunits is None:
raise _invalid("Subunit applicability cannot be empty.")
if "is_active" in payload.model_fields_set and payload.is_active is None:
raise _invalid("Active state cannot be empty.")
if "settings" in payload.model_fields_set and payload.settings is None:
raise _invalid("Settings cannot be empty.")
for field in (
"identity_id",
"account_id",
"applies_to_subunits",
"source",
"delegated_from_assignment_id",
"acting_for_account_id",
"valid_from",
"valid_until",
"is_active",
"settings",
):
if field in payload.model_fields_set:
setattr(item, field, getattr(payload, field))
if item.delegated_from_assignment_id == item.id:
raise _invalid("A function assignment cannot delegate from itself.")
if item.delegated_from_assignment_id is not None:
_get_tenant_row(session, OrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
return _item_assignment(_commit(session, item))
def _optional_function_type(session: Session, tenant_id: str, function_type_id: str | None) -> OrganizationFunctionType | None:
if function_type_id is None:
return None
return _get_tenant_row(session, OrganizationFunctionType, function_type_id, tenant_id, "Organization function type")
def _ensure_function_slug(session: Session, tenant_id: str, organization_unit_id: str, slug: str, *, exclude_id: str | None = None) -> None:
query = session.query(OrganizationFunction).filter(
OrganizationFunction.tenant_id == tenant_id,
OrganizationFunction.organization_unit_id == organization_unit_id,
OrganizationFunction.slug == slug,
)
if exclude_id is not None:
query = query.filter(OrganizationFunction.id != exclude_id)
if query.count():
raise _conflict(f"Function slug already exists in this organization unit: {slug}")
def _apply_slugged_update_for_function(session: Session, item: OrganizationFunction, payload: FunctionUpdateRequest, tenant_id: str) -> None:
fields = payload.model_fields_set
if "slug" in fields:
slug = _slug(payload.slug, item.name)
_ensure_function_slug(session, tenant_id, item.organization_unit_id, slug, exclude_id=item.id)
item.slug = slug
if "name" in fields:
if payload.name is None:
raise _invalid("Name cannot be empty.")
item.name = payload.name.strip()
if "description" in fields:
item.description = payload.description
if "is_active" in fields:
if payload.is_active is None:
raise _invalid("Active state cannot be empty.")
item.is_active = payload.is_active
if "settings" in fields:
if payload.settings is None:
raise _invalid("Settings cannot be empty.")
item.settings = payload.settings
def _validated_relation_parts(
session: Session,
tenant_id: str,
*,
structure_id: str,
relation_type_id: str,
source_unit_id: str,
target_unit_id: str,
) -> tuple[OrganizationStructure, OrganizationRelationType, OrganizationUnit, OrganizationUnit]:
structure = _get_tenant_row(session, OrganizationStructure, structure_id, tenant_id, "Organization structure")
relation_type = _get_tenant_row(session, OrganizationRelationType, relation_type_id, tenant_id, "Organization relation type")
source = _get_tenant_row(session, OrganizationUnit, source_unit_id, tenant_id, "Source organization unit")
target = _get_tenant_row(session, OrganizationUnit, target_unit_id, tenant_id, "Target organization unit")
if relation_type.structure_id is not None and relation_type.structure_id != structure.id:
raise _invalid("Relation type is bound to another structure.")
if source.id == target.id:
raise _invalid("A relation cannot connect an organization unit to itself.")
if relation_type.source_unit_type_id is not None and source.unit_type_id != relation_type.source_unit_type_id:
raise _invalid("Source organization unit type is not valid for this relation type.")
if relation_type.target_unit_type_id is not None and target.unit_type_id != relation_type.target_unit_type_id:
raise _invalid("Target organization unit type is not valid for this relation type.")
return structure, relation_type, source, target
def _validate_relation_edge(
session: Session,
tenant_id: str,
structure: OrganizationStructure,
relation_type: OrganizationRelationType,
source: OrganizationUnit,
target: OrganizationUnit,
*,
exclude_relation_id: str | None = None,
) -> None:
if relation_type.is_hierarchical and not relation_type.allow_cycles:
if _would_create_cycle(
session,
tenant_id=tenant_id,
structure_id=structure.id,
source_unit_id=source.id,
target_unit_id=target.id,
exclude_relation_id=exclude_relation_id,
):
raise _invalid("This hierarchical relation would create a cycle.")
def _would_create_cycle(
session: Session,
*,
tenant_id: str,
structure_id: str,
source_unit_id: str,
target_unit_id: str,
exclude_relation_id: str | None = None,
) -> bool:
pending = [target_unit_id]
seen: set[str] = set()
while pending:
current = pending.pop()
if current == source_unit_id:
return True
if current in seen:
continue
seen.add(current)
query = session.query(OrganizationRelation.target_unit_id).filter(
OrganizationRelation.tenant_id == tenant_id,
OrganizationRelation.structure_id == structure_id,
OrganizationRelation.source_unit_id == current,
OrganizationRelation.is_active.is_(True),
)
if exclude_relation_id is not None:
query = query.filter(OrganizationRelation.id != exclude_relation_id)
pending.extend(row[0] for row in query.all())
return False

View File

@@ -0,0 +1,275 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
StructureKind = Literal["hierarchy", "network", "membership", "classification"]
class OrganizationUnitTypeItem(BaseModel):
id: str
tenant_id: str
slug: str
name: str
description: str | None = None
is_active: bool
settings: dict[str, Any]
created_at: datetime
updated_at: datetime
class OrganizationStructureItem(BaseModel):
id: str
tenant_id: str
slug: str
name: str
description: str | None = None
structure_kind: str
is_active: bool
settings: dict[str, Any]
created_at: datetime
updated_at: datetime
class OrganizationRelationTypeItem(BaseModel):
id: str
tenant_id: str
structure_id: str | None = None
slug: str
name: str
description: str | None = None
source_unit_type_id: str | None = None
target_unit_type_id: str | None = None
is_hierarchical: bool
allow_cycles: bool
is_active: bool
settings: dict[str, Any]
created_at: datetime
updated_at: datetime
class OrganizationUnitItem(BaseModel):
id: str
tenant_id: str
unit_type_id: str | None = None
parent_id: str | None = None
slug: str
name: str
description: str | None = None
is_active: bool
settings: dict[str, Any]
created_at: datetime
updated_at: datetime
class OrganizationRelationItem(BaseModel):
id: str
tenant_id: str
structure_id: str
relation_type_id: str
source_unit_id: str
target_unit_id: str
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool
settings: dict[str, Any]
created_at: datetime
updated_at: datetime
class OrganizationFunctionTypeItem(BaseModel):
id: str
tenant_id: str
slug: str
name: str
description: str | None = None
organization_unit_type_id: str | None = None
delegable: bool
act_in_place_allowed: bool
is_active: bool
settings: dict[str, Any]
created_at: datetime
updated_at: datetime
class OrganizationFunctionItem(BaseModel):
id: str
tenant_id: str
function_type_id: str | None = None
organization_unit_id: str
slug: str
name: str
description: str | None = None
delegable: bool
act_in_place_allowed: bool
is_active: bool
settings: dict[str, Any]
created_at: datetime
updated_at: datetime
class OrganizationFunctionAssignmentItem(BaseModel):
id: str
tenant_id: str
identity_id: str
account_id: str | None = None
function_id: str
organization_unit_id: str
applies_to_subunits: bool
source: str
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool
settings: dict[str, Any]
created_at: datetime
updated_at: datetime
class OrganizationModelResponse(BaseModel):
unit_types: list[OrganizationUnitTypeItem]
structures: list[OrganizationStructureItem]
relation_types: list[OrganizationRelationTypeItem]
units: list[OrganizationUnitItem]
relations: list[OrganizationRelationItem]
function_types: list[OrganizationFunctionTypeItem]
functions: list[OrganizationFunctionItem]
function_assignments: list[OrganizationFunctionAssignmentItem]
class SluggedCreateRequest(BaseModel):
slug: str | None = Field(default=None, max_length=100)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
is_active: bool = True
settings: dict[str, Any] = Field(default_factory=dict)
class SluggedUpdateRequest(BaseModel):
slug: str | None = Field(default=None, max_length=100)
name: str | None = Field(default=None, max_length=255)
description: str | None = None
is_active: bool | None = None
settings: dict[str, Any] | None = None
class UnitTypeCreateRequest(SluggedCreateRequest):
pass
class UnitTypeUpdateRequest(SluggedUpdateRequest):
pass
class StructureCreateRequest(SluggedCreateRequest):
structure_kind: StructureKind = "hierarchy"
class StructureUpdateRequest(SluggedUpdateRequest):
structure_kind: StructureKind | None = None
class RelationTypeCreateRequest(SluggedCreateRequest):
structure_id: str | None = None
source_unit_type_id: str | None = None
target_unit_type_id: str | None = None
is_hierarchical: bool = True
allow_cycles: bool = False
class RelationTypeUpdateRequest(SluggedUpdateRequest):
structure_id: str | None = None
source_unit_type_id: str | None = None
target_unit_type_id: str | None = None
is_hierarchical: bool | None = None
allow_cycles: bool | None = None
class UnitCreateRequest(SluggedCreateRequest):
unit_type_id: str | None = None
parent_id: str | None = None
class UnitUpdateRequest(SluggedUpdateRequest):
unit_type_id: str | None = None
parent_id: str | None = None
class RelationCreateRequest(BaseModel):
structure_id: str
relation_type_id: str
source_unit_id: str
target_unit_id: str
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool = True
settings: dict[str, Any] = Field(default_factory=dict)
class RelationUpdateRequest(BaseModel):
structure_id: str | None = None
relation_type_id: str | None = None
source_unit_id: str | None = None
target_unit_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool | None = None
settings: dict[str, Any] | None = None
class FunctionTypeCreateRequest(SluggedCreateRequest):
organization_unit_type_id: str | None = None
delegable: bool = False
act_in_place_allowed: bool = False
class FunctionTypeUpdateRequest(SluggedUpdateRequest):
organization_unit_type_id: str | None = None
delegable: bool | None = None
act_in_place_allowed: bool | None = None
class FunctionCreateRequest(SluggedCreateRequest):
organization_unit_id: str
function_type_id: str | None = None
delegable: bool | None = None
act_in_place_allowed: bool | None = None
class FunctionUpdateRequest(SluggedUpdateRequest):
organization_unit_id: str | None = None
function_type_id: str | None = None
delegable: bool | None = None
act_in_place_allowed: bool | None = None
class FunctionAssignmentCreateRequest(BaseModel):
identity_id: str
function_id: str
account_id: str | None = None
applies_to_subunits: bool = False
source: str = "direct"
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool = True
settings: dict[str, Any] = Field(default_factory=dict)
class FunctionAssignmentUpdateRequest(BaseModel):
identity_id: str | None = None
function_id: str | None = None
account_id: str | None = None
applies_to_subunits: bool | None = None
source: str | None = None
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool | None = None
settings: dict[str, Any] | None = None

View File

@@ -4,7 +4,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, JSON, String, Text, UniqueConstraint from sqlalchemy import Boolean, DateTime, ForeignKey, Index, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin from govoplan_core.db.base import Base, TimestampMixin
@@ -19,7 +19,8 @@ class OrganizationUnit(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_units_tenant_slug"),) __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_units_tenant_slug"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
unit_type_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_unit_types.id", ondelete="SET NULL"), nullable=True, index=True)
parent_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_units.id", ondelete="SET NULL"), nullable=True, index=True) parent_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_units.id", ondelete="SET NULL"), nullable=True, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False) slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
@@ -28,12 +29,104 @@ class OrganizationUnit(Base, TimestampMixin):
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class OrganizationUnitType(Base, TimestampMixin):
__tablename__ = "organizations_unit_types"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_unit_types_tenant_slug"),)
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)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
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"),)
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)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
structure_kind: Mapped[str] = mapped_column(String(30), default="hierarchy", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class OrganizationRelationType(Base, TimestampMixin):
__tablename__ = "organizations_relation_types"
__table_args__ = (
UniqueConstraint("tenant_id", "slug", name="uq_organizations_relation_types_tenant_slug"),
Index("ix_organizations_relation_types_structure", "tenant_id", "structure_id"),
)
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)
structure_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_structures.id", ondelete="SET NULL"), nullable=True, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
source_unit_type_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_unit_types.id", ondelete="SET NULL"), nullable=True, index=True)
target_unit_type_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_unit_types.id", ondelete="SET NULL"), nullable=True, index=True)
is_hierarchical: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_cycles: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class OrganizationRelation(Base, TimestampMixin):
__tablename__ = "organizations_relations"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"structure_id",
"relation_type_id",
"source_unit_id",
"target_unit_id",
name="uq_organizations_relations_edge",
),
Index("ix_organizations_relations_structure_source", "tenant_id", "structure_id", "source_unit_id"),
Index("ix_organizations_relations_structure_target", "tenant_id", "structure_id", "target_unit_id"),
)
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)
structure_id: Mapped[str] = mapped_column(ForeignKey("organizations_structures.id", ondelete="CASCADE"), nullable=False, index=True)
relation_type_id: Mapped[str] = mapped_column(ForeignKey("organizations_relation_types.id", ondelete="CASCADE"), nullable=False, index=True)
source_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True)
target_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True)
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class OrganizationFunctionType(Base, TimestampMixin):
__tablename__ = "organizations_function_types"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_function_types_tenant_slug"),)
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)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
organization_unit_type_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_unit_types.id", ondelete="SET NULL"), nullable=True, index=True)
delegable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
act_in_place_allowed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class OrganizationFunction(Base, TimestampMixin): class OrganizationFunction(Base, TimestampMixin):
__tablename__ = "organizations_functions" __tablename__ = "organizations_functions"
__table_args__ = (UniqueConstraint("tenant_id", "organization_unit_id", "slug", name="uq_organizations_functions_tenant_unit_slug"),) __table_args__ = (UniqueConstraint("tenant_id", "organization_unit_id", "slug", name="uq_organizations_functions_tenant_unit_slug"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
function_type_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_function_types.id", ondelete="SET NULL"), nullable=True, index=True)
organization_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True) organization_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False) slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
@@ -49,17 +142,17 @@ class OrganizationFunctionAssignment(Base, TimestampMixin):
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
"tenant_id", "tenant_id",
"account_id", "identity_id",
"function_id", "function_id",
"organization_unit_id", "organization_unit_id",
name="uq_organizations_function_assignments_account_scope", name="uq_organizations_function_assignments_identity_scope",
), ),
) )
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) identity_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
identity_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
function_id: Mapped[str] = mapped_column(ForeignKey("organizations_functions.id", ondelete="CASCADE"), nullable=False, index=True) function_id: Mapped[str] = mapped_column(ForeignKey("organizations_functions.id", ondelete="CASCADE"), nullable=False, index=True)
organization_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True) organization_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True)
applies_to_subunits: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) applies_to_subunits: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
@@ -75,6 +168,11 @@ class OrganizationFunctionAssignment(Base, TimestampMixin):
__all__ = [ __all__ = [
"OrganizationFunction", "OrganizationFunction",
"OrganizationFunctionAssignment", "OrganizationFunctionAssignment",
"OrganizationFunctionType",
"OrganizationRelation",
"OrganizationRelationType",
"OrganizationStructure",
"OrganizationUnit", "OrganizationUnit",
"OrganizationUnitType",
"new_uuid", "new_uuid",
] ]

View File

@@ -8,6 +8,7 @@ from govoplan_core.core.organizations import (
OrganizationFunctionRef, OrganizationFunctionRef,
OrganizationUnitRef, OrganizationUnitRef,
) )
from govoplan_core.core.identity import IdentityDirectory
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
from govoplan_core.security.time import utc_now from govoplan_core.security.time import utc_now
from govoplan_organizations.backend.db.models import ( from govoplan_organizations.backend.db.models import (
@@ -27,6 +28,7 @@ def _unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
tenant_id=item.tenant_id, tenant_id=item.tenant_id,
slug=item.slug, slug=item.slug,
name=item.name, name=item.name,
unit_type_id=item.unit_type_id,
parent_id=item.parent_id, parent_id=item.parent_id,
description=item.description, description=item.description,
status=_status(item.is_active), # type: ignore[arg-type] status=_status(item.is_active), # type: ignore[arg-type]
@@ -40,6 +42,7 @@ def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
organization_unit_id=item.organization_unit_id, organization_unit_id=item.organization_unit_id,
slug=item.slug, slug=item.slug,
name=item.name, name=item.name,
function_type_id=item.function_type_id,
description=item.description, description=item.description,
delegable=item.delegable, delegable=item.delegable,
act_in_place_allowed=item.act_in_place_allowed, act_in_place_allowed=item.act_in_place_allowed,
@@ -51,8 +54,8 @@ def _assignment_ref(item: OrganizationFunctionAssignment) -> OrganizationFunctio
return OrganizationFunctionAssignmentRef( return OrganizationFunctionAssignmentRef(
id=item.id, id=item.id,
tenant_id=item.tenant_id, tenant_id=item.tenant_id,
account_id=item.account_id,
identity_id=item.identity_id, identity_id=item.identity_id,
account_id=item.account_id,
function_id=item.function_id, function_id=item.function_id,
organization_unit_id=item.organization_unit_id, organization_unit_id=item.organization_unit_id,
applies_to_subunits=item.applies_to_subunits, applies_to_subunits=item.applies_to_subunits,
@@ -66,6 +69,9 @@ def _assignment_ref(item: OrganizationFunctionAssignment) -> OrganizationFunctio
class SqlOrganizationDirectory(OrganizationDirectory): class SqlOrganizationDirectory(OrganizationDirectory):
def __init__(self, *, identity_directory: IdentityDirectory | None = None) -> None:
self._identity_directory = identity_directory
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None: def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
with get_database().session() as session: with get_database().session() as session:
item = session.get(OrganizationUnit, organization_unit_id) item = session.get(OrganizationUnit, organization_unit_id)
@@ -126,14 +132,52 @@ class SqlOrganizationDirectory(OrganizationDirectory):
account_id: str, account_id: str,
*, *,
tenant_id: str | None = None, tenant_id: str | None = None,
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
identity_ids: set[str] = set()
if self._identity_directory is not None:
identity = self._identity_directory.identity_for_account(account_id)
if identity is not None:
identity_ids.add(identity.id)
if not identity_ids:
return self._function_assignments_for_identity_ids(
identity_ids=(),
tenant_id=tenant_id,
fallback_account_id=account_id,
)
return self._function_assignments_for_identity_ids(
identity_ids=tuple(identity_ids),
tenant_id=tenant_id,
fallback_account_id=account_id,
)
def function_assignments_for_identity(
self,
identity_id: str,
*,
tenant_id: str | None = None,
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
return self._function_assignments_for_identity_ids(identity_ids=(identity_id,), tenant_id=tenant_id)
def _function_assignments_for_identity_ids(
self,
*,
identity_ids: tuple[str, ...],
tenant_id: str | None = None,
fallback_account_id: str | None = None,
) -> tuple[OrganizationFunctionAssignmentRef, ...]: ) -> tuple[OrganizationFunctionAssignmentRef, ...]:
now = utc_now() now = utc_now()
with get_database().session() as session: with get_database().session() as session:
identity_or_account_filter = OrganizationFunctionAssignment.identity_id.in_(identity_ids)
if fallback_account_id:
identity_or_account_filter = or_(
identity_or_account_filter,
OrganizationFunctionAssignment.account_id == fallback_account_id,
)
query = ( query = (
session.query(OrganizationFunctionAssignment) session.query(OrganizationFunctionAssignment)
.join(OrganizationFunction, OrganizationFunction.id == OrganizationFunctionAssignment.function_id) .join(OrganizationFunction, OrganizationFunction.id == OrganizationFunctionAssignment.function_id)
.filter( .filter(
OrganizationFunctionAssignment.account_id == account_id, identity_or_account_filter,
OrganizationFunctionAssignment.is_active.is_(True), OrganizationFunctionAssignment.is_active.is_(True),
OrganizationFunction.is_active.is_(True), OrganizationFunction.is_active.is_(True),
or_(OrganizationFunctionAssignment.valid_from.is_(None), OrganizationFunctionAssignment.valid_from <= now), or_(OrganizationFunctionAssignment.valid_from.is_(None), OrganizationFunctionAssignment.valid_from <= now),

View File

@@ -1,28 +1,122 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
from govoplan_core.core.module_guards import persistent_table_uninstall_guard from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import DocumentationTopic, MigrationSpec, ModuleContext, ModuleManifest from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata
def _organization_directory(context: ModuleContext) -> object: ORGANIZATIONS_READ_SCOPES = (
"organizations:model:read",
"organizations:unit:read",
"organizations:function:read",
"admin:settings:read",
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Organizations",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
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: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."),
_permission("organizations:function:write", "Manage organization functions", "Create and edit function definitions."),
_permission("organizations:function:assign", "Assign organization functions", "Assign identity-held functions in organization units."),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="organization_modeler",
name="Organization modeler",
description="Manage organization meta-model, concrete units, structures, functions, and assignments.",
permissions=tuple(permission.scope for permission in PERMISSIONS),
),
RoleTemplate(
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"),
),
)
def _route_factory(context: ModuleContext):
del context del context
from govoplan_organizations.backend.api.v1.routes import router
return router
def _organization_directory(context: ModuleContext) -> object:
identity_directory: IdentityDirectory | None = None
registry = context.registry
if hasattr(registry, "has_capability") and registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if isinstance(capability, IdentityDirectory):
identity_directory = capability
from govoplan_organizations.backend.directory import SqlOrganizationDirectory from govoplan_organizations.backend.directory import SqlOrganizationDirectory
return SqlOrganizationDirectory() return SqlOrganizationDirectory(identity_directory=identity_directory)
manifest = ModuleManifest( manifest = ModuleManifest(
id="organizations", id="organizations",
name="Organizations", name="Organizations",
version="0.1.6", version="0.1.6",
dependencies=("tenancy",), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
migration_spec=MigrationSpec(module_id="organizations", metadata=Base.metadata), optional_dependencies=("tenancy", "identity", "access", "audit", "policy"),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_route_factory,
nav_items=(NavItem(path="/organizations", label="Organizations", icon="users", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
frontend=FrontendModule(
module_id="organizations",
package_name="@govoplan/organizations-webui",
routes=(FrontendRoute(path="/organizations", component="OrganizationsPage", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
nav_items=(NavItem(path="/organizations", label="Organizations", icon="users", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
),
migration_spec=MigrationSpec(
module_id="organizations",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
),
uninstall_guard_providers=( uninstall_guard_providers=(
persistent_table_uninstall_guard( persistent_table_uninstall_guard(
organization_models.OrganizationUnitType,
organization_models.OrganizationStructure,
organization_models.OrganizationRelationType,
organization_models.OrganizationRelation,
organization_models.OrganizationUnit, organization_models.OrganizationUnit,
organization_models.OrganizationFunctionType,
organization_models.OrganizationFunction, organization_models.OrganizationFunction,
organization_models.OrganizationFunctionAssignment, organization_models.OrganizationFunctionAssignment,
label="Organizations", label="Organizations",
@@ -37,8 +131,9 @@ manifest = ModuleManifest(
title="Organization model", title="Organization model",
summary="Organizations owns units, hierarchy, functions, and function assignments. Access maps those facts to roles and rights.", summary="Organizations owns units, hierarchy, functions, and function assignments. Access maps those facts to roles and rights.",
body=( body=(
"Use organization functions to model responsibilities that outlive people. " "Use organization unit types, structures, and relation types to model how the institution describes itself. "
"A function assignment can apply to one organization unit or to that unit and all subunits." "A concrete organization unit can participate in several structures at the same time, such as an employer hierarchy and an academic structure. "
"Functions are held by identities in organization units. Accounts only exercise those functions through identity and access policy."
), ),
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),

View File

@@ -0,0 +1 @@
"""Organizations module migrations."""

View File

@@ -0,0 +1,299 @@
"""organization meta-model
Revision ID: 6d7e8f9a0b1c
Revises: 2e3f4a5b6c7d
Create Date: 2026-07-10 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "6d7e8f9a0b1c"
down_revision = "2e3f4a5b6c7d"
branch_labels = None
depends_on = None
def _tables() -> set[str]:
return set(sa.inspect(op.get_bind()).get_table_names())
def _columns(table_name: str) -> set[str]:
if table_name not in _tables():
return set()
return {item["name"] for item in sa.inspect(op.get_bind()).get_columns(table_name)}
def _indexes(table_name: str) -> set[str]:
if table_name not in _tables():
return set()
return {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table_name)}
def _create_index_if_missing(name: str, table_name: str, columns: list[str], *, unique: bool = False) -> None:
if table_name in _tables() and name not in _indexes(table_name):
op.create_index(name, table_name, columns, unique=unique)
def _drop_index_if_exists(name: str, table_name: str) -> None:
if table_name in _tables() and name in _indexes(table_name):
op.drop_index(name, table_name=table_name)
def _json_column(name: str) -> sa.Column:
return sa.Column(name, sa.JSON(), nullable=False)
def _timestamp_columns() -> tuple[sa.Column, sa.Column]:
return (
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
def _create_unit_types() -> None:
if "organizations_unit_types" not in _tables():
op.create_table(
"organizations_unit_types",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("slug", sa.String(length=100), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
_json_column("settings"),
*_timestamp_columns(),
sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_unit_types")),
sa.UniqueConstraint("tenant_id", "slug", name="uq_organizations_unit_types_tenant_slug"),
)
_create_index_if_missing(op.f("ix_organizations_unit_types_tenant_id"), "organizations_unit_types", ["tenant_id"])
def _create_structures() -> None:
if "organizations_structures" not in _tables():
op.create_table(
"organizations_structures",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("slug", sa.String(length=100), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("structure_kind", sa.String(length=30), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
_json_column("settings"),
*_timestamp_columns(),
sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_structures")),
sa.UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),
)
_create_index_if_missing(op.f("ix_organizations_structures_tenant_id"), "organizations_structures", ["tenant_id"])
def _create_relation_types() -> None:
if "organizations_relation_types" not in _tables():
op.create_table(
"organizations_relation_types",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("structure_id", sa.String(length=36), nullable=True),
sa.Column("slug", sa.String(length=100), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("source_unit_type_id", sa.String(length=36), nullable=True),
sa.Column("target_unit_type_id", sa.String(length=36), nullable=True),
sa.Column("is_hierarchical", sa.Boolean(), nullable=False),
sa.Column("allow_cycles", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
_json_column("settings"),
*_timestamp_columns(),
sa.ForeignKeyConstraint(["source_unit_type_id"], ["organizations_unit_types.id"], name=op.f("fk_organizations_relation_types_source_unit_type_id_organizations_unit_types"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["structure_id"], ["organizations_structures.id"], name=op.f("fk_organizations_relation_types_structure_id_organizations_structures"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["target_unit_type_id"], ["organizations_unit_types.id"], name=op.f("fk_organizations_relation_types_target_unit_type_id_organizations_unit_types"), ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_relation_types")),
sa.UniqueConstraint("tenant_id", "slug", name="uq_organizations_relation_types_tenant_slug"),
)
for column in ("tenant_id", "source_unit_type_id", "structure_id", "target_unit_type_id"):
_create_index_if_missing(op.f(f"ix_organizations_relation_types_{column}"), "organizations_relation_types", [column])
_create_index_if_missing("ix_organizations_relation_types_structure", "organizations_relation_types", ["tenant_id", "structure_id"])
def _create_units() -> None:
if "organizations_units" not in _tables():
op.create_table(
"organizations_units",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("unit_type_id", sa.String(length=36), nullable=True),
sa.Column("parent_id", sa.String(length=36), nullable=True),
sa.Column("slug", sa.String(length=100), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
_json_column("settings"),
*_timestamp_columns(),
sa.ForeignKeyConstraint(["parent_id"], ["organizations_units.id"], name=op.f("fk_organizations_units_parent_id_organizations_units"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["unit_type_id"], ["organizations_unit_types.id"], name=op.f("fk_organizations_units_unit_type_id_organizations_unit_types"), ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_units")),
sa.UniqueConstraint("tenant_id", "slug", name="uq_organizations_units_tenant_slug"),
)
elif "unit_type_id" not in _columns("organizations_units"):
op.add_column("organizations_units", sa.Column("unit_type_id", sa.String(length=36), nullable=True))
for column in ("parent_id", "tenant_id", "unit_type_id"):
_create_index_if_missing(op.f(f"ix_organizations_units_{column}"), "organizations_units", [column])
def _create_relations() -> None:
if "organizations_relations" not in _tables():
op.create_table(
"organizations_relations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("structure_id", sa.String(length=36), nullable=False),
sa.Column("relation_type_id", sa.String(length=36), nullable=False),
sa.Column("source_unit_id", sa.String(length=36), nullable=False),
sa.Column("target_unit_id", sa.String(length=36), nullable=False),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
_json_column("settings"),
*_timestamp_columns(),
sa.ForeignKeyConstraint(["relation_type_id"], ["organizations_relation_types.id"], name=op.f("fk_organizations_relations_relation_type_id_organizations_relation_types"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["source_unit_id"], ["organizations_units.id"], name=op.f("fk_organizations_relations_source_unit_id_organizations_units"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["structure_id"], ["organizations_structures.id"], name=op.f("fk_organizations_relations_structure_id_organizations_structures"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["target_unit_id"], ["organizations_units.id"], name=op.f("fk_organizations_relations_target_unit_id_organizations_units"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_relations")),
sa.UniqueConstraint("tenant_id", "structure_id", "relation_type_id", "source_unit_id", "target_unit_id", name="uq_organizations_relations_edge"),
)
for column in ("relation_type_id", "source_unit_id", "structure_id", "target_unit_id", "tenant_id"):
_create_index_if_missing(op.f(f"ix_organizations_relations_{column}"), "organizations_relations", [column])
_create_index_if_missing("ix_organizations_relations_structure_source", "organizations_relations", ["tenant_id", "structure_id", "source_unit_id"])
_create_index_if_missing("ix_organizations_relations_structure_target", "organizations_relations", ["tenant_id", "structure_id", "target_unit_id"])
def _create_function_types() -> None:
if "organizations_function_types" not in _tables():
op.create_table(
"organizations_function_types",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("slug", sa.String(length=100), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("organization_unit_type_id", sa.String(length=36), nullable=True),
sa.Column("delegable", sa.Boolean(), nullable=False),
sa.Column("act_in_place_allowed", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
_json_column("settings"),
*_timestamp_columns(),
sa.ForeignKeyConstraint(["organization_unit_type_id"], ["organizations_unit_types.id"], name=op.f("fk_organizations_function_types_organization_unit_type_id_organizations_unit_types"), ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_function_types")),
sa.UniqueConstraint("tenant_id", "slug", name="uq_organizations_function_types_tenant_slug"),
)
for column in ("organization_unit_type_id", "tenant_id"):
_create_index_if_missing(op.f(f"ix_organizations_function_types_{column}"), "organizations_function_types", [column])
def _create_functions() -> None:
if "organizations_functions" not in _tables():
op.create_table(
"organizations_functions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("function_type_id", sa.String(length=36), nullable=True),
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
sa.Column("slug", sa.String(length=100), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("delegable", sa.Boolean(), nullable=False),
sa.Column("act_in_place_allowed", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
_json_column("settings"),
*_timestamp_columns(),
sa.ForeignKeyConstraint(["function_type_id"], ["organizations_function_types.id"], name=op.f("fk_organizations_functions_function_type_id_organizations_function_types"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["organization_unit_id"], ["organizations_units.id"], name=op.f("fk_organizations_functions_organization_unit_id_organizations_units"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_functions")),
sa.UniqueConstraint("tenant_id", "organization_unit_id", "slug", name="uq_organizations_functions_tenant_unit_slug"),
)
elif "function_type_id" not in _columns("organizations_functions"):
op.add_column("organizations_functions", sa.Column("function_type_id", sa.String(length=36), nullable=True))
for column in ("function_type_id", "organization_unit_id", "tenant_id"):
_create_index_if_missing(op.f(f"ix_organizations_functions_{column}"), "organizations_functions", [column])
def _create_function_assignments() -> None:
if "organizations_function_assignments" not in _tables():
op.create_table(
"organizations_function_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("identity_id", sa.String(length=36), nullable=False),
sa.Column("account_id", sa.String(length=36), nullable=True),
sa.Column("function_id", sa.String(length=36), nullable=False),
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
sa.Column("source", sa.String(length=50), nullable=False),
sa.Column("delegated_from_assignment_id", sa.String(length=36), nullable=True),
sa.Column("acting_for_account_id", sa.String(length=36), nullable=True),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
_json_column("settings"),
*_timestamp_columns(),
sa.ForeignKeyConstraint(["delegated_from_assignment_id"], ["organizations_function_assignments.id"], name=op.f("fk_organizations_function_assignments_delegated_from_assignment_id_organizations_function_assignments"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["function_id"], ["organizations_functions.id"], name=op.f("fk_organizations_function_assignments_function_id_organizations_functions"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["organization_unit_id"], ["organizations_units.id"], name=op.f("fk_organizations_function_assignments_organization_unit_id_organizations_units"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_function_assignments")),
sa.UniqueConstraint("tenant_id", "identity_id", "function_id", "organization_unit_id", name="uq_organizations_function_assignments_identity_scope"),
)
else:
columns = _columns("organizations_function_assignments")
if "identity_id" not in columns:
op.add_column("organizations_function_assignments", sa.Column("identity_id", sa.String(length=36), nullable=True))
op.execute(sa.text("UPDATE organizations_function_assignments SET identity_id = account_id WHERE identity_id IS NULL"))
for column in (
"account_id",
"acting_for_account_id",
"delegated_from_assignment_id",
"function_id",
"identity_id",
"organization_unit_id",
"tenant_id",
):
_create_index_if_missing(op.f(f"ix_organizations_function_assignments_{column}"), "organizations_function_assignments", [column])
_create_index_if_missing(
"uq_organizations_function_assignments_identity_scope",
"organizations_function_assignments",
["tenant_id", "identity_id", "function_id", "organization_unit_id"],
unique=True,
)
def upgrade() -> None:
_create_unit_types()
_create_structures()
_create_relation_types()
_create_units()
_create_relations()
_create_function_types()
_create_functions()
_create_function_assignments()
def downgrade() -> None:
for table_name, indexes in (
("organizations_function_assignments", ("uq_organizations_function_assignments_identity_scope",)),
("organizations_relations", ("ix_organizations_relations_structure_source", "ix_organizations_relations_structure_target")),
("organizations_relation_types", ("ix_organizations_relation_types_structure",)),
):
for index_name in indexes:
_drop_index_if_exists(index_name, table_name)
for table_name in (
"organizations_relations",
"organizations_relation_types",
"organizations_structures",
"organizations_function_types",
"organizations_unit_types",
):
if table_name in _tables():
op.drop_table(table_name)

View File

@@ -0,0 +1 @@
"""Organizations Alembic revisions."""

31
webui/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@govoplan/organizations-webui",
"version": "0.1.6",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/organizations.css": "./src/styles/organizations.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

View File

@@ -0,0 +1,270 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type OrganizationUnitTypeItem = {
id: string;
tenant_id: string;
slug: string;
name: string;
description?: string | null;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationStructureKind = "hierarchy" | "network" | "membership" | "classification";
export type OrganizationStructureItem = {
id: string;
tenant_id: string;
slug: string;
name: string;
description?: string | null;
structure_kind: OrganizationStructureKind | string;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationRelationTypeItem = {
id: string;
tenant_id: string;
structure_id?: string | null;
slug: string;
name: string;
description?: string | null;
source_unit_type_id?: string | null;
target_unit_type_id?: string | null;
is_hierarchical: boolean;
allow_cycles: boolean;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationUnitItem = {
id: string;
tenant_id: string;
unit_type_id?: string | null;
parent_id?: string | null;
slug: string;
name: string;
description?: string | null;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationRelationItem = {
id: string;
tenant_id: string;
structure_id: string;
relation_type_id: string;
source_unit_id: string;
target_unit_id: string;
valid_from?: string | null;
valid_until?: string | null;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationFunctionTypeItem = {
id: string;
tenant_id: string;
slug: string;
name: string;
description?: string | null;
organization_unit_type_id?: string | null;
delegable: boolean;
act_in_place_allowed: boolean;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationFunctionItem = {
id: string;
tenant_id: string;
function_type_id?: string | null;
organization_unit_id: string;
slug: string;
name: string;
description?: string | null;
delegable: boolean;
act_in_place_allowed: boolean;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationFunctionAssignmentItem = {
id: string;
tenant_id: string;
identity_id: string;
account_id?: string | null;
function_id: string;
organization_unit_id: string;
applies_to_subunits: boolean;
source: string;
delegated_from_assignment_id?: string | null;
acting_for_account_id?: string | null;
valid_from?: string | null;
valid_until?: string | null;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationModel = {
unit_types: OrganizationUnitTypeItem[];
structures: OrganizationStructureItem[];
relation_types: OrganizationRelationTypeItem[];
units: OrganizationUnitItem[];
relations: OrganizationRelationItem[];
function_types: OrganizationFunctionTypeItem[];
functions: OrganizationFunctionItem[];
function_assignments: OrganizationFunctionAssignmentItem[];
};
export type SluggedCreatePayload = {
slug?: string | null;
name: string;
description?: string | null;
is_active?: boolean;
settings?: Record<string, unknown>;
};
export type StructureCreatePayload = SluggedCreatePayload & {
structure_kind: OrganizationStructureKind;
};
export type RelationTypeCreatePayload = SluggedCreatePayload & {
structure_id?: string | null;
source_unit_type_id?: string | null;
target_unit_type_id?: string | null;
is_hierarchical: boolean;
allow_cycles: boolean;
};
export type UnitCreatePayload = SluggedCreatePayload & {
unit_type_id?: string | null;
parent_id?: string | null;
};
export type RelationCreatePayload = {
structure_id: string;
relation_type_id: string;
source_unit_id: string;
target_unit_id: string;
is_active?: boolean;
settings?: Record<string, unknown>;
};
export type FunctionTypeCreatePayload = SluggedCreatePayload & {
organization_unit_type_id?: string | null;
delegable: boolean;
act_in_place_allowed: boolean;
};
export type FunctionCreatePayload = SluggedCreatePayload & {
organization_unit_id: string;
function_type_id?: string | null;
delegable?: boolean | null;
act_in_place_allowed?: boolean | null;
};
export type FunctionAssignmentCreatePayload = {
identity_id: string;
account_id?: string | null;
function_id: string;
applies_to_subunits: boolean;
source: string;
delegated_from_assignment_id?: string | null;
acting_for_account_id?: string | null;
is_active?: boolean;
settings?: Record<string, unknown>;
};
function post<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
return apiFetch<T>(settings, path, { method: "POST", body: JSON.stringify(payload) });
}
function patch<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
return apiFetch<T>(settings, path, { method: "PATCH", body: JSON.stringify(payload) });
}
export function getOrganizationModel(settings: ApiSettings): Promise<OrganizationModel> {
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
}
export function createUnitType(settings: ApiSettings, payload: SluggedCreatePayload): Promise<OrganizationUnitTypeItem> {
return post(settings, "/api/v1/organizations/unit-types", payload);
}
export function patchUnitType(settings: ApiSettings, id: string, payload: Partial<SluggedCreatePayload>): Promise<OrganizationUnitTypeItem> {
return patch(settings, `/api/v1/organizations/unit-types/${encodeURIComponent(id)}`, payload);
}
export function createStructure(settings: ApiSettings, payload: StructureCreatePayload): Promise<OrganizationStructureItem> {
return post(settings, "/api/v1/organizations/structures", payload);
}
export function patchStructure(settings: ApiSettings, id: string, payload: Partial<StructureCreatePayload>): Promise<OrganizationStructureItem> {
return patch(settings, `/api/v1/organizations/structures/${encodeURIComponent(id)}`, payload);
}
export function createRelationType(settings: ApiSettings, payload: RelationTypeCreatePayload): Promise<OrganizationRelationTypeItem> {
return post(settings, "/api/v1/organizations/relation-types", payload);
}
export function patchRelationType(settings: ApiSettings, id: string, payload: Partial<RelationTypeCreatePayload>): Promise<OrganizationRelationTypeItem> {
return patch(settings, `/api/v1/organizations/relation-types/${encodeURIComponent(id)}`, payload);
}
export function createUnit(settings: ApiSettings, payload: UnitCreatePayload): Promise<OrganizationUnitItem> {
return post(settings, "/api/v1/organizations/units", payload);
}
export function patchUnit(settings: ApiSettings, id: string, payload: Partial<UnitCreatePayload>): Promise<OrganizationUnitItem> {
return patch(settings, `/api/v1/organizations/units/${encodeURIComponent(id)}`, payload);
}
export function createRelation(settings: ApiSettings, payload: RelationCreatePayload): Promise<OrganizationRelationItem> {
return post(settings, "/api/v1/organizations/relations", payload);
}
export function patchRelation(settings: ApiSettings, id: string, payload: Partial<RelationCreatePayload>): Promise<OrganizationRelationItem> {
return patch(settings, `/api/v1/organizations/relations/${encodeURIComponent(id)}`, payload);
}
export function createFunctionType(settings: ApiSettings, payload: FunctionTypeCreatePayload): Promise<OrganizationFunctionTypeItem> {
return post(settings, "/api/v1/organizations/function-types", payload);
}
export function patchFunctionType(settings: ApiSettings, id: string, payload: Partial<FunctionTypeCreatePayload>): Promise<OrganizationFunctionTypeItem> {
return patch(settings, `/api/v1/organizations/function-types/${encodeURIComponent(id)}`, payload);
}
export function createFunction(settings: ApiSettings, payload: FunctionCreatePayload): Promise<OrganizationFunctionItem> {
return post(settings, "/api/v1/organizations/functions", payload);
}
export function patchFunction(settings: ApiSettings, id: string, payload: Partial<FunctionCreatePayload>): Promise<OrganizationFunctionItem> {
return patch(settings, `/api/v1/organizations/functions/${encodeURIComponent(id)}`, payload);
}
export function createFunctionAssignment(settings: ApiSettings, payload: FunctionAssignmentCreatePayload): Promise<OrganizationFunctionAssignmentItem> {
return post(settings, "/api/v1/organizations/function-assignments", payload);
}
export function patchFunctionAssignment(settings: ApiSettings, id: string, payload: Partial<FunctionAssignmentCreatePayload>): Promise<OrganizationFunctionAssignmentItem> {
return patch(settings, `/api/v1/organizations/function-assignments/${encodeURIComponent(id)}`, payload);
}

View File

@@ -0,0 +1,18 @@
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import OrganizationsPage, { type OrganizationSection } from "./OrganizationsPage";
const MODEL_SECTIONS: OrganizationSection[] = ["model"];
export default function OrganizationsAdminPanel({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
return (
<OrganizationsPage
settings={settings}
auth={auth}
mode="admin"
initialSection="model"
availableSections={MODEL_SECTIONS}
title="i18n:govoplan-organizations.organization_model.4f924c0e"
description="i18n:govoplan-organizations.organization_model_admin_description.35dc9f10"
/>
);
}

View File

@@ -0,0 +1,974 @@
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { RefreshCw, Save, XCircle } from "lucide-react";
import {
ApiError,
Button,
Card,
DataGrid,
DismissibleAlert,
FormField,
LoadingFrame,
ModuleSubnav,
PageTitle,
StatusBadge,
hasScope,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo,
type DataGridColumn,
type ModuleSubnavGroup
} from "@govoplan/core-webui";
import {
createFunction,
createFunctionAssignment,
createFunctionType,
createRelation,
createRelationType,
createStructure,
createUnit,
createUnitType,
getOrganizationModel,
patchFunction,
patchFunctionAssignment,
patchFunctionType,
patchRelation,
patchRelationType,
patchStructure,
patchUnit,
patchUnitType,
type FunctionAssignmentCreatePayload,
type FunctionCreatePayload,
type FunctionTypeCreatePayload,
type OrganizationFunctionAssignmentItem,
type OrganizationFunctionItem,
type OrganizationFunctionTypeItem,
type OrganizationModel,
type OrganizationRelationItem,
type OrganizationRelationTypeItem,
type OrganizationStructureItem,
type OrganizationStructureKind,
type OrganizationUnitItem,
type OrganizationUnitTypeItem,
type RelationCreatePayload,
type RelationTypeCreatePayload,
type SluggedCreatePayload,
type StructureCreatePayload,
type UnitCreatePayload
} from "../../api/organizations";
export type OrganizationSection = "model" | "units" | "relations" | "functions" | "assignments";
type OrganizationsPageMode = "workspace" | "admin";
type OrganizationsPageProps = {
settings: ApiSettings;
auth: AuthInfo;
mode?: OrganizationsPageMode;
initialSection?: OrganizationSection;
availableSections?: OrganizationSection[];
title?: string;
description?: string;
};
type SluggedDraft = {
name: string;
slug: string;
description: string;
is_active: boolean;
};
type StructureDraft = SluggedDraft & {
structure_kind: OrganizationStructureKind;
};
type RelationTypeDraft = SluggedDraft & {
structure_id: string;
source_unit_type_id: string;
target_unit_type_id: string;
is_hierarchical: boolean;
allow_cycles: boolean;
};
type UnitDraft = SluggedDraft & {
unit_type_id: string;
parent_id: string;
};
type RelationDraft = {
structure_id: string;
relation_type_id: string;
source_unit_id: string;
target_unit_id: string;
is_active: boolean;
};
type FunctionTypeDraft = SluggedDraft & {
organization_unit_type_id: string;
delegable: boolean;
act_in_place_allowed: boolean;
};
type FunctionDraft = SluggedDraft & {
organization_unit_id: string;
function_type_id: string;
delegable: boolean;
act_in_place_allowed: boolean;
};
type AssignmentDraft = {
identity_id: string;
account_id: string;
function_id: string;
applies_to_subunits: boolean;
source: string;
delegated_from_assignment_id: string;
acting_for_account_id: string;
is_active: boolean;
};
type ActiveItem = {
id: string;
is_active: boolean;
};
const EMPTY_MODEL: OrganizationModel = {
unit_types: [],
structures: [],
relation_types: [],
units: [],
relations: [],
function_types: [],
functions: [],
function_assignments: []
};
const SECTION_GROUPS: ModuleSubnavGroup<OrganizationSection>[] = [
{
title: "i18n:govoplan-organizations.organizations.220edf64",
items: [
{ id: "model", label: "i18n:govoplan-organizations.meta_model.7398487c", primary: true },
{ id: "units", label: "i18n:govoplan-organizations.units.e14d0d92" },
{ id: "relations", label: "i18n:govoplan-organizations.relations.1c796711" },
{ id: "functions", label: "i18n:govoplan-organizations.functions.805dc49b" },
{ id: "assignments", label: "i18n:govoplan-organizations.assignments.278f513e" }
]
}
];
const STRUCTURE_KINDS: Array<{ value: OrganizationStructureKind; label: string }> = [
{ value: "hierarchy", label: "i18n:govoplan-organizations.hierarchy.74797f37" },
{ value: "network", label: "i18n:govoplan-organizations.network.a24d31c3" },
{ value: "membership", label: "i18n:govoplan-organizations.membership.4531d86d" },
{ value: "classification", label: "i18n:govoplan-organizations.classification.3e9f6c3a" }
];
const ALL_SECTIONS: OrganizationSection[] = ["model", "units", "relations", "functions", "assignments"];
function emptySluggedDraft(): SluggedDraft {
return { name: "", slug: "", description: "", is_active: true };
}
function emptyStructureDraft(): StructureDraft {
return { ...emptySluggedDraft(), structure_kind: "hierarchy" };
}
function emptyRelationTypeDraft(): RelationTypeDraft {
return {
...emptySluggedDraft(),
structure_id: "",
source_unit_type_id: "",
target_unit_type_id: "",
is_hierarchical: true,
allow_cycles: false
};
}
function emptyUnitDraft(): UnitDraft {
return { ...emptySluggedDraft(), unit_type_id: "", parent_id: "" };
}
function emptyRelationDraft(): RelationDraft {
return { structure_id: "", relation_type_id: "", source_unit_id: "", target_unit_id: "", is_active: true };
}
function emptyFunctionTypeDraft(): FunctionTypeDraft {
return { ...emptySluggedDraft(), organization_unit_type_id: "", delegable: false, act_in_place_allowed: false };
}
function emptyFunctionDraft(): FunctionDraft {
return { ...emptySluggedDraft(), organization_unit_id: "", function_type_id: "", delegable: false, act_in_place_allowed: false };
}
function emptyAssignmentDraft(): AssignmentDraft {
return {
identity_id: "",
account_id: "",
function_id: "",
applies_to_subunits: false,
source: "direct",
delegated_from_assignment_id: "",
acting_for_account_id: "",
is_active: true
};
}
function textOrNull(value: string): string | null {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function sluggedPayload(draft: SluggedDraft): SluggedCreatePayload {
return {
name: draft.name.trim(),
slug: textOrNull(draft.slug),
description: textOrNull(draft.description),
is_active: draft.is_active,
settings: {}
};
}
function isSluggedDirty(draft: SluggedDraft): boolean {
return draft.name.trim() !== "" || draft.slug.trim() !== "" || draft.description.trim() !== "" || !draft.is_active;
}
function isRelationTypeDirty(draft: RelationTypeDraft): boolean {
return isSluggedDirty(draft) || Boolean(draft.structure_id || draft.source_unit_type_id || draft.target_unit_type_id) || !draft.is_hierarchical || draft.allow_cycles;
}
function isUnitDirty(draft: UnitDraft): boolean {
return isSluggedDirty(draft) || Boolean(draft.unit_type_id || draft.parent_id);
}
function isRelationDirty(draft: RelationDraft): boolean {
return Boolean(draft.structure_id || draft.relation_type_id || draft.source_unit_id || draft.target_unit_id) || !draft.is_active;
}
function isFunctionTypeDirty(draft: FunctionTypeDraft): boolean {
return isSluggedDirty(draft) || Boolean(draft.organization_unit_type_id) || draft.delegable || draft.act_in_place_allowed;
}
function isFunctionDirty(draft: FunctionDraft): boolean {
return isSluggedDirty(draft) || Boolean(draft.organization_unit_id || draft.function_type_id) || draft.delegable || draft.act_in_place_allowed;
}
function isAssignmentDirty(draft: AssignmentDraft): boolean {
return Boolean(
draft.identity_id.trim() ||
draft.account_id.trim() ||
draft.function_id ||
draft.applies_to_subunits ||
draft.source.trim() !== "direct" ||
draft.delegated_from_assignment_id ||
draft.acting_for_account_id ||
!draft.is_active
);
}
function mapById<T extends { id: string }>(items: T[]): Map<string, T> {
return new Map(items.map((item) => [item.id, item]));
}
function sectionGroupsFor(sections: ReadonlySet<OrganizationSection>): ModuleSubnavGroup<OrganizationSection>[] {
return SECTION_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => sections.has(item.id))
})).filter((group) => group.items.length > 0);
}
function apiErrorMessage(error: unknown): string {
if (error instanceof ApiError) return error.message;
if (error instanceof Error) return error.message;
return String(error);
}
function optionalLabel(label: string | null | undefined): JSX.Element {
return label ? <span>{label}</span> : <span className="organizations-empty">i18n:govoplan-organizations.none.334c4a4c</span>;
}
function activeStatus(active: boolean): JSX.Element {
return <StatusBadge status={active ? "success" : "inactive"} label={active ? "i18n:govoplan-organizations.active.a733b809" : "i18n:govoplan-organizations.inactive.09af574c"} />;
}
function ActiveToggleButton({ item, disabled, onToggle }: { item: ActiveItem; disabled: boolean; onToggle: () => void }) {
const label = item.is_active ? "i18n:govoplan-organizations.deactivate.46ab070d" : "i18n:govoplan-organizations.reactivate.58f2855d";
return (
<Button type="button" variant={item.is_active ? "secondary" : "primary"} disabled={disabled} onClick={onToggle} title={label}>
{label}
</Button>
);
}
function SluggedFields({
draft,
onChange,
disabled
}: {
draft: SluggedDraft;
onChange: (next: SluggedDraft) => void;
disabled: boolean;
}) {
return (
<>
<FormField label="i18n:govoplan-organizations.name.709a2322">
<input value={draft.name} disabled={disabled} onChange={(event) => onChange({ ...draft, name: event.target.value })} />
</FormField>
<FormField label="i18n:govoplan-organizations.slug.094da9b9">
<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">
<textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => onChange({ ...draft, description: event.target.value })} />
</FormField>
</div>
<label className="organizations-inline-check wide">
<input type="checkbox" checked={draft.is_active} disabled={disabled} onChange={(event) => onChange({ ...draft, is_active: event.target.checked })} />
<span>i18n:govoplan-organizations.active.a733b809</span>
</label>
</>
);
}
export default function OrganizationsPage({
settings,
auth,
mode = "workspace",
initialSection = "model",
availableSections = ALL_SECTIONS,
title = "i18n:govoplan-organizations.organizations.220edf64",
description = "i18n:govoplan-organizations.organizations_intro.4e67c4bb"
}: OrganizationsPageProps) {
const sectionSet = useMemo(() => new Set(availableSections.length ? availableSections : ALL_SECTIONS), [availableSections]);
const firstAvailableSection = availableSections.find((section) => sectionSet.has(section)) ?? "model";
const [active, setActive] = useState<OrganizationSection>(sectionSet.has(initialSection) ? initialSection : firstAvailableSection);
const visibleSectionGroups = useMemo(() => sectionGroupsFor(sectionSet), [sectionSet]);
const [model, setModel] = useState<OrganizationModel>(EMPTY_MODEL);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [unitTypeDraft, setUnitTypeDraft] = useState<SluggedDraft>(() => emptySluggedDraft());
const [structureDraft, setStructureDraft] = useState<StructureDraft>(() => emptyStructureDraft());
const [relationTypeDraft, setRelationTypeDraft] = useState<RelationTypeDraft>(() => emptyRelationTypeDraft());
const [unitDraft, setUnitDraft] = useState<UnitDraft>(() => emptyUnitDraft());
const [relationDraft, setRelationDraft] = useState<RelationDraft>(() => emptyRelationDraft());
const [functionTypeDraft, setFunctionTypeDraft] = useState<FunctionTypeDraft>(() => emptyFunctionTypeDraft());
const [functionDraft, setFunctionDraft] = useState<FunctionDraft>(() => emptyFunctionDraft());
const [assignmentDraft, setAssignmentDraft] = useState<AssignmentDraft>(() => emptyAssignmentDraft());
const canWriteModel = hasScope(auth, "organizations:model:write");
const canWriteUnits = hasScope(auth, "organizations:unit:write");
const canWriteFunctions = hasScope(auth, "organizations:function:write");
const canAssignFunctions = hasScope(auth, "organizations:function:assign");
const unitTypeById = useMemo(() => mapById(model.unit_types), [model.unit_types]);
const structureById = useMemo(() => mapById(model.structures), [model.structures]);
const relationTypeById = useMemo(() => mapById(model.relation_types), [model.relation_types]);
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 hasDirtyDraft = isSluggedDirty(unitTypeDraft) ||
isSluggedDirty(structureDraft) ||
isRelationTypeDirty(relationTypeDraft) ||
isUnitDirty(unitDraft) ||
isRelationDirty(relationDraft) ||
isFunctionTypeDirty(functionTypeDraft) ||
isFunctionDirty(functionDraft) ||
isAssignmentDirty(assignmentDraft);
const loadModel = useCallback(async () => {
setLoading(true);
setError("");
try {
setModel(await getOrganizationModel(settings));
} catch (caught) {
setError(apiErrorMessage(caught));
} finally {
setLoading(false);
}
}, [settings]);
useEffect(() => {
void loadModel();
}, [loadModel]);
useEffect(() => {
if (!sectionSet.has(active)) setActive(firstAvailableSection);
}, [active, firstAvailableSection, sectionSet]);
const runAction = useCallback(async (action: () => Promise<unknown>, successMessage = ""): Promise<boolean> => {
setBusy(true);
setError("");
setSuccess("");
try {
await action();
if (successMessage) setSuccess(successMessage);
await loadModel();
return true;
} catch (caught) {
setError(apiErrorMessage(caught));
return false;
} finally {
setBusy(false);
}
}, [loadModel]);
const discardDrafts = useCallback(() => {
setUnitTypeDraft(emptySluggedDraft());
setStructureDraft(emptyStructureDraft());
setRelationTypeDraft(emptyRelationTypeDraft());
setUnitDraft(emptyUnitDraft());
setRelationDraft(emptyRelationDraft());
setFunctionTypeDraft(emptyFunctionTypeDraft());
setFunctionDraft(emptyFunctionDraft());
setAssignmentDraft(emptyAssignmentDraft());
}, []);
function rejectMissingName(): Promise<boolean> {
setError("i18n:govoplan-organizations.name_is_required.27cd3782");
return Promise.resolve(false);
}
function rejectMissingWritePermission(): Promise<boolean> {
setError("i18n:govoplan-organizations.write_permission_required.8b09fd67");
return Promise.resolve(false);
}
const submitUnitType = useCallback(async (event?: FormEvent): Promise<boolean> => {
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());
return ok;
}, [canWriteModel, runAction, settings, unitTypeDraft]);
const submitStructure = useCallback(async (event?: FormEvent): Promise<boolean> => {
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());
return ok;
}, [canWriteModel, runAction, settings, structureDraft]);
const submitRelationType = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canWriteModel) return rejectMissingWritePermission();
if (!relationTypeDraft.name.trim()) return rejectMissingName();
const payload: RelationTypeCreatePayload = {
...sluggedPayload(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(() => createRelationType(settings, payload), "i18n:govoplan-organizations.relation_type_added.6f1edff1");
if (ok) setRelationTypeDraft(emptyRelationTypeDraft());
return ok;
}, [canWriteModel, relationTypeDraft, runAction, settings]);
const submitUnit = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canWriteUnits) return rejectMissingWritePermission();
if (!unitDraft.name.trim()) return rejectMissingName();
const payload: UnitCreatePayload = {
...sluggedPayload(unitDraft),
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());
return ok;
}, [canWriteUnits, runAction, settings, unitDraft]);
const submitRelation = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canWriteUnits) return rejectMissingWritePermission();
if (!relationDraft.structure_id || !relationDraft.relation_type_id || !relationDraft.source_unit_id || !relationDraft.target_unit_id) {
setError("i18n:govoplan-organizations.select_structure.c10f551c");
return false;
}
const payload: RelationCreatePayload = { ...relationDraft, settings: {} };
const ok = await runAction(() => createRelation(settings, payload), "i18n:govoplan-organizations.relation_added.ca90461a");
if (ok) setRelationDraft(emptyRelationDraft());
return ok;
}, [canWriteUnits, relationDraft, runAction, settings]);
const submitFunctionType = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canWriteModel) return rejectMissingWritePermission();
if (!functionTypeDraft.name.trim()) return rejectMissingName();
const payload: FunctionTypeCreatePayload = {
...sluggedPayload(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(() => createFunctionType(settings, payload), "i18n:govoplan-organizations.function_type_added.2cd9e899");
if (ok) setFunctionTypeDraft(emptyFunctionTypeDraft());
return ok;
}, [canWriteModel, functionTypeDraft, runAction, settings]);
const submitFunction = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canWriteFunctions) return rejectMissingWritePermission();
if (!functionDraft.name.trim()) return rejectMissingName();
if (!functionDraft.organization_unit_id) {
setError("i18n:govoplan-organizations.select_unit.013bf13a");
return false;
}
const payload: FunctionCreatePayload = {
...sluggedPayload(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(() => createFunction(settings, payload), "i18n:govoplan-organizations.function_added.e2266702");
if (ok) setFunctionDraft(emptyFunctionDraft());
return ok;
}, [canWriteFunctions, functionDraft, runAction, settings]);
const submitAssignment = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canAssignFunctions) return rejectMissingWritePermission();
if (!assignmentDraft.identity_id.trim() || !assignmentDraft.function_id) {
setError("i18n:govoplan-organizations.select_function.4895a67d");
return false;
}
const payload: FunctionAssignmentCreatePayload = {
identity_id: assignmentDraft.identity_id.trim(),
account_id: textOrNull(assignmentDraft.account_id),
function_id: assignmentDraft.function_id,
applies_to_subunits: assignmentDraft.applies_to_subunits,
source: assignmentDraft.source.trim() || "direct",
delegated_from_assignment_id: textOrNull(assignmentDraft.delegated_from_assignment_id),
acting_for_account_id: textOrNull(assignmentDraft.acting_for_account_id),
is_active: assignmentDraft.is_active,
settings: {}
};
const ok = await runAction(() => createFunctionAssignment(settings, payload), "i18n:govoplan-organizations.assignment_added.94263d1b");
if (ok) setAssignmentDraft(emptyAssignmentDraft());
return ok;
}, [assignmentDraft, canAssignFunctions, runAction, settings]);
const saveDrafts = useCallback(async (): Promise<boolean> => {
if (isSluggedDirty(unitTypeDraft) && !(await submitUnitType())) return false;
if (isSluggedDirty(structureDraft) && !(await submitStructure())) return false;
if (isRelationTypeDirty(relationTypeDraft) && !(await submitRelationType())) return false;
if (isUnitDirty(unitDraft) && !(await submitUnit())) return false;
if (isRelationDirty(relationDraft) && !(await submitRelation())) return false;
if (isFunctionTypeDirty(functionTypeDraft) && !(await submitFunctionType())) return false;
if (isFunctionDirty(functionDraft) && !(await submitFunction())) return false;
if (isAssignmentDirty(assignmentDraft) && !(await submitAssignment())) return false;
return true;
}, [
assignmentDraft,
functionDraft,
functionTypeDraft,
relationDraft,
relationTypeDraft,
structureDraft,
submitAssignment,
submitFunction,
submitFunctionType,
submitRelation,
submitRelationType,
submitStructure,
submitUnit,
submitUnitType,
unitDraft,
unitTypeDraft
]);
useUnsavedDraftGuard({
dirty: hasDirtyDraft,
onSave: saveDrafts,
onDiscard: discardDrafts,
title: "i18n:govoplan-core.unsaved_changes.29267269",
message: "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b"
});
const toggleUnitType = useCallback((item: OrganizationUnitTypeItem) => {
void runAction(() => patchUnitType(settings, item.id, { is_active: !item.is_active }));
}, [runAction, settings]);
const toggleStructure = useCallback((item: OrganizationStructureItem) => {
void runAction(() => patchStructure(settings, item.id, { is_active: !item.is_active }));
}, [runAction, settings]);
const toggleRelationType = useCallback((item: OrganizationRelationTypeItem) => {
void runAction(() => patchRelationType(settings, item.id, { is_active: !item.is_active }));
}, [runAction, settings]);
const toggleUnit = useCallback((item: OrganizationUnitItem) => {
void runAction(() => patchUnit(settings, item.id, { is_active: !item.is_active }));
}, [runAction, settings]);
const toggleRelation = useCallback((item: OrganizationRelationItem) => {
void runAction(() => patchRelation(settings, item.id, { is_active: !item.is_active }));
}, [runAction, settings]);
const toggleFunctionType = useCallback((item: OrganizationFunctionTypeItem) => {
void runAction(() => patchFunctionType(settings, item.id, { is_active: !item.is_active }));
}, [runAction, settings]);
const toggleFunction = useCallback((item: OrganizationFunctionItem) => {
void runAction(() => patchFunction(settings, item.id, { is_active: !item.is_active }));
}, [runAction, settings]);
const toggleAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => {
void runAction(() => patchFunctionAssignment(settings, item.id, { is_active: !item.is_active }));
}, [runAction, settings]);
const unitTypeColumns: DataGridColumn<OrganizationUnitTypeItem>[] = [
{ 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) => <ActiveToggleButton item={row} disabled={!canWriteModel || busy} onToggle={() => toggleUnitType(row)} /> }
];
const structureColumns: DataGridColumn<OrganizationStructureItem>[] = [
{ id: "name", header: "i18n:govoplan-organizations.name.709a2322", minWidth: 180, sortable: true, filterable: true, value: (row) => row.name },
{ 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) => <ActiveToggleButton item={row} disabled={!canWriteModel || busy} onToggle={() => toggleStructure(row)} /> }
];
const relationTypeColumns: DataGridColumn<OrganizationRelationTypeItem>[] = [
{ id: "name", header: "i18n:govoplan-organizations.name.709a2322", minWidth: 180, value: (row) => row.name, sortable: true, filterable: true },
{ id: "structure", header: "i18n:govoplan-organizations.structure.7732fb0b", minWidth: 170, render: (row) => optionalLabel(structureById.get(row.structure_id || "")?.name) },
{ id: "source", header: "i18n:govoplan-organizations.source_unit.2fbd8baa", minWidth: 170, render: (row) => optionalLabel(unitTypeById.get(row.source_unit_type_id || "")?.name) },
{ 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) => <ActiveToggleButton item={row} disabled={!canWriteModel || busy} onToggle={() => toggleRelationType(row)} /> }
];
const unitColumns: DataGridColumn<OrganizationUnitItem>[] = [
{ id: "name", header: "i18n:govoplan-organizations.name.709a2322", minWidth: 190, sortable: true, filterable: true, value: (row) => row.name },
{ id: "type", header: "i18n:govoplan-organizations.type.599dcce2", minWidth: 160, render: (row) => optionalLabel(unitTypeById.get(row.unit_type_id || "")?.name) },
{ 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) => <ActiveToggleButton item={row} disabled={!canWriteUnits || busy} onToggle={() => toggleUnit(row)} /> }
];
const relationColumns: DataGridColumn<OrganizationRelationItem>[] = [
{ id: "structure", header: "i18n:govoplan-organizations.structure.7732fb0b", minWidth: 170, render: (row) => optionalLabel(structureById.get(row.structure_id)?.name) },
{ id: "type", header: "i18n:govoplan-organizations.relation_type.d0aee2e7", minWidth: 170, render: (row) => optionalLabel(relationTypeById.get(row.relation_type_id)?.name) },
{ 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) => <ActiveToggleButton item={row} disabled={!canWriteUnits || busy} onToggle={() => toggleRelation(row)} /> }
];
const functionTypeColumns: DataGridColumn<OrganizationFunctionTypeItem>[] = [
{ id: "name", header: "i18n:govoplan-organizations.name.709a2322", minWidth: 180, sortable: true, filterable: true, value: (row) => row.name },
{ id: "unitType", header: "i18n:govoplan-organizations.unit_type.9e62810d", minWidth: 180, render: (row) => optionalLabel(unitTypeById.get(row.organization_unit_type_id || "")?.name) },
{ 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) => <ActiveToggleButton item={row} disabled={!canWriteModel || busy} onToggle={() => toggleFunctionType(row)} /> }
];
const functionColumns: DataGridColumn<OrganizationFunctionItem>[] = [
{ id: "name", header: "i18n:govoplan-organizations.name.709a2322", minWidth: 180, sortable: true, filterable: true, value: (row) => row.name },
{ id: "unit", header: "i18n:govoplan-organizations.unit.8fe4d595", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.organization_unit_id)?.name) },
{ 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) => <ActiveToggleButton item={row} disabled={!canWriteFunctions || busy} onToggle={() => toggleFunction(row)} /> }
];
const assignmentColumns: DataGridColumn<OrganizationFunctionAssignmentItem>[] = [
{ id: "identity", header: "i18n:govoplan-organizations.identity_id.b6ef5010", minWidth: 220, render: (row) => <span className="organizations-id">{row.identity_id}</span> },
{ id: "function", header: "i18n:govoplan-organizations.function.28822f3a", minWidth: 190, render: (row) => optionalLabel(functionById.get(row.function_id)?.name) },
{ id: "unit", header: "i18n:govoplan-organizations.unit.8fe4d595", minWidth: 180, render: (row) => optionalLabel(unitById.get(row.organization_unit_id)?.name) },
{ 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) => <ActiveToggleButton item={row} disabled={!canAssignFunctions || busy} onToggle={() => toggleAssignment(row)} /> }
];
const canWriteActive = active === "model" ? canWriteModel : active === "units" || active === "relations" ? canWriteUnits : active === "functions" ? canWriteFunctions : canAssignFunctions;
const content = (
<div className={`${mode === "workspace" ? "content-pad " : ""}organizations-page ${mode === "admin" ? "organizations-admin-page" : ""}`.trim()}>
<div className="page-heading split organizations-heading">
<div>
<PageTitle loading={loading}>{title}</PageTitle>
<p>{description}</p>
</div>
<div className="organizations-toolbar">
<Button type="button" onClick={() => void loadModel()} disabled={loading || busy} title="i18n:govoplan-organizations.reload.cce71553">
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-organizations.reload.cce71553
</Button>
{hasDirtyDraft && (
<>
<Button type="button" onClick={() => void saveDrafts()} disabled={busy} title="i18n:govoplan-organizations.save_drafts.606f9401">
<Save size={16} aria-hidden="true" /> i18n:govoplan-organizations.save_drafts.606f9401
</Button>
<Button type="button" variant="ghost" onClick={discardDrafts} disabled={busy} title="i18n:govoplan-organizations.discard_organization_drafts.766d5f46">
<XCircle size={16} aria-hidden="true" /> i18n:govoplan-organizations.discard_organization_drafts.766d5f46
</Button>
</>
)}
</div>
</div>
{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>}
<LoadingFrame loading={loading || busy} label="i18n:govoplan-organizations.loading_organization_model.846aa317">
{active === "model" && renderModelSection()}
{active === "units" && renderUnitsSection()}
{active === "relations" && renderRelationsSection()}
{active === "functions" && renderFunctionsSection()}
{active === "assignments" && renderAssignmentsSection()}
</LoadingFrame>
</div>
);
if (mode === "admin") return content;
return (
<div className="workspace organizations-workspace">
<ModuleSubnav active={active} groups={visibleSectionGroups} onSelect={setActive} />
<main className="workspace-content">
{content}
</main>
</div>
);
function renderModelSection() {
return (
<div className="organizations-table-stack">
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.unit_types.c7afc174">
<form className="organizations-form-grid" onSubmit={(event) => void submitUnitType(event)}>
<SluggedFields draft={unitTypeDraft} onChange={setUnitTypeDraft} disabled={!canWriteModel || busy} />
<Button className="wide" type="submit" variant="primary" disabled={!canWriteModel || busy}>i18n:govoplan-organizations.add_unit_type.58f2c05a</Button>
</form>
</Card>
<Card title="i18n:govoplan-organizations.unit_types.c7afc174">
<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>
</div>
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.structures.f9b7f3b4">
<form className="organizations-form-grid" onSubmit={(event) => void submitStructure(event)}>
<SluggedFields draft={structureDraft} onChange={setStructureDraft} disabled={!canWriteModel || busy} />
<FormField label="i18n:govoplan-organizations.kind.794c9d9c">
<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>
</FormField>
<Button className="wide" type="submit" variant="primary" disabled={!canWriteModel || busy}>i18n:govoplan-organizations.add_structure.b722042a</Button>
</form>
</Card>
<Card title="i18n:govoplan-organizations.structures.f9b7f3b4">
<DataGrid id="organizations-structures" rows={model.structures} columns={structureColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_structures_found.20b382d0" initialFit="container" />
</Card>
</div>
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.relation_types.e5890528">
<form className="organizations-form-grid" onSubmit={(event) => void submitRelationType(event)}>
<SluggedFields draft={relationTypeDraft} onChange={setRelationTypeDraft} disabled={!canWriteModel || busy} />
<FormField label="i18n:govoplan-organizations.structure.7732fb0b">
<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">
<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">
<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">
<label><input type="checkbox" checked={relationTypeDraft.is_hierarchical} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, is_hierarchical: event.target.checked })} /> i18n:govoplan-organizations.hierarchical.8964f313</label>
<label><input type="checkbox" checked={relationTypeDraft.allow_cycles} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, allow_cycles: event.target.checked })} /> i18n:govoplan-organizations.allow_cycles.31327578</label>
</div>
<Button className="wide" type="submit" variant="primary" disabled={!canWriteModel || busy}>i18n:govoplan-organizations.add_relation_type.2ad19d03</Button>
</form>
</Card>
<Card title="i18n:govoplan-organizations.relation_types.e5890528">
<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>
</div>
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.function_types.172c01fe">
<form className="organizations-form-grid" onSubmit={(event) => void submitFunctionType(event)}>
<SluggedFields draft={functionTypeDraft} onChange={setFunctionTypeDraft} disabled={!canWriteModel || busy} />
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d">
<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">
<label><input type="checkbox" checked={functionTypeDraft.delegable} disabled={!canWriteModel || busy} onChange={(event) => setFunctionTypeDraft({ ...functionTypeDraft, delegable: event.target.checked })} /> i18n:govoplan-organizations.delegable.b4f0137d</label>
<label><input type="checkbox" checked={functionTypeDraft.act_in_place_allowed} disabled={!canWriteModel || busy} onChange={(event) => setFunctionTypeDraft({ ...functionTypeDraft, act_in_place_allowed: event.target.checked })} /> i18n:govoplan-organizations.act_in_place.49b942bd</label>
</div>
<Button className="wide" type="submit" variant="primary" disabled={!canWriteModel || busy}>i18n:govoplan-organizations.add_function_type.90d793f1</Button>
</form>
</Card>
<Card title="i18n:govoplan-organizations.function_types.172c01fe">
<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>
</div>
);
}
function renderUnitsSection() {
return (
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.add_unit.8fa12fb1">
<form className="organizations-form-grid" onSubmit={(event) => void submitUnit(event)}>
<SluggedFields draft={unitDraft} onChange={setUnitDraft} disabled={!canWriteUnits || busy} />
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d">
<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">
<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.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<Button className="wide" type="submit" variant="primary" disabled={!canWriteUnits || busy}>i18n:govoplan-organizations.add_unit.8fa12fb1</Button>
</form>
</Card>
<Card title="i18n:govoplan-organizations.units.e14d0d92">
<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>
);
}
function renderRelationsSection() {
return (
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.add_relation.6b6e67ea">
<form className="organizations-form-grid" onSubmit={(event) => void submitRelation(event)}>
<FormField label="i18n:govoplan-organizations.structure.7732fb0b">
<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">
<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">
<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">
<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>)}
</select>
</FormField>
<label className="organizations-inline-check wide">
<input type="checkbox" checked={relationDraft.is_active} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, is_active: event.target.checked })} />
<span>i18n:govoplan-organizations.active.a733b809</span>
</label>
<Button className="wide" type="submit" variant="primary" disabled={!canWriteUnits || busy}>i18n:govoplan-organizations.add_relation.6b6e67ea</Button>
</form>
</Card>
<Card title="i18n:govoplan-organizations.relations.1c796711">
<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>
);
}
function renderFunctionsSection() {
return (
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.add_function.6abafee0">
<form className="organizations-form-grid" onSubmit={(event) => void submitFunction(event)}>
<SluggedFields draft={functionDraft} onChange={setFunctionDraft} disabled={!canWriteFunctions || busy} />
<FormField label="i18n:govoplan-organizations.unit.8fe4d595">
<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">
<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">
<label><input type="checkbox" checked={functionDraft.delegable} disabled={!canWriteFunctions || busy} onChange={(event) => setFunctionDraft({ ...functionDraft, delegable: event.target.checked })} /> i18n:govoplan-organizations.delegable.b4f0137d</label>
<label><input type="checkbox" checked={functionDraft.act_in_place_allowed} disabled={!canWriteFunctions || busy} onChange={(event) => setFunctionDraft({ ...functionDraft, act_in_place_allowed: event.target.checked })} /> i18n:govoplan-organizations.act_in_place.49b942bd</label>
</div>
<Button className="wide" type="submit" variant="primary" disabled={!canWriteFunctions || busy}>i18n:govoplan-organizations.add_function.6abafee0</Button>
</form>
</Card>
<Card title="i18n:govoplan-organizations.functions.805dc49b">
<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>
);
}
function renderAssignmentsSection() {
return (
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.add_assignment.6682f5f5">
<form className="organizations-form-grid" onSubmit={(event) => void submitAssignment(event)}>
<FormField label="i18n:govoplan-organizations.identity_id.b6ef5010">
<input value={assignmentDraft.identity_id} placeholder="i18n:govoplan-organizations.identity_id_placeholder.298cbd85" disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, identity_id: event.target.value })} />
</FormField>
<FormField label="i18n:govoplan-organizations.optional_account_id.5fcf495c">
<input value={assignmentDraft.account_id} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })} />
</FormField>
<FormField label="i18n:govoplan-organizations.function.28822f3a">
<select value={assignmentDraft.function_id} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, function_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.select_function.4895a67d</option>
{model.functions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.source.6da13add">
<input value={assignmentDraft.source} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, source: event.target.value })} />
</FormField>
<div className="organizations-check-list">
<label><input type="checkbox" checked={assignmentDraft.applies_to_subunits} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, applies_to_subunits: event.target.checked })} /> i18n:govoplan-organizations.applies_to_subunits.7a15f741</label>
<label><input type="checkbox" checked={assignmentDraft.is_active} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, is_active: event.target.checked })} /> i18n:govoplan-organizations.active.a733b809</label>
</div>
<Button className="wide" type="submit" variant="primary" disabled={!canAssignFunctions || busy}>i18n:govoplan-organizations.add_assignment.6682f5f5</Button>
</form>
</Card>
<Card title="i18n:govoplan-organizations.function_assignments.4c5c6dae">
<DataGrid id="organizations-assignments" rows={model.function_assignments} columns={assignmentColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_assignments_found.7ecc5bb7" initialFit="container" />
</Card>
</div>
);
}
}

View File

@@ -0,0 +1,184 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
en: {
"i18n:govoplan-organizations.act_in_place.49b942bd": "Act in place",
"i18n:govoplan-organizations.active.a733b809": "Active",
"i18n:govoplan-organizations.add_assignment.6682f5f5": "Add assignment",
"i18n:govoplan-organizations.add_function.6abafee0": "Add function",
"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_structure.b722042a": "Add structure",
"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.assignments.278f513e": "Assignments",
"i18n:govoplan-organizations.classification.3e9f6c3a": "Classification",
"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.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_types.172c01fe": "Function types",
"i18n:govoplan-organizations.functions.805dc49b": "Functions",
"i18n:govoplan-organizations.hierarchical.8964f313": "Hierarchical",
"i18n:govoplan-organizations.hierarchy.74797f37": "Hierarchy",
"i18n:govoplan-organizations.identity_id.b6ef5010": "Identity ID",
"i18n:govoplan-organizations.identity_id_placeholder.298cbd85": "identity UUID",
"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.membership.4531d86d": "Membership",
"i18n:govoplan-organizations.meta_model.7398487c": "Meta-model",
"i18n:govoplan-organizations.model.20f35e63": "Model",
"i18n:govoplan-organizations.name.709a2322": "Name",
"i18n:govoplan-organizations.name_is_required.27cd3782": "Name is required.",
"i18n:govoplan-organizations.network.a24d31c3": "Network",
"i18n:govoplan-organizations.no_assignments_found.7ecc5bb7": "No function assignments found.",
"i18n:govoplan-organizations.no_function_types_found.5eef31b1": "No function types found.",
"i18n:govoplan-organizations.no_functions_found.54e759a0": "No functions found.",
"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_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.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_type.d0aee2e7": "Relation type",
"i18n:govoplan-organizations.relation_type_added.6f1edff1": "Relation type added.",
"i18n:govoplan-organizations.relation_types.e5890528": "Relation types",
"i18n:govoplan-organizations.relations.1c796711": "Relations",
"i18n:govoplan-organizations.reload.cce71553": "Reload",
"i18n:govoplan-organizations.save_drafts.606f9401": "Save drafts",
"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",
"i18n:govoplan-organizations.select_source_unit.9b5a29c8": "Select source unit",
"i18n:govoplan-organizations.select_structure.c10f551c": "Select structure",
"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.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.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_type.9e62810d": "Unit type",
"i18n:govoplan-organizations.unit_type_added.e017dc8c": "Unit type added.",
"i18n:govoplan-organizations.unit_types.c7afc174": "Unit types",
"i18n:govoplan-organizations.units.e14d0d92": "Units",
"i18n:govoplan-organizations.write_permission_required.8b09fd67": "You do not have write permission for this organization section."
},
de: {
"i18n:govoplan-organizations.act_in_place.49b942bd": "In Vertretung handeln",
"i18n:govoplan-organizations.active.a733b809": "Aktiv",
"i18n:govoplan-organizations.add_assignment.6682f5f5": "Zuordnung hinzufügen",
"i18n:govoplan-organizations.add_function.6abafee0": "Funktion hinzufügen",
"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_structure.b722042a": "Struktur 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.assignments.278f513e": "Zuordnungen",
"i18n:govoplan-organizations.classification.3e9f6c3a": "Klassifikation",
"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.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_types.172c01fe": "Funktionstypen",
"i18n:govoplan-organizations.functions.805dc49b": "Funktionen",
"i18n:govoplan-organizations.hierarchical.8964f313": "Hierarchisch",
"i18n:govoplan-organizations.hierarchy.74797f37": "Hierarchie",
"i18n:govoplan-organizations.identity_id.b6ef5010": "Identitäts-ID",
"i18n:govoplan-organizations.identity_id_placeholder.298cbd85": "Identitäts-UUID",
"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.membership.4531d86d": "Mitgliedschaft",
"i18n:govoplan-organizations.meta_model.7398487c": "Metamodell",
"i18n:govoplan-organizations.model.20f35e63": "Modell",
"i18n:govoplan-organizations.name.709a2322": "Name",
"i18n:govoplan-organizations.name_is_required.27cd3782": "Name ist erforderlich.",
"i18n:govoplan-organizations.network.a24d31c3": "Netzwerk",
"i18n:govoplan-organizations.no_assignments_found.7ecc5bb7": "Keine Funktionszuordnungen gefunden.",
"i18n:govoplan-organizations.no_function_types_found.5eef31b1": "Keine Funktionstypen gefunden.",
"i18n:govoplan-organizations.no_functions_found.54e759a0": "Keine Funktionen gefunden.",
"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_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.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_type.d0aee2e7": "Beziehungstyp",
"i18n:govoplan-organizations.relation_type_added.6f1edff1": "Beziehungstyp hinzugefügt.",
"i18n:govoplan-organizations.relation_types.e5890528": "Beziehungstypen",
"i18n:govoplan-organizations.relations.1c796711": "Beziehungen",
"i18n:govoplan-organizations.reload.cce71553": "Neu laden",
"i18n:govoplan-organizations.save_drafts.606f9401": "Entwürfe 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",
"i18n:govoplan-organizations.select_source_unit.9b5a29c8": "Quell-Einheit auswählen",
"i18n:govoplan-organizations.select_structure.c10f551c": "Struktur auswählen",
"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.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.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_type.9e62810d": "Einheitstyp",
"i18n:govoplan-organizations.unit_type_added.e017dc8c": "Einheitstyp hinzugefügt.",
"i18n:govoplan-organizations.unit_types.c7afc174": "Einheitstypen",
"i18n:govoplan-organizations.units.e14d0d92": "Einheiten",
"i18n:govoplan-organizations.write_permission_required.8b09fd67": "Du hast keine Schreibberechtigung für diesen Organisationsbereich."
}
};

5
webui/src/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { default } from "./module";
export * from "./module";
export { default as OrganizationsPage } from "./features/organizations/OrganizationsPage";
export * from "./api/organizations";
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";

63
webui/src/module.ts Normal file
View File

@@ -0,0 +1,63 @@
import { createElement, lazy } from "react";
import type { AdminSectionsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/organizations.css";
const OrganizationsPage = lazy(() => import("./features/organizations/OrganizationsPage"));
const OrganizationsAdminPanel = lazy(() => import("./features/organizations/OrganizationsAdminPanel"));
const organizationReadScopes = [
"organizations:model:read",
"organizations:unit:read",
"organizations:function:read",
"admin:settings:read"
];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
const organizationAdminSections: AdminSectionsUiCapability = {
sections: [
{
id: "tenant-organization-model",
label: "i18n:govoplan-organizations.organization_model.4f924c0e",
group: "TENANT",
order: 85,
anyOf: ["organizations:model:read", "admin:settings:read"],
render: ({ settings, auth }) => createElement(OrganizationsAdminPanel, { settings, auth })
}
]
};
export const organizationsModule: PlatformWebModule = {
id: "organizations",
label: "i18n:govoplan-organizations.organizations.220edf64",
version: "1.0.0",
dependencies: ["access"],
optionalDependencies: ["identity", "admin"],
translations,
navItems: [
{
to: "/organizations",
label: "i18n:govoplan-organizations.organizations.220edf64",
iconName: "users",
anyOf: organizationReadScopes,
order: 70
}
],
routes: [
{
path: "/organizations",
anyOf: organizationReadScopes,
order: 70,
render: ({ settings, auth }) => createElement(OrganizationsPage, { settings, auth })
}
],
uiCapabilities: {
"admin.sections": organizationAdminSections
}
};
export default organizationsModule;

View File

@@ -0,0 +1,106 @@
.organizations-workspace {
grid-template-columns: 230px minmax(0, 1fr);
background: var(--bg, #f8f7f4);
}
.organizations-page {
display: grid;
gap: 18px;
width: 100%;
max-width: 1480px;
}
.organizations-admin-page {
max-width: 100%;
}
.organizations-heading {
margin-bottom: 4px;
}
.organizations-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.organizations-section-grid {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.organizations-form-panel,
.organizations-table-panel {
min-width: 0;
}
.organizations-form-panel .card-body,
.organizations-table-panel .card-body {
min-width: 0;
}
.organizations-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.organizations-form-grid .wide {
grid-column: 1 / -1;
}
.organizations-check-list {
display: grid;
gap: 10px;
grid-column: 1 / -1;
}
.organizations-check-list label,
.organizations-inline-check {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text);
font-weight: 600;
}
.organizations-table-stack {
display: grid;
gap: 18px;
}
.organizations-field-note {
margin: 8px 0 0;
color: var(--muted);
font-size: 12px;
}
.organizations-id {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: var(--muted);
}
.organizations-empty {
color: var(--muted);
}
@media (max-width: 1100px) {
.organizations-section-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.organizations-workspace {
grid-template-columns: 1fr;
}
.organizations-form-grid {
grid-template-columns: 1fr;
}
}