Sync GovOPlaN module state

This commit is contained in:
2026-07-10 21:57:27 +02:00
parent 669647a986
commit 2be346f76a
15 changed files with 485 additions and 722 deletions

267
.gitignore vendored
View File

@@ -6,3 +6,270 @@ __pycache__/
.ruff_cache/ .ruff_cache/
build/ build/
dist/ dist/
# GovOPlaN shared ignore rules from govoplan-core
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
# ---> Python
# Byte-compiled / optimized / DLL files
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
develop-eggs/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
# PyPI configuration file
.pypirc
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
*.db
# GovOPlaN local runtime state
runtime/
# GovOPlaN WebUI test output
webui/.module-test-build/
webui/.component-test-build/

View File

@@ -3,10 +3,10 @@
`govoplan-organizations` is the canonical organizational model module for `govoplan-organizations` is the canonical organizational model module for
GovOPlaN. GovOPlaN.
It owns organization unit types, organization structures, concrete units, It owns organization unit types, organization structures, concrete units, and
functions, and identity-held function assignments. These facts exist functions. These facts exist independently of authentication and authorization.
independently of authentication and authorization. Identity links people to Identity links people to accounts, IDM links identities to organization
accounts, and Access projects organization facts into roles and rights. functions, and Access projects accepted facts into roles and rights.
## Boundary ## Boundary
@@ -17,19 +17,18 @@ Organizations owns:
- parallel organization structures and relation types - parallel organization structures and relation types
- functions in organization units - functions in organization units
- whether a function may be delegated or used for acting in place - whether a function may be delegated or used for acting in place
- 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: Organizations does not own:
- login accounts, identity lifecycle, or account linking - login accounts, identity lifecycle, or account linking
- identity-to-function assignments or candidate search; those bridges are owned by `govoplan-idm`
- role and permission evaluation - role and permission evaluation
- postboxes, workflows, portals, files, or cases - postboxes, workflows, portals, files, or cases
- external IDM connector internals - external IDM connector internals
The WebUI exposed by this repository is a normal module UI at The WebUI exposed by this repository is a normal module UI at
`/organizations`. It is the editing surface for the meta-model, concrete `/organizations`. It is the editing surface for the meta-model, concrete
organization tree, structures, functions, and assignments. organization tree, structures, and functions.
Admin-only controls are contributed by this module through the platform Admin-only controls are contributed by this module through the platform
`admin.sections` WebUI capability, so the admin shell can show them when the `admin.sections` WebUI capability, so the admin shell can show them when the

View File

@@ -15,24 +15,23 @@ The organization model answers where responsibility lives.
school, institute, or other unit. school, institute, or other unit.
- Function: a named responsibility in an organization unit, such as clerk, - Function: a named responsibility in an organization unit, such as clerk,
reviewer, approver, committee secretary, intake desk, or resource manager. reviewer, approver, committee secretary, intake desk, or resource manager.
- Function assignment: an 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 ## Boundary With Identity And IDM
Organization assignments refer to identity IDs and may optionally carry account This module does not own login accounts, identity lifecycle, account linking,
IDs for account-specific exercise of a function. This module does not own login or identity-to-function assignments. `govoplan-identity` owns the
accounts, identity lifecycle, or account linking. `govoplan-identity` owns the identity/account directory. `govoplan-idm` records that an identity currently
identity/account directory. The organization module records that an identity holds an organizational function and may constrain that link to one account.
currently holds an organizational function.
Resolving accounts to identities, choosing candidate identities for an
organization assignment, and importing those mappings from external directories
belongs to `govoplan-idm`, not to organizations.
## Boundary With Access ## Boundary With Access
A function assignment does not grant rights on its own. `govoplan-access` An IDM function assignment does not grant rights on its own. `govoplan-access`
maps organization facts to roles, rights, permission decisions, delegation maps organization and IDM facts to roles, rights, permission decisions,
enforcement, and explain responses. delegation enforcement, and explain responses.
This separation keeps the institution model stable even when authorization This separation keeps the institution model stable even when authorization
policy changes. policy changes.
@@ -40,10 +39,10 @@ policy changes.
## UI Boundary ## UI Boundary
The organization workspace at `/organizations` is the primary module UI for The organization workspace at `/organizations` is the primary module UI for
modelling the meta-model, concrete units, structures, functions, and modelling the meta-model, concrete units, structures, and function definitions.
assignments. Admin-only organization controls are still owned by this module, Admin-only organization controls are still owned by this
but are contributed to the platform admin shell through the `admin.sections` module, but are contributed to the platform admin shell through the
WebUI capability. `admin.sections` WebUI capability.
The admin contribution must not duplicate the organization editor. It is for The admin contribution must not duplicate the organization editor. It is for
governance and policy settings: whether tenant admins may customize the governance and policy settings: whether tenant admins may customize the
@@ -52,7 +51,9 @@ audit detail and retention behave for organization changes.
## Boundary With IDM ## Boundary With IDM
`govoplan-idm` may import organization units, functions, and assignments from `govoplan-idm` may import organization-unit and organization-function
external directories. It should write through organization capabilities or APIs definitions from external directories. It should write those definitions
after preview, approval, and rollback planning. IDM is a synchronization layer, through organization capabilities or APIs after preview, approval, and rollback
not the canonical owner of the organization model. planning. IDM owns identity-to-function assignments and bridge views that
combine identity and organization facts, such as identity candidates for
function assignments.

View File

@@ -17,7 +17,6 @@ from govoplan_core.core.configuration_control import (
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_organizations.backend.db.models import ( from govoplan_organizations.backend.db.models import (
OrganizationFunction, OrganizationFunction,
OrganizationFunctionAssignment,
OrganizationFunctionType, OrganizationFunctionType,
OrganizationRelation, OrganizationRelation,
OrganizationRelationType, OrganizationRelationType,
@@ -28,13 +27,10 @@ from govoplan_organizations.backend.db.models import (
) )
from .schemas import ( from .schemas import (
FunctionAssignmentCreateRequest,
FunctionAssignmentUpdateRequest,
FunctionCreateRequest, FunctionCreateRequest,
FunctionTypeCreateRequest, FunctionTypeCreateRequest,
FunctionTypeUpdateRequest, FunctionTypeUpdateRequest,
FunctionUpdateRequest, FunctionUpdateRequest,
OrganizationFunctionAssignmentItem,
OrganizationFunctionItem, OrganizationFunctionItem,
OrganizationFunctionTypeItem, OrganizationFunctionTypeItem,
OrganizationModelResponse, OrganizationModelResponse,
@@ -69,7 +65,6 @@ ORG_READ_SCOPES = (
ORG_MODEL_WRITE_SCOPES = ("organizations:model:write",) ORG_MODEL_WRITE_SCOPES = ("organizations:model:write",)
ORG_UNIT_WRITE_SCOPES = ("organizations:unit:write",) ORG_UNIT_WRITE_SCOPES = ("organizations:unit:write",)
ORG_FUNCTION_WRITE_SCOPES = ("organizations:function:write",) ORG_FUNCTION_WRITE_SCOPES = ("organizations:function:write",)
ORG_ASSIGN_SCOPES = ("organizations:function:assign",)
ORG_SETTINGS_READ_SCOPES = ("organizations:settings:read", "organizations:model:read", "admin:settings:read") ORG_SETTINGS_READ_SCOPES = ("organizations:settings:read", "organizations:model:read", "admin:settings:read")
ORG_SETTINGS_WRITE_SCOPES = ("organizations:settings:write", "admin:settings:write") ORG_SETTINGS_WRITE_SCOPES = ("organizations:settings:write", "admin:settings:write")
ORG_CHANGE_CONTROL_KEY = "organizations.model" ORG_CHANGE_CONTROL_KEY = "organizations.model"
@@ -262,10 +257,6 @@ def _item_function(item: OrganizationFunction) -> OrganizationFunctionItem:
return OrganizationFunctionItem(**_row_fields(item)) return OrganizationFunctionItem(**_row_fields(item))
def _item_assignment(item: OrganizationFunctionAssignment) -> OrganizationFunctionAssignmentItem:
return OrganizationFunctionAssignmentItem(**_row_fields(item))
def _row_fields(item: object) -> dict[str, Any]: def _row_fields(item: object) -> dict[str, Any]:
keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined] keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined]
return {key: getattr(item, key) for key in keys} return {key: getattr(item, key) for key in keys}
@@ -352,7 +343,6 @@ def get_organization_model(
relations=[_item_relation(item) for item in session.query(OrganizationRelation).filter(OrganizationRelation.tenant_id == tenant_id).order_by(OrganizationRelation.created_at.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()], 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()], 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()],
) )
@@ -738,89 +728,6 @@ def update_function(
return result return result
@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)
approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="function_assignment", operation="created", payload=payload)
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)
saved = _commit(session, item)
result = _item_assignment(saved)
_record_organization_change_applied(session, principal, approval=approval, target={**target, "resource_id": saved.id}, before=None, after=result.model_dump(mode="json"))
return result
@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")
before = _row_fields(item)
approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="function_assignment", operation="updated", payload=payload, resource_id=item_id)
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")
result = _item_assignment(_commit(session, item))
_record_organization_change_applied(session, principal, approval=approval, target=target, before=before, after=result.model_dump(mode="json"))
return result
def _optional_function_type(session: Session, tenant_id: str, function_type_id: str | None) -> OrganizationFunctionType | None: def _optional_function_type(session: Session, tenant_id: str, function_type_id: str | None) -> OrganizationFunctionType | None:
if function_type_id is None: if function_type_id is None:
return None return None

