Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58857654e9 | |||
| 00212ea331 | |||
| 35aebe8759 | |||
| 28f799e426 | |||
| df91c70491 | |||
| 81e532fd54 | |||
| 025067eb87 | |||
| 6b0b2d2d0b | |||
| f09fdf2ef7 | |||
| c240778ad2 | |||
| 45cda1a33f |
@@ -19,7 +19,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN organizations module."""
|
||||
|
||||
__version__ = "0.1.6"
|
||||
__version__ = "0.1.8"
|
||||
|
||||
@@ -14,6 +14,7 @@ from govoplan_core.core.configuration_control import (
|
||||
ensure_configuration_change_allowed,
|
||||
record_configuration_change_applied,
|
||||
)
|
||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
OrganizationFunction,
|
||||
@@ -55,6 +56,8 @@ from .schemas import (
|
||||
|
||||
|
||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||
ORGANIZATION_MODEL_COLLECTION_LIMIT = 5_000
|
||||
ORGANIZATION_MODEL_TOTAL_LIMIT = 20_000
|
||||
|
||||
ORG_READ_SCOPES = (
|
||||
"organizations:model:read",
|
||||
@@ -128,6 +131,13 @@ def _ensure_unique_slug(session: Session, model: type, tenant_id: str, slug: str
|
||||
|
||||
|
||||
def _commit(session: Session, item: ModelT) -> ModelT:
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=getattr(item, "tenant_id", None),
|
||||
source_module="organizations",
|
||||
resource_type=item.__class__.__name__,
|
||||
resource_id=str(getattr(item, "id", getattr(item, "tenant_id", "system"))),
|
||||
)
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
@@ -335,15 +345,90 @@ def get_organization_model(
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)),
|
||||
) -> OrganizationModelResponse:
|
||||
tenant_id = _tenant_id(principal)
|
||||
return OrganizationModelResponse(
|
||||
unit_types=[_item_unit_type(item) for item in session.query(OrganizationUnitType).filter(OrganizationUnitType.tenant_id == tenant_id).order_by(OrganizationUnitType.name.asc()).all()],
|
||||
structures=[_item_structure(item) for item in session.query(OrganizationStructure).filter(OrganizationStructure.tenant_id == tenant_id).order_by(OrganizationStructure.name.asc()).all()],
|
||||
relation_types=[_item_relation_type(item) for item in session.query(OrganizationRelationType).filter(OrganizationRelationType.tenant_id == tenant_id).order_by(OrganizationRelationType.name.asc()).all()],
|
||||
units=[_item_unit(item) for item in session.query(OrganizationUnit).filter(OrganizationUnit.tenant_id == tenant_id).order_by(OrganizationUnit.name.asc()).all()],
|
||||
relations=[_item_relation(item) for item in session.query(OrganizationRelation).filter(OrganizationRelation.tenant_id == tenant_id).order_by(OrganizationRelation.created_at.asc()).all()],
|
||||
function_types=[_item_function_type(item) for item in session.query(OrganizationFunctionType).filter(OrganizationFunctionType.tenant_id == tenant_id).order_by(OrganizationFunctionType.name.asc()).all()],
|
||||
functions=[_item_function(item) for item in session.query(OrganizationFunction).filter(OrganizationFunction.tenant_id == tenant_id).order_by(OrganizationFunction.name.asc()).all()],
|
||||
unit_types = _bounded_organization_rows(
|
||||
session.query(OrganizationUnitType)
|
||||
.filter(OrganizationUnitType.tenant_id == tenant_id)
|
||||
.order_by(OrganizationUnitType.name.asc()),
|
||||
"unit types",
|
||||
)
|
||||
structures = _bounded_organization_rows(
|
||||
session.query(OrganizationStructure)
|
||||
.filter(OrganizationStructure.tenant_id == tenant_id)
|
||||
.order_by(OrganizationStructure.name.asc()),
|
||||
"structures",
|
||||
)
|
||||
relation_types = _bounded_organization_rows(
|
||||
session.query(OrganizationRelationType)
|
||||
.filter(OrganizationRelationType.tenant_id == tenant_id)
|
||||
.order_by(OrganizationRelationType.name.asc()),
|
||||
"relation types",
|
||||
)
|
||||
units = _bounded_organization_rows(
|
||||
session.query(OrganizationUnit)
|
||||
.filter(OrganizationUnit.tenant_id == tenant_id)
|
||||
.order_by(OrganizationUnit.name.asc()),
|
||||
"units",
|
||||
)
|
||||
relations = _bounded_organization_rows(
|
||||
session.query(OrganizationRelation)
|
||||
.filter(OrganizationRelation.tenant_id == tenant_id)
|
||||
.order_by(OrganizationRelation.created_at.asc()),
|
||||
"relations",
|
||||
)
|
||||
function_types = _bounded_organization_rows(
|
||||
session.query(OrganizationFunctionType)
|
||||
.filter(OrganizationFunctionType.tenant_id == tenant_id)
|
||||
.order_by(OrganizationFunctionType.name.asc()),
|
||||
"function types",
|
||||
)
|
||||
functions = _bounded_organization_rows(
|
||||
session.query(OrganizationFunction)
|
||||
.filter(OrganizationFunction.tenant_id == tenant_id)
|
||||
.order_by(OrganizationFunction.name.asc()),
|
||||
"functions",
|
||||
)
|
||||
if sum(
|
||||
len(items)
|
||||
for items in (
|
||||
unit_types,
|
||||
structures,
|
||||
relation_types,
|
||||
units,
|
||||
relations,
|
||||
function_types,
|
||||
functions,
|
||||
)
|
||||
) > ORGANIZATION_MODEL_TOTAL_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=(
|
||||
"The organization model is too large for the aggregate "
|
||||
"endpoint and cannot be returned as one response."
|
||||
),
|
||||
)
|
||||
return OrganizationModelResponse(
|
||||
unit_types=[_item_unit_type(item) for item in unit_types],
|
||||
structures=[_item_structure(item) for item in structures],
|
||||
relation_types=[_item_relation_type(item) for item in relation_types],
|
||||
units=[_item_unit(item) for item in units],
|
||||
relations=[_item_relation(item) for item in relations],
|
||||
function_types=[_item_function_type(item) for item in function_types],
|
||||
functions=[_item_function(item) for item in functions],
|
||||
)
|
||||
|
||||
|
||||
def _bounded_organization_rows(query: Any, label: str) -> list[Any]:
|
||||
rows = query.limit(ORGANIZATION_MODEL_COLLECTION_LIMIT + 1).all()
|
||||
if len(rows) > ORGANIZATION_MODEL_COLLECTION_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=(
|
||||
f"The organization model has more than "
|
||||
f"{ORGANIZATION_MODEL_COLLECTION_LIMIT} {label} and cannot "
|
||||
"be returned as one response."
|
||||
),
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@router.post("/unit-types", response_model=OrganizationUnitTypeItem, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -16,6 +16,7 @@ from govoplan_core.core.modules import (
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata
|
||||
|
||||
@@ -98,6 +99,15 @@ manifest = ModuleManifest(
|
||||
package_name="@govoplan/organizations-webui",
|
||||
routes=(FrontendRoute(path="/organizations", component="OrganizationsPage", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
|
||||
nav_items=(NavItem(path="/organizations", label="Organizations", icon="users", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="organizations.admin.tenant",
|
||||
module_id="organizations",
|
||||
kind="section",
|
||||
label="Organizations administration",
|
||||
order=85,
|
||||
),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="organizations",
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test:organizations-tree": "node scripts/test-organizations-tree-structure.mjs"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
@@ -14,12 +17,12 @@
|
||||
"./styles/organizations.css": "./src/styles/organizations.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-router-dom": ">=7.18.2 <8",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
},
|
||||
|
||||
24
webui/scripts/test-organizations-tree-structure.mjs
Normal file
24
webui/scripts/test-organizations-tree-structure.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const source = readFileSync(
|
||||
new URL("../src/features/organizations/OrganizationsPage.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const styles = readFileSync(
|
||||
new URL("../src/styles/organizations.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert(source.includes("ExplorerTree,"), "Organizations imports the central ExplorerTree");
|
||||
assert(source.includes("<ExplorerTree"), "the organization hierarchy uses ExplorerTree");
|
||||
assert(source.includes("collapsible={false}"), "the always-expanded hierarchy uses the non-collapsible contract");
|
||||
assert(source.includes("renderNodeActions="), "per-unit controls use the sibling node-action slot");
|
||||
assert(source.includes("<IconButton"), "per-unit controls use the central icon-only action primitive");
|
||||
assert(!source.includes("renderUnitTreeNodes"), "the custom recursive renderer was removed");
|
||||
assert(!source.includes("organizations-tree-row"), "the custom tree row was removed");
|
||||
assert(!source.includes("organizations-tree-node"), "the custom tree node was removed");
|
||||
assert(!styles.includes("organizations-tree-"), "Organizations no longer owns shared tree styling");
|
||||
@@ -121,7 +121,7 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-organizations.audit_and_retention.3ba1d2fc">
|
||||
<div className="organizations-form-grid">
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="i18n:govoplan-organizations.audit_detail_level.7397355d">
|
||||
<select value={draft.audit_detail_level} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, audit_detail_level: event.target.value as OrganizationAuditDetailLevel })}>
|
||||
{AUDIT_DETAIL_LEVELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
|
||||
@@ -8,20 +8,25 @@ import {
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
ExplorerTree,
|
||||
FormField,
|
||||
IconButton,
|
||||
LoadingFrame,
|
||||
ModuleSubnav,
|
||||
PageTitle,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
hasScope,
|
||||
isViewSurfaceVisible,
|
||||
useEffectiveView,
|
||||
useUnsavedDraftGuard,
|
||||
usePlatformUiCapabilities,
|
||||
useViewSurfaces,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn,
|
||||
type ModuleSubnavGroup,
|
||||
type OrganizationFunctionActionContribution,
|
||||
type OrganizationFunctionActionsUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
@@ -294,15 +299,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 RowActions({ disabled, onEdit, extra }: { disabled: boolean; onEdit: () => void; extra?: JSX.Element[] }) {
|
||||
return (
|
||||
<div className="organizations-row-actions">
|
||||
<AdminIconButton label="i18n:govoplan-organizations.edit.7dce1220" icon={<Edit3 size={16} aria-hidden="true" />} disabled={disabled} onClick={onEdit} />
|
||||
{extra}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SluggedFields({
|
||||
draft,
|
||||
onChange,
|
||||
@@ -385,6 +381,8 @@ export default function OrganizationsPage({
|
||||
const [selectedUnitId, setSelectedUnitId] = useState("");
|
||||
const [changeRequestId, setChangeRequestId] = useState("");
|
||||
const functionActionCapabilities = usePlatformUiCapabilities<OrganizationFunctionActionsUiCapability>("organizations.functionActions");
|
||||
const effectiveView = useEffectiveView();
|
||||
const viewSurfaces = useViewSurfaces();
|
||||
|
||||
const canWriteModel = hasScope(auth, "organizations:model:write");
|
||||
const canWriteUnits = hasScope(auth, "organizations:unit:write");
|
||||
@@ -400,8 +398,11 @@ export default function OrganizationsPage({
|
||||
() => functionActionCapabilities
|
||||
.flatMap((capability) => capability.actions)
|
||||
.filter((contribution) => contributionVisible(auth, contribution))
|
||||
.filter((contribution) =>
|
||||
isViewSurfaceVisible(effectiveView, contribution.surfaceId, viewSurfaces)
|
||||
)
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
||||
[auth, functionActionCapabilities]
|
||||
[auth, effectiveView, functionActionCapabilities, viewSurfaces]
|
||||
);
|
||||
const unitsByParentId = useMemo(() => {
|
||||
const mapped = new Map<string, OrganizationUnitItem[]>();
|
||||
@@ -870,7 +871,7 @@ export default function OrganizationsPage({
|
||||
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, sortable: true, filterable: true, value: (row) => row.slug },
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{ id: "description", header: "i18n:govoplan-organizations.description.55f8ebc8", minWidth: 220, value: (row) => row.description ?? "" },
|
||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteModel || busy} onEdit={() => editUnitType(row)} /> }
|
||||
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editUnitType(row) }]} /> }
|
||||
];
|
||||
|
||||
const structureColumns: DataGridColumn<OrganizationStructureItem>[] = [
|
||||
@@ -878,7 +879,7 @@ export default function OrganizationsPage({
|
||||
{ id: "kind", header: "i18n:govoplan-organizations.kind.794c9d9c", width: 140, sortable: true, filterable: true, value: (row) => row.structure_kind },
|
||||
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteModel || busy} onEdit={() => editStructure(row)} /> }
|
||||
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editStructure(row) }]} /> }
|
||||
];
|
||||
|
||||
const relationTypeColumns: DataGridColumn<OrganizationRelationTypeItem>[] = [
|
||||
@@ -888,7 +889,7 @@ export default function OrganizationsPage({
|
||||
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 170, render: (row) => optionalLabel(unitTypeById.get(row.target_unit_type_id || "")?.name) },
|
||||
{ id: "hierarchical", header: "i18n:govoplan-organizations.hierarchical.8964f313", width: 130, render: (row) => activeStatus(row.is_hierarchical) },
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteModel || busy} onEdit={() => editRelationType(row)} /> }
|
||||
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editRelationType(row) }]} /> }
|
||||
];
|
||||
|
||||
const unitColumns: DataGridColumn<OrganizationUnitItem>[] = [
|
||||
@@ -897,7 +898,7 @@ export default function OrganizationsPage({
|
||||
{ id: "parent", header: "i18n:govoplan-organizations.parent_unit.1986c35e", minWidth: 180, render: (row) => optionalLabel(unitById.get(row.parent_id || "")?.name) },
|
||||
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteUnits || busy} onEdit={() => editUnit(row)} /> }
|
||||
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, onClick: () => editUnit(row) }]} /> }
|
||||
];
|
||||
|
||||
const relationColumns: DataGridColumn<OrganizationRelationItem>[] = [
|
||||
@@ -906,7 +907,7 @@ export default function OrganizationsPage({
|
||||
{ id: "source", header: "i18n:govoplan-organizations.source_unit.2fbd8baa", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.source_unit_id)?.name) },
|
||||
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.target_unit_id)?.name) },
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteUnits || busy} onEdit={() => editRelation(row)} /> }
|
||||
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, onClick: () => editRelation(row) }]} /> }
|
||||
];
|
||||
|
||||
const functionTypeColumns: DataGridColumn<OrganizationFunctionTypeItem>[] = [
|
||||
@@ -915,7 +916,7 @@ export default function OrganizationsPage({
|
||||
{ id: "delegable", header: "i18n:govoplan-organizations.delegable.b4f0137d", width: 120, render: (row) => activeStatus(row.delegable) },
|
||||
{ id: "actInPlace", header: "i18n:govoplan-organizations.act_in_place.49b942bd", width: 140, render: (row) => activeStatus(row.act_in_place_allowed) },
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteModel || busy} onEdit={() => editFunctionType(row)} /> }
|
||||
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editFunctionType(row) }]} /> }
|
||||
];
|
||||
|
||||
const functionColumns: DataGridColumn<OrganizationFunctionItem>[] = [
|
||||
@@ -926,20 +927,26 @@ export default function OrganizationsPage({
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
width: functionActionContributions.length ? 220 : 88,
|
||||
header: "i18n:govoplan-organizations.actions.c3cd636a",
|
||||
width: 88 + (functionActionContributions.length * 40),
|
||||
sticky: "end",
|
||||
render: (row) => (
|
||||
<RowActions
|
||||
disabled={!canWriteFunctions || busy}
|
||||
onEdit={() => editFunction(row)}
|
||||
extra={functionActionContributions.map((contribution) => (
|
||||
<span className="organizations-contributed-action" key={contribution.id}>
|
||||
{contribution.render({ settings, auth, function: row })}
|
||||
</span>
|
||||
))}
|
||||
/>
|
||||
)
|
||||
resizable: false,
|
||||
align: "right",
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{
|
||||
id: "edit",
|
||||
label: "i18n:govoplan-organizations.edit.7dce1220",
|
||||
icon: <Edit3 size={16} aria-hidden="true" />,
|
||||
disabled: !canWriteFunctions || busy,
|
||||
onClick: () => editFunction(row)
|
||||
},
|
||||
...functionActionContributions.map((contribution) => ({
|
||||
id: contribution.id,
|
||||
label: contribution.label,
|
||||
icon: contribution.icon,
|
||||
onClick: () => contribution.onClick({ settings, auth, function: row })
|
||||
}))
|
||||
]} />
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1003,41 +1010,42 @@ export default function OrganizationsPage({
|
||||
);
|
||||
}
|
||||
|
||||
function renderUnitTreeNodes(parentId = "", depth = 0, seen: ReadonlySet<string> = new Set()): JSX.Element[] {
|
||||
return (unitsByParentId.get(parentId) ?? []).flatMap((unit) => {
|
||||
if (seen.has(unit.id)) return [];
|
||||
const nextSeen = new Set(seen);
|
||||
nextSeen.add(unit.id);
|
||||
return [
|
||||
<div className="organizations-tree-row" key={unit.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`organizations-tree-node ${selectedUnitId === unit.id ? "active" : ""}`.trim()}
|
||||
style={{ paddingLeft: `${10 + depth * 18}px` }}
|
||||
onClick={() => setSelectedUnitId(unit.id)}
|
||||
>
|
||||
<strong>{unit.name}</strong>
|
||||
<span>{unitTypeById.get(unit.unit_type_id || "")?.name ?? "i18n:govoplan-organizations.no_unit_type.73e799f5"}</span>
|
||||
</button>
|
||||
<Button type="button" variant="ghost" disabled={!canWriteUnits || busy} onClick={() => addSubunit(unit)} title="i18n:govoplan-organizations.add_subunit.8256a8f7">
|
||||
<Plus size={16} aria-hidden="true" /> i18n:govoplan-organizations.add_subunit.8256a8f7
|
||||
</Button>
|
||||
</div>,
|
||||
...renderUnitTreeNodes(unit.id, depth + 1, nextSeen)
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function renderUnitsSection() {
|
||||
return (
|
||||
<div className="organizations-table-stack">
|
||||
<Card title="i18n:govoplan-organizations.organization_tree.e5bfb195" actions={<AdminIconButton label="i18n:govoplan-organizations.add_root_unit.1ef8a9f5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} onClick={() => openUnitCreate()} />}>
|
||||
<div className="organizations-tree-toolbar">
|
||||
<div className="explorer-tree-toolbar">
|
||||
{selectedUnitId && <Button type="button" variant="ghost" disabled={busy} onClick={() => setSelectedUnitId("")}>i18n:govoplan-organizations.none.334c4a4c</Button>}
|
||||
</div>
|
||||
<div className="organizations-tree-list">
|
||||
{model.units.length ? renderUnitTreeNodes() : <p className="muted small-note">i18n:govoplan-organizations.no_units_found.eea2dd0c</p>}
|
||||
</div>
|
||||
{model.units.length ? (
|
||||
<ExplorerTree
|
||||
nodes={unitsByParentId.get("") ?? []}
|
||||
getNodeId={(unit) => unit.id}
|
||||
getNodeLabel={(unit) => unit.name}
|
||||
getNodeChildren={(unit) => unitsByParentId.get(unit.id) ?? []}
|
||||
activeId={selectedUnitId}
|
||||
collapsible={false}
|
||||
depth={0}
|
||||
className="explorer-tree-scroll-region"
|
||||
getNodeWrapStyle={(_unit, context) => ({ paddingLeft: `${Math.min(context.depth * 18, 72)}px` })}
|
||||
onOpen={(unit) => setSelectedUnitId(unit.id)}
|
||||
renderNodeContent={(unit) => (
|
||||
<span className="explorer-tree-node-content">
|
||||
<strong>{unit.name}</strong>
|
||||
<small>{unitTypeById.get(unit.unit_type_id || "")?.name ?? "i18n:govoplan-organizations.no_unit_type.73e799f5"}</small>
|
||||
</span>
|
||||
)}
|
||||
renderNodeActions={(unit) => (
|
||||
<IconButton
|
||||
label="i18n:govoplan-organizations.add_subunit.8256a8f7"
|
||||
icon={<Plus size={16} aria-hidden="true" />}
|
||||
variant="ghost"
|
||||
disabled={!canWriteUnits || busy}
|
||||
onClick={() => addSubunit(unit)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : <p className="muted small-note">i18n:govoplan-organizations.no_units_found.eea2dd0c</p>}
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-organizations.units.e14d0d92" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit.8fa12fb1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} onClick={() => openUnitCreate()} />}>
|
||||
<DataGrid id="organizations-units" rows={model.units} columns={unitColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_units_found.eea2dd0c" initialFit="container" />
|
||||
@@ -1129,7 +1137,7 @@ export default function OrganizationsPage({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<form id={formId} className="organizations-form-grid" onSubmit={(event) => void submit(event)}>
|
||||
<form id={formId} className="admin-form-grid two-columns" onSubmit={(event) => void submit(event)}>
|
||||
{renderEditorFields()}
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
@@ -23,6 +23,7 @@ const organizationAdminSections: AdminSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "tenant-organization-settings",
|
||||
surfaceId: "organizations.admin.tenant",
|
||||
label: "i18n:govoplan-organizations.organizations.220edf64",
|
||||
group: "TENANT",
|
||||
order: 85,
|
||||
@@ -45,6 +46,15 @@ export const organizationsModule: PlatformWebModule = {
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["admin"],
|
||||
translations,
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "organizations.admin.tenant",
|
||||
moduleId: "organizations",
|
||||
kind: "section",
|
||||
label: "Organizations administration",
|
||||
order: 85
|
||||
}
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
to: "/organizations",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.organizations-workspace {
|
||||
grid-template-columns: 230px minmax(0, 1fr);
|
||||
background: var(--bg, #f8f7f4);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.organizations-page {
|
||||
@@ -30,32 +30,6 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.organizations-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.organizations-form-grid .wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.organizations-form-actions,
|
||||
.organizations-row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.organizations-row-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.organizations-contributed-action {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.organizations-check-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -76,51 +50,6 @@
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.organizations-tree-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.organizations-tree-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-height: 640px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.organizations-tree-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.organizations-tree-node {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.organizations-tree-node:hover,
|
||||
.organizations-tree-node.active {
|
||||
border-color: var(--line);
|
||||
background: var(--surface-muted, #f6f7f9);
|
||||
}
|
||||
|
||||
.organizations-tree-node span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.organizations-field-note {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
@@ -147,8 +76,4 @@
|
||||
.organizations-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.organizations-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user