diff --git a/.gitignore b/.gitignore index 7082bff..7f8199d 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 06d878f..976fe00 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ORGANIZATION_MODEL.md b/docs/ORGANIZATION_MODEL.md index fa26f00..cb3ff32 100644 --- a/docs/ORGANIZATION_MODEL.md +++ b/docs/ORGANIZATION_MODEL.md @@ -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. diff --git a/src/govoplan_organizations/backend/api/v1/routes.py b/src/govoplan_organizations/backend/api/v1/routes.py index ed73965..f6252fe 100644 --- a/src/govoplan_organizations/backend/api/v1/routes.py +++ b/src/govoplan_organizations/backend/api/v1/routes.py @@ -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 diff --git a/src/govoplan_organizations/backend/api/v1/schemas.py b/src/govoplan_organizations/backend/api/v1/schemas.py index 849ec64..1cacfa8 100644 --- a/src/govoplan_organizations/backend/api/v1/schemas.py +++ b/src/govoplan_organizations/backend/api/v1/schemas.py @@ -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 diff --git a/src/govoplan_organizations/backend/db/models.py b/src/govoplan_organizations/backend/db/models.py index bfa463f..012927f 100644 --- a/src/govoplan_organizations/backend/db/models.py +++ b/src/govoplan_organizations/backend/db/models.py @@ -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", diff --git a/src/govoplan_organizations/backend/directory.py b/src/govoplan_organizations/backend/directory.py index 636a31f..c5fcace 100644 --- a/src/govoplan_organizations/backend/directory.py +++ b/src/govoplan_organizations/backend/directory.py @@ -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()) diff --git a/src/govoplan_organizations/backend/manifest.py b/src/govoplan_organizations/backend/manifest.py index 9ecd3b9..63e15f2 100644 --- a/src/govoplan_organizations/backend/manifest.py +++ b/src/govoplan_organizations/backend/manifest.py @@ -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"), diff --git a/src/govoplan_organizations/backend/migrations/versions/6d7e8f9a0b1c_organization_meta_model.py b/src/govoplan_organizations/backend/migrations/versions/6d7e8f9a0b1c_organization_meta_model.py index 8e19233..4b4471a 100644 --- a/src/govoplan_organizations/backend/migrations/versions/6d7e8f9a0b1c_organization_meta_model.py +++ b/src/govoplan_organizations/backend/migrations/versions/6d7e8f9a0b1c_organization_meta_model.py @@ -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",)), ): diff --git a/webui/src/api/organizations.ts b/webui/src/api/organizations.ts index a44ab52..20d3f46 100644 --- a/webui/src/api/organizations.ts +++ b/webui/src/api/organizations.ts @@ -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; - 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; -} & OrganizationChangeRequestPayload; - function post>(settings: ApiSettings, path: string, payload: P): Promise { return apiFetch(settings, path, { method: "POST", body: JSON.stringify(payload) }); } @@ -244,15 +198,6 @@ export function getOrganizationModel(settings: ApiSettings): Promise(settings, "/api/v1/organizations/model"); } -export async function searchIdentityOptions(settings: ApiSettings, query = "", limit = 25): Promise { - const params = new URLSearchParams(); - const trimmed = query.trim(); - if (trimmed) params.set("query", trimmed); - params.set("limit", String(limit)); - const response = await apiFetch(settings, `/api/v1/identity/identities?${params.toString()}`); - return response.identities; -} - export function getOrganizationSettings(settings: ApiSettings): Promise { return apiFetch(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): Promise { return patch(settings, `/api/v1/organizations/functions/${encodeURIComponent(id)}`, payload); } - -export function createFunctionAssignment(settings: ApiSettings, payload: FunctionAssignmentCreatePayload): Promise { - return post(settings, "/api/v1/organizations/function-assignments", payload); -} - -export function patchFunctionAssignment(settings: ApiSettings, id: string, payload: Partial): Promise { - return patch(settings, `/api/v1/organizations/function-assignments/${encodeURIComponent(id)}`, payload); -} diff --git a/webui/src/features/organizations/OrganizationFunctionPicker.tsx b/webui/src/features/organizations/OrganizationFunctionPicker.tsx new file mode 100644 index 0000000..12160fc --- /dev/null +++ b/webui/src/features/organizations/OrganizationFunctionPicker.tsx @@ -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(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 ( + <> + + {error &&

i18n:govoplan-organizations.functions_unavailable.4fd6765d

} + + ); +} + +export function OrganizationFunctionLabel({ settings, auth, sourceModule, functionId, fallback }: OrganizationFunctionLabelContext) { + const [model, setModel] = useState(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 ( +
+ {option.label} +
{option.organizationUnitLabel ?? option.slug}
+
+ ); +} + +function functionOptions(model: OrganizationModel | null): FunctionOption[] { + if (!model) return []; + const unitById = new Map(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 + }; +} diff --git a/webui/src/features/organizations/OrganizationsPage.tsx b/webui/src/features/organizations/OrganizationsPage.tsx index 51574ec..da2e607 100644 --- a/webui/src/features/organizations/OrganizationsPage.tsx +++ b/webui/src/features/organizations/OrganizationsPage.tsx @@ -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[] = [ @@ -150,8 +135,7 @@ const SECTION_GROUPS: ModuleSubnavGroup[] = [ { 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(items: T[]): Map { return new Map(items.map((item) => [item.id, item])); } @@ -317,21 +275,6 @@ function activeStatus(active: boolean): JSX.Element { return ; } -function identityOptionLabel(identity: IdentityOption): string { - return identity.display_name || identity.external_subject || identity.primary_account_id || identity.id; -} - -function identityDisplay(identityId: string, identities: Map): JSX.Element { - const identity = identities.get(identityId); - if (!identity) return {identityId}; - return ( - - {identityOptionLabel(identity)} - {identity.id} - - ); -} - 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 (
+ {extra}
); } @@ -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(() => emptyRelationDraft()); const [functionTypeDraft, setFunctionTypeDraft] = useState(() => emptyFunctionTypeDraft()); const [functionDraft, setFunctionDraft] = useState(() => emptyFunctionDraft()); - const [assignmentDraft, setAssignmentDraft] = useState(() => emptyAssignmentDraft()); const [editingUnitTypeId, setEditingUnitTypeId] = useState(null); const [editingStructureId, setEditingStructureId] = useState(null); const [editingRelationTypeId, setEditingRelationTypeId] = useState(null); @@ -416,18 +367,13 @@ export default function OrganizationsPage({ const [editingRelationId, setEditingRelationId] = useState(null); const [editingFunctionTypeId, setEditingFunctionTypeId] = useState(null); const [editingFunctionId, setEditingFunctionId] = useState(null); - const [editingAssignmentId, setEditingAssignmentId] = useState(null); const [selectedUnitId, setSelectedUnitId] = useState(""); const [changeRequestId, setChangeRequestId] = useState(""); - const [identitySearch, setIdentitySearch] = useState(""); - const [identityOptions, setIdentityOptions] = useState([]); - const [identityLoading, setIdentityLoading] = useState(false); - const [identityLookupAvailable, setIdentityLookupAvailable] = useState(true); + const functionActionCapabilities = usePlatformUiCapabilities("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(); 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, successMessage = ""): Promise => { 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 { @@ -728,35 +647,6 @@ export default function OrganizationsPage({ return ok; }, [canWriteFunctions, editingFunctionId, functionDraft, runAction, settings, withChangeRequest]); - const submitAssignment = useCallback(async (event?: FormEvent): Promise => { - 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 => { 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[] = [ { 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) => editFunction(row)} onToggle={() => toggleFunction(row)} /> } + { + id: "actions", + header: "", + width: functionActionContributions.length ? 340 : 220, + sticky: "end", + render: (row) => ( + editFunction(row)} + onToggle={() => toggleFunction(row)} + extra={functionActionContributions.map((contribution) => ( + + {contribution.render({ settings, auth, function: row })} + + ))} + /> + ) + } ]; - const assignmentColumns: DataGridColumn[] = [ - { 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) => 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 ; @@ -1032,7 +907,6 @@ export default function OrganizationsPage({ {active === "units" && renderUnitsSection()} {active === "relations" && renderRelationsSection()} {active === "functions" && renderFunctionsSection()} - {active === "assignments" && renderAssignmentsSection()} ); @@ -1302,77 +1176,4 @@ export default function OrganizationsPage({ ); } - function renderAssignmentsSection() { - const selectedIdentity = identityOptionById.get(assignmentDraft.identity_id); - return ( -
- -
void submitAssignment(event)}> - {identityLookupAvailable ? ( -
- - setIdentitySearch(event.target.value)} /> - - - - -
- ) : ( - <> -

i18n:govoplan-organizations.identity_lookup_unavailable.5a3e77b2

- - setAssignmentDraft({ ...assignmentDraft, identity_id: event.target.value })} /> - - - )} - - {selectedIdentity && selectedIdentity.account_ids.length > 0 ? ( - - ) : ( - setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })} /> - )} - - - - - - setAssignmentDraft({ ...assignmentDraft, source: event.target.value })} /> - -
- - -
-
- - {editingAssignmentId && cancelEditButton(() => { setEditingAssignmentId(null); setAssignmentDraft(emptyAssignmentDraft()); })} -
-
-
- - row.id} emptyText="i18n:govoplan-organizations.no_assignments_found.7ecc5bb7" initialFit="container" /> - -
- ); - } } diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 787288b..ad1a27d 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -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", diff --git a/webui/src/module.ts b/webui/src/module.ts index 831c376..333506d 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -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 } }; diff --git a/webui/src/styles/organizations.css b/webui/src/styles/organizations.css index 9615491..4c51e28 100644 --- a/webui/src/styles/organizations.css +++ b/webui/src/styles/organizations.css @@ -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;