View File

@@ -132,25 +132,6 @@ class OrganizationFunctionItem(BaseModel):
updated_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): class OrganizationModelResponse(BaseModel):
unit_types: list[OrganizationUnitTypeItem] unit_types: list[OrganizationUnitTypeItem]
structures: list[OrganizationStructureItem] structures: list[OrganizationStructureItem]
@@ -159,7 +140,6 @@ class OrganizationModelResponse(BaseModel):
relations: list[OrganizationRelationItem] relations: list[OrganizationRelationItem]
function_types: list[OrganizationFunctionTypeItem] function_types: list[OrganizationFunctionTypeItem]
functions: list[OrganizationFunctionItem] functions: list[OrganizationFunctionItem]
function_assignments: list[OrganizationFunctionAssignmentItem]
class SluggedCreateRequest(BaseModel): class SluggedCreateRequest(BaseModel):
@@ -270,33 +250,3 @@ class FunctionUpdateRequest(SluggedUpdateRequest):
function_type_id: str | None = None function_type_id: str | None = None
delegable: bool | None = None delegable: bool | None = None
act_in_place_allowed: 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)
change_request_id: str | None = None
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
change_request_id: str | None = None

View File

@@ -150,37 +150,8 @@ class OrganizationFunction(Base, TimestampMixin):
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class OrganizationFunctionAssignment(Base, TimestampMixin):
__tablename__ = "organizations_function_assignments"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"identity_id",
"function_id",
"organization_unit_id",
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(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)
source: Mapped[str] = mapped_column(String(50), default="direct", nullable=False)
delegated_from_assignment_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_function_assignments.id", ondelete="SET NULL"), nullable=True, index=True)
acting_for_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, 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)
__all__ = [ __all__ = [
"OrganizationFunction", "OrganizationFunction",
"OrganizationFunctionAssignment",
"OrganizationFunctionType", "OrganizationFunctionType",
"OrganizationRelation", "OrganizationRelation",
"OrganizationRelationType", "OrganizationRelationType",

View File

@@ -1,19 +1,13 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy import or_
from govoplan_core.core.organizations import ( from govoplan_core.core.organizations import (
OrganizationDirectory, OrganizationDirectory,
OrganizationFunctionAssignmentRef,
OrganizationFunctionRef, OrganizationFunctionRef,
OrganizationUnitRef, OrganizationUnitRef,
) )
from govoplan_core.core.identity import IdentityDirectory
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
from govoplan_core.security.time import utc_now
from govoplan_organizations.backend.db.models import ( from govoplan_organizations.backend.db.models import (
OrganizationFunction, OrganizationFunction,
OrganizationFunctionAssignment,
OrganizationUnit, OrganizationUnit,
) )
@@ -50,28 +44,7 @@ def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
) )
def _assignment_ref(item: OrganizationFunctionAssignment) -> OrganizationFunctionAssignmentRef:
return OrganizationFunctionAssignmentRef(
id=item.id,
tenant_id=item.tenant_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,
source=item.source, # type: ignore[arg-type]
delegated_from_assignment_id=item.delegated_from_assignment_id,
acting_for_account_id=item.acting_for_account_id,
valid_from=item.valid_from,
valid_until=item.valid_until,
status=_status(item.is_active), # type: ignore[arg-type]
)
class SqlOrganizationDirectory(OrganizationDirectory): class SqlOrganizationDirectory(OrganizationDirectory):
def __init__(self, *, identity_directory: IdentityDirectory | None = None) -> None:
self._identity_directory = identity_directory
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None: def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
with get_database().session() as session: with get_database().session() as session:
item = session.get(OrganizationUnit, organization_unit_id) item = session.get(OrganizationUnit, organization_unit_id)
@@ -121,70 +94,3 @@ class SqlOrganizationDirectory(OrganizationDirectory):
.all() .all()
) )
return tuple(_function_ref(item) for item in rows) return tuple(_function_ref(item) for item in rows)
def get_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
with get_database().session() as session:
item = session.get(OrganizationFunctionAssignment, assignment_id)
return _assignment_ref(item) if item is not None else None
def function_assignments_for_account(
self,
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(
identity_or_account_filter,
OrganizationFunctionAssignment.is_active.is_(True),
OrganizationFunction.is_active.is_(True),
or_(OrganizationFunctionAssignment.valid_from.is_(None), OrganizationFunctionAssignment.valid_from <= now),
or_(OrganizationFunctionAssignment.valid_until.is_(None), OrganizationFunctionAssignment.valid_until > now),
)
.order_by(OrganizationFunctionAssignment.created_at.asc())
)
if tenant_id is not None:
query = query.filter(OrganizationFunctionAssignment.tenant_id == tenant_id)
return tuple(_assignment_ref(item) for item in query.all())

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
from govoplan_core.core.module_guards import persistent_table_uninstall_guard from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationTopic, DocumentationTopic,
@@ -50,22 +49,21 @@ PERMISSIONS = (
_permission("organizations:settings:write", "Manage organization settings", "Edit organization governance, audit, and retention settings."), _permission("organizations:settings:write", "Manage organization settings", "Edit organization governance, audit, and retention settings."),
_permission("organizations:unit:read", "View organization units", "Read concrete organization units and relations."), _permission("organizations:unit: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: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:read", "View organization functions", "Read function definitions."),
_permission("organizations:function:write", "Manage organization functions", "Create and edit function definitions."), _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 = ( ROLE_TEMPLATES = (
RoleTemplate( RoleTemplate(
slug="organization_modeler", slug="organization_modeler",
name="Organization modeler", name="Organization modeler",
description="Manage organization meta-model, concrete units, structures, functions, and assignments.", description="Manage organization meta-model, concrete units, structures, and functions.",
permissions=tuple(permission.scope for permission in PERMISSIONS), permissions=tuple(permission.scope for permission in PERMISSIONS),
), ),
RoleTemplate( RoleTemplate(
slug="organization_viewer", slug="organization_viewer",
name="Organization viewer", name="Organization viewer",
description="Read organization model, organization units, and function assignments.", description="Read organization model, organization units, and functions.",
permissions=("organizations:model:read", "organizations:settings:read", "organizations:unit:read", "organizations:function:read"), permissions=("organizations:model:read", "organizations:settings:read", "organizations:unit:read", "organizations:function:read"),
), ),
) )
@@ -79,15 +77,10 @@ def _route_factory(context: ModuleContext):
def _organization_directory(context: ModuleContext) -> object: def _organization_directory(context: ModuleContext) -> object:
identity_directory: IdentityDirectory | None = None del context
registry = context.registry
if hasattr(registry, "has_capability") and registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if isinstance(capability, IdentityDirectory):
identity_directory = capability
from govoplan_organizations.backend.directory import SqlOrganizationDirectory from govoplan_organizations.backend.directory import SqlOrganizationDirectory
return SqlOrganizationDirectory(identity_directory=identity_directory) return SqlOrganizationDirectory()
manifest = ModuleManifest( manifest = ModuleManifest(
@@ -95,7 +88,7 @@ manifest = ModuleManifest(
name="Organizations", name="Organizations",
version="0.1.6", version="0.1.6",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("tenancy", "identity", "access", "audit", "policy"), optional_dependencies=("tenancy", "access", "audit", "policy"),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
route_factory=_route_factory, route_factory=_route_factory,
@@ -121,7 +114,6 @@ manifest = ModuleManifest(
organization_models.OrganizationUnit, organization_models.OrganizationUnit,
organization_models.OrganizationFunctionType, organization_models.OrganizationFunctionType,
organization_models.OrganizationFunction, organization_models.OrganizationFunction,
organization_models.OrganizationFunctionAssignment,
label="Organizations", label="Organizations",
), ),
), ),
@@ -132,11 +124,11 @@ manifest = ModuleManifest(
DocumentationTopic( DocumentationTopic(
id="organizations.model", id="organizations.model",
title="Organization model", title="Organization model",
summary="Organizations owns units, hierarchy, functions, and function assignments. Access maps those facts to roles and rights.", summary="Organizations owns units, hierarchy, and functions. IDM links identities to functions, and Access maps accepted facts to roles and rights.",
body=( body=(
"Use organization unit types, structures, and relation types to model how the institution describes itself. " "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. " "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." "Functions describe responsibilities in organization units. IDM links identities to those functions, and Access maps accepted facts to roles and rights."
), ),
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),

View File

@@ -239,54 +239,6 @@ def _create_functions() -> None:
_create_index_if_missing(op.f(f"ix_organizations_functions_{column}"), "organizations_functions", [column]) _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: def upgrade() -> None:
_create_unit_types() _create_unit_types()
_create_tenant_settings() _create_tenant_settings()
@@ -296,12 +248,10 @@ def upgrade() -> None:
_create_relations() _create_relations()
_create_function_types() _create_function_types()
_create_functions() _create_functions()
_create_function_assignments()
def downgrade() -> None: def downgrade() -> None:
for table_name, indexes in ( 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_relations", ("ix_organizations_relations_structure_source", "ix_organizations_relations_structure_target")),
("organizations_relation_types", ("ix_organizations_relation_types_structure",)), ("organizations_relation_types", ("ix_organizations_relation_types_structure",)),
): ):

View File

@@ -125,25 +125,6 @@ export type OrganizationFunctionItem = {
updated_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 = { export type OrganizationModel = {
unit_types: OrganizationUnitTypeItem[]; unit_types: OrganizationUnitTypeItem[];
structures: OrganizationStructureItem[]; structures: OrganizationStructureItem[];
@@ -152,21 +133,6 @@ export type OrganizationModel = {
relations: OrganizationRelationItem[]; relations: OrganizationRelationItem[];
function_types: OrganizationFunctionTypeItem[]; function_types: OrganizationFunctionTypeItem[];
functions: OrganizationFunctionItem[]; functions: OrganizationFunctionItem[];
function_assignments: OrganizationFunctionAssignmentItem[];
};
export type IdentityOption = {
id: string;
display_name?: string | null;
external_subject?: string | null;
source: string;
primary_account_id?: string | null;
account_ids: string[];
status: string;
};
export type IdentityListResponse = {
identities: IdentityOption[];
}; };
export type OrganizationChangeRequestPayload = { export type OrganizationChangeRequestPayload = {
@@ -220,18 +186,6 @@ export type FunctionCreatePayload = SluggedCreatePayload & {
act_in_place_allowed?: 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>;
} & OrganizationChangeRequestPayload;
function post<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> { 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) }); return apiFetch<T>(settings, path, { method: "POST", body: JSON.stringify(payload) });
} }
@@ -244,15 +198,6 @@ export function getOrganizationModel(settings: ApiSettings): Promise<Organizatio
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model"); return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
} }
export async function searchIdentityOptions(settings: ApiSettings, query = "", limit = 25): Promise<IdentityOption[]> {
const params = new URLSearchParams();
const trimmed = query.trim();
if (trimmed) params.set("query", trimmed);
params.set("limit", String(limit));
const response = await apiFetch<IdentityListResponse>(settings, `/api/v1/identity/identities?${params.toString()}`);
return response.identities;
}
export function getOrganizationSettings(settings: ApiSettings): Promise<OrganizationSettingsItem> { export function getOrganizationSettings(settings: ApiSettings): Promise<OrganizationSettingsItem> {
return apiFetch<OrganizationSettingsItem>(settings, "/api/v1/organizations/settings"); return apiFetch<OrganizationSettingsItem>(settings, "/api/v1/organizations/settings");
} }
@@ -316,11 +261,3 @@ export function createFunction(settings: ApiSettings, payload: FunctionCreatePay
export function patchFunction(settings: ApiSettings, id: string, payload: Partial<FunctionCreatePayload>): Promise<OrganizationFunctionItem> { export function patchFunction(settings: ApiSettings, id: string, payload: Partial<FunctionCreatePayload>): Promise<OrganizationFunctionItem> {
return patch(settings, `/api/v1/organizations/functions/${encodeURIComponent(id)}`, payload); return patch(settings, `/api/v1/organizations/functions/${encodeURIComponent(id)}`, payload);
} }
export function createFunctionAssignment(settings: ApiSettings, payload: FunctionAssignmentCreatePayload): Promise<OrganizationFunctionAssignmentItem> {
return post(settings, "/api/v1/organizations/function-assignments", payload);
}
export function patchFunctionAssignment(settings: ApiSettings, id: string, payload: Partial<FunctionAssignmentCreatePayload>): Promise<OrganizationFunctionAssignmentItem> {
return patch(settings, `/api/v1/organizations/function-assignments/${encodeURIComponent(id)}`, payload);
}

View File

@@ -0,0 +1,114 @@
import { useEffect, useMemo, useState } from "react";
import type {
OrganizationFunctionLabelContext,
OrganizationFunctionPickerContext,
OrganizationFunctionSelection
} from "@govoplan/core-webui";
import { getOrganizationModel, type OrganizationFunctionItem, type OrganizationModel, type OrganizationUnitItem } from "../../api/organizations";
type FunctionOption = OrganizationFunctionSelection & {
slug: string;
active: boolean;
};
export function OrganizationFunctionPicker({ settings, auth, value, disabled, onChange }: OrganizationFunctionPickerContext) {
const [model, setModel] = useState<OrganizationModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const tenantId = (auth.active_tenant ?? auth.tenant).id;
useEffect(() => {
let cancelled = false;
setLoading(true);
setError("");
getOrganizationModel(settings)
.then((nextModel) => {
if (!cancelled) setModel(nextModel);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [settings.accessToken, settings.apiBaseUrl, tenantId]);
const options = useMemo(() => functionOptions(model), [model]);
const selectedId = value?.functionId ?? "";
return (
<>
<select
value={selectedId}
disabled={disabled || loading || Boolean(error)}
onChange={(event) => {
const selected = options.find((option) => option.functionId === event.target.value);
onChange(selected ?? null);
}}
>
<option value="">{loading ? "i18n:govoplan-organizations.loading_functions.77e8e684" : "i18n:govoplan-organizations.select_function.4895a67d"}</option>
{options.map((option) => (
<option key={option.functionId} value={option.functionId}>
{option.label}{option.organizationUnitLabel ? ` (${option.organizationUnitLabel})` : ""}
</option>
))}
</select>
{error && <p className="muted small-note">i18n:govoplan-organizations.functions_unavailable.4fd6765d</p>}
</>
);
}
export function OrganizationFunctionLabel({ settings, auth, sourceModule, functionId, fallback }: OrganizationFunctionLabelContext) {
const [model, setModel] = useState<OrganizationModel | null>(null);
const tenantId = (auth.active_tenant ?? auth.tenant).id;
useEffect(() => {
let cancelled = false;
getOrganizationModel(settings)
.then((nextModel) => {
if (!cancelled) setModel(nextModel);
})
.catch(() => {
if (!cancelled) setModel(null);
});
return () => {
cancelled = true;
};
}, [settings.accessToken, settings.apiBaseUrl, tenantId]);
const option = useMemo(() => functionOptions(model).find((item) => item.functionId === functionId), [functionId, model]);
if (sourceModule !== "organizations" || !option) return <>{fallback ?? functionId}</>;
return (
<div>
<strong>{option.label}</strong>
<div className="muted small-note">{option.organizationUnitLabel ?? option.slug}</div>
</div>
);
}
function functionOptions(model: OrganizationModel | null): FunctionOption[] {
if (!model) return [];
const unitById = new Map<string, OrganizationUnitItem>(model.units.map((unit) => [unit.id, unit]));
return model.functions
.filter((item) => item.is_active)
.map((item) => toOption(item, unitById.get(item.organization_unit_id)))
.sort((left, right) => {
const unitDelta = (left.organizationUnitLabel ?? "").localeCompare(right.organizationUnitLabel ?? "");
return unitDelta !== 0 ? unitDelta : (left.label ?? left.functionId).localeCompare(right.label ?? right.functionId);
});
}
function toOption(item: OrganizationFunctionItem, unit: OrganizationUnitItem | undefined): FunctionOption {
return {
sourceModule: "organizations",
functionId: item.id,
label: item.name,
organizationUnitId: item.organization_unit_id,
organizationUnitLabel: unit?.name ?? null,
slug: item.slug,
active: item.is_active
};
}

View File

@@ -13,14 +13,16 @@ import {
StatusBadge, StatusBadge,
hasScope, hasScope,
useUnsavedDraftGuard, useUnsavedDraftGuard,
usePlatformUiCapabilities,
type ApiSettings, type ApiSettings,
type AuthInfo, type AuthInfo,
type DataGridColumn, type DataGridColumn,
type ModuleSubnavGroup type ModuleSubnavGroup,
type OrganizationFunctionActionContribution,
type OrganizationFunctionActionsUiCapability
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
createFunction, createFunction,
createFunctionAssignment,
createFunctionType, createFunctionType,
createRelation, createRelation,
createRelationType, createRelationType,
@@ -29,19 +31,14 @@ import {
createUnitType, createUnitType,
getOrganizationModel, getOrganizationModel,
patchFunction, patchFunction,
patchFunctionAssignment,
patchFunctionType, patchFunctionType,
patchRelation, patchRelation,
patchRelationType, patchRelationType,
patchStructure, patchStructure,
patchUnit, patchUnit,
patchUnitType, patchUnitType,
searchIdentityOptions,
type FunctionAssignmentCreatePayload,
type FunctionCreatePayload, type FunctionCreatePayload,
type FunctionTypeCreatePayload, type FunctionTypeCreatePayload,
type IdentityOption,
type OrganizationFunctionAssignmentItem,
type OrganizationFunctionItem, type OrganizationFunctionItem,
type OrganizationFunctionTypeItem, type OrganizationFunctionTypeItem,
type OrganizationModel, type OrganizationModel,
@@ -58,7 +55,7 @@ import {
type UnitCreatePayload type UnitCreatePayload
} from "../../api/organizations"; } from "../../api/organizations";
export type OrganizationSection = "model" | "units" | "relations" | "functions" | "assignments"; export type OrganizationSection = "model" | "units" | "relations" | "functions";
type OrganizationsPageMode = "workspace" | "admin"; type OrganizationsPageMode = "workspace" | "admin";
type OrganizationsPageProps = { type OrganizationsPageProps = {
@@ -116,17 +113,6 @@ type FunctionDraft = SluggedDraft & {
act_in_place_allowed: 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 = { type ActiveItem = {
id: string; id: string;
is_active: boolean; is_active: boolean;
@@ -139,8 +125,7 @@ const EMPTY_MODEL: OrganizationModel = {
units: [], units: [],
relations: [], relations: [],
function_types: [], function_types: [],
functions: [], functions: []
function_assignments: []
}; };
const SECTION_GROUPS: ModuleSubnavGroup<OrganizationSection>[] = [ const SECTION_GROUPS: ModuleSubnavGroup<OrganizationSection>[] = [
@@ -150,8 +135,7 @@ const SECTION_GROUPS: ModuleSubnavGroup<OrganizationSection>[] = [
{ id: "model", label: "i18n:govoplan-organizations.meta_model.7398487c", primary: true }, { id: "model", label: "i18n:govoplan-organizations.meta_model.7398487c", primary: true },
{ id: "units", label: "i18n:govoplan-organizations.units.e14d0d92" }, { id: "units", label: "i18n:govoplan-organizations.units.e14d0d92" },
{ id: "relations", label: "i18n:govoplan-organizations.relations.1c796711" }, { id: "relations", label: "i18n:govoplan-organizations.relations.1c796711" },
{ id: "functions", label: "i18n:govoplan-organizations.functions.805dc49b" }, { id: "functions", label: "i18n:govoplan-organizations.functions.805dc49b" }
{ id: "assignments", label: "i18n:govoplan-organizations.assignments.278f513e" }
] ]
} }
]; ];
@@ -163,7 +147,7 @@ const STRUCTURE_KINDS: Array<{ value: OrganizationStructureKind; label: string }
{ value: "classification", label: "i18n:govoplan-organizations.classification.3e9f6c3a" } { value: "classification", label: "i18n:govoplan-organizations.classification.3e9f6c3a" }
]; ];
const ALL_SECTIONS: OrganizationSection[] = ["model", "units", "relations", "functions", "assignments"]; const ALL_SECTIONS: OrganizationSection[] = ["model", "units", "relations", "functions"];
function emptySluggedDraft(): SluggedDraft { function emptySluggedDraft(): SluggedDraft {
return { name: "", slug: "", description: "", is_active: true }; return { name: "", slug: "", description: "", is_active: true };
@@ -200,19 +184,6 @@ function emptyFunctionDraft(): FunctionDraft {
return { ...emptySluggedDraft(), organization_unit_id: "", function_type_id: "", delegable: false, act_in_place_allowed: false }; 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 { function textOrNull(value: string): string | null {
const trimmed = value.trim(); const trimmed = value.trim();
return trimmed ? trimmed : null; return trimmed ? trimmed : null;
@@ -270,19 +241,6 @@ function isFunctionDirty(draft: FunctionDraft): boolean {
return isSluggedDirty(draft) || Boolean(draft.organization_unit_id || draft.function_type_id) || draft.delegable || draft.act_in_place_allowed; 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> { function mapById<T extends { id: string }>(items: T[]): Map<string, T> {
return new Map(items.map((item) => [item.id, item])); return new Map(items.map((item) => [item.id, item]));
} }
@@ -317,21 +275,6 @@ function activeStatus(active: boolean): JSX.Element {
return <StatusBadge status={active ? "success" : "inactive"} label={active ? "i18n:govoplan-organizations.active.a733b809" : "i18n:govoplan-organizations.inactive.09af574c"} />; return <StatusBadge status={active ? "success" : "inactive"} label={active ? "i18n:govoplan-organizations.active.a733b809" : "i18n:govoplan-organizations.inactive.09af574c"} />;
} }
function identityOptionLabel(identity: IdentityOption): string {
return identity.display_name || identity.external_subject || identity.primary_account_id || identity.id;
}
function identityDisplay(identityId: string, identities: Map<string, IdentityOption>): JSX.Element {
const identity = identities.get(identityId);
if (!identity) return <span className="organizations-id">{identityId}</span>;
return (
<span className="organizations-identity">
<span>{identityOptionLabel(identity)}</span>
<span className="organizations-id">{identity.id}</span>
</span>
);
}
function ActiveToggleButton({ item, disabled, onToggle }: { item: ActiveItem; disabled: boolean; onToggle: () => void }) { 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"; const label = item.is_active ? "i18n:govoplan-organizations.deactivate.46ab070d" : "i18n:govoplan-organizations.reactivate.58f2855d";
return ( return (
@@ -341,13 +284,14 @@ function ActiveToggleButton({ item, disabled, onToggle }: { item: ActiveItem; di
); );
} }
function RowActions({ item, disabled, onEdit, onToggle }: { item: ActiveItem; disabled: boolean; onEdit: () => void; onToggle: () => void }) { function RowActions({ item, disabled, onEdit, onToggle, extra }: { item: ActiveItem; disabled: boolean; onEdit: () => void; onToggle: () => void; extra?: JSX.Element[] }) {
return ( return (
<div className="organizations-row-actions"> <div className="organizations-row-actions">
<Button type="button" variant="ghost" disabled={disabled} onClick={onEdit} title="i18n:govoplan-organizations.edit.7dce1220"> <Button type="button" variant="ghost" disabled={disabled} onClick={onEdit} title="i18n:govoplan-organizations.edit.7dce1220">
<Edit3 size={16} aria-hidden="true" /> i18n:govoplan-organizations.edit.7dce1220 <Edit3 size={16} aria-hidden="true" /> i18n:govoplan-organizations.edit.7dce1220
</Button> </Button>
<ActiveToggleButton item={item} disabled={disabled} onToggle={onToggle} /> <ActiveToggleButton item={item} disabled={disabled} onToggle={onToggle} />
{extra}
</div> </div>
); );
} }
@@ -382,6 +326,14 @@ function SluggedFields({
); );
} }
function contributionVisible(auth: AuthInfo, contribution: { anyOf?: string[]; allOf?: string[] }): boolean {
const anyOf = contribution.anyOf ?? [];
const allOf = contribution.allOf ?? [];
if (anyOf.length && !anyOf.some((scope) => hasScope(auth, scope))) return false;
if (allOf.length && !allOf.every((scope) => hasScope(auth, scope))) return false;
return true;
}
export default function OrganizationsPage({ export default function OrganizationsPage({
settings, settings,
auth, auth,
@@ -408,7 +360,6 @@ export default function OrganizationsPage({
const [relationDraft, setRelationDraft] = useState<RelationDraft>(() => emptyRelationDraft()); const [relationDraft, setRelationDraft] = useState<RelationDraft>(() => emptyRelationDraft());
const [functionTypeDraft, setFunctionTypeDraft] = useState<FunctionTypeDraft>(() => emptyFunctionTypeDraft()); const [functionTypeDraft, setFunctionTypeDraft] = useState<FunctionTypeDraft>(() => emptyFunctionTypeDraft());
const [functionDraft, setFunctionDraft] = useState<FunctionDraft>(() => emptyFunctionDraft()); const [functionDraft, setFunctionDraft] = useState<FunctionDraft>(() => emptyFunctionDraft());
const [assignmentDraft, setAssignmentDraft] = useState<AssignmentDraft>(() => emptyAssignmentDraft());
const [editingUnitTypeId, setEditingUnitTypeId] = useState<string | null>(null); const [editingUnitTypeId, setEditingUnitTypeId] = useState<string | null>(null);
const [editingStructureId, setEditingStructureId] = useState<string | null>(null); const [editingStructureId, setEditingStructureId] = useState<string | null>(null);
const [editingRelationTypeId, setEditingRelationTypeId] = useState<string | null>(null); const [editingRelationTypeId, setEditingRelationTypeId] = useState<string | null>(null);
@@ -416,18 +367,13 @@ export default function OrganizationsPage({
const [editingRelationId, setEditingRelationId] = useState<string | null>(null); const [editingRelationId, setEditingRelationId] = useState<string | null>(null);
const [editingFunctionTypeId, setEditingFunctionTypeId] = useState<string | null>(null); const [editingFunctionTypeId, setEditingFunctionTypeId] = useState<string | null>(null);
const [editingFunctionId, setEditingFunctionId] = useState<string | null>(null); const [editingFunctionId, setEditingFunctionId] = useState<string | null>(null);
const [editingAssignmentId, setEditingAssignmentId] = useState<string | null>(null);
const [selectedUnitId, setSelectedUnitId] = useState(""); const [selectedUnitId, setSelectedUnitId] = useState("");
const [changeRequestId, setChangeRequestId] = useState(""); const [changeRequestId, setChangeRequestId] = useState("");
const [identitySearch, setIdentitySearch] = useState(""); const functionActionCapabilities = usePlatformUiCapabilities<OrganizationFunctionActionsUiCapability>("organizations.functionActions");
const [identityOptions, setIdentityOptions] = useState<IdentityOption[]>([]);
const [identityLoading, setIdentityLoading] = useState(false);
const [identityLookupAvailable, setIdentityLookupAvailable] = useState(true);
const canWriteModel = hasScope(auth, "organizations:model:write"); const canWriteModel = hasScope(auth, "organizations:model:write");
const canWriteUnits = hasScope(auth, "organizations:unit:write"); const canWriteUnits = hasScope(auth, "organizations:unit:write");
const canWriteFunctions = hasScope(auth, "organizations:function: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 unitTypeById = useMemo(() => mapById(model.unit_types), [model.unit_types]);
const structureById = useMemo(() => mapById(model.structures), [model.structures]); const structureById = useMemo(() => mapById(model.structures), [model.structures]);
@@ -435,7 +381,13 @@ export default function OrganizationsPage({
const unitById = useMemo(() => mapById(model.units), [model.units]); const unitById = useMemo(() => mapById(model.units), [model.units]);
const functionTypeById = useMemo(() => mapById(model.function_types), [model.function_types]); const functionTypeById = useMemo(() => mapById(model.function_types), [model.function_types]);
const functionById = useMemo(() => mapById(model.functions), [model.functions]); const functionById = useMemo(() => mapById(model.functions), [model.functions]);
const identityOptionById = useMemo(() => mapById(identityOptions), [identityOptions]); const functionActionContributions = useMemo(
() => functionActionCapabilities
.flatMap((capability) => capability.actions)
.filter((contribution) => contributionVisible(auth, contribution))
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
[auth, functionActionCapabilities]
);
const unitsByParentId = useMemo(() => { const unitsByParentId = useMemo(() => {
const mapped = new Map<string, OrganizationUnitItem[]>(); const mapped = new Map<string, OrganizationUnitItem[]>();
for (const unit of model.units) { for (const unit of model.units) {
@@ -452,8 +404,7 @@ export default function OrganizationsPage({
isUnitDirty(unitDraft) || isUnitDirty(unitDraft) ||
isRelationDirty(relationDraft) || isRelationDirty(relationDraft) ||
isFunctionTypeDirty(functionTypeDraft) || isFunctionTypeDirty(functionTypeDraft) ||
isFunctionDirty(functionDraft) || isFunctionDirty(functionDraft);
isAssignmentDirty(assignmentDraft);
const loadModel = useCallback(async () => { const loadModel = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -484,36 +435,6 @@ export default function OrganizationsPage({
if (selectedUnitId && !unitById.has(selectedUnitId)) setSelectedUnitId(""); if (selectedUnitId && !unitById.has(selectedUnitId)) setSelectedUnitId("");
}, [selectedUnitId, unitById]); }, [selectedUnitId, unitById]);
useEffect(() => {
if (active !== "assignments" || !identityLookupAvailable) return undefined;
let cancelled = false;
const timeout = window.setTimeout(() => {
setIdentityLoading(true);
searchIdentityOptions(settings, identitySearch, 50).
then((items) => {
if (cancelled) return;
setIdentityOptions(items);
setIdentityLookupAvailable(true);
}).
catch((caught) => {
if (cancelled) return;
if (caught instanceof ApiError && (caught.status === 403 || caught.status === 404)) {
setIdentityLookupAvailable(false);
setIdentityOptions([]);
return;
}
setError(apiErrorMessage(caught));
}).
finally(() => {
if (!cancelled) setIdentityLoading(false);
});
}, 250);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [active, identityLookupAvailable, identitySearch, settings]);
const runAction = useCallback(async (action: () => Promise<unknown>, successMessage = ""): Promise<boolean> => { const runAction = useCallback(async (action: () => Promise<unknown>, successMessage = ""): Promise<boolean> => {
setBusy(true); setBusy(true);
setError(""); setError("");
@@ -539,7 +460,6 @@ export default function OrganizationsPage({
setRelationDraft(emptyRelationDraft()); setRelationDraft(emptyRelationDraft());
setFunctionTypeDraft(emptyFunctionTypeDraft()); setFunctionTypeDraft(emptyFunctionTypeDraft());
setFunctionDraft(emptyFunctionDraft()); setFunctionDraft(emptyFunctionDraft());
setAssignmentDraft(emptyAssignmentDraft());
setEditingUnitTypeId(null); setEditingUnitTypeId(null);
setEditingStructureId(null); setEditingStructureId(null);
setEditingRelationTypeId(null); setEditingRelationTypeId(null);
@@ -547,7 +467,6 @@ export default function OrganizationsPage({
setEditingRelationId(null); setEditingRelationId(null);
setEditingFunctionTypeId(null); setEditingFunctionTypeId(null);
setEditingFunctionId(null); setEditingFunctionId(null);
setEditingAssignmentId(null);
}, []); }, []);
function rejectMissingName(): Promise<boolean> { function rejectMissingName(): Promise<boolean> {
@@ -728,35 +647,6 @@ export default function OrganizationsPage({
return ok; return ok;
}, [canWriteFunctions, editingFunctionId, functionDraft, runAction, settings, withChangeRequest]); }, [canWriteFunctions, editingFunctionId, functionDraft, runAction, settings, withChangeRequest]);
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(
() => editingAssignmentId ? patchFunctionAssignment(settings, editingAssignmentId, withChangeRequest(payload)) : createFunctionAssignment(settings, withChangeRequest(payload)),
editingAssignmentId ? "i18n:govoplan-organizations.assignment_updated.680b6e33" : "i18n:govoplan-organizations.assignment_added.94263d1b"
);
if (ok) {
setAssignmentDraft(emptyAssignmentDraft());
setEditingAssignmentId(null);
}
return ok;
}, [assignmentDraft, canAssignFunctions, editingAssignmentId, runAction, settings, withChangeRequest]);
const saveDrafts = useCallback(async (): Promise<boolean> => { const saveDrafts = useCallback(async (): Promise<boolean> => {
if (isSluggedDirty(unitTypeDraft) && !(await submitUnitType())) return false; if (isSluggedDirty(unitTypeDraft) && !(await submitUnitType())) return false;
if (isSluggedDirty(structureDraft) && !(await submitStructure())) return false; if (isSluggedDirty(structureDraft) && !(await submitStructure())) return false;
@@ -765,16 +655,13 @@ export default function OrganizationsPage({
if (isRelationDirty(relationDraft) && !(await submitRelation())) return false; if (isRelationDirty(relationDraft) && !(await submitRelation())) return false;
if (isFunctionTypeDirty(functionTypeDraft) && !(await submitFunctionType())) return false; if (isFunctionTypeDirty(functionTypeDraft) && !(await submitFunctionType())) return false;
if (isFunctionDirty(functionDraft) && !(await submitFunction())) return false; if (isFunctionDirty(functionDraft) && !(await submitFunction())) return false;
if (isAssignmentDirty(assignmentDraft) && !(await submitAssignment())) return false;
return true; return true;
}, [ }, [
assignmentDraft,
functionDraft, functionDraft,
functionTypeDraft, functionTypeDraft,
relationDraft, relationDraft,
relationTypeDraft, relationTypeDraft,
structureDraft, structureDraft,
submitAssignment,
submitFunction, submitFunction,
submitFunctionType, submitFunctionType,
submitRelation, submitRelation,
@@ -822,10 +709,6 @@ export default function OrganizationsPage({
void runAction(() => patchFunction(settings, item.id, withChangeRequest({ is_active: !item.is_active }))); void runAction(() => patchFunction(settings, item.id, withChangeRequest({ is_active: !item.is_active })));
}, [runAction, settings, withChangeRequest]); }, [runAction, settings, withChangeRequest]);
const toggleAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => {
void runAction(() => patchFunctionAssignment(settings, item.id, withChangeRequest({ is_active: !item.is_active })));
}, [runAction, settings, withChangeRequest]);
const editUnitType = useCallback((item: OrganizationUnitTypeItem) => { const editUnitType = useCallback((item: OrganizationUnitTypeItem) => {
setEditingUnitTypeId(item.id); setEditingUnitTypeId(item.id);
setUnitTypeDraft(sluggedDraftFrom(item)); setUnitTypeDraft(sluggedDraftFrom(item));
@@ -901,22 +784,6 @@ export default function OrganizationsPage({
setActive("functions"); setActive("functions");
}, []); }, []);
const editAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => {
setEditingAssignmentId(item.id);
setSelectedUnitId(item.organization_unit_id);
setAssignmentDraft({
identity_id: item.identity_id,
account_id: item.account_id ?? "",
function_id: item.function_id,
applies_to_subunits: item.applies_to_subunits,
source: item.source,
delegated_from_assignment_id: item.delegated_from_assignment_id ?? "",
acting_for_account_id: item.acting_for_account_id ?? "",
is_active: item.is_active
});
setActive("assignments");
}, []);
const unitTypeColumns: DataGridColumn<OrganizationUnitTypeItem>[] = [ const unitTypeColumns: DataGridColumn<OrganizationUnitTypeItem>[] = [
{ id: "name", header: "i18n:govoplan-organizations.name.709a2322", minWidth: 180, sortable: true, filterable: true, value: (row) => row.name }, { 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: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, sortable: true, filterable: true, value: (row) => row.slug },
@@ -976,20 +843,28 @@ export default function OrganizationsPage({
{ id: "type", header: "i18n:govoplan-organizations.function_type.501fe7c0", minWidth: 180, render: (row) => optionalLabel(functionTypeById.get(row.function_type_id || "")?.name) }, { id: "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: "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: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
{ id: "actions", header: "", width: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canWriteFunctions || busy} onEdit={() => editFunction(row)} onToggle={() => toggleFunction(row)} /> } {
id: "actions",
header: "",
width: functionActionContributions.length ? 340 : 220,
sticky: "end",
render: (row) => (
<RowActions
item={row}
disabled={!canWriteFunctions || busy}
onEdit={() => editFunction(row)}
onToggle={() => toggleFunction(row)}
extra={functionActionContributions.map((contribution) => (
<span className="organizations-contributed-action" key={contribution.id}>
{contribution.render({ settings, auth, function: row })}
</span>
))}
/>
)
}
]; ];
const assignmentColumns: DataGridColumn<OrganizationFunctionAssignmentItem>[] = [ const canWriteActive = active === "model" ? canWriteModel : active === "units" || active === "relations" ? canWriteUnits : canWriteFunctions;
{ id: "identity", header: "i18n:govoplan-organizations.identity.3252f35f", minWidth: 220, render: (row) => identityDisplay(row.identity_id, identityOptionById) },
{ 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: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canAssignFunctions || busy} onEdit={() => editAssignment(row)} onToggle={() => toggleAssignment(row)} /> }
];
const canWriteActive = active === "model" ? canWriteModel : active === "units" || active === "relations" ? canWriteUnits : active === "functions" ? canWriteFunctions : canAssignFunctions;
function cancelEditButton(onCancel: () => void) { function cancelEditButton(onCancel: () => void) {
return <Button type="button" variant="ghost" onClick={onCancel} disabled={busy}>i18n:govoplan-organizations.cancel_edit.309c2a6f</Button>; return <Button type="button" variant="ghost" onClick={onCancel} disabled={busy}>i18n:govoplan-organizations.cancel_edit.309c2a6f</Button>;
@@ -1032,7 +907,6 @@ export default function OrganizationsPage({
{active === "units" && renderUnitsSection()} {active === "units" && renderUnitsSection()}
{active === "relations" && renderRelationsSection()} {active === "relations" && renderRelationsSection()}
{active === "functions" && renderFunctionsSection()} {active === "functions" && renderFunctionsSection()}
{active === "assignments" && renderAssignmentsSection()}
</LoadingFrame> </LoadingFrame>
</div> </div>
); );
@@ -1302,77 +1176,4 @@ export default function OrganizationsPage({
); );
} }
function renderAssignmentsSection() {
const selectedIdentity = identityOptionById.get(assignmentDraft.identity_id);
return (
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.add_assignment.6682f5f5">
<form className="organizations-form-grid" onSubmit={(event) => void submitAssignment(event)}>
{identityLookupAvailable ? (
<div className="organizations-identity-picker wide">
<FormField label="i18n:govoplan-organizations.identity_search.eff6eb16">
<input value={identitySearch} placeholder="i18n:govoplan-organizations.search_identities.004afdfd" disabled={!canAssignFunctions || busy} onChange={(event) => setIdentitySearch(event.target.value)} />
</FormField>
<FormField label="i18n:govoplan-organizations.identity.3252f35f">
<select
value={assignmentDraft.identity_id}
disabled={!canAssignFunctions || busy || identityLoading}
onChange={(event) => {
const identity = identityOptionById.get(event.target.value);
setAssignmentDraft({
...assignmentDraft,
identity_id: event.target.value,
account_id: identity?.primary_account_id ?? identity?.account_ids[0] ?? ""
});
}}
>
<option value="">{identityLoading ? "i18n:govoplan-organizations.loading_identities.9c31d2b5" : "i18n:govoplan-organizations.select_identity.510f1134"}</option>
{assignmentDraft.identity_id && !identityOptionById.has(assignmentDraft.identity_id) && <option value={assignmentDraft.identity_id}>{assignmentDraft.identity_id}</option>}
{identityOptions.map((identity) => <option key={identity.id} value={identity.id}>{identityOptionLabel(identity)}</option>)}
</select>
</FormField>
</div>
) : (
<>
<p className="organizations-muted wide">i18n:govoplan-organizations.identity_lookup_unavailable.5a3e77b2</p>
<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">
{selectedIdentity && selectedIdentity.account_ids.length > 0 ? (
<select value={assignmentDraft.account_id} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{selectedIdentity.account_ids.map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
</select>
) : (
<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>
<div className="organizations-form-actions wide">
<Button type="submit" variant="primary" disabled={!canAssignFunctions || busy}>{editingAssignmentId ? "i18n:govoplan-organizations.update_assignment.bf130723" : "i18n:govoplan-organizations.add_assignment.6682f5f5"}</Button>
{editingAssignmentId && cancelEditButton(() => { setEditingAssignmentId(null); setAssignmentDraft(emptyAssignmentDraft()); })}
</div>
</form>
</Card>
<Card title="i18n:govoplan-organizations.function_assignments.4c5c6dae">
<DataGrid id="organizations-assignments" rows={model.function_assignments} columns={assignmentColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_assignments_found.7ecc5bb7" initialFit="container" />
</Card>
</div>
);
}
} }

View File

@@ -4,7 +4,6 @@ export const generatedTranslations: PlatformTranslations = {
en: { en: {
"i18n:govoplan-organizations.act_in_place.49b942bd": "Act in place", "i18n:govoplan-organizations.act_in_place.49b942bd": "Act in place",
"i18n:govoplan-organizations.active.a733b809": "Active", "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.6abafee0": "Add function",
"i18n:govoplan-organizations.add_function_type.90d793f1": "Add function type", "i18n:govoplan-organizations.add_function_type.90d793f1": "Add function type",
"i18n:govoplan-organizations.add_relation.6b6e67ea": "Add relation", "i18n:govoplan-organizations.add_relation.6b6e67ea": "Add relation",
@@ -15,10 +14,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.add_unit.8fa12fb1": "Add unit", "i18n:govoplan-organizations.add_unit.8fa12fb1": "Add unit",
"i18n:govoplan-organizations.add_unit_type.58f2c05a": "Add unit type", "i18n:govoplan-organizations.add_unit_type.58f2c05a": "Add unit type",
"i18n:govoplan-organizations.allow_cycles.31327578": "Allow cycles", "i18n:govoplan-organizations.allow_cycles.31327578": "Allow cycles",
"i18n:govoplan-organizations.applies_to_subunits.7a15f741": "Applies to subunits",
"i18n:govoplan-organizations.assignment_added.94263d1b": "Function assignment added.",
"i18n:govoplan-organizations.assignment_updated.680b6e33": "Function assignment updated.",
"i18n:govoplan-organizations.assignments.278f513e": "Assignments",
"i18n:govoplan-organizations.audit_and_retention.3ba1d2fc": "Audit and retention", "i18n:govoplan-organizations.audit_and_retention.3ba1d2fc": "Audit and retention",
"i18n:govoplan-organizations.audit_detail_level.7397355d": "Audit detail level", "i18n:govoplan-organizations.audit_detail_level.7397355d": "Audit detail level",
"i18n:govoplan-organizations.audit_full.0c83669c": "Full", "i18n:govoplan-organizations.audit_full.0c83669c": "Full",
@@ -34,28 +29,22 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.deactivate.46ab070d": "Deactivate", "i18n:govoplan-organizations.deactivate.46ab070d": "Deactivate",
"i18n:govoplan-organizations.delegable.b4f0137d": "Delegable", "i18n:govoplan-organizations.delegable.b4f0137d": "Delegable",
"i18n:govoplan-organizations.description.55f8ebc8": "Description", "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.discard_organization_drafts.766d5f46": "Discard organization drafts",
"i18n:govoplan-organizations.edit.7dce1220": "Edit", "i18n:govoplan-organizations.edit.7dce1220": "Edit",
"i18n:govoplan-organizations.function.28822f3a": "Function", "i18n:govoplan-organizations.function.28822f3a": "Function",
"i18n:govoplan-organizations.function_added.e2266702": "Function added.", "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.501fe7c0": "Function type",
"i18n:govoplan-organizations.function_type_added.2cd9e899": "Function type added.", "i18n:govoplan-organizations.function_type_added.2cd9e899": "Function type added.",
"i18n:govoplan-organizations.function_type_updated.1a4f29f6": "Function type updated.", "i18n:govoplan-organizations.function_type_updated.1a4f29f6": "Function type updated.",
"i18n:govoplan-organizations.function_types.172c01fe": "Function types", "i18n:govoplan-organizations.function_types.172c01fe": "Function types",
"i18n:govoplan-organizations.function_updated.65016009": "Function updated.", "i18n:govoplan-organizations.function_updated.65016009": "Function updated.",
"i18n:govoplan-organizations.functions_unavailable.4fd6765d": "Functions are unavailable.",
"i18n:govoplan-organizations.functions.805dc49b": "Functions", "i18n:govoplan-organizations.functions.805dc49b": "Functions",
"i18n:govoplan-organizations.hierarchical.8964f313": "Hierarchical", "i18n:govoplan-organizations.hierarchical.8964f313": "Hierarchical",
"i18n:govoplan-organizations.hierarchy.74797f37": "Hierarchy", "i18n:govoplan-organizations.hierarchy.74797f37": "Hierarchy",
"i18n:govoplan-organizations.identity.3252f35f": "Identity",
"i18n:govoplan-organizations.identity_id.b6ef5010": "Identity ID",
"i18n:govoplan-organizations.identity_id_placeholder.298cbd85": "identity UUID",
"i18n:govoplan-organizations.identity_lookup_unavailable.5a3e77b2": "Identity lookup is unavailable. Enable the identity module or enter an identity ID manually.",
"i18n:govoplan-organizations.identity_search.eff6eb16": "Identity search",
"i18n:govoplan-organizations.inactive.09af574c": "Inactive", "i18n:govoplan-organizations.inactive.09af574c": "Inactive",
"i18n:govoplan-organizations.kind.794c9d9c": "Kind", "i18n:govoplan-organizations.kind.794c9d9c": "Kind",
"i18n:govoplan-organizations.loading_identities.9c31d2b5": "Loading identities...", "i18n:govoplan-organizations.loading_functions.77e8e684": "Loading functions...",
"i18n:govoplan-organizations.loading_organization_model.846aa317": "Loading organization model...", "i18n:govoplan-organizations.loading_organization_model.846aa317": "Loading organization model...",
"i18n:govoplan-organizations.loading_organization_settings.c6008db8": "Loading organization settings...", "i18n:govoplan-organizations.loading_organization_settings.c6008db8": "Loading organization settings...",
"i18n:govoplan-organizations.membership.4531d86d": "Membership", "i18n:govoplan-organizations.membership.4531d86d": "Membership",
@@ -66,7 +55,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.name.709a2322": "Name", "i18n:govoplan-organizations.name.709a2322": "Name",
"i18n:govoplan-organizations.name_is_required.27cd3782": "Name is required.", "i18n:govoplan-organizations.name_is_required.27cd3782": "Name is required.",
"i18n:govoplan-organizations.network.a24d31c3": "Network", "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_function_types_found.5eef31b1": "No function types found.",
"i18n:govoplan-organizations.no_functions_found.54e759a0": "No functions 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_relation_types_found.6f90bb81": "No relation types found.",
@@ -76,7 +64,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.no_unit_types_found.c81fb2a7": "No unit types 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.no_units_found.eea2dd0c": "No units found.",
"i18n:govoplan-organizations.none.334c4a4c": "None", "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.4f924c0e": "Organization model",
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Configure tenant-local unit types, parallel structures, relation types, and function types used by the organization module.", "i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Configure tenant-local unit types, parallel structures, relation types, and function types used by the organization module.",
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organization settings", "i18n:govoplan-organizations.organization_settings.c9ab9829": "Organization settings",
@@ -84,7 +71,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organization settings saved.", "i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organization settings saved.",
"i18n:govoplan-organizations.organization_tree.e5bfb195": "Organization tree", "i18n:govoplan-organizations.organization_tree.e5bfb195": "Organization tree",
"i18n:govoplan-organizations.organizations.220edf64": "Organizations", "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.organizations_intro.4e67c4bb": "Model organization types, concrete units, structures, and functions.",
"i18n:govoplan-organizations.parent_unit.1986c35e": "Parent unit", "i18n:govoplan-organizations.parent_unit.1986c35e": "Parent unit",
"i18n:govoplan-organizations.reactivate.58f2855d": "Reactivate", "i18n:govoplan-organizations.reactivate.58f2855d": "Reactivate",
"i18n:govoplan-organizations.relation_added.ca90461a": "Relation added.", "i18n:govoplan-organizations.relation_added.ca90461a": "Relation added.",
@@ -99,10 +86,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.save_settings.913aba9f": "Save settings", "i18n:govoplan-organizations.save_settings.913aba9f": "Save settings",
"i18n:govoplan-organizations.save_drafts.606f9401": "Save drafts", "i18n:govoplan-organizations.save_drafts.606f9401": "Save drafts",
"i18n:govoplan-organizations.saving.56a2285c": "Saving...", "i18n:govoplan-organizations.saving.56a2285c": "Saving...",
"i18n:govoplan-organizations.search_identities.004afdfd": "Search by name, subject, account, or ID",
"i18n:govoplan-organizations.select_function.4895a67d": "Select function", "i18n:govoplan-organizations.select_function.4895a67d": "Select function",
"i18n:govoplan-organizations.select_function_type.2f426c43": "Select function type", "i18n:govoplan-organizations.select_function_type.2f426c43": "Select function type",
"i18n:govoplan-organizations.select_identity.510f1134": "Select identity",
"i18n:govoplan-organizations.select_relation_type.73f92478": "Select relation 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_source_unit.9b5a29c8": "Select source unit",
"i18n:govoplan-organizations.select_structure.c10f551c": "Select structure", "i18n:govoplan-organizations.select_structure.c10f551c": "Select structure",
@@ -129,7 +114,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.unit_types.c7afc174": "Unit types", "i18n:govoplan-organizations.unit_types.c7afc174": "Unit types",
"i18n:govoplan-organizations.units.e14d0d92": "Units", "i18n:govoplan-organizations.units.e14d0d92": "Units",
"i18n:govoplan-organizations.unlimited.35569464": "Unlimited", "i18n:govoplan-organizations.unlimited.35569464": "Unlimited",
"i18n:govoplan-organizations.update_assignment.bf130723": "Update assignment",
"i18n:govoplan-organizations.update_function.504f03e5": "Update function", "i18n:govoplan-organizations.update_function.504f03e5": "Update function",
"i18n:govoplan-organizations.update_function_type.0b7e07d3": "Update function type", "i18n:govoplan-organizations.update_function_type.0b7e07d3": "Update function type",
"i18n:govoplan-organizations.update_relation.4c44ce83": "Update relation", "i18n:govoplan-organizations.update_relation.4c44ce83": "Update relation",
@@ -142,7 +126,6 @@ export const generatedTranslations: PlatformTranslations = {
de: { de: {
"i18n:govoplan-organizations.act_in_place.49b942bd": "In Vertretung handeln", "i18n:govoplan-organizations.act_in_place.49b942bd": "In Vertretung handeln",
"i18n:govoplan-organizations.active.a733b809": "Aktiv", "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.6abafee0": "Funktion hinzufügen",
"i18n:govoplan-organizations.add_function_type.90d793f1": "Funktionstyp 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.6b6e67ea": "Beziehung hinzufügen",
@@ -153,10 +136,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.add_unit.8fa12fb1": "Einheit hinzufügen", "i18n:govoplan-organizations.add_unit.8fa12fb1": "Einheit hinzufügen",
"i18n:govoplan-organizations.add_unit_type.58f2c05a": "Einheitstyp hinzufügen", "i18n:govoplan-organizations.add_unit_type.58f2c05a": "Einheitstyp hinzufügen",
"i18n:govoplan-organizations.allow_cycles.31327578": "Zyklen erlauben", "i18n:govoplan-organizations.allow_cycles.31327578": "Zyklen erlauben",
"i18n:govoplan-organizations.applies_to_subunits.7a15f741": "Gilt für Untereinheiten",
"i18n:govoplan-organizations.assignment_added.94263d1b": "Funktionszuordnung hinzugefügt.",
"i18n:govoplan-organizations.assignment_updated.680b6e33": "Funktionszuordnung aktualisiert.",
"i18n:govoplan-organizations.assignments.278f513e": "Zuordnungen",
"i18n:govoplan-organizations.audit_and_retention.3ba1d2fc": "Audit und Aufbewahrung", "i18n:govoplan-organizations.audit_and_retention.3ba1d2fc": "Audit und Aufbewahrung",
"i18n:govoplan-organizations.audit_detail_level.7397355d": "Audit-Detailgrad", "i18n:govoplan-organizations.audit_detail_level.7397355d": "Audit-Detailgrad",
"i18n:govoplan-organizations.audit_full.0c83669c": "Vollständig", "i18n:govoplan-organizations.audit_full.0c83669c": "Vollständig",
@@ -172,28 +151,22 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.deactivate.46ab070d": "Deaktivieren", "i18n:govoplan-organizations.deactivate.46ab070d": "Deaktivieren",
"i18n:govoplan-organizations.delegable.b4f0137d": "Delegierbar", "i18n:govoplan-organizations.delegable.b4f0137d": "Delegierbar",
"i18n:govoplan-organizations.description.55f8ebc8": "Beschreibung", "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.discard_organization_drafts.766d5f46": "Organisationsentwürfe verwerfen",
"i18n:govoplan-organizations.edit.7dce1220": "Bearbeiten", "i18n:govoplan-organizations.edit.7dce1220": "Bearbeiten",
"i18n:govoplan-organizations.function.28822f3a": "Funktion", "i18n:govoplan-organizations.function.28822f3a": "Funktion",
"i18n:govoplan-organizations.function_added.e2266702": "Funktion hinzugefügt.", "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.501fe7c0": "Funktionstyp",
"i18n:govoplan-organizations.function_type_added.2cd9e899": "Funktionstyp hinzugefügt.", "i18n:govoplan-organizations.function_type_added.2cd9e899": "Funktionstyp hinzugefügt.",
"i18n:govoplan-organizations.function_type_updated.1a4f29f6": "Funktionstyp aktualisiert.", "i18n:govoplan-organizations.function_type_updated.1a4f29f6": "Funktionstyp aktualisiert.",
"i18n:govoplan-organizations.function_types.172c01fe": "Funktionstypen", "i18n:govoplan-organizations.function_types.172c01fe": "Funktionstypen",
"i18n:govoplan-organizations.function_updated.65016009": "Funktion aktualisiert.", "i18n:govoplan-organizations.function_updated.65016009": "Funktion aktualisiert.",
"i18n:govoplan-organizations.functions_unavailable.4fd6765d": "Funktionen sind nicht verfügbar.",
"i18n:govoplan-organizations.functions.805dc49b": "Funktionen", "i18n:govoplan-organizations.functions.805dc49b": "Funktionen",
"i18n:govoplan-organizations.hierarchical.8964f313": "Hierarchisch", "i18n:govoplan-organizations.hierarchical.8964f313": "Hierarchisch",
"i18n:govoplan-organizations.hierarchy.74797f37": "Hierarchie", "i18n:govoplan-organizations.hierarchy.74797f37": "Hierarchie",
"i18n:govoplan-organizations.identity.3252f35f": "Identität",
"i18n:govoplan-organizations.identity_id.b6ef5010": "Identitäts-ID",
"i18n:govoplan-organizations.identity_id_placeholder.298cbd85": "Identitäts-UUID",
"i18n:govoplan-organizations.identity_lookup_unavailable.5a3e77b2": "Die Identitätssuche ist nicht verfügbar. Aktiviere das Identitätsmodul oder trage eine Identitäts-ID manuell ein.",
"i18n:govoplan-organizations.identity_search.eff6eb16": "Identitätssuche",
"i18n:govoplan-organizations.inactive.09af574c": "Inaktiv", "i18n:govoplan-organizations.inactive.09af574c": "Inaktiv",
"i18n:govoplan-organizations.kind.794c9d9c": "Art", "i18n:govoplan-organizations.kind.794c9d9c": "Art",
"i18n:govoplan-organizations.loading_identities.9c31d2b5": "Identitäten werden geladen...", "i18n:govoplan-organizations.loading_functions.77e8e684": "Funktionen werden geladen...",
"i18n:govoplan-organizations.loading_organization_model.846aa317": "Organisationsmodell wird geladen...", "i18n:govoplan-organizations.loading_organization_model.846aa317": "Organisationsmodell wird geladen...",
"i18n:govoplan-organizations.loading_organization_settings.c6008db8": "Organisationseinstellungen werden geladen...", "i18n:govoplan-organizations.loading_organization_settings.c6008db8": "Organisationseinstellungen werden geladen...",
"i18n:govoplan-organizations.membership.4531d86d": "Mitgliedschaft", "i18n:govoplan-organizations.membership.4531d86d": "Mitgliedschaft",
@@ -204,7 +177,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.name.709a2322": "Name", "i18n:govoplan-organizations.name.709a2322": "Name",
"i18n:govoplan-organizations.name_is_required.27cd3782": "Name ist erforderlich.", "i18n:govoplan-organizations.name_is_required.27cd3782": "Name ist erforderlich.",
"i18n:govoplan-organizations.network.a24d31c3": "Netzwerk", "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_function_types_found.5eef31b1": "Keine Funktionstypen gefunden.",
"i18n:govoplan-organizations.no_functions_found.54e759a0": "Keine Funktionen 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_relation_types_found.6f90bb81": "Keine Beziehungstypen gefunden.",
@@ -214,7 +186,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.no_unit_types_found.c81fb2a7": "Keine Einheitstypen 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.no_units_found.eea2dd0c": "Keine Einheiten gefunden.",
"i18n:govoplan-organizations.none.334c4a4c": "Keine", "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.4f924c0e": "Organisationsmodell",
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Konfiguriere mandantenbezogene Einheitstypen, parallele Strukturen, Beziehungstypen und Funktionstypen des Organisationsmoduls.", "i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Konfiguriere mandantenbezogene Einheitstypen, parallele Strukturen, Beziehungstypen und Funktionstypen des Organisationsmoduls.",
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organisationseinstellungen", "i18n:govoplan-organizations.organization_settings.c9ab9829": "Organisationseinstellungen",
@@ -222,7 +193,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organisationseinstellungen gespeichert.", "i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organisationseinstellungen gespeichert.",
"i18n:govoplan-organizations.organization_tree.e5bfb195": "Organisationsbaum", "i18n:govoplan-organizations.organization_tree.e5bfb195": "Organisationsbaum",
"i18n:govoplan-organizations.organizations.220edf64": "Organisationen", "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.organizations_intro.4e67c4bb": "Modelliere Organisationstypen, konkrete Einheiten, Strukturen und Funktionen.",
"i18n:govoplan-organizations.parent_unit.1986c35e": "Übergeordnete Einheit", "i18n:govoplan-organizations.parent_unit.1986c35e": "Übergeordnete Einheit",
"i18n:govoplan-organizations.reactivate.58f2855d": "Reaktivieren", "i18n:govoplan-organizations.reactivate.58f2855d": "Reaktivieren",
"i18n:govoplan-organizations.relation_added.ca90461a": "Beziehung hinzugefügt.", "i18n:govoplan-organizations.relation_added.ca90461a": "Beziehung hinzugefügt.",
@@ -237,10 +208,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.save_settings.913aba9f": "Einstellungen speichern", "i18n:govoplan-organizations.save_settings.913aba9f": "Einstellungen speichern",
"i18n:govoplan-organizations.save_drafts.606f9401": "Entwürfe speichern", "i18n:govoplan-organizations.save_drafts.606f9401": "Entwürfe speichern",
"i18n:govoplan-organizations.saving.56a2285c": "Speichern...", "i18n:govoplan-organizations.saving.56a2285c": "Speichern...",
"i18n:govoplan-organizations.search_identities.004afdfd": "Nach Name, Subject, Konto oder ID suchen",
"i18n:govoplan-organizations.select_function.4895a67d": "Funktion auswählen", "i18n:govoplan-organizations.select_function.4895a67d": "Funktion auswählen",
"i18n:govoplan-organizations.select_function_type.2f426c43": "Funktionstyp auswählen", "i18n:govoplan-organizations.select_function_type.2f426c43": "Funktionstyp auswählen",
"i18n:govoplan-organizations.select_identity.510f1134": "Identität auswählen",
"i18n:govoplan-organizations.select_relation_type.73f92478": "Beziehungstyp 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_source_unit.9b5a29c8": "Quell-Einheit auswählen",
"i18n:govoplan-organizations.select_structure.c10f551c": "Struktur auswählen", "i18n:govoplan-organizations.select_structure.c10f551c": "Struktur auswählen",
@@ -267,7 +236,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.unit_types.c7afc174": "Einheitstypen", "i18n:govoplan-organizations.unit_types.c7afc174": "Einheitstypen",
"i18n:govoplan-organizations.units.e14d0d92": "Einheiten", "i18n:govoplan-organizations.units.e14d0d92": "Einheiten",
"i18n:govoplan-organizations.unlimited.35569464": "Unbegrenzt", "i18n:govoplan-organizations.unlimited.35569464": "Unbegrenzt",
"i18n:govoplan-organizations.update_assignment.bf130723": "Zuordnung aktualisieren",
"i18n:govoplan-organizations.update_function.504f03e5": "Funktion aktualisieren", "i18n:govoplan-organizations.update_function.504f03e5": "Funktion aktualisieren",
"i18n:govoplan-organizations.update_function_type.0b7e07d3": "Funktionstyp aktualisieren", "i18n:govoplan-organizations.update_function_type.0b7e07d3": "Funktionstyp aktualisieren",
"i18n:govoplan-organizations.update_relation.4c44ce83": "Beziehung aktualisieren", "i18n:govoplan-organizations.update_relation.4c44ce83": "Beziehung aktualisieren",

View File

@@ -1,6 +1,7 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { AdminSectionsUiCapability, PlatformWebModule } from "@govoplan/core-webui"; import type { AdminSectionsUiCapability, OrganizationFunctionPickerUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations"; import { generatedTranslations } from "./i18n/generatedTranslations";
import { OrganizationFunctionLabel, OrganizationFunctionPicker } from "./features/organizations/OrganizationFunctionPicker";
import "./styles/organizations.css"; import "./styles/organizations.css";
const OrganizationsPage = lazy(() => import("./features/organizations/OrganizationsPage")); const OrganizationsPage = lazy(() => import("./features/organizations/OrganizationsPage"));
@@ -31,12 +32,18 @@ const organizationAdminSections: AdminSectionsUiCapability = {
] ]
}; };
const organizationFunctionPicker: OrganizationFunctionPickerUiCapability = {
sourceModule: "organizations",
renderPicker: (context) => createElement(OrganizationFunctionPicker, context),
renderLabel: (context) => createElement(OrganizationFunctionLabel, context)
};
export const organizationsModule: PlatformWebModule = { export const organizationsModule: PlatformWebModule = {
id: "organizations", id: "organizations",
label: "i18n:govoplan-organizations.organizations.220edf64", label: "i18n:govoplan-organizations.organizations.220edf64",
version: "1.0.0", version: "1.0.0",
dependencies: ["access"], dependencies: ["access"],
optionalDependencies: ["identity", "admin"], optionalDependencies: ["admin"],
translations, translations,
navItems: [ navItems: [
{ {
@@ -56,7 +63,8 @@ export const organizationsModule: PlatformWebModule = {
} }
], ],
uiCapabilities: { uiCapabilities: {
"admin.sections": organizationAdminSections "admin.sections": organizationAdminSections,
"organizations.functionPicker": organizationFunctionPicker
} }
}; };

View File

@@ -90,6 +90,10 @@
justify-content: flex-end; justify-content: flex-end;
} }
.organizations-contributed-action {
display: inline-flex;
}
.organizations-check-list { .organizations-check-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -167,18 +171,6 @@
font-size: 13px; font-size: 13px;
} }
.organizations-identity-picker {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
.organizations-identity {
display: grid;
gap: 2px;
min-width: 0;
}
.organizations-id { .organizations-id {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; font-size: 12px;