Add organization tenant settings

This commit is contained in:
2026-07-10 17:54:14 +02:00
parent ad2561b50f
commit becbc8dd04
11 changed files with 830 additions and 79 deletions

View File

@@ -1,18 +1,156 @@
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import OrganizationsPage, { type OrganizationSection } from "./OrganizationsPage";
import { useEffect, useState } from "react";
import {
AdminPageLayout,
Button,
Card,
FormField,
ToggleSwitch,
adminErrorMessage,
hasAnyScope,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
getOrganizationSettings,
patchOrganizationSettings,
type OrganizationAuditDetailLevel,
type OrganizationSettingsItem
} from "../../api/organizations";
const MODEL_SECTIONS: OrganizationSection[] = ["model"];
const FALLBACK_SETTINGS: OrganizationSettingsItem = {
tenant_id: "",
allow_tenant_model_customization: true,
require_model_change_requests: false,
audit_detail_level: "standard",
change_retention_days: null,
settings: {}
};
const AUDIT_DETAIL_LEVELS: Array<{ value: OrganizationAuditDetailLevel; label: string }> = [
{ value: "summary", label: "i18n:govoplan-organizations.audit_summary.6a7d68a5" },
{ value: "standard", label: "i18n:govoplan-organizations.audit_standard.785d981f" },
{ value: "full", label: "i18n:govoplan-organizations.audit_full.0c83669c" }
];
export default function OrganizationsAdminPanel({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const [draft, setDraft] = useState<OrganizationSettingsItem>(FALLBACK_SETTINGS);
const [savedDraft, setSavedDraft] = useState<OrganizationSettingsItem>(FALLBACK_SETTINGS);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const canWrite = hasAnyScope(auth, ["organizations:settings:write", "admin:settings:write"]);
const dirty = settingsKey(draft) !== settingsKey(savedDraft);
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: () => setDraft(savedDraft)
});
useEffect(() => {
void load();
}, [settings.accessToken, settings.apiBaseUrl]);
async function load() {
setLoading(true);
setError("");
try {
const next = await getOrganizationSettings(settings);
setDraft(next);
setSavedDraft(next);
} catch (caught) {
setError(adminErrorMessage(caught));
} finally {
setLoading(false);
}
}
async function save(): Promise<boolean> {
setBusy(true);
setError("");
setSuccess("");
try {
const saved = await patchOrganizationSettings(settings, {
allow_tenant_model_customization: draft.allow_tenant_model_customization,
require_model_change_requests: draft.require_model_change_requests,
audit_detail_level: draft.audit_detail_level,
change_retention_days: draft.change_retention_days ?? null,
settings: draft.settings
});
setDraft(saved);
setSavedDraft(saved);
setSuccess("i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa");
return true;
} catch (caught) {
setError(adminErrorMessage(caught));
return false;
} finally {
setBusy(false);
}
}
return (
<OrganizationsPage
settings={settings}
auth={auth}
mode="admin"
initialSection="model"
availableSections={MODEL_SECTIONS}
title="i18n:govoplan-organizations.organization_model.4f924c0e"
description="i18n:govoplan-organizations.organization_model_admin_description.35dc9f10"
/>
<AdminPageLayout
title="i18n:govoplan-organizations.organization_settings.c9ab9829"
description="i18n:govoplan-organizations.organization_settings_description.35dc9f10"
loading={loading}
loadingLabel="i18n:govoplan-organizations.loading_organization_settings.c6008db8"
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading || busy}>i18n:govoplan-organizations.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !dirty}>{busy ? "i18n:govoplan-organizations.saving.56a2285c" : "i18n:govoplan-organizations.save_settings.913aba9f"}</Button></>}
>
<div className="organizations-settings-grid">
<Card title="i18n:govoplan-organizations.model_governance.6aa18fd0">
<div className="settings-list">
<ToggleSwitch
checked={draft.allow_tenant_model_customization}
disabled={!canWrite || busy}
onChange={(checked) => setDraft({ ...draft, allow_tenant_model_customization: checked })}
label="i18n:govoplan-organizations.allow_tenant_model_customization.2425d751"
/>
<ToggleSwitch
checked={draft.require_model_change_requests}
disabled={!canWrite || busy}
onChange={(checked) => setDraft({ ...draft, require_model_change_requests: checked })}
label="i18n:govoplan-organizations.require_model_change_requests.83454cad"
/>
</div>
<p className="muted small-note">i18n:govoplan-organizations.model_governance_help.0fa69021</p>
</Card>
<Card title="i18n:govoplan-organizations.audit_and_retention.3ba1d2fc">
<div className="organizations-form-grid">
<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>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.change_retention_days.71bbd140">
<input
type="number"
min={0}
value={draft.change_retention_days ?? ""}
disabled={!canWrite || busy}
placeholder="i18n:govoplan-organizations.unlimited.35569464"
onChange={(event) => setDraft({ ...draft, change_retention_days: event.target.value === "" ? null : Math.max(0, Number(event.target.value)) })}
/>
</FormField>
</div>
<p className="muted small-note">i18n:govoplan-organizations.audit_retention_help.42dec57d</p>
</Card>
</div>
</AdminPageLayout>
);
}
function settingsKey(settings: OrganizationSettingsItem): string {
return JSON.stringify({
allow_tenant_model_customization: settings.allow_tenant_model_customization,
require_model_change_requests: settings.require_model_change_requests,
audit_detail_level: settings.audit_detail_level,
change_retention_days: settings.change_retention_days ?? null,
settings: settings.settings
});
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { RefreshCw, Save, XCircle } from "lucide-react";
import { Edit3, Plus, RefreshCw, Save, XCircle } from "lucide-react";
import {
ApiError,
Button,
@@ -226,6 +226,24 @@ function sluggedPayload(draft: SluggedDraft): SluggedCreatePayload {
};
}
function sluggedPatchPayload(draft: SluggedDraft): Partial<SluggedCreatePayload> {
return {
name: draft.name.trim(),
slug: textOrNull(draft.slug),
description: textOrNull(draft.description),
is_active: draft.is_active
};
}
function sluggedDraftFrom(item: { name: string; slug: string; description?: string | null; is_active: boolean }): SluggedDraft {
return {
name: item.name,
slug: item.slug,
description: item.description ?? "",
is_active: item.is_active
};
}
function isSluggedDirty(draft: SluggedDraft): boolean {
return draft.name.trim() !== "" || draft.slug.trim() !== "" || draft.description.trim() !== "" || !draft.is_active;
}
@@ -297,6 +315,17 @@ function ActiveToggleButton({ item, disabled, onToggle }: { item: ActiveItem; di
);
}
function RowActions({ item, disabled, onEdit, onToggle }: { item: ActiveItem; disabled: boolean; onEdit: () => void; onToggle: () => void }) {
return (
<div className="organizations-row-actions">
<Button type="button" variant="ghost" disabled={disabled} onClick={onEdit} title="i18n:govoplan-organizations.edit.7dce1220">
<Edit3 size={16} aria-hidden="true" /> i18n:govoplan-organizations.edit.7dce1220
</Button>
<ActiveToggleButton item={item} disabled={disabled} onToggle={onToggle} />
</div>
);
}
function SluggedFields({
draft,
onChange,
@@ -354,6 +383,15 @@ export default function OrganizationsPage({
const [functionTypeDraft, setFunctionTypeDraft] = useState<FunctionTypeDraft>(() => emptyFunctionTypeDraft());
const [functionDraft, setFunctionDraft] = useState<FunctionDraft>(() => emptyFunctionDraft());
const [assignmentDraft, setAssignmentDraft] = useState<AssignmentDraft>(() => emptyAssignmentDraft());
const [editingUnitTypeId, setEditingUnitTypeId] = useState<string | null>(null);
const [editingStructureId, setEditingStructureId] = useState<string | null>(null);
const [editingRelationTypeId, setEditingRelationTypeId] = useState<string | null>(null);
const [editingUnitId, setEditingUnitId] = useState<string | null>(null);
const [editingRelationId, setEditingRelationId] = useState<string | null>(null);
const [editingFunctionTypeId, setEditingFunctionTypeId] = useState<string | null>(null);
const [editingFunctionId, setEditingFunctionId] = useState<string | null>(null);
const [editingAssignmentId, setEditingAssignmentId] = useState<string | null>(null);
const [selectedUnitId, setSelectedUnitId] = useState("");
const canWriteModel = hasScope(auth, "organizations:model:write");
const canWriteUnits = hasScope(auth, "organizations:unit:write");
@@ -366,6 +404,15 @@ 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 unitsByParentId = useMemo(() => {
const mapped = new Map<string, OrganizationUnitItem[]>();
for (const unit of model.units) {
const key = unit.parent_id || "";
mapped.set(key, [...(mapped.get(key) ?? []), unit]);
}
for (const units of mapped.values()) units.sort((left, right) => left.name.localeCompare(right.name));
return mapped;
}, [model.units]);
const hasDirtyDraft = isSluggedDirty(unitTypeDraft) ||
isSluggedDirty(structureDraft) ||
@@ -396,6 +443,10 @@ export default function OrganizationsPage({
if (!sectionSet.has(active)) setActive(firstAvailableSection);
}, [active, firstAvailableSection, sectionSet]);
useEffect(() => {
if (selectedUnitId && !unitById.has(selectedUnitId)) setSelectedUnitId("");
}, [selectedUnitId, unitById]);
const runAction = useCallback(async (action: () => Promise<unknown>, successMessage = ""): Promise<boolean> => {
setBusy(true);
setError("");
@@ -422,6 +473,14 @@ export default function OrganizationsPage({
setFunctionTypeDraft(emptyFunctionTypeDraft());
setFunctionDraft(emptyFunctionDraft());
setAssignmentDraft(emptyAssignmentDraft());
setEditingUnitTypeId(null);
setEditingStructureId(null);
setEditingRelationTypeId(null);
setEditingUnitId(null);
setEditingRelationId(null);
setEditingFunctionTypeId(null);
setEditingFunctionId(null);
setEditingAssignmentId(null);
}, []);
function rejectMissingName(): Promise<boolean> {
@@ -438,20 +497,33 @@ export default function OrganizationsPage({
event?.preventDefault();
if (!canWriteModel) return rejectMissingWritePermission();
if (!unitTypeDraft.name.trim()) return rejectMissingName();
const ok = await runAction(() => createUnitType(settings, sluggedPayload(unitTypeDraft)), "i18n:govoplan-organizations.unit_type_added.e017dc8c");
if (ok) setUnitTypeDraft(emptySluggedDraft());
const ok = await runAction(
() => editingUnitTypeId ? patchUnitType(settings, editingUnitTypeId, sluggedPatchPayload(unitTypeDraft)) : createUnitType(settings, sluggedPayload(unitTypeDraft)),
editingUnitTypeId ? "i18n:govoplan-organizations.unit_type_updated.6d8f2a16" : "i18n:govoplan-organizations.unit_type_added.e017dc8c"
);
if (ok) {
setUnitTypeDraft(emptySluggedDraft());
setEditingUnitTypeId(null);
}
return ok;
}, [canWriteModel, runAction, settings, unitTypeDraft]);
}, [canWriteModel, editingUnitTypeId, runAction, settings, unitTypeDraft]);
const submitStructure = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canWriteModel) return rejectMissingWritePermission();
if (!structureDraft.name.trim()) return rejectMissingName();
const payload: StructureCreatePayload = { ...sluggedPayload(structureDraft), structure_kind: structureDraft.structure_kind };
const ok = await runAction(() => createStructure(settings, payload), "i18n:govoplan-organizations.structure_added.f6cf8e74");
if (ok) setStructureDraft(emptyStructureDraft());
const patchPayload: Partial<StructureCreatePayload> = { ...sluggedPatchPayload(structureDraft), structure_kind: structureDraft.structure_kind };
const ok = await runAction(
() => editingStructureId ? patchStructure(settings, editingStructureId, patchPayload) : createStructure(settings, payload),
editingStructureId ? "i18n:govoplan-organizations.structure_updated.2591e75f" : "i18n:govoplan-organizations.structure_added.f6cf8e74"
);
if (ok) {
setStructureDraft(emptyStructureDraft());
setEditingStructureId(null);
}
return ok;
}, [canWriteModel, runAction, settings, structureDraft]);
}, [canWriteModel, editingStructureId, runAction, settings, structureDraft]);
const submitRelationType = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
@@ -465,10 +537,24 @@ export default function OrganizationsPage({
is_hierarchical: relationTypeDraft.is_hierarchical,
allow_cycles: relationTypeDraft.allow_cycles
};
const ok = await runAction(() => createRelationType(settings, payload), "i18n:govoplan-organizations.relation_type_added.6f1edff1");
if (ok) setRelationTypeDraft(emptyRelationTypeDraft());
const patchPayload: Partial<RelationTypeCreatePayload> = {
...sluggedPatchPayload(relationTypeDraft),
structure_id: textOrNull(relationTypeDraft.structure_id),
source_unit_type_id: textOrNull(relationTypeDraft.source_unit_type_id),
target_unit_type_id: textOrNull(relationTypeDraft.target_unit_type_id),
is_hierarchical: relationTypeDraft.is_hierarchical,
allow_cycles: relationTypeDraft.allow_cycles
};
const ok = await runAction(
() => editingRelationTypeId ? patchRelationType(settings, editingRelationTypeId, patchPayload) : createRelationType(settings, payload),
editingRelationTypeId ? "i18n:govoplan-organizations.relation_type_updated.5ac57ec7" : "i18n:govoplan-organizations.relation_type_added.6f1edff1"
);
if (ok) {
setRelationTypeDraft(emptyRelationTypeDraft());
setEditingRelationTypeId(null);
}
return ok;
}, [canWriteModel, relationTypeDraft, runAction, settings]);
}, [canWriteModel, editingRelationTypeId, relationTypeDraft, runAction, settings]);
const submitUnit = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
@@ -479,10 +565,21 @@ export default function OrganizationsPage({
unit_type_id: textOrNull(unitDraft.unit_type_id),
parent_id: textOrNull(unitDraft.parent_id)
};
const ok = await runAction(() => createUnit(settings, payload), "i18n:govoplan-organizations.unit_added.760a8512");
if (ok) setUnitDraft(emptyUnitDraft());
const patchPayload: Partial<UnitCreatePayload> = {
...sluggedPatchPayload(unitDraft),
unit_type_id: textOrNull(unitDraft.unit_type_id),
parent_id: textOrNull(unitDraft.parent_id)
};
const ok = await runAction(
() => editingUnitId ? patchUnit(settings, editingUnitId, patchPayload) : createUnit(settings, payload),
editingUnitId ? "i18n:govoplan-organizations.unit_updated.15f02216" : "i18n:govoplan-organizations.unit_added.760a8512"
);
if (ok) {
setUnitDraft(emptyUnitDraft());
setEditingUnitId(null);
}
return ok;
}, [canWriteUnits, runAction, settings, unitDraft]);
}, [canWriteUnits, editingUnitId, runAction, settings, unitDraft]);
const submitRelation = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
@@ -492,10 +589,17 @@ export default function OrganizationsPage({
return false;
}
const payload: RelationCreatePayload = { ...relationDraft, settings: {} };
const ok = await runAction(() => createRelation(settings, payload), "i18n:govoplan-organizations.relation_added.ca90461a");
if (ok) setRelationDraft(emptyRelationDraft());
const patchPayload: Partial<RelationCreatePayload> = { ...relationDraft };
const ok = await runAction(
() => editingRelationId ? patchRelation(settings, editingRelationId, patchPayload) : createRelation(settings, payload),
editingRelationId ? "i18n:govoplan-organizations.relation_updated.c6212776" : "i18n:govoplan-organizations.relation_added.ca90461a"
);
if (ok) {
setRelationDraft(emptyRelationDraft());
setEditingRelationId(null);
}
return ok;
}, [canWriteUnits, relationDraft, runAction, settings]);
}, [canWriteUnits, editingRelationId, relationDraft, runAction, settings]);
const submitFunctionType = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
@@ -507,10 +611,22 @@ export default function OrganizationsPage({
delegable: functionTypeDraft.delegable,
act_in_place_allowed: functionTypeDraft.act_in_place_allowed
};
const ok = await runAction(() => createFunctionType(settings, payload), "i18n:govoplan-organizations.function_type_added.2cd9e899");
if (ok) setFunctionTypeDraft(emptyFunctionTypeDraft());
const patchPayload: Partial<FunctionTypeCreatePayload> = {
...sluggedPatchPayload(functionTypeDraft),
organization_unit_type_id: textOrNull(functionTypeDraft.organization_unit_type_id),
delegable: functionTypeDraft.delegable,
act_in_place_allowed: functionTypeDraft.act_in_place_allowed
};
const ok = await runAction(
() => editingFunctionTypeId ? patchFunctionType(settings, editingFunctionTypeId, patchPayload) : createFunctionType(settings, payload),
editingFunctionTypeId ? "i18n:govoplan-organizations.function_type_updated.1a4f29f6" : "i18n:govoplan-organizations.function_type_added.2cd9e899"
);
if (ok) {
setFunctionTypeDraft(emptyFunctionTypeDraft());
setEditingFunctionTypeId(null);
}
return ok;
}, [canWriteModel, functionTypeDraft, runAction, settings]);
}, [canWriteModel, editingFunctionTypeId, functionTypeDraft, runAction, settings]);
const submitFunction = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
@@ -527,10 +643,23 @@ export default function OrganizationsPage({
delegable: functionDraft.delegable,
act_in_place_allowed: functionDraft.act_in_place_allowed
};
const ok = await runAction(() => createFunction(settings, payload), "i18n:govoplan-organizations.function_added.e2266702");
if (ok) setFunctionDraft(emptyFunctionDraft());
const patchPayload: Partial<FunctionCreatePayload> = {
...sluggedPatchPayload(functionDraft),
organization_unit_id: functionDraft.organization_unit_id,
function_type_id: textOrNull(functionDraft.function_type_id),
delegable: functionDraft.delegable,
act_in_place_allowed: functionDraft.act_in_place_allowed
};
const ok = await runAction(
() => editingFunctionId ? patchFunction(settings, editingFunctionId, patchPayload) : createFunction(settings, payload),
editingFunctionId ? "i18n:govoplan-organizations.function_updated.65016009" : "i18n:govoplan-organizations.function_added.e2266702"
);
if (ok) {
setFunctionDraft(emptyFunctionDraft());
setEditingFunctionId(null);
}
return ok;
}, [canWriteFunctions, functionDraft, runAction, settings]);
}, [canWriteFunctions, editingFunctionId, functionDraft, runAction, settings]);
const submitAssignment = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
@@ -550,10 +679,16 @@ export default function OrganizationsPage({
is_active: assignmentDraft.is_active,
settings: {}
};
const ok = await runAction(() => createFunctionAssignment(settings, payload), "i18n:govoplan-organizations.assignment_added.94263d1b");
if (ok) setAssignmentDraft(emptyAssignmentDraft());
const ok = await runAction(
() => editingAssignmentId ? patchFunctionAssignment(settings, editingAssignmentId, payload) : createFunctionAssignment(settings, payload),
editingAssignmentId ? "i18n:govoplan-organizations.assignment_updated.680b6e33" : "i18n:govoplan-organizations.assignment_added.94263d1b"
);
if (ok) {
setAssignmentDraft(emptyAssignmentDraft());
setEditingAssignmentId(null);
}
return ok;
}, [assignmentDraft, canAssignFunctions, runAction, settings]);
}, [assignmentDraft, canAssignFunctions, editingAssignmentId, runAction, settings]);
const saveDrafts = useCallback(async (): Promise<boolean> => {
if (isSluggedDirty(unitTypeDraft) && !(await submitUnitType())) return false;
@@ -624,12 +759,103 @@ export default function OrganizationsPage({
void runAction(() => patchFunctionAssignment(settings, item.id, { is_active: !item.is_active }));
}, [runAction, settings]);
const editUnitType = useCallback((item: OrganizationUnitTypeItem) => {
setEditingUnitTypeId(item.id);
setUnitTypeDraft(sluggedDraftFrom(item));
setActive("model");
}, []);
const editStructure = useCallback((item: OrganizationStructureItem) => {
setEditingStructureId(item.id);
setStructureDraft({ ...sluggedDraftFrom(item), structure_kind: item.structure_kind as OrganizationStructureKind });
setActive("model");
}, []);
const editRelationType = useCallback((item: OrganizationRelationTypeItem) => {
setEditingRelationTypeId(item.id);
setRelationTypeDraft({
...sluggedDraftFrom(item),
structure_id: item.structure_id ?? "",
source_unit_type_id: item.source_unit_type_id ?? "",
target_unit_type_id: item.target_unit_type_id ?? "",
is_hierarchical: item.is_hierarchical,
allow_cycles: item.allow_cycles
});
setActive("model");
}, []);
const editUnit = useCallback((item: OrganizationUnitItem) => {
setEditingUnitId(item.id);
setSelectedUnitId(item.id);
setUnitDraft({ ...sluggedDraftFrom(item), unit_type_id: item.unit_type_id ?? "", parent_id: item.parent_id ?? "" });
setActive("units");
}, []);
const addSubunit = useCallback((item: OrganizationUnitItem) => {
setSelectedUnitId(item.id);
setEditingUnitId(null);
setUnitDraft({ ...emptyUnitDraft(), parent_id: item.id });
setActive("units");
}, []);
const editRelation = useCallback((item: OrganizationRelationItem) => {
setEditingRelationId(item.id);
setRelationDraft({
structure_id: item.structure_id,
relation_type_id: item.relation_type_id,
source_unit_id: item.source_unit_id,
target_unit_id: item.target_unit_id,
is_active: item.is_active
});
setActive("relations");
}, []);
const editFunctionType = useCallback((item: OrganizationFunctionTypeItem) => {
setEditingFunctionTypeId(item.id);
setFunctionTypeDraft({
...sluggedDraftFrom(item),
organization_unit_type_id: item.organization_unit_type_id ?? "",
delegable: item.delegable,
act_in_place_allowed: item.act_in_place_allowed
});
setActive("model");
}, []);
const editFunction = useCallback((item: OrganizationFunctionItem) => {
setEditingFunctionId(item.id);
setSelectedUnitId(item.organization_unit_id);
setFunctionDraft({
...sluggedDraftFrom(item),
organization_unit_id: item.organization_unit_id,
function_type_id: item.function_type_id ?? "",
delegable: item.delegable,
act_in_place_allowed: item.act_in_place_allowed
});
setActive("functions");
}, []);
const editAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => {
setEditingAssignmentId(item.id);
setSelectedUnitId(item.organization_unit_id);
setAssignmentDraft({
identity_id: item.identity_id,
account_id: item.account_id ?? "",
function_id: item.function_id,
applies_to_subunits: item.applies_to_subunits,
source: item.source,
delegated_from_assignment_id: item.delegated_from_assignment_id ?? "",
acting_for_account_id: item.acting_for_account_id ?? "",
is_active: item.is_active
});
setActive("assignments");
}, []);
const unitTypeColumns: DataGridColumn<OrganizationUnitTypeItem>[] = [
{ id: "name", header: "i18n:govoplan-organizations.name.709a2322", minWidth: 180, sortable: true, filterable: true, value: (row) => row.name },
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, sortable: true, filterable: true, value: (row) => row.slug },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
{ id: "description", header: "i18n:govoplan-organizations.description.55f8ebc8", minWidth: 220, value: (row) => row.description ?? "" },
{ id: "actions", header: "", width: 130, sticky: "end", render: (row) => <ActiveToggleButton item={row} disabled={!canWriteModel || busy} onToggle={() => toggleUnitType(row)} /> }
{ id: "actions", header: "", width: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canWriteModel || busy} onEdit={() => editUnitType(row)} onToggle={() => toggleUnitType(row)} /> }
];
const structureColumns: DataGridColumn<OrganizationStructureItem>[] = [
@@ -637,7 +863,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: 130, sticky: "end", render: (row) => <ActiveToggleButton item={row} disabled={!canWriteModel || busy} onToggle={() => toggleStructure(row)} /> }
{ id: "actions", header: "", width: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canWriteModel || busy} onEdit={() => editStructure(row)} onToggle={() => toggleStructure(row)} /> }
];
const relationTypeColumns: DataGridColumn<OrganizationRelationTypeItem>[] = [
@@ -647,7 +873,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: 130, sticky: "end", render: (row) => <ActiveToggleButton item={row} disabled={!canWriteModel || busy} onToggle={() => toggleRelationType(row)} /> }
{ id: "actions", header: "", width: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canWriteModel || busy} onEdit={() => editRelationType(row)} onToggle={() => toggleRelationType(row)} /> }
];
const unitColumns: DataGridColumn<OrganizationUnitItem>[] = [
@@ -656,7 +882,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: 130, sticky: "end", render: (row) => <ActiveToggleButton item={row} disabled={!canWriteUnits || busy} onToggle={() => toggleUnit(row)} /> }
{ id: "actions", header: "", width: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canWriteUnits || busy} onEdit={() => editUnit(row)} onToggle={() => toggleUnit(row)} /> }
];
const relationColumns: DataGridColumn<OrganizationRelationItem>[] = [
@@ -665,7 +891,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: 130, sticky: "end", render: (row) => <ActiveToggleButton item={row} disabled={!canWriteUnits || busy} onToggle={() => toggleRelation(row)} /> }
{ id: "actions", header: "", width: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canWriteUnits || busy} onEdit={() => editRelation(row)} onToggle={() => toggleRelation(row)} /> }
];
const functionTypeColumns: DataGridColumn<OrganizationFunctionTypeItem>[] = [
@@ -674,7 +900,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: 130, sticky: "end", render: (row) => <ActiveToggleButton item={row} disabled={!canWriteModel || busy} onToggle={() => toggleFunctionType(row)} /> }
{ id: "actions", header: "", width: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canWriteModel || busy} onEdit={() => editFunctionType(row)} onToggle={() => toggleFunctionType(row)} /> }
];
const functionColumns: DataGridColumn<OrganizationFunctionItem>[] = [
@@ -683,7 +909,7 @@ 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: 130, sticky: "end", render: (row) => <ActiveToggleButton item={row} disabled={!canWriteFunctions || busy} onToggle={() => toggleFunction(row)} /> }
{ id: "actions", header: "", width: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canWriteFunctions || busy} onEdit={() => editFunction(row)} onToggle={() => toggleFunction(row)} /> }
];
const assignmentColumns: DataGridColumn<OrganizationFunctionAssignmentItem>[] = [
@@ -693,11 +919,15 @@ export default function OrganizationsPage({
{ id: "subunits", header: "i18n:govoplan-organizations.applies_to_subunits.7a15f741", width: 150, render: (row) => activeStatus(row.applies_to_subunits) },
{ id: "source", header: "i18n:govoplan-organizations.source.6da13add", width: 130, value: (row) => row.source },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
{ id: "actions", header: "", width: 130, sticky: "end", render: (row) => <ActiveToggleButton item={row} disabled={!canAssignFunctions || busy} onToggle={() => toggleAssignment(row)} /> }
{ id: "actions", header: "", width: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canAssignFunctions || busy} onEdit={() => editAssignment(row)} onToggle={() => toggleAssignment(row)} /> }
];
const canWriteActive = active === "model" ? canWriteModel : active === "units" || active === "relations" ? canWriteUnits : active === "functions" ? canWriteFunctions : canAssignFunctions;
function cancelEditButton(onCancel: () => void) {
return <Button type="button" variant="ghost" onClick={onCancel} disabled={busy}>i18n:govoplan-organizations.cancel_edit.309c2a6f</Button>;
}
const content = (
<div className={`${mode === "workspace" ? "content-pad " : ""}organizations-page ${mode === "admin" ? "organizations-admin-page" : ""}`.trim()}>
<div className="page-heading split organizations-heading">
@@ -754,7 +984,10 @@ export default function OrganizationsPage({
<Card title="i18n:govoplan-organizations.unit_types.c7afc174">
<form className="organizations-form-grid" onSubmit={(event) => void submitUnitType(event)}>
<SluggedFields draft={unitTypeDraft} onChange={setUnitTypeDraft} disabled={!canWriteModel || busy} />
<Button className="wide" type="submit" variant="primary" disabled={!canWriteModel || busy}>i18n:govoplan-organizations.add_unit_type.58f2c05a</Button>
<div className="organizations-form-actions wide">
<Button type="submit" variant="primary" disabled={!canWriteModel || busy}>{editingUnitTypeId ? "i18n:govoplan-organizations.update_unit_type.b96fc0ad" : "i18n:govoplan-organizations.add_unit_type.58f2c05a"}</Button>
{editingUnitTypeId && cancelEditButton(() => { setEditingUnitTypeId(null); setUnitTypeDraft(emptySluggedDraft()); })}
</div>
</form>
</Card>
<Card title="i18n:govoplan-organizations.unit_types.c7afc174">
@@ -770,7 +1003,10 @@ export default function OrganizationsPage({
{STRUCTURE_KINDS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</FormField>
<Button className="wide" type="submit" variant="primary" disabled={!canWriteModel || busy}>i18n:govoplan-organizations.add_structure.b722042a</Button>
<div className="organizations-form-actions wide">
<Button type="submit" variant="primary" disabled={!canWriteModel || busy}>{editingStructureId ? "i18n:govoplan-organizations.update_structure.b2e25446" : "i18n:govoplan-organizations.add_structure.b722042a"}</Button>
{editingStructureId && cancelEditButton(() => { setEditingStructureId(null); setStructureDraft(emptyStructureDraft()); })}
</div>
</form>
</Card>
<Card title="i18n:govoplan-organizations.structures.f9b7f3b4">
@@ -803,7 +1039,10 @@ export default function OrganizationsPage({
<label><input type="checkbox" checked={relationTypeDraft.is_hierarchical} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, is_hierarchical: event.target.checked })} /> i18n:govoplan-organizations.hierarchical.8964f313</label>
<label><input type="checkbox" checked={relationTypeDraft.allow_cycles} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, allow_cycles: event.target.checked })} /> i18n:govoplan-organizations.allow_cycles.31327578</label>
</div>
<Button className="wide" type="submit" variant="primary" disabled={!canWriteModel || busy}>i18n:govoplan-organizations.add_relation_type.2ad19d03</Button>
<div className="organizations-form-actions wide">
<Button type="submit" variant="primary" disabled={!canWriteModel || busy}>{editingRelationTypeId ? "i18n:govoplan-organizations.update_relation_type.e6e7f30c" : "i18n:govoplan-organizations.add_relation_type.2ad19d03"}</Button>
{editingRelationTypeId && cancelEditButton(() => { setEditingRelationTypeId(null); setRelationTypeDraft(emptyRelationTypeDraft()); })}
</div>
</form>
</Card>
<Card title="i18n:govoplan-organizations.relation_types.e5890528">
@@ -824,7 +1063,10 @@ export default function OrganizationsPage({
<label><input type="checkbox" checked={functionTypeDraft.delegable} disabled={!canWriteModel || busy} onChange={(event) => setFunctionTypeDraft({ ...functionTypeDraft, delegable: event.target.checked })} /> i18n:govoplan-organizations.delegable.b4f0137d</label>
<label><input type="checkbox" checked={functionTypeDraft.act_in_place_allowed} disabled={!canWriteModel || busy} onChange={(event) => setFunctionTypeDraft({ ...functionTypeDraft, act_in_place_allowed: event.target.checked })} /> i18n:govoplan-organizations.act_in_place.49b942bd</label>
</div>
<Button className="wide" type="submit" variant="primary" disabled={!canWriteModel || busy}>i18n:govoplan-organizations.add_function_type.90d793f1</Button>
<div className="organizations-form-actions wide">
<Button type="submit" variant="primary" disabled={!canWriteModel || busy}>{editingFunctionTypeId ? "i18n:govoplan-organizations.update_function_type.0b7e07d3" : "i18n:govoplan-organizations.add_function_type.90d793f1"}</Button>
{editingFunctionTypeId && cancelEditButton(() => { setEditingFunctionTypeId(null); setFunctionTypeDraft(emptyFunctionTypeDraft()); })}
</div>
</form>
</Card>
<Card title="i18n:govoplan-organizations.function_types.172c01fe">
@@ -835,30 +1077,75 @@ 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-section-grid">
<Card title="i18n:govoplan-organizations.add_unit.8fa12fb1">
<form className="organizations-form-grid" onSubmit={(event) => void submitUnit(event)}>
<SluggedFields draft={unitDraft} onChange={setUnitDraft} disabled={!canWriteUnits || busy} />
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d">
<select value={unitDraft.unit_type_id} disabled={!canWriteUnits || busy} onChange={(event) => setUnitDraft({ ...unitDraft, unit_type_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.parent_unit.1986c35e">
<select value={unitDraft.parent_id} disabled={!canWriteUnits || busy} onChange={(event) => setUnitDraft({ ...unitDraft, parent_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<Button className="wide" type="submit" variant="primary" disabled={!canWriteUnits || busy}>i18n:govoplan-organizations.add_unit.8fa12fb1</Button>
</form>
</Card>
<Card title="i18n:govoplan-organizations.units.e14d0d92">
<DataGrid id="organizations-units" rows={model.units} columns={unitColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_units_found.eea2dd0c" initialFit="container" />
<div className="organizations-editor-grid">
<Card title="i18n:govoplan-organizations.organization_tree.e5bfb195">
<div className="organizations-tree-toolbar">
<Button type="button" variant="secondary" disabled={!canWriteUnits || busy} onClick={() => { setEditingUnitId(null); setUnitDraft(emptyUnitDraft()); }} title="i18n:govoplan-organizations.add_root_unit.1ef8a9f5">
<Plus size={16} aria-hidden="true" /> i18n:govoplan-organizations.add_root_unit.1ef8a9f5
</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>
</Card>
<div className="organizations-table-stack">
<Card title="i18n:govoplan-organizations.add_unit.8fa12fb1">
<form className="organizations-form-grid" onSubmit={(event) => void submitUnit(event)}>
<SluggedFields draft={unitDraft} onChange={setUnitDraft} disabled={!canWriteUnits || busy} />
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d">
<select value={unitDraft.unit_type_id} disabled={!canWriteUnits || busy} onChange={(event) => setUnitDraft({ ...unitDraft, unit_type_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.parent_unit.1986c35e">
<select value={unitDraft.parent_id} disabled={!canWriteUnits || busy} onChange={(event) => setUnitDraft({ ...unitDraft, parent_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{model.units.filter((item) => item.id !== editingUnitId).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
{unitDraft.parent_id && (
<p className="organizations-field-note wide">
i18n:govoplan-organizations.selected_parent.0e013d44 <strong>{unitById.get(unitDraft.parent_id)?.name ?? unitDraft.parent_id}</strong>
</p>
)}
<div className="organizations-form-actions wide">
<Button type="submit" variant="primary" disabled={!canWriteUnits || busy}>{editingUnitId ? "i18n:govoplan-organizations.update_unit.67e2500f" : "i18n:govoplan-organizations.add_unit.8fa12fb1"}</Button>
{editingUnitId && cancelEditButton(() => { setEditingUnitId(null); setUnitDraft(emptyUnitDraft()); })}
</div>
</form>
</Card>
<Card title="i18n:govoplan-organizations.units.e14d0d92">
<DataGrid id="organizations-units" rows={model.units} columns={unitColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_units_found.eea2dd0c" initialFit="container" />
</Card>
</div>
</div>
);
}
@@ -896,7 +1183,10 @@ export default function OrganizationsPage({
<input type="checkbox" checked={relationDraft.is_active} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, is_active: event.target.checked })} />
<span>i18n:govoplan-organizations.active.a733b809</span>
</label>
<Button className="wide" type="submit" variant="primary" disabled={!canWriteUnits || busy}>i18n:govoplan-organizations.add_relation.6b6e67ea</Button>
<div className="organizations-form-actions wide">
<Button type="submit" variant="primary" disabled={!canWriteUnits || busy}>{editingRelationId ? "i18n:govoplan-organizations.update_relation.4c44ce83" : "i18n:govoplan-organizations.add_relation.6b6e67ea"}</Button>
{editingRelationId && cancelEditButton(() => { setEditingRelationId(null); setRelationDraft(emptyRelationDraft()); })}
</div>
</form>
</Card>
<Card title="i18n:govoplan-organizations.relations.1c796711">
@@ -928,7 +1218,10 @@ export default function OrganizationsPage({
<label><input type="checkbox" checked={functionDraft.delegable} disabled={!canWriteFunctions || busy} onChange={(event) => setFunctionDraft({ ...functionDraft, delegable: event.target.checked })} /> i18n:govoplan-organizations.delegable.b4f0137d</label>
<label><input type="checkbox" checked={functionDraft.act_in_place_allowed} disabled={!canWriteFunctions || busy} onChange={(event) => setFunctionDraft({ ...functionDraft, act_in_place_allowed: event.target.checked })} /> i18n:govoplan-organizations.act_in_place.49b942bd</label>
</div>
<Button className="wide" type="submit" variant="primary" disabled={!canWriteFunctions || busy}>i18n:govoplan-organizations.add_function.6abafee0</Button>
<div className="organizations-form-actions wide">
<Button type="submit" variant="primary" disabled={!canWriteFunctions || busy}>{editingFunctionId ? "i18n:govoplan-organizations.update_function.504f03e5" : "i18n:govoplan-organizations.add_function.6abafee0"}</Button>
{editingFunctionId && cancelEditButton(() => { setEditingFunctionId(null); setFunctionDraft(emptyFunctionDraft()); })}
</div>
</form>
</Card>
<Card title="i18n:govoplan-organizations.functions.805dc49b">
@@ -962,7 +1255,10 @@ export default function OrganizationsPage({
<label><input type="checkbox" checked={assignmentDraft.applies_to_subunits} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, applies_to_subunits: event.target.checked })} /> i18n:govoplan-organizations.applies_to_subunits.7a15f741</label>
<label><input type="checkbox" checked={assignmentDraft.is_active} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, is_active: event.target.checked })} /> i18n:govoplan-organizations.active.a733b809</label>
</div>
<Button className="wide" type="submit" variant="primary" disabled={!canAssignFunctions || busy}>i18n:govoplan-organizations.add_assignment.6682f5f5</Button>
<div className="organizations-form-actions wide">
<Button type="submit" variant="primary" disabled={!canAssignFunctions || busy}>{editingAssignmentId ? "i18n:govoplan-organizations.update_assignment.bf130723" : "i18n:govoplan-organizations.add_assignment.6682f5f5"}</Button>
{editingAssignmentId && cancelEditButton(() => { setEditingAssignmentId(null); setAssignmentDraft(emptyAssignmentDraft()); })}
</div>
</form>
</Card>
<Card title="i18n:govoplan-organizations.function_assignments.4c5c6dae">