Files
govoplan-organizations/webui/src/features/organizations/OrganizationsPage.tsx

1227 lines
63 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { Edit3, Plus, RefreshCw, Save, XCircle } from "lucide-react";
import {
ApiError,
Button,
Card,
DataGrid,
DismissibleAlert,
FormField,
LoadingFrame,
ModuleSubnav,
PageTitle,
StatusBadge,
hasScope,
useUnsavedDraftGuard,
usePlatformUiCapabilities,
type ApiSettings,
type AuthInfo,
type DataGridColumn,
type ModuleSubnavGroup,
type OrganizationFunctionActionContribution,
type OrganizationFunctionActionsUiCapability
} from "@govoplan/core-webui";
import {
createFunction,
createFunctionType,
createRelation,
createRelationType,
createStructure,
createUnit,
createUnitType,
getOrganizationModel,
patchFunction,
patchFunctionType,
patchRelation,
patchRelationType,
patchStructure,
patchUnit,
patchUnitType,
type FunctionCreatePayload,
type FunctionTypeCreatePayload,
type OrganizationFunctionItem,
type OrganizationFunctionTypeItem,
type OrganizationModel,
type OrganizationRelationItem,
type OrganizationRelationTypeItem,
type OrganizationStructureItem,
type OrganizationStructureKind,
type OrganizationUnitItem,
type OrganizationUnitTypeItem,
type RelationCreatePayload,
type RelationTypeCreatePayload,
type SluggedCreatePayload,
type StructureCreatePayload,
type UnitCreatePayload
} from "../../api/organizations";
export type OrganizationSection = "model" | "units" | "relations" | "functions";
type OrganizationsPageMode = "workspace" | "admin";
type OrganizationsPageProps = {
settings: ApiSettings;
auth: AuthInfo;
mode?: OrganizationsPageMode;
initialSection?: OrganizationSection;
availableSections?: OrganizationSection[];
title?: string;
description?: string;
};
type SluggedDraft = {
name: string;
slug: string;
description: string;
is_active: boolean;
};
type StructureDraft = SluggedDraft & {
structure_kind: OrganizationStructureKind;
};
type RelationTypeDraft = SluggedDraft & {
structure_id: string;
source_unit_type_id: string;
target_unit_type_id: string;
is_hierarchical: boolean;
allow_cycles: boolean;
};
type UnitDraft = SluggedDraft & {
unit_type_id: string;
parent_id: string;
};
type RelationDraft = {
structure_id: string;
relation_type_id: string;
source_unit_id: string;
target_unit_id: string;
is_active: boolean;
};
type FunctionTypeDraft = SluggedDraft & {
organization_unit_type_id: string;
delegable: boolean;
act_in_place_allowed: boolean;
};
type FunctionDraft = SluggedDraft & {
organization_unit_id: string;
function_type_id: string;
delegable: boolean;
act_in_place_allowed: boolean;
};
type ActiveItem = {
id: string;
is_active: boolean;
};
type OrganizationsInitialQuery = {
section: OrganizationSection | "";
unitId: string;
functionId: string;
};
function organizationSectionFromQuery(value: string | null): OrganizationSection | "" {
return value === "model" || value === "units" || value === "relations" || value === "functions" ? value : "";
}
function organizationsInitialQuery(): OrganizationsInitialQuery {
if (typeof window === "undefined") return { section: "", unitId: "", functionId: "" };
const params = new URLSearchParams(window.location.search);
return {
section: organizationSectionFromQuery(params.get("section")),
unitId: params.get("unit_id") || "",
functionId: params.get("function_id") || ""
};
}
const EMPTY_MODEL: OrganizationModel = {
unit_types: [],
structures: [],
relation_types: [],
units: [],
relations: [],
function_types: [],
functions: []
};
const SECTION_GROUPS: ModuleSubnavGroup<OrganizationSection>[] = [
{
title: "i18n:govoplan-organizations.organizations.220edf64",
items: [
{ 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" }
]
}
];
const STRUCTURE_KINDS: Array<{ value: OrganizationStructureKind; label: string }> = [
{ value: "hierarchy", label: "i18n:govoplan-organizations.hierarchy.74797f37" },
{ value: "network", label: "i18n:govoplan-organizations.network.a24d31c3" },
{ value: "membership", label: "i18n:govoplan-organizations.membership.4531d86d" },
{ value: "classification", label: "i18n:govoplan-organizations.classification.3e9f6c3a" }
];
const ALL_SECTIONS: OrganizationSection[] = ["model", "units", "relations", "functions"];
function emptySluggedDraft(): SluggedDraft {
return { name: "", slug: "", description: "", is_active: true };
}
function emptyStructureDraft(): StructureDraft {
return { ...emptySluggedDraft(), structure_kind: "hierarchy" };
}
function emptyRelationTypeDraft(): RelationTypeDraft {
return {
...emptySluggedDraft(),
structure_id: "",
source_unit_type_id: "",
target_unit_type_id: "",
is_hierarchical: true,
allow_cycles: false
};
}
function emptyUnitDraft(): UnitDraft {
return { ...emptySluggedDraft(), unit_type_id: "", parent_id: "" };
}
function emptyRelationDraft(): RelationDraft {
return { structure_id: "", relation_type_id: "", source_unit_id: "", target_unit_id: "", is_active: true };
}
function emptyFunctionTypeDraft(): FunctionTypeDraft {
return { ...emptySluggedDraft(), organization_unit_type_id: "", delegable: false, act_in_place_allowed: false };
}
function emptyFunctionDraft(): FunctionDraft {
return { ...emptySluggedDraft(), organization_unit_id: "", function_type_id: "", delegable: false, act_in_place_allowed: false };
}
function textOrNull(value: string): string | null {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function sluggedPayload(draft: SluggedDraft): SluggedCreatePayload {
return {
name: draft.name.trim(),
slug: textOrNull(draft.slug),
description: textOrNull(draft.description),
is_active: draft.is_active,
settings: {}
};
}
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;
}
function isRelationTypeDirty(draft: RelationTypeDraft): boolean {
return isSluggedDirty(draft) || Boolean(draft.structure_id || draft.source_unit_type_id || draft.target_unit_type_id) || !draft.is_hierarchical || draft.allow_cycles;
}
function isUnitDirty(draft: UnitDraft): boolean {
return isSluggedDirty(draft) || Boolean(draft.unit_type_id || draft.parent_id);
}
function isRelationDirty(draft: RelationDraft): boolean {
return Boolean(draft.structure_id || draft.relation_type_id || draft.source_unit_id || draft.target_unit_id) || !draft.is_active;
}
function isFunctionTypeDirty(draft: FunctionTypeDraft): boolean {
return isSluggedDirty(draft) || Boolean(draft.organization_unit_type_id) || draft.delegable || draft.act_in_place_allowed;
}
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 mapById<T extends { id: string }>(items: T[]): Map<string, T> {
return new Map(items.map((item) => [item.id, item]));
}
function sectionGroupsFor(sections: ReadonlySet<OrganizationSection>): ModuleSubnavGroup<OrganizationSection>[] {
return SECTION_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => sections.has(item.id))
})).filter((group) => group.items.length > 0);
}
function apiErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
try {
const parsed = JSON.parse(error.body) as { detail?: string | { message?: string } };
if (typeof parsed.detail === "string") return parsed.detail;
if (parsed.detail && typeof parsed.detail.message === "string") return parsed.detail.message;
} catch {
// Fall back to the transport message below.
}
return error.message;
}
if (error instanceof Error) return error.message;
return String(error);
}
function optionalLabel(label: string | null | undefined): JSX.Element {
return label ? <span>{label}</span> : <span className="organizations-empty">i18n:govoplan-organizations.none.334c4a4c</span>;
}
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 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 (
<Button type="button" variant={item.is_active ? "secondary" : "primary"} disabled={disabled} onClick={onToggle} title={label}>
{label}
</Button>
);
}
function RowActions({ item, disabled, onEdit, onToggle, extra }: { item: ActiveItem; disabled: boolean; onEdit: () => void; onToggle: () => void; extra?: JSX.Element[] }) {
return (
<div className="organizations-row-actions">
<Button type="button" variant="ghost" disabled={disabled} onClick={onEdit} title="i18n:govoplan-organizations.edit.7dce1220">
<Edit3 size={16} aria-hidden="true" /> i18n:govoplan-organizations.edit.7dce1220
</Button>
<ActiveToggleButton item={item} disabled={disabled} onToggle={onToggle} />
{extra}
</div>
);
}
function SluggedFields({
draft,
onChange,
disabled
}: {
draft: SluggedDraft;
onChange: (next: SluggedDraft) => void;
disabled: boolean;
}) {
return (
<>
<FormField label="i18n:govoplan-organizations.name.709a2322">
<input value={draft.name} disabled={disabled} onChange={(event) => onChange({ ...draft, name: event.target.value })} />
</FormField>
<FormField label="i18n:govoplan-organizations.slug.094da9b9">
<input value={draft.slug} disabled={disabled} onChange={(event) => onChange({ ...draft, slug: event.target.value })} />
</FormField>
<div className="wide">
<FormField label="i18n:govoplan-organizations.description.55f8ebc8">
<textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => onChange({ ...draft, description: event.target.value })} />
</FormField>
</div>
<label className="organizations-inline-check wide">
<input type="checkbox" checked={draft.is_active} disabled={disabled} onChange={(event) => onChange({ ...draft, is_active: event.target.checked })} />
<span>i18n:govoplan-organizations.active.a733b809</span>
</label>
</>
);
}
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,
mode = "workspace",
initialSection = "model",
availableSections = ALL_SECTIONS,
title = "i18n:govoplan-organizations.organizations.220edf64",
description = "i18n:govoplan-organizations.organizations_intro.4e67c4bb"
}: OrganizationsPageProps) {
const sectionSet = useMemo(() => new Set(availableSections.length ? availableSections : ALL_SECTIONS), [availableSections]);
const firstAvailableSection = availableSections.find((section) => sectionSet.has(section)) ?? "model";
const initialQuery = useMemo(() => organizationsInitialQuery(), []);
const requestedInitialSection = initialQuery.section && sectionSet.has(initialQuery.section) ? initialQuery.section : initialSection;
const [active, setActive] = useState<OrganizationSection>(sectionSet.has(requestedInitialSection) ? requestedInitialSection : firstAvailableSection);
const appliedInitialQueryRef = useRef(false);
const visibleSectionGroups = useMemo(() => sectionGroupsFor(sectionSet), [sectionSet]);
const [model, setModel] = useState<OrganizationModel>(EMPTY_MODEL);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [unitTypeDraft, setUnitTypeDraft] = useState<SluggedDraft>(() => emptySluggedDraft());
const [structureDraft, setStructureDraft] = useState<StructureDraft>(() => emptyStructureDraft());
const [relationTypeDraft, setRelationTypeDraft] = useState<RelationTypeDraft>(() => emptyRelationTypeDraft());
const [unitDraft, setUnitDraft] = useState<UnitDraft>(() => emptyUnitDraft());
const [relationDraft, setRelationDraft] = useState<RelationDraft>(() => emptyRelationDraft());
const [functionTypeDraft, setFunctionTypeDraft] = useState<FunctionTypeDraft>(() => emptyFunctionTypeDraft());
const [functionDraft, setFunctionDraft] = useState<FunctionDraft>(() => emptyFunctionDraft());
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 [selectedUnitId, setSelectedUnitId] = useState("");
const [changeRequestId, setChangeRequestId] = useState("");
const functionActionCapabilities = usePlatformUiCapabilities<OrganizationFunctionActionsUiCapability>("organizations.functionActions");
const canWriteModel = hasScope(auth, "organizations:model:write");
const canWriteUnits = hasScope(auth, "organizations:unit:write");
const canWriteFunctions = hasScope(auth, "organizations:function:write");
const unitTypeById = useMemo(() => mapById(model.unit_types), [model.unit_types]);
const structureById = useMemo(() => mapById(model.structures), [model.structures]);
const relationTypeById = useMemo(() => mapById(model.relation_types), [model.relation_types]);
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 functionActionContributions = useMemo(
() => functionActionCapabilities
.flatMap((capability) => capability.actions)
.filter((contribution) => contributionVisible(auth, contribution))
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
[auth, functionActionCapabilities]
);
const unitsByParentId = useMemo(() => {
const mapped = new Map<string, OrganizationUnitItem[]>();
for (const unit of model.units) {
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) ||
isRelationTypeDirty(relationTypeDraft) ||
isUnitDirty(unitDraft) ||
isRelationDirty(relationDraft) ||
isFunctionTypeDirty(functionTypeDraft) ||
isFunctionDirty(functionDraft);
const loadModel = useCallback(async () => {
setLoading(true);
setError("");
try {
setModel(await getOrganizationModel(settings));
} catch (caught) {
setError(apiErrorMessage(caught));
} finally {
setLoading(false);
}
}, [settings]);
const withChangeRequest = useCallback(<T extends object,>(payload: T): T & { change_request_id?: string | null } => {
const requestId = textOrNull(changeRequestId);
return requestId ? { ...payload, change_request_id: requestId } : payload;
}, [changeRequestId]);
useEffect(() => {
void loadModel();
}, [loadModel]);
useEffect(() => {
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("");
setSuccess("");
try {
await action();
if (successMessage) setSuccess(successMessage);
await loadModel();
return true;
} catch (caught) {
setError(apiErrorMessage(caught));
return false;
} finally {
setBusy(false);
}
}, [loadModel]);
const discardDrafts = useCallback(() => {
setUnitTypeDraft(emptySluggedDraft());
setStructureDraft(emptyStructureDraft());
setRelationTypeDraft(emptyRelationTypeDraft());
setUnitDraft(emptyUnitDraft());
setRelationDraft(emptyRelationDraft());
setFunctionTypeDraft(emptyFunctionTypeDraft());
setFunctionDraft(emptyFunctionDraft());
setEditingUnitTypeId(null);
setEditingStructureId(null);
setEditingRelationTypeId(null);
setEditingUnitId(null);
setEditingRelationId(null);
setEditingFunctionTypeId(null);
setEditingFunctionId(null);
}, []);
function rejectMissingName(): Promise<boolean> {
setError("i18n:govoplan-organizations.name_is_required.27cd3782");
return Promise.resolve(false);
}
function rejectMissingWritePermission(): Promise<boolean> {
setError("i18n:govoplan-organizations.write_permission_required.8b09fd67");
return Promise.resolve(false);
}
const submitUnitType = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canWriteModel) return rejectMissingWritePermission();
if (!unitTypeDraft.name.trim()) return rejectMissingName();
const ok = await runAction(
() => editingUnitTypeId ? patchUnitType(settings, editingUnitTypeId, withChangeRequest(sluggedPatchPayload(unitTypeDraft))) : createUnitType(settings, withChangeRequest(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, editingUnitTypeId, runAction, settings, unitTypeDraft, withChangeRequest]);
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 patchPayload: Partial<StructureCreatePayload> = { ...sluggedPatchPayload(structureDraft), structure_kind: structureDraft.structure_kind };
const ok = await runAction(
() => editingStructureId ? patchStructure(settings, editingStructureId, withChangeRequest(patchPayload)) : createStructure(settings, withChangeRequest(payload)),
editingStructureId ? "i18n:govoplan-organizations.structure_updated.2591e75f" : "i18n:govoplan-organizations.structure_added.f6cf8e74"
);
if (ok) {
setStructureDraft(emptyStructureDraft());
setEditingStructureId(null);
}
return ok;
}, [canWriteModel, editingStructureId, runAction, settings, structureDraft, withChangeRequest]);
const submitRelationType = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canWriteModel) return rejectMissingWritePermission();
if (!relationTypeDraft.name.trim()) return rejectMissingName();
const payload: RelationTypeCreatePayload = {
...sluggedPayload(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 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, withChangeRequest(patchPayload)) : createRelationType(settings, withChangeRequest(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, editingRelationTypeId, relationTypeDraft, runAction, settings, withChangeRequest]);
const submitUnit = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canWriteUnits) return rejectMissingWritePermission();
if (!unitDraft.name.trim()) return rejectMissingName();
const payload: UnitCreatePayload = {
...sluggedPayload(unitDraft),
unit_type_id: textOrNull(unitDraft.unit_type_id),
parent_id: textOrNull(unitDraft.parent_id)
};
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, withChangeRequest(patchPayload)) : createUnit(settings, withChangeRequest(payload)),
editingUnitId ? "i18n:govoplan-organizations.unit_updated.15f02216" : "i18n:govoplan-organizations.unit_added.760a8512"
);
if (ok) {
setUnitDraft(emptyUnitDraft());
setEditingUnitId(null);
}
return ok;
}, [canWriteUnits, editingUnitId, runAction, settings, unitDraft, withChangeRequest]);
const submitRelation = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canWriteUnits) return rejectMissingWritePermission();
if (!relationDraft.structure_id || !relationDraft.relation_type_id || !relationDraft.source_unit_id || !relationDraft.target_unit_id) {
setError("i18n:govoplan-organizations.select_structure.c10f551c");
return false;
}
const payload: RelationCreatePayload = { ...relationDraft, settings: {} };
const patchPayload: Partial<RelationCreatePayload> = { ...relationDraft };
const ok = await runAction(
() => editingRelationId ? patchRelation(settings, editingRelationId, withChangeRequest(patchPayload)) : createRelation(settings, withChangeRequest(payload)),
editingRelationId ? "i18n:govoplan-organizations.relation_updated.c6212776" : "i18n:govoplan-organizations.relation_added.ca90461a"
);
if (ok) {
setRelationDraft(emptyRelationDraft());
setEditingRelationId(null);
}
return ok;
}, [canWriteUnits, editingRelationId, relationDraft, runAction, settings, withChangeRequest]);
const submitFunctionType = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canWriteModel) return rejectMissingWritePermission();
if (!functionTypeDraft.name.trim()) return rejectMissingName();
const payload: FunctionTypeCreatePayload = {
...sluggedPayload(functionTypeDraft),
organization_unit_type_id: textOrNull(functionTypeDraft.organization_unit_type_id),
delegable: functionTypeDraft.delegable,
act_in_place_allowed: functionTypeDraft.act_in_place_allowed
};
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, withChangeRequest(patchPayload)) : createFunctionType(settings, withChangeRequest(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, editingFunctionTypeId, functionTypeDraft, runAction, settings, withChangeRequest]);
const submitFunction = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
if (!canWriteFunctions) return rejectMissingWritePermission();
if (!functionDraft.name.trim()) return rejectMissingName();
if (!functionDraft.organization_unit_id) {
setError("i18n:govoplan-organizations.select_unit.013bf13a");
return false;
}
const payload: FunctionCreatePayload = {
...sluggedPayload(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 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, withChangeRequest(patchPayload)) : createFunction(settings, withChangeRequest(payload)),
editingFunctionId ? "i18n:govoplan-organizations.function_updated.65016009" : "i18n:govoplan-organizations.function_added.e2266702"
);
if (ok) {
setFunctionDraft(emptyFunctionDraft());
setEditingFunctionId(null);
}
return ok;
}, [canWriteFunctions, editingFunctionId, functionDraft, runAction, settings, withChangeRequest]);
const saveDrafts = useCallback(async (): Promise<boolean> => {
if (isSluggedDirty(unitTypeDraft) && !(await submitUnitType())) return false;
if (isSluggedDirty(structureDraft) && !(await submitStructure())) return false;
if (isRelationTypeDirty(relationTypeDraft) && !(await submitRelationType())) return false;
if (isUnitDirty(unitDraft) && !(await submitUnit())) return false;
if (isRelationDirty(relationDraft) && !(await submitRelation())) return false;
if (isFunctionTypeDirty(functionTypeDraft) && !(await submitFunctionType())) return false;
if (isFunctionDirty(functionDraft) && !(await submitFunction())) return false;
return true;
}, [
functionDraft,
functionTypeDraft,
relationDraft,
relationTypeDraft,
structureDraft,
submitFunction,
submitFunctionType,
submitRelation,
submitRelationType,
submitStructure,
submitUnit,
submitUnitType,
unitDraft,
unitTypeDraft
]);
useUnsavedDraftGuard({
dirty: hasDirtyDraft,
onSave: saveDrafts,
onDiscard: discardDrafts,
title: "i18n:govoplan-core.unsaved_changes.29267269",
message: "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b"
});
const toggleUnitType = useCallback((item: OrganizationUnitTypeItem) => {
void runAction(() => patchUnitType(settings, item.id, withChangeRequest({ is_active: !item.is_active })));
}, [runAction, settings, withChangeRequest]);
const toggleStructure = useCallback((item: OrganizationStructureItem) => {
void runAction(() => patchStructure(settings, item.id, withChangeRequest({ is_active: !item.is_active })));
}, [runAction, settings, withChangeRequest]);
const toggleRelationType = useCallback((item: OrganizationRelationTypeItem) => {
void runAction(() => patchRelationType(settings, item.id, withChangeRequest({ is_active: !item.is_active })));
}, [runAction, settings, withChangeRequest]);
const toggleUnit = useCallback((item: OrganizationUnitItem) => {
void runAction(() => patchUnit(settings, item.id, withChangeRequest({ is_active: !item.is_active })));
}, [runAction, settings, withChangeRequest]);
const toggleRelation = useCallback((item: OrganizationRelationItem) => {
void runAction(() => patchRelation(settings, item.id, withChangeRequest({ is_active: !item.is_active })));
}, [runAction, settings, withChangeRequest]);
const toggleFunctionType = useCallback((item: OrganizationFunctionTypeItem) => {
void runAction(() => patchFunctionType(settings, item.id, withChangeRequest({ is_active: !item.is_active })));
}, [runAction, settings, withChangeRequest]);
const toggleFunction = useCallback((item: OrganizationFunctionItem) => {
void runAction(() => patchFunction(settings, item.id, withChangeRequest({ is_active: !item.is_active })));
}, [runAction, settings, withChangeRequest]);
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");
}, []);
useEffect(() => {
if (appliedInitialQueryRef.current || loading) return;
if (initialQuery.functionId) {
const item = functionById.get(initialQuery.functionId);
if (item) {
appliedInitialQueryRef.current = true;
editFunction(item);
return;
}
}
if (initialQuery.unitId) {
const item = unitById.get(initialQuery.unitId);
if (item) {
appliedInitialQueryRef.current = true;
editUnit(item);
return;
}
}
if (initialQuery.section && sectionSet.has(initialQuery.section)) {
appliedInitialQueryRef.current = true;
setActive(initialQuery.section);
}
}, [editFunction, editUnit, functionById, initialQuery, loading, sectionSet, unitById]);
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: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canWriteModel || busy} onEdit={() => editUnitType(row)} onToggle={() => toggleUnitType(row)} /> }
];
const structureColumns: DataGridColumn<OrganizationStructureItem>[] = [
{ id: "name", header: "i18n:govoplan-organizations.name.709a2322", minWidth: 180, sortable: true, filterable: true, value: (row) => row.name },
{ 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: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canWriteModel || busy} onEdit={() => editStructure(row)} onToggle={() => toggleStructure(row)} /> }
];
const relationTypeColumns: DataGridColumn<OrganizationRelationTypeItem>[] = [
{ id: "name", header: "i18n:govoplan-organizations.name.709a2322", minWidth: 180, value: (row) => row.name, sortable: true, filterable: true },
{ id: "structure", header: "i18n:govoplan-organizations.structure.7732fb0b", minWidth: 170, render: (row) => optionalLabel(structureById.get(row.structure_id || "")?.name) },
{ id: "source", header: "i18n:govoplan-organizations.source_unit.2fbd8baa", minWidth: 170, render: (row) => optionalLabel(unitTypeById.get(row.source_unit_type_id || "")?.name) },
{ 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: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canWriteModel || busy} onEdit={() => editRelationType(row)} onToggle={() => toggleRelationType(row)} /> }
];
const unitColumns: DataGridColumn<OrganizationUnitItem>[] = [
{ id: "name", header: "i18n:govoplan-organizations.name.709a2322", minWidth: 190, sortable: true, filterable: true, value: (row) => row.name },
{ id: "type", header: "i18n:govoplan-organizations.type.599dcce2", minWidth: 160, render: (row) => optionalLabel(unitTypeById.get(row.unit_type_id || "")?.name) },
{ 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: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canWriteUnits || busy} onEdit={() => editUnit(row)} onToggle={() => toggleUnit(row)} /> }
];
const relationColumns: DataGridColumn<OrganizationRelationItem>[] = [
{ id: "structure", header: "i18n:govoplan-organizations.structure.7732fb0b", minWidth: 170, render: (row) => optionalLabel(structureById.get(row.structure_id)?.name) },
{ id: "type", header: "i18n:govoplan-organizations.relation_type.d0aee2e7", minWidth: 170, render: (row) => optionalLabel(relationTypeById.get(row.relation_type_id)?.name) },
{ 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: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canWriteUnits || busy} onEdit={() => editRelation(row)} onToggle={() => toggleRelation(row)} /> }
];
const functionTypeColumns: DataGridColumn<OrganizationFunctionTypeItem>[] = [
{ id: "name", header: "i18n:govoplan-organizations.name.709a2322", minWidth: 180, sortable: true, filterable: true, value: (row) => row.name },
{ id: "unitType", header: "i18n:govoplan-organizations.unit_type.9e62810d", minWidth: 180, render: (row) => optionalLabel(unitTypeById.get(row.organization_unit_type_id || "")?.name) },
{ 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: 220, sticky: "end", render: (row) => <RowActions item={row} disabled={!canWriteModel || busy} onEdit={() => editFunctionType(row)} onToggle={() => toggleFunctionType(row)} /> }
];
const functionColumns: DataGridColumn<OrganizationFunctionItem>[] = [
{ id: "name", header: "i18n:govoplan-organizations.name.709a2322", minWidth: 180, sortable: true, filterable: true, value: (row) => row.name },
{ id: "unit", header: "i18n:govoplan-organizations.unit.8fe4d595", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.organization_unit_id)?.name) },
{ id: "type", header: "i18n:govoplan-organizations.function_type.501fe7c0", minWidth: 180, render: (row) => optionalLabel(functionTypeById.get(row.function_type_id || "")?.name) },
{ id: "delegable", header: "i18n:govoplan-organizations.delegable.b4f0137d", width: 120, render: (row) => activeStatus(row.delegable) },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
{
id: "actions",
header: "",
width: functionActionContributions.length ? 340 : 220,
sticky: "end",
render: (row) => (
<RowActions
item={row}
disabled={!canWriteFunctions || busy}
onEdit={() => editFunction(row)}
onToggle={() => toggleFunction(row)}
extra={functionActionContributions.map((contribution) => (
<span className="organizations-contributed-action" key={contribution.id}>
{contribution.render({ settings, auth, function: row })}
</span>
))}
/>
)
}
];
const canWriteActive = active === "model" ? canWriteModel : active === "units" || active === "relations" ? canWriteUnits : canWriteFunctions;
function cancelEditButton(onCancel: () => void) {
return <Button type="button" variant="ghost" onClick={onCancel} disabled={busy}>i18n:govoplan-organizations.cancel_edit.309c2a6f</Button>;
}
const content = (
<div className={`${mode === "workspace" ? "content-pad " : ""}organizations-page ${mode === "admin" ? "organizations-admin-page" : ""}`.trim()}>
<div className="page-heading split organizations-heading">
<div>
<PageTitle loading={loading}>{title}</PageTitle>
<p>{description}</p>
</div>
<div className="organizations-toolbar">
<label className="organizations-change-request">
<span>i18n:govoplan-organizations.change_request_id.a6e7fd6b</span>
<input value={changeRequestId} onChange={(event) => setChangeRequestId(event.target.value)} placeholder="cfgreq-..." disabled={busy} />
</label>
<Button type="button" onClick={() => void loadModel()} disabled={loading || busy} title="i18n:govoplan-organizations.reload.cce71553">
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-organizations.reload.cce71553
</Button>
{hasDirtyDraft && (
<>
<Button type="button" onClick={() => void saveDrafts()} disabled={busy} title="i18n:govoplan-organizations.save_drafts.606f9401">
<Save size={16} aria-hidden="true" /> i18n:govoplan-organizations.save_drafts.606f9401
</Button>
<Button type="button" variant="ghost" onClick={discardDrafts} disabled={busy} title="i18n:govoplan-organizations.discard_organization_drafts.766d5f46">
<XCircle size={16} aria-hidden="true" /> i18n:govoplan-organizations.discard_organization_drafts.766d5f46
</Button>
</>
)}
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
{!canWriteActive && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-organizations.write_permission_required.8b09fd67</DismissibleAlert>}
<LoadingFrame loading={loading || busy} label="i18n:govoplan-organizations.loading_organization_model.846aa317">
{active === "model" && renderModelSection()}
{active === "units" && renderUnitsSection()}
{active === "relations" && renderRelationsSection()}
{active === "functions" && renderFunctionsSection()}
</LoadingFrame>
</div>
);
if (mode === "admin") return content;
return (
<div className="workspace organizations-workspace">
<ModuleSubnav active={active} groups={visibleSectionGroups} onSelect={setActive} />
<main className="workspace-content">
{content}
</main>
</div>
);
function renderModelSection() {
return (
<div className="organizations-table-stack">
<div className="organizations-section-grid">
<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} />
<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">
<DataGrid id="organizations-unit-types" rows={model.unit_types} columns={unitTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_unit_types_found.c81fb2a7" initialFit="container" />
</Card>
</div>
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.structures.f9b7f3b4">
<form className="organizations-form-grid" onSubmit={(event) => void submitStructure(event)}>
<SluggedFields draft={structureDraft} onChange={setStructureDraft} disabled={!canWriteModel || busy} />
<FormField label="i18n:govoplan-organizations.kind.794c9d9c">
<select value={structureDraft.structure_kind} disabled={!canWriteModel || busy} onChange={(event) => setStructureDraft({ ...structureDraft, structure_kind: event.target.value as OrganizationStructureKind })}>
{STRUCTURE_KINDS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</FormField>
<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">
<DataGrid id="organizations-structures" rows={model.structures} columns={structureColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_structures_found.20b382d0" initialFit="container" />
</Card>
</div>
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.relation_types.e5890528">
<form className="organizations-form-grid" onSubmit={(event) => void submitRelationType(event)}>
<SluggedFields draft={relationTypeDraft} onChange={setRelationTypeDraft} disabled={!canWriteModel || busy} />
<FormField label="i18n:govoplan-organizations.structure.7732fb0b">
<select value={relationTypeDraft.structure_id} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, structure_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{model.structures.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.source_unit.2fbd8baa">
<select value={relationTypeDraft.source_unit_type_id} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, source_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.target_unit.a1507d86">
<select value={relationTypeDraft.target_unit_type_id} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, target_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>
<div className="organizations-check-list">
<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>
<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">
<DataGrid id="organizations-relation-types" rows={model.relation_types} columns={relationTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_relation_types_found.6f90bb81" initialFit="container" />
</Card>
</div>
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.function_types.172c01fe">
<form className="organizations-form-grid" onSubmit={(event) => void submitFunctionType(event)}>
<SluggedFields draft={functionTypeDraft} onChange={setFunctionTypeDraft} disabled={!canWriteModel || busy} />
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d">
<select value={functionTypeDraft.organization_unit_type_id} disabled={!canWriteModel || busy} onChange={(event) => setFunctionTypeDraft({ ...functionTypeDraft, organization_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>
<div className="organizations-check-list">
<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>
<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">
<DataGrid id="organizations-function-types" rows={model.function_types} columns={functionTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_function_types_found.5eef31b1" initialFit="container" />
</Card>
</div>
</div>
);
}
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-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>
);
}
function renderRelationsSection() {
return (
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.add_relation.6b6e67ea">
<form className="organizations-form-grid" onSubmit={(event) => void submitRelation(event)}>
<FormField label="i18n:govoplan-organizations.structure.7732fb0b">
<select value={relationDraft.structure_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, structure_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.select_structure.c10f551c</option>
{model.structures.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.relation_type.d0aee2e7">
<select value={relationDraft.relation_type_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, relation_type_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.select_relation_type.73f92478</option>
{model.relation_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.source_unit.2fbd8baa">
<select value={relationDraft.source_unit_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, source_unit_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.select_source_unit.9b5a29c8</option>
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.target_unit.a1507d86">
<select value={relationDraft.target_unit_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, target_unit_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.select_target_unit.8d607541</option>
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<label className="organizations-inline-check wide">
<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>
<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">
<DataGrid id="organizations-relations" rows={model.relations} columns={relationColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_relations_found.4e75b11d" initialFit="container" />
</Card>
</div>
);
}
function renderFunctionsSection() {
return (
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.add_function.6abafee0">
<form className="organizations-form-grid" onSubmit={(event) => void submitFunction(event)}>
<SluggedFields draft={functionDraft} onChange={setFunctionDraft} disabled={!canWriteFunctions || busy} />
<FormField label="i18n:govoplan-organizations.unit.8fe4d595">
<select value={functionDraft.organization_unit_id} disabled={!canWriteFunctions || busy} onChange={(event) => setFunctionDraft({ ...functionDraft, organization_unit_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.select_unit.013bf13a</option>
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<FormField label="i18n:govoplan-organizations.function_type.501fe7c0">
<select value={functionDraft.function_type_id} disabled={!canWriteFunctions || busy} onChange={(event) => setFunctionDraft({ ...functionDraft, function_type_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{model.function_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</FormField>
<div className="organizations-check-list">
<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>
<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">
<DataGrid id="organizations-functions" rows={model.functions} columns={functionColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_functions_found.54e759a0" initialFit="container" />
</Card>
</div>
);
}
}