Sync GovOPlaN module state

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

267
.gitignore vendored
View File

@@ -6,3 +6,270 @@ __pycache__/
.ruff_cache/
build/
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.
It owns organization unit types, organization structures, concrete units,
functions, and identity-held function assignments. These facts exist
independently of authentication and authorization. Identity links people to
accounts, and Access projects organization facts into roles and rights.
It owns organization unit types, organization structures, concrete units, and
functions. These facts exist independently of authentication and authorization.
Identity links people to accounts, IDM links identities to organization
functions, and Access projects accepted facts into roles and rights.
## Boundary
@@ -17,19 +17,18 @@ Organizations owns:
- parallel organization structures and relation types
- functions in organization units
- 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:
- 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
- postboxes, workflows, portals, files, or cases
- external IDM connector internals
The WebUI exposed by this repository is a normal module UI at
`/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.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.
- Function: a named responsibility in an organization unit, such as clerk,
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
IDs for account-specific exercise of a function. This module does not own login
accounts, identity lifecycle, or account linking. `govoplan-identity` owns the
identity/account directory. The organization module records that an identity
currently holds an organizational function.
This module does not own login accounts, identity lifecycle, account linking,
or identity-to-function assignments. `govoplan-identity` owns the
identity/account directory. `govoplan-idm` records that an identity currently
holds an organizational function and may constrain that link to one account.
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
A function assignment does not grant rights on its own. `govoplan-access`
maps organization facts to roles, rights, permission decisions, delegation
enforcement, and explain responses.
An IDM function assignment does not grant rights on its own. `govoplan-access`
maps organization and IDM facts to roles, rights, permission decisions,
delegation enforcement, and explain responses.
This separation keeps the institution model stable even when authorization
policy changes.
@@ -40,10 +39,10 @@ policy changes.
## UI Boundary
The organization workspace at `/organizations` is the primary module UI for
modelling the meta-model, concrete units, structures, functions, and
assignments. Admin-only organization controls are still owned by this module,
but are contributed to the platform admin shell through the `admin.sections`
WebUI capability.
modelling the meta-model, concrete units, structures, and function definitions.
Admin-only organization controls are still owned by this
module, but are contributed to the platform admin shell through the
`admin.sections` WebUI capability.
The admin contribution must not duplicate the organization editor. It is for
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
`govoplan-idm` may import organization units, functions, and assignments from
external directories. It should write through organization capabilities or APIs
after preview, approval, and rollback planning. IDM is a synchronization layer,
not the canonical owner of the organization model.
`govoplan-idm` may import organization-unit and organization-function
definitions from external directories. It should write those definitions
through organization capabilities or APIs after preview, approval, and rollback
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_organizations.backend.db.models import (
OrganizationFunction,
OrganizationFunctionAssignment,
OrganizationFunctionType,
OrganizationRelation,
OrganizationRelationType,
@@ -28,13 +27,10 @@ from govoplan_organizations.backend.db.models import (
)
from .schemas import (
FunctionAssignmentCreateRequest,
FunctionAssignmentUpdateRequest,
FunctionCreateRequest,
FunctionTypeCreateRequest,
FunctionTypeUpdateRequest,
FunctionUpdateRequest,
OrganizationFunctionAssignmentItem,
OrganizationFunctionItem,
OrganizationFunctionTypeItem,
OrganizationModelResponse,
@@ -69,7 +65,6 @@ ORG_READ_SCOPES = (
ORG_MODEL_WRITE_SCOPES = ("organizations:model:write",)
ORG_UNIT_WRITE_SCOPES = ("organizations:unit:write",)
ORG_FUNCTION_WRITE_SCOPES = ("organizations:function:write",)
ORG_ASSIGN_SCOPES = ("organizations:function:assign",)
ORG_SETTINGS_READ_SCOPES = ("organizations:settings:read", "organizations:model:read", "admin:settings:read")
ORG_SETTINGS_WRITE_SCOPES = ("organizations:settings:write", "admin:settings:write")
ORG_CHANGE_CONTROL_KEY = "organizations.model"
@@ -262,10 +257,6 @@ def _item_function(item: OrganizationFunction) -> OrganizationFunctionItem:
return OrganizationFunctionItem(**_row_fields(item))
def _item_assignment(item: OrganizationFunctionAssignment) -> OrganizationFunctionAssignmentItem:
return OrganizationFunctionAssignmentItem(**_row_fields(item))
def _row_fields(item: object) -> dict[str, Any]:
keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined]
return {key: getattr(item, key) for key in keys}
@@ -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()],
function_types=[_item_function_type(item) for item in session.query(OrganizationFunctionType).filter(OrganizationFunctionType.tenant_id == tenant_id).order_by(OrganizationFunctionType.name.asc()).all()],
functions=[_item_function(item) for item in session.query(OrganizationFunction).filter(OrganizationFunction.tenant_id == tenant_id).order_by(OrganizationFunction.name.asc()).all()],
function_assignments=[_item_assignment(item) for item in session.query(OrganizationFunctionAssignment).filter(OrganizationFunctionAssignment.tenant_id == tenant_id).order_by(OrganizationFunctionAssignment.created_at.asc()).all()],
)
@@ -738,89 +728,6 @@ def update_function(
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:
if function_type_id is None:
return None

View File

@@ -132,25 +132,6 @@ class OrganizationFunctionItem(BaseModel):
updated_at: datetime
class OrganizationFunctionAssignmentItem(BaseModel):
id: str
tenant_id: str
identity_id: str
account_id: str | None = None
function_id: str
organization_unit_id: str
applies_to_subunits: bool
source: str
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool
settings: dict[str, Any]
created_at: datetime
updated_at: datetime
class OrganizationModelResponse(BaseModel):
unit_types: list[OrganizationUnitTypeItem]
structures: list[OrganizationStructureItem]
@@ -159,7 +140,6 @@ class OrganizationModelResponse(BaseModel):
relations: list[OrganizationRelationItem]
function_types: list[OrganizationFunctionTypeItem]
functions: list[OrganizationFunctionItem]
function_assignments: list[OrganizationFunctionAssignmentItem]
class SluggedCreateRequest(BaseModel):
@@ -270,33 +250,3 @@ class FunctionUpdateRequest(SluggedUpdateRequest):
function_type_id: str | None = None
delegable: bool | None = None
act_in_place_allowed: bool | None = None
class FunctionAssignmentCreateRequest(BaseModel):
identity_id: str
function_id: str
account_id: str | None = None
applies_to_subunits: bool = False
source: str = "direct"
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool = True
settings: dict[str, Any] = Field(default_factory=dict)
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)
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__ = [
"OrganizationFunction",
"OrganizationFunctionAssignment",
"OrganizationFunctionType",
"OrganizationRelation",
"OrganizationRelationType",

View File

@@ -1,19 +1,13 @@
from __future__ import annotations
from sqlalchemy import or_
from govoplan_core.core.organizations import (
OrganizationDirectory,
OrganizationFunctionAssignmentRef,
OrganizationFunctionRef,
OrganizationUnitRef,
)
from govoplan_core.core.identity import IdentityDirectory
from govoplan_core.db.session import get_database
from govoplan_core.security.time import utc_now
from govoplan_organizations.backend.db.models import (
OrganizationFunction,
OrganizationFunctionAssignment,
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):
def __init__(self, *, identity_directory: IdentityDirectory | None = None) -> None:
self._identity_directory = identity_directory
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
with get_database().session() as session:
item = session.get(OrganizationUnit, organization_unit_id)
@@ -121,70 +94,3 @@ class SqlOrganizationDirectory(OrganizationDirectory):
.all()
)
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 govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationTopic,
@@ -50,22 +49,21 @@ PERMISSIONS = (
_permission("organizations:settings:write", "Manage organization settings", "Edit organization governance, audit, and retention settings."),
_permission("organizations:unit:read", "View organization units", "Read concrete organization units and relations."),
_permission("organizations:unit:write", "Manage organization units", "Create and edit concrete organization units and relations."),
_permission("organizations:function:read", "View organization functions", "Read function definitions and identity-held assignments."),
_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:assign", "Assign organization functions", "Assign identity-held functions in organization units."),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="organization_modeler",
name="Organization modeler",
description="Manage organization meta-model, concrete units, structures, functions, and assignments.",
description="Manage organization meta-model, concrete units, structures, and functions.",
permissions=tuple(permission.scope for permission in PERMISSIONS),
),
RoleTemplate(
slug="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"),
),
)
@@ -79,15 +77,10 @@ def _route_factory(context: ModuleContext):
def _organization_directory(context: ModuleContext) -> object:
identity_directory: IdentityDirectory | None = None
registry = context.registry
if hasattr(registry, "has_capability") and registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if isinstance(capability, IdentityDirectory):
identity_directory = capability
del context
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
return SqlOrganizationDirectory(identity_directory=identity_directory)
return SqlOrganizationDirectory()
manifest = ModuleManifest(
@@ -95,7 +88,7 @@ manifest = ModuleManifest(
name="Organizations",
version="0.1.6",
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,
role_templates=ROLE_TEMPLATES,
route_factory=_route_factory,
@@ -121,7 +114,6 @@ manifest = ModuleManifest(
organization_models.OrganizationUnit,
organization_models.OrganizationFunctionType,
organization_models.OrganizationFunction,
organization_models.OrganizationFunctionAssignment,
label="Organizations",
),
),
@@ -132,11 +124,11 @@ manifest = ModuleManifest(
DocumentationTopic(
id="organizations.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=(
"Use organization unit types, structures, and relation types to model how the institution describes itself. "
"A concrete organization unit can participate in several structures at the same time, such as an employer hierarchy and an academic structure. "
"Functions are held by identities in organization units. Accounts only exercise those functions through identity and access policy."
"Functions describe responsibilities in organization units. IDM links identities to those functions, and Access maps accepted facts to roles and rights."
),
layer="configured",
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])
def _create_function_assignments() -> None:
if "organizations_function_assignments" not in _tables():
op.create_table(
"organizations_function_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("identity_id", sa.String(length=36), nullable=False),
sa.Column("account_id", sa.String(length=36), nullable=True),
sa.Column("function_id", sa.String(length=36), nullable=False),
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
sa.Column("source", sa.String(length=50), nullable=False),
sa.Column("delegated_from_assignment_id", sa.String(length=36), nullable=True),
sa.Column("acting_for_account_id", sa.String(length=36), nullable=True),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
_json_column("settings"),
*_timestamp_columns(),
sa.ForeignKeyConstraint(["delegated_from_assignment_id"], ["organizations_function_assignments.id"], name=op.f("fk_organizations_function_assignments_delegated_from_assignment_id_organizations_function_assignments"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["function_id"], ["organizations_functions.id"], name=op.f("fk_organizations_function_assignments_function_id_organizations_functions"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["organization_unit_id"], ["organizations_units.id"], name=op.f("fk_organizations_function_assignments_organization_unit_id_organizations_units"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_function_assignments")),
sa.UniqueConstraint("tenant_id", "identity_id", "function_id", "organization_unit_id", name="uq_organizations_function_assignments_identity_scope"),
)
else:
columns = _columns("organizations_function_assignments")
if "identity_id" not in columns:
op.add_column("organizations_function_assignments", sa.Column("identity_id", sa.String(length=36), nullable=True))
op.execute(sa.text("UPDATE organizations_function_assignments SET identity_id = account_id WHERE identity_id IS NULL"))
for column in (
"account_id",
"acting_for_account_id",
"delegated_from_assignment_id",
"function_id",
"identity_id",
"organization_unit_id",
"tenant_id",
):
_create_index_if_missing(op.f(f"ix_organizations_function_assignments_{column}"), "organizations_function_assignments", [column])
_create_index_if_missing(
"uq_organizations_function_assignments_identity_scope",
"organizations_function_assignments",
["tenant_id", "identity_id", "function_id", "organization_unit_id"],
unique=True,
)
def upgrade() -> None:
_create_unit_types()
_create_tenant_settings()
@@ -296,12 +248,10 @@ def upgrade() -> None:
_create_relations()
_create_function_types()
_create_functions()
_create_function_assignments()
def downgrade() -> None:
for table_name, indexes in (
("organizations_function_assignments", ("uq_organizations_function_assignments_identity_scope",)),
("organizations_relations", ("ix_organizations_relations_structure_source", "ix_organizations_relations_structure_target")),
("organizations_relation_types", ("ix_organizations_relation_types_structure",)),
):

View File

@@ -125,25 +125,6 @@ export type OrganizationFunctionItem = {
updated_at: string;
};
export type OrganizationFunctionAssignmentItem = {
id: string;
tenant_id: string;
identity_id: string;
account_id?: string | null;
function_id: string;
organization_unit_id: string;
applies_to_subunits: boolean;
source: string;
delegated_from_assignment_id?: string | null;
acting_for_account_id?: string | null;
valid_from?: string | null;
valid_until?: string | null;
is_active: boolean;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationModel = {
unit_types: OrganizationUnitTypeItem[];
structures: OrganizationStructureItem[];
@@ -152,21 +133,6 @@ export type OrganizationModel = {
relations: OrganizationRelationItem[];
function_types: OrganizationFunctionTypeItem[];
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 = {
@@ -220,18 +186,6 @@ export type FunctionCreatePayload = SluggedCreatePayload & {
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> {
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");
}
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> {
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> {
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,
hasScope,
useUnsavedDraftGuard,
usePlatformUiCapabilities,
type ApiSettings,
type AuthInfo,
type DataGridColumn,
type ModuleSubnavGroup
type ModuleSubnavGroup,
type OrganizationFunctionActionContribution,
type OrganizationFunctionActionsUiCapability
} from "@govoplan/core-webui";
import {
createFunction,
createFunctionAssignment,
createFunctionType,
createRelation,
createRelationType,
@@ -29,19 +31,14 @@ import {
createUnitType,
getOrganizationModel,
patchFunction,
patchFunctionAssignment,
patchFunctionType,
patchRelation,
patchRelationType,
patchStructure,
patchUnit,
patchUnitType,
searchIdentityOptions,
type FunctionAssignmentCreatePayload,
type FunctionCreatePayload,
type FunctionTypeCreatePayload,
type IdentityOption,
type OrganizationFunctionAssignmentItem,
type OrganizationFunctionItem,
type OrganizationFunctionTypeItem,
type OrganizationModel,
@@ -58,7 +55,7 @@ import {
type UnitCreatePayload
} from "../../api/organizations";
export type OrganizationSection = "model" | "units" | "relations" | "functions" | "assignments";
export type OrganizationSection = "model" | "units" | "relations" | "functions";
type OrganizationsPageMode = "workspace" | "admin";
type OrganizationsPageProps = {
@@ -116,17 +113,6 @@ type FunctionDraft = SluggedDraft & {
act_in_place_allowed: boolean;
};
type AssignmentDraft = {
identity_id: string;
account_id: string;
function_id: string;
applies_to_subunits: boolean;
source: string;
delegated_from_assignment_id: string;
acting_for_account_id: string;
is_active: boolean;
};
type ActiveItem = {
id: string;
is_active: boolean;
@@ -139,8 +125,7 @@ const EMPTY_MODEL: OrganizationModel = {
units: [],
relations: [],
function_types: [],
functions: [],
function_assignments: []
functions: []
};
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: "units", label: "i18n:govoplan-organizations.units.e14d0d92" },
{ id: "relations", label: "i18n:govoplan-organizations.relations.1c796711" },
{ id: "functions", label: "i18n:govoplan-organizations.functions.805dc49b" },
{ id: "assignments", label: "i18n:govoplan-organizations.assignments.278f513e" }
{ id: "functions", label: "i18n:govoplan-organizations.functions.805dc49b" }
]
}
];
@@ -163,7 +147,7 @@ const STRUCTURE_KINDS: Array<{ value: OrganizationStructureKind; label: string }
{ 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 {
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 };
}
function emptyAssignmentDraft(): AssignmentDraft {
return {
identity_id: "",
account_id: "",
function_id: "",
applies_to_subunits: false,
source: "direct",
delegated_from_assignment_id: "",
acting_for_account_id: "",
is_active: true
};
}
function textOrNull(value: string): string | null {
const trimmed = value.trim();
return trimmed ? trimmed : null;
@@ -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;
}
function isAssignmentDirty(draft: AssignmentDraft): boolean {
return Boolean(
draft.identity_id.trim() ||
draft.account_id.trim() ||
draft.function_id ||
draft.applies_to_subunits ||
draft.source.trim() !== "direct" ||
draft.delegated_from_assignment_id ||
draft.acting_for_account_id ||
!draft.is_active
);
}
function mapById<T extends { id: string }>(items: T[]): Map<string, T> {
return new Map(items.map((item) => [item.id, item]));
}
@@ -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"} />;
}
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 }) {
const label = item.is_active ? "i18n:govoplan-organizations.deactivate.46ab070d" : "i18n:govoplan-organizations.reactivate.58f2855d";
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 (
<div className="organizations-row-actions">
<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
</Button>
<ActiveToggleButton item={item} disabled={disabled} onToggle={onToggle} />
{extra}
</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({
settings,
auth,
@@ -408,7 +360,6 @@ export default function OrganizationsPage({
const [relationDraft, setRelationDraft] = useState<RelationDraft>(() => emptyRelationDraft());
const [functionTypeDraft, setFunctionTypeDraft] = useState<FunctionTypeDraft>(() => emptyFunctionTypeDraft());
const [functionDraft, setFunctionDraft] = useState<FunctionDraft>(() => emptyFunctionDraft());
const [assignmentDraft, setAssignmentDraft] = useState<AssignmentDraft>(() => emptyAssignmentDraft());
const [editingUnitTypeId, setEditingUnitTypeId] = useState<string | null>(null);
const [editingStructureId, setEditingStructureId] = 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 [editingFunctionTypeId, setEditingFunctionTypeId] = useState<string | null>(null);
const [editingFunctionId, setEditingFunctionId] = useState<string | null>(null);
const [editingAssignmentId, setEditingAssignmentId] = useState<string | null>(null);
const [selectedUnitId, setSelectedUnitId] = useState("");
const [changeRequestId, setChangeRequestId] = useState("");
const [identitySearch, setIdentitySearch] = useState("");
const [identityOptions, setIdentityOptions] = useState<IdentityOption[]>([]);
const [identityLoading, setIdentityLoading] = useState(false);
const [identityLookupAvailable, setIdentityLookupAvailable] = useState(true);
const functionActionCapabilities = usePlatformUiCapabilities<OrganizationFunctionActionsUiCapability>("organizations.functionActions");
const canWriteModel = hasScope(auth, "organizations:model:write");
const canWriteUnits = hasScope(auth, "organizations:unit:write");
const canWriteFunctions = hasScope(auth, "organizations:function:write");
const canAssignFunctions = hasScope(auth, "organizations:function:assign");
const unitTypeById = useMemo(() => mapById(model.unit_types), [model.unit_types]);
const structureById = useMemo(() => mapById(model.structures), [model.structures]);
@@ -435,7 +381,13 @@ export default function OrganizationsPage({
const unitById = useMemo(() => mapById(model.units), [model.units]);
const functionTypeById = useMemo(() => mapById(model.function_types), [model.function_types]);
const functionById = useMemo(() => mapById(model.functions), [model.functions]);
const 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 mapped = new Map<string, OrganizationUnitItem[]>();
for (const unit of model.units) {
@@ -452,8 +404,7 @@ export default function OrganizationsPage({
isUnitDirty(unitDraft) ||
isRelationDirty(relationDraft) ||
isFunctionTypeDirty(functionTypeDraft) ||
isFunctionDirty(functionDraft) ||
isAssignmentDirty(assignmentDraft);
isFunctionDirty(functionDraft);
const loadModel = useCallback(async () => {
setLoading(true);
@@ -484,36 +435,6 @@ export default function OrganizationsPage({
if (selectedUnitId && !unitById.has(selectedUnitId)) setSelectedUnitId("");
}, [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> => {
setBusy(true);
setError("");
@@ -539,7 +460,6 @@ export default function OrganizationsPage({
setRelationDraft(emptyRelationDraft());
setFunctionTypeDraft(emptyFunctionTypeDraft());
setFunctionDraft(emptyFunctionDraft());
setAssignmentDraft(emptyAssignmentDraft());
setEditingUnitTypeId(null);
setEditingStructureId(null);
setEditingRelationTypeId(null);
@@ -547,7 +467,6 @@ export default function OrganizationsPage({
setEditingRelationId(null);
setEditingFunctionTypeId(null);
setEditingFunctionId(null);
setEditingAssignmentId(null);
}, []);
function rejectMissingName(): Promise<boolean> {
@@ -728,35 +647,6 @@ export default function OrganizationsPage({
return ok;
}, [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> => {
if (isSluggedDirty(unitTypeDraft) && !(await submitUnitType())) 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 (isFunctionTypeDirty(functionTypeDraft) && !(await submitFunctionType())) return false;
if (isFunctionDirty(functionDraft) && !(await submitFunction())) return false;
if (isAssignmentDirty(assignmentDraft) && !(await submitAssignment())) return false;
return true;
}, [
assignmentDraft,
functionDraft,
functionTypeDraft,
relationDraft,
relationTypeDraft,
structureDraft,
submitAssignment,
submitFunction,
submitFunctionType,
submitRelation,
@@ -822,10 +709,6 @@ export default function OrganizationsPage({
void runAction(() => patchFunction(settings, item.id, withChangeRequest({ is_active: !item.is_active })));
}, [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) => {
setEditingUnitTypeId(item.id);
setUnitTypeDraft(sluggedDraftFrom(item));
@@ -901,22 +784,6 @@ export default function OrganizationsPage({
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>[] = [
{ 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 },
@@ -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: "delegable", header: "i18n:govoplan-organizations.delegable.b4f0137d", width: 120, render: (row) => activeStatus(row.delegable) },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
{ id: "actions", header: "", width: 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>[] = [
{ 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;
const canWriteActive = active === "model" ? canWriteModel : active === "units" || active === "relations" ? canWriteUnits : canWriteFunctions;
function cancelEditButton(onCancel: () => void) {
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 === "relations" && renderRelationsSection()}
{active === "functions" && renderFunctionsSection()}
{active === "assignments" && renderAssignmentsSection()}
</LoadingFrame>
</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: {
"i18n:govoplan-organizations.act_in_place.49b942bd": "Act in place",
"i18n:govoplan-organizations.active.a733b809": "Active",
"i18n:govoplan-organizations.add_assignment.6682f5f5": "Add assignment",
"i18n:govoplan-organizations.add_function.6abafee0": "Add function",
"i18n:govoplan-organizations.add_function_type.90d793f1": "Add function type",
"i18n:govoplan-organizations.add_relation.6b6e67ea": "Add relation",
@@ -15,10 +14,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.add_unit.8fa12fb1": "Add unit",
"i18n:govoplan-organizations.add_unit_type.58f2c05a": "Add unit type",
"i18n:govoplan-organizations.allow_cycles.31327578": "Allow cycles",
"i18n:govoplan-organizations.applies_to_subunits.7a15f741": "Applies to subunits",
"i18n:govoplan-organizations.assignment_added.94263d1b": "Function assignment added.",
"i18n:govoplan-organizations.assignment_updated.680b6e33": "Function assignment updated.",
"i18n:govoplan-organizations.assignments.278f513e": "Assignments",
"i18n:govoplan-organizations.audit_and_retention.3ba1d2fc": "Audit and retention",
"i18n:govoplan-organizations.audit_detail_level.7397355d": "Audit detail level",
"i18n:govoplan-organizations.audit_full.0c83669c": "Full",
@@ -34,28 +29,22 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.deactivate.46ab070d": "Deactivate",
"i18n:govoplan-organizations.delegable.b4f0137d": "Delegable",
"i18n:govoplan-organizations.description.55f8ebc8": "Description",
"i18n:govoplan-organizations.direct_assignment.2a068aac": "direct",
"i18n:govoplan-organizations.discard_organization_drafts.766d5f46": "Discard organization drafts",
"i18n:govoplan-organizations.edit.7dce1220": "Edit",
"i18n:govoplan-organizations.function.28822f3a": "Function",
"i18n:govoplan-organizations.function_added.e2266702": "Function added.",
"i18n:govoplan-organizations.function_assignments.4c5c6dae": "Function assignments",
"i18n:govoplan-organizations.function_type.501fe7c0": "Function type",
"i18n:govoplan-organizations.function_type_added.2cd9e899": "Function type added.",
"i18n:govoplan-organizations.function_type_updated.1a4f29f6": "Function type updated.",
"i18n:govoplan-organizations.function_types.172c01fe": "Function types",
"i18n:govoplan-organizations.function_updated.65016009": "Function updated.",
"i18n:govoplan-organizations.functions_unavailable.4fd6765d": "Functions are unavailable.",
"i18n:govoplan-organizations.functions.805dc49b": "Functions",
"i18n:govoplan-organizations.hierarchical.8964f313": "Hierarchical",
"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.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_settings.c6008db8": "Loading organization settings...",
"i18n:govoplan-organizations.membership.4531d86d": "Membership",
@@ -66,7 +55,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.name.709a2322": "Name",
"i18n:govoplan-organizations.name_is_required.27cd3782": "Name is required.",
"i18n:govoplan-organizations.network.a24d31c3": "Network",
"i18n:govoplan-organizations.no_assignments_found.7ecc5bb7": "No function assignments found.",
"i18n:govoplan-organizations.no_function_types_found.5eef31b1": "No function types found.",
"i18n:govoplan-organizations.no_functions_found.54e759a0": "No functions found.",
"i18n:govoplan-organizations.no_relation_types_found.6f90bb81": "No relation types found.",
@@ -76,7 +64,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.no_unit_types_found.c81fb2a7": "No unit types found.",
"i18n:govoplan-organizations.no_units_found.eea2dd0c": "No units found.",
"i18n:govoplan-organizations.none.334c4a4c": "None",
"i18n:govoplan-organizations.optional_account_id.5fcf495c": "Optional account ID",
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organization model",
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Configure tenant-local unit types, parallel structures, relation types, and function types used by the organization module.",
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organization settings",
@@ -84,7 +71,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organization settings saved.",
"i18n:govoplan-organizations.organization_tree.e5bfb195": "Organization tree",
"i18n:govoplan-organizations.organizations.220edf64": "Organizations",
"i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Model organization types, concrete units, structures, functions, and identity-held function assignments.",
"i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Model organization types, concrete units, structures, and functions.",
"i18n:govoplan-organizations.parent_unit.1986c35e": "Parent unit",
"i18n:govoplan-organizations.reactivate.58f2855d": "Reactivate",
"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_drafts.606f9401": "Save drafts",
"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_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_source_unit.9b5a29c8": "Select source unit",
"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.units.e14d0d92": "Units",
"i18n:govoplan-organizations.unlimited.35569464": "Unlimited",
"i18n:govoplan-organizations.update_assignment.bf130723": "Update assignment",
"i18n:govoplan-organizations.update_function.504f03e5": "Update function",
"i18n:govoplan-organizations.update_function_type.0b7e07d3": "Update function type",
"i18n:govoplan-organizations.update_relation.4c44ce83": "Update relation",
@@ -142,7 +126,6 @@ export const generatedTranslations: PlatformTranslations = {
de: {
"i18n:govoplan-organizations.act_in_place.49b942bd": "In Vertretung handeln",
"i18n:govoplan-organizations.active.a733b809": "Aktiv",
"i18n:govoplan-organizations.add_assignment.6682f5f5": "Zuordnung hinzufügen",
"i18n:govoplan-organizations.add_function.6abafee0": "Funktion hinzufügen",
"i18n:govoplan-organizations.add_function_type.90d793f1": "Funktionstyp hinzufügen",
"i18n:govoplan-organizations.add_relation.6b6e67ea": "Beziehung hinzufügen",
@@ -153,10 +136,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.add_unit.8fa12fb1": "Einheit hinzufügen",
"i18n:govoplan-organizations.add_unit_type.58f2c05a": "Einheitstyp hinzufügen",
"i18n:govoplan-organizations.allow_cycles.31327578": "Zyklen erlauben",
"i18n:govoplan-organizations.applies_to_subunits.7a15f741": "Gilt für Untereinheiten",
"i18n:govoplan-organizations.assignment_added.94263d1b": "Funktionszuordnung hinzugefügt.",
"i18n:govoplan-organizations.assignment_updated.680b6e33": "Funktionszuordnung aktualisiert.",
"i18n:govoplan-organizations.assignments.278f513e": "Zuordnungen",
"i18n:govoplan-organizations.audit_and_retention.3ba1d2fc": "Audit und Aufbewahrung",
"i18n:govoplan-organizations.audit_detail_level.7397355d": "Audit-Detailgrad",
"i18n:govoplan-organizations.audit_full.0c83669c": "Vollständig",
@@ -172,28 +151,22 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.deactivate.46ab070d": "Deaktivieren",
"i18n:govoplan-organizations.delegable.b4f0137d": "Delegierbar",
"i18n:govoplan-organizations.description.55f8ebc8": "Beschreibung",
"i18n:govoplan-organizations.direct_assignment.2a068aac": "direkt",
"i18n:govoplan-organizations.discard_organization_drafts.766d5f46": "Organisationsentwürfe verwerfen",
"i18n:govoplan-organizations.edit.7dce1220": "Bearbeiten",
"i18n:govoplan-organizations.function.28822f3a": "Funktion",
"i18n:govoplan-organizations.function_added.e2266702": "Funktion hinzugefügt.",
"i18n:govoplan-organizations.function_assignments.4c5c6dae": "Funktionszuordnungen",
"i18n:govoplan-organizations.function_type.501fe7c0": "Funktionstyp",
"i18n:govoplan-organizations.function_type_added.2cd9e899": "Funktionstyp hinzugefügt.",
"i18n:govoplan-organizations.function_type_updated.1a4f29f6": "Funktionstyp aktualisiert.",
"i18n:govoplan-organizations.function_types.172c01fe": "Funktionstypen",
"i18n:govoplan-organizations.function_updated.65016009": "Funktion aktualisiert.",
"i18n:govoplan-organizations.functions_unavailable.4fd6765d": "Funktionen sind nicht verfügbar.",
"i18n:govoplan-organizations.functions.805dc49b": "Funktionen",
"i18n:govoplan-organizations.hierarchical.8964f313": "Hierarchisch",
"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.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_settings.c6008db8": "Organisationseinstellungen werden geladen...",
"i18n:govoplan-organizations.membership.4531d86d": "Mitgliedschaft",
@@ -204,7 +177,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.name.709a2322": "Name",
"i18n:govoplan-organizations.name_is_required.27cd3782": "Name ist erforderlich.",
"i18n:govoplan-organizations.network.a24d31c3": "Netzwerk",
"i18n:govoplan-organizations.no_assignments_found.7ecc5bb7": "Keine Funktionszuordnungen gefunden.",
"i18n:govoplan-organizations.no_function_types_found.5eef31b1": "Keine Funktionstypen gefunden.",
"i18n:govoplan-organizations.no_functions_found.54e759a0": "Keine Funktionen gefunden.",
"i18n:govoplan-organizations.no_relation_types_found.6f90bb81": "Keine Beziehungstypen gefunden.",
@@ -214,7 +186,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.no_unit_types_found.c81fb2a7": "Keine Einheitstypen gefunden.",
"i18n:govoplan-organizations.no_units_found.eea2dd0c": "Keine Einheiten gefunden.",
"i18n:govoplan-organizations.none.334c4a4c": "Keine",
"i18n:govoplan-organizations.optional_account_id.5fcf495c": "Optionale Konto-ID",
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organisationsmodell",
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Konfiguriere mandantenbezogene Einheitstypen, parallele Strukturen, Beziehungstypen und Funktionstypen des Organisationsmoduls.",
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organisationseinstellungen",
@@ -222,7 +193,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organisationseinstellungen gespeichert.",
"i18n:govoplan-organizations.organization_tree.e5bfb195": "Organisationsbaum",
"i18n:govoplan-organizations.organizations.220edf64": "Organisationen",
"i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Modelliere Organisationstypen, konkrete Einheiten, Strukturen, Funktionen und identitätsbezogene Funktionszuordnungen.",
"i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Modelliere Organisationstypen, konkrete Einheiten, Strukturen und Funktionen.",
"i18n:govoplan-organizations.parent_unit.1986c35e": "Übergeordnete Einheit",
"i18n:govoplan-organizations.reactivate.58f2855d": "Reaktivieren",
"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_drafts.606f9401": "Entwürfe 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_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_source_unit.9b5a29c8": "Quell-Einheit 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.units.e14d0d92": "Einheiten",
"i18n:govoplan-organizations.unlimited.35569464": "Unbegrenzt",
"i18n:govoplan-organizations.update_assignment.bf130723": "Zuordnung aktualisieren",
"i18n:govoplan-organizations.update_function.504f03e5": "Funktion aktualisieren",
"i18n:govoplan-organizations.update_function_type.0b7e07d3": "Funktionstyp aktualisieren",
"i18n:govoplan-organizations.update_relation.4c44ce83": "Beziehung aktualisieren",

View File

@@ -1,6 +1,7 @@
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 { OrganizationFunctionLabel, OrganizationFunctionPicker } from "./features/organizations/OrganizationFunctionPicker";
import "./styles/organizations.css";
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 = {
id: "organizations",
label: "i18n:govoplan-organizations.organizations.220edf64",
version: "1.0.0",
dependencies: ["access"],
optionalDependencies: ["identity", "admin"],
optionalDependencies: ["admin"],
translations,
navItems: [
{
@@ -56,7 +63,8 @@ export const organizationsModule: PlatformWebModule = {
}
],
uiCapabilities: {
"admin.sections": organizationAdminSections
"admin.sections": organizationAdminSections,
"organizations.functionPicker": organizationFunctionPicker
}
};

View File

@@ -90,6 +90,10 @@
justify-content: flex-end;
}
.organizations-contributed-action {
display: inline-flex;
}
.organizations-check-list {
display: grid;
gap: 10px;
@@ -167,18 +171,6 @@
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 {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;