1227 lines
63 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|
|
}
|