Add organizations module surface
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
## Scope
|
||||
|
||||
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.
|
||||
|
||||
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 `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
|
||||
typed DTOs.
|
||||
- Treat function assignment as organizational responsibility, not as a login
|
||||
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
|
||||
|
||||
|
||||
22
README.md
22
README.md
@@ -3,29 +3,35 @@
|
||||
`govoplan-organizations` is the canonical organizational model module for
|
||||
GovOPlaN.
|
||||
|
||||
It owns organization units, organizational hierarchy, functions, and
|
||||
account-held function assignments. These facts exist independently of
|
||||
authentication and authorization. IDM can import them, Identity can link them
|
||||
to people/accounts, and Access can project them into roles and rights.
|
||||
It owns organization unit types, organization structures, concrete units,
|
||||
functions, and identity-held function assignments. These facts exist
|
||||
independently of authentication and authorization. Identity links people to
|
||||
accounts, and Access projects organization facts into roles and rights.
|
||||
|
||||
## Boundary
|
||||
|
||||
Organizations owns:
|
||||
|
||||
- tenant-local organization units
|
||||
- parent/child organizational hierarchy
|
||||
- tenant-local organization unit types
|
||||
- tenant-local concrete organization units
|
||||
- parallel organization structures and relation types
|
||||
- functions in organization units
|
||||
- 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
|
||||
|
||||
Organizations does not own:
|
||||
|
||||
- login accounts or identity lifecycle
|
||||
- login accounts, identity lifecycle, or account linking
|
||||
- role and permission evaluation
|
||||
- postboxes, workflows, portals, files, or cases
|
||||
- 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
|
||||
|
||||
The module registers the `organizations.directory` capability from
|
||||
|
||||
@@ -5,19 +5,28 @@ The organization model answers where responsibility lives.
|
||||
## Layers
|
||||
|
||||
- Tenant: the administrative space.
|
||||
- Organization unit: a department, office, committee, service desk, school,
|
||||
institute, or other structural unit.
|
||||
- Organization unit type: an abstract type such as faculty, institute,
|
||||
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,
|
||||
reviewer, approver, committee secretary, intake desk, or resource manager.
|
||||
- Function assignment: an account holds a function in an organization unit,
|
||||
optionally for that unit and all subunits.
|
||||
- Function assignment: an identity holds a function in an organization unit,
|
||||
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
|
||||
|
||||
Organization assignments refer to account and identity IDs, but this module
|
||||
does not own login accounts or identity lifecycle. `govoplan-identity` owns the
|
||||
identity/account directory. The organization module only records that an
|
||||
account or identity currently holds an organizational function.
|
||||
Organization assignments refer to identity IDs and may optionally carry account
|
||||
IDs for account-specific exercise of a function. This module does not own login
|
||||
accounts, identity lifecycle, or account linking. `govoplan-identity` owns the
|
||||
identity/account directory. The organization module records that an identity
|
||||
currently holds an organizational function.
|
||||
|
||||
## Boundary With Access
|
||||
|
||||
@@ -28,6 +37,13 @@ enforcement, and explain responses.
|
||||
This separation keeps the institution model stable even when authorization
|
||||
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
|
||||
|
||||
`govoplan-idm` may import organization units, functions, and assignments from
|
||||
|
||||
36
package.json
Normal file
36
package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/govoplan_organizations/backend/api/__init__.py
Normal file
1
src/govoplan_organizations/backend/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Organizations API package."""
|
||||
1
src/govoplan_organizations/backend/api/v1/__init__.py
Normal file
1
src/govoplan_organizations/backend/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Organizations API v1 package."""
|
||||
720
src/govoplan_organizations/backend/api/v1/routes.py
Normal file
720
src/govoplan_organizations/backend/api/v1/routes.py
Normal 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
|
||||
275
src/govoplan_organizations/backend/api/v1/schemas.py
Normal file
275
src/govoplan_organizations/backend/api/v1/schemas.py
Normal 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
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
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 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"),)
|
||||
|
||||
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)
|
||||
slug: Mapped[str] = mapped_column(String(100), 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)
|
||||
|
||||
|
||||
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):
|
||||
__tablename__ = "organizations_functions"
|
||||
__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)
|
||||
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)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
@@ -49,17 +142,17 @@ class OrganizationFunctionAssignment(Base, TimestampMixin):
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"account_id",
|
||||
"identity_id",
|
||||
"function_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)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
identity_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
identity_id: Mapped[str] = mapped_column(String(36), nullable=False, 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)
|
||||
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)
|
||||
@@ -75,6 +168,11 @@ class OrganizationFunctionAssignment(Base, TimestampMixin):
|
||||
__all__ = [
|
||||
"OrganizationFunction",
|
||||
"OrganizationFunctionAssignment",
|
||||
"OrganizationFunctionType",
|
||||
"OrganizationRelation",
|
||||
"OrganizationRelationType",
|
||||
"OrganizationStructure",
|
||||
"OrganizationUnit",
|
||||
"OrganizationUnitType",
|
||||
"new_uuid",
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ from govoplan_core.core.organizations import (
|
||||
OrganizationFunctionRef,
|
||||
OrganizationUnitRef,
|
||||
)
|
||||
from govoplan_core.core.identity import IdentityDirectory
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
@@ -27,6 +28,7 @@ def _unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
|
||||
tenant_id=item.tenant_id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
unit_type_id=item.unit_type_id,
|
||||
parent_id=item.parent_id,
|
||||
description=item.description,
|
||||
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,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
function_type_id=item.function_type_id,
|
||||
description=item.description,
|
||||
delegable=item.delegable,
|
||||
act_in_place_allowed=item.act_in_place_allowed,
|
||||
@@ -51,8 +54,8 @@ def _assignment_ref(item: OrganizationFunctionAssignment) -> OrganizationFunctio
|
||||
return OrganizationFunctionAssignmentRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
account_id=item.account_id,
|
||||
identity_id=item.identity_id,
|
||||
account_id=item.account_id,
|
||||
function_id=item.function_id,
|
||||
organization_unit_id=item.organization_unit_id,
|
||||
applies_to_subunits=item.applies_to_subunits,
|
||||
@@ -66,6 +69,9 @@ def _assignment_ref(item: OrganizationFunctionAssignment) -> OrganizationFunctio
|
||||
|
||||
|
||||
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:
|
||||
with get_database().session() as session:
|
||||
item = session.get(OrganizationUnit, organization_unit_id)
|
||||
@@ -126,14 +132,52 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
||||
account_id: str,
|
||||
*,
|
||||
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, ...]:
|
||||
now = utc_now()
|
||||
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 = (
|
||||
session.query(OrganizationFunctionAssignment)
|
||||
.join(OrganizationFunction, OrganizationFunction.id == OrganizationFunctionAssignment.function_id)
|
||||
.filter(
|
||||
OrganizationFunctionAssignment.account_id == account_id,
|
||||
identity_or_account_filter,
|
||||
OrganizationFunctionAssignment.is_active.is_(True),
|
||||
OrganizationFunction.is_active.is_(True),
|
||||
or_(OrganizationFunctionAssignment.valid_from.is_(None), OrganizationFunctionAssignment.valid_from <= now),
|
||||
|
||||
@@ -1,28 +1,122 @@
|
||||
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.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.db.base import Base
|
||||
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
|
||||
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
|
||||
|
||||
return SqlOrganizationDirectory()
|
||||
return SqlOrganizationDirectory(identity_directory=identity_directory)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="organizations",
|
||||
name="Organizations",
|
||||
version="0.1.6",
|
||||
dependencies=("tenancy",),
|
||||
migration_spec=MigrationSpec(module_id="organizations", metadata=Base.metadata),
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
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=(
|
||||
persistent_table_uninstall_guard(
|
||||
organization_models.OrganizationUnitType,
|
||||
organization_models.OrganizationStructure,
|
||||
organization_models.OrganizationRelationType,
|
||||
organization_models.OrganizationRelation,
|
||||
organization_models.OrganizationUnit,
|
||||
organization_models.OrganizationFunctionType,
|
||||
organization_models.OrganizationFunction,
|
||||
organization_models.OrganizationFunctionAssignment,
|
||||
label="Organizations",
|
||||
@@ -37,8 +131,9 @@ manifest = ModuleManifest(
|
||||
title="Organization model",
|
||||
summary="Organizations owns units, hierarchy, functions, and function assignments. Access maps those facts to roles and rights.",
|
||||
body=(
|
||||
"Use organization functions to model responsibilities that outlive people. "
|
||||
"A function assignment can apply to one organization unit or to that unit and all subunits."
|
||||
"Use organization unit types, structures, and relation types to model how the institution describes itself. "
|
||||
"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",
|
||||
documentation_types=("admin", "user"),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Organizations module migrations."""
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Organizations Alembic revisions."""
|
||||
31
webui/package.json
Normal file
31
webui/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
270
webui/src/api/organizations.ts
Normal file
270
webui/src/api/organizations.ts
Normal 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);
|
||||
}
|
||||
18
webui/src/features/organizations/OrganizationsAdminPanel.tsx
Normal file
18
webui/src/features/organizations/OrganizationsAdminPanel.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
974
webui/src/features/organizations/OrganizationsPage.tsx
Normal file
974
webui/src/features/organizations/OrganizationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
184
webui/src/i18n/generatedTranslations.ts
Normal file
184
webui/src/i18n/generatedTranslations.ts
Normal 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
5
webui/src/index.ts
Normal 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
63
webui/src/module.ts
Normal 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;
|
||||
106
webui/src/styles/organizations.css
Normal file
106
webui/src/styles/organizations.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user