Add organization change-control workflow

This commit is contained in:
2026-07-10 18:57:32 +02:00
parent becbc8dd04
commit 669647a986
9 changed files with 421 additions and 71 deletions

View File

@@ -36,9 +36,11 @@ import {
patchStructure,
patchUnit,
patchUnitType,
searchIdentityOptions,
type FunctionAssignmentCreatePayload,
type FunctionCreatePayload,
type FunctionTypeCreatePayload,
type IdentityOption,
type OrganizationFunctionAssignmentItem,
type OrganizationFunctionItem,
type OrganizationFunctionTypeItem,
@@ -293,7 +295,16 @@ function sectionGroupsFor(sections: ReadonlySet<OrganizationSection>): ModuleSub
}
function apiErrorMessage(error: unknown): string {
if (error instanceof ApiError) return error.message;
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);
}
@@ -306,6 +317,21 @@ 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 identityOptionLabel(identity: IdentityOption): string {
return identity.display_name || identity.external_subject || identity.primary_account_id || identity.id;
}
function identityDisplay(identityId: string, identities: Map<string, IdentityOption>): JSX.Element {
const identity = identities.get(identityId);
if (!identity) return <span className="organizations-id">{identityId}</span>;
return (
<span className="organizations-identity">
<span>{identityOptionLabel(identity)}</span>
<span className="organizations-id">{identity.id}</span>
</span>
);
}
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 (
@@ -392,6 +418,11 @@ export default function OrganizationsPage({
const [editingFunctionId, setEditingFunctionId] = useState<string | null>(null);
const [editingAssignmentId, setEditingAssignmentId] = useState<string | null>(null);
const [selectedUnitId, setSelectedUnitId] = useState("");
const [changeRequestId, setChangeRequestId] = useState("");
const [identitySearch, setIdentitySearch] = useState("");
const [identityOptions, setIdentityOptions] = useState<IdentityOption[]>([]);
const [identityLoading, setIdentityLoading] = useState(false);
const [identityLookupAvailable, setIdentityLookupAvailable] = useState(true);
const canWriteModel = hasScope(auth, "organizations:model:write");
const canWriteUnits = hasScope(auth, "organizations:unit:write");
@@ -404,6 +435,7 @@ export default function OrganizationsPage({
const unitById = useMemo(() => mapById(model.units), [model.units]);
const functionTypeById = useMemo(() => mapById(model.function_types), [model.function_types]);
const functionById = useMemo(() => mapById(model.functions), [model.functions]);
const identityOptionById = useMemo(() => mapById(identityOptions), [identityOptions]);
const unitsByParentId = useMemo(() => {
const mapped = new Map<string, OrganizationUnitItem[]>();
for (const unit of model.units) {
@@ -435,6 +467,11 @@ export default function OrganizationsPage({
}
}, [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]);
@@ -447,6 +484,36 @@ export default function OrganizationsPage({
if (selectedUnitId && !unitById.has(selectedUnitId)) setSelectedUnitId("");
}, [selectedUnitId, unitById]);
useEffect(() => {
if (active !== "assignments" || !identityLookupAvailable) return undefined;
let cancelled = false;
const timeout = window.setTimeout(() => {
setIdentityLoading(true);
searchIdentityOptions(settings, identitySearch, 50).
then((items) => {
if (cancelled) return;
setIdentityOptions(items);
setIdentityLookupAvailable(true);
}).
catch((caught) => {
if (cancelled) return;
if (caught instanceof ApiError && (caught.status === 403 || caught.status === 404)) {
setIdentityLookupAvailable(false);
setIdentityOptions([]);
return;
}
setError(apiErrorMessage(caught));
}).
finally(() => {
if (!cancelled) setIdentityLoading(false);
});
}, 250);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [active, identityLookupAvailable, identitySearch, settings]);
const runAction = useCallback(async (action: () => Promise<unknown>, successMessage = ""): Promise<boolean> => {
setBusy(true);
setError("");
@@ -498,7 +565,7 @@ export default function OrganizationsPage({
if (!canWriteModel) return rejectMissingWritePermission();
if (!unitTypeDraft.name.trim()) return rejectMissingName();
const ok = await runAction(
() => editingUnitTypeId ? patchUnitType(settings, editingUnitTypeId, sluggedPatchPayload(unitTypeDraft)) : createUnitType(settings, sluggedPayload(unitTypeDraft)),
() => 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) {
@@ -506,7 +573,7 @@ export default function OrganizationsPage({
setEditingUnitTypeId(null);
}
return ok;
}, [canWriteModel, editingUnitTypeId, runAction, settings, unitTypeDraft]);
}, [canWriteModel, editingUnitTypeId, runAction, settings, unitTypeDraft, withChangeRequest]);
const submitStructure = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
@@ -515,7 +582,7 @@ export default function OrganizationsPage({
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, patchPayload) : createStructure(settings, payload),
() => 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) {
@@ -523,7 +590,7 @@ export default function OrganizationsPage({
setEditingStructureId(null);
}
return ok;
}, [canWriteModel, editingStructureId, runAction, settings, structureDraft]);
}, [canWriteModel, editingStructureId, runAction, settings, structureDraft, withChangeRequest]);
const submitRelationType = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
@@ -546,7 +613,7 @@ export default function OrganizationsPage({
allow_cycles: relationTypeDraft.allow_cycles
};
const ok = await runAction(
() => editingRelationTypeId ? patchRelationType(settings, editingRelationTypeId, patchPayload) : createRelationType(settings, payload),
() => 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) {
@@ -554,7 +621,7 @@ export default function OrganizationsPage({
setEditingRelationTypeId(null);
}
return ok;
}, [canWriteModel, editingRelationTypeId, relationTypeDraft, runAction, settings]);
}, [canWriteModel, editingRelationTypeId, relationTypeDraft, runAction, settings, withChangeRequest]);
const submitUnit = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
@@ -571,7 +638,7 @@ export default function OrganizationsPage({
parent_id: textOrNull(unitDraft.parent_id)
};
const ok = await runAction(
() => editingUnitId ? patchUnit(settings, editingUnitId, patchPayload) : createUnit(settings, payload),
() => 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) {
@@ -579,7 +646,7 @@ export default function OrganizationsPage({
setEditingUnitId(null);
}
return ok;
}, [canWriteUnits, editingUnitId, runAction, settings, unitDraft]);
}, [canWriteUnits, editingUnitId, runAction, settings, unitDraft, withChangeRequest]);
const submitRelation = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
@@ -591,7 +658,7 @@ export default function OrganizationsPage({
const payload: RelationCreatePayload = { ...relationDraft, settings: {} };
const patchPayload: Partial<RelationCreatePayload> = { ...relationDraft };
const ok = await runAction(
() => editingRelationId ? patchRelation(settings, editingRelationId, patchPayload) : createRelation(settings, payload),
() => 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) {
@@ -599,7 +666,7 @@ export default function OrganizationsPage({
setEditingRelationId(null);
}
return ok;
}, [canWriteUnits, editingRelationId, relationDraft, runAction, settings]);
}, [canWriteUnits, editingRelationId, relationDraft, runAction, settings, withChangeRequest]);
const submitFunctionType = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
@@ -618,7 +685,7 @@ export default function OrganizationsPage({
act_in_place_allowed: functionTypeDraft.act_in_place_allowed
};
const ok = await runAction(
() => editingFunctionTypeId ? patchFunctionType(settings, editingFunctionTypeId, patchPayload) : createFunctionType(settings, payload),
() => 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) {
@@ -626,7 +693,7 @@ export default function OrganizationsPage({
setEditingFunctionTypeId(null);
}
return ok;
}, [canWriteModel, editingFunctionTypeId, functionTypeDraft, runAction, settings]);
}, [canWriteModel, editingFunctionTypeId, functionTypeDraft, runAction, settings, withChangeRequest]);
const submitFunction = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
@@ -651,7 +718,7 @@ export default function OrganizationsPage({
act_in_place_allowed: functionDraft.act_in_place_allowed
};
const ok = await runAction(
() => editingFunctionId ? patchFunction(settings, editingFunctionId, patchPayload) : createFunction(settings, payload),
() => 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) {
@@ -659,7 +726,7 @@ export default function OrganizationsPage({
setEditingFunctionId(null);
}
return ok;
}, [canWriteFunctions, editingFunctionId, functionDraft, runAction, settings]);
}, [canWriteFunctions, editingFunctionId, functionDraft, runAction, settings, withChangeRequest]);
const submitAssignment = useCallback(async (event?: FormEvent): Promise<boolean> => {
event?.preventDefault();
@@ -680,7 +747,7 @@ export default function OrganizationsPage({
settings: {}
};
const ok = await runAction(
() => editingAssignmentId ? patchFunctionAssignment(settings, editingAssignmentId, payload) : createFunctionAssignment(settings, payload),
() => editingAssignmentId ? patchFunctionAssignment(settings, editingAssignmentId, withChangeRequest(payload)) : createFunctionAssignment(settings, withChangeRequest(payload)),
editingAssignmentId ? "i18n:govoplan-organizations.assignment_updated.680b6e33" : "i18n:govoplan-organizations.assignment_added.94263d1b"
);
if (ok) {
@@ -688,7 +755,7 @@ export default function OrganizationsPage({
setEditingAssignmentId(null);
}
return ok;
}, [assignmentDraft, canAssignFunctions, editingAssignmentId, runAction, settings]);
}, [assignmentDraft, canAssignFunctions, editingAssignmentId, runAction, settings, withChangeRequest]);
const saveDrafts = useCallback(async (): Promise<boolean> => {
if (isSluggedDirty(unitTypeDraft) && !(await submitUnitType())) return false;
@@ -728,36 +795,36 @@ export default function OrganizationsPage({
});
const toggleUnitType = useCallback((item: OrganizationUnitTypeItem) => {
void runAction(() => patchUnitType(settings, item.id, { is_active: !item.is_active }));
}, [runAction, settings]);
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, { is_active: !item.is_active }));
}, [runAction, settings]);
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, { is_active: !item.is_active }));
}, [runAction, settings]);
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, { is_active: !item.is_active }));
}, [runAction, settings]);
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, { is_active: !item.is_active }));
}, [runAction, settings]);
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, { is_active: !item.is_active }));
}, [runAction, settings]);
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, { is_active: !item.is_active }));
}, [runAction, settings]);
void runAction(() => patchFunction(settings, item.id, withChangeRequest({ is_active: !item.is_active })));
}, [runAction, settings, withChangeRequest]);
const toggleAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => {
void runAction(() => patchFunctionAssignment(settings, item.id, { is_active: !item.is_active }));
}, [runAction, settings]);
void runAction(() => patchFunctionAssignment(settings, item.id, withChangeRequest({ is_active: !item.is_active })));
}, [runAction, settings, withChangeRequest]);
const editUnitType = useCallback((item: OrganizationUnitTypeItem) => {
setEditingUnitTypeId(item.id);
@@ -913,7 +980,7 @@ export default function OrganizationsPage({
];
const assignmentColumns: DataGridColumn<OrganizationFunctionAssignmentItem>[] = [
{ id: "identity", header: "i18n:govoplan-organizations.identity_id.b6ef5010", minWidth: 220, render: (row) => <span className="organizations-id">{row.identity_id}</span> },
{ id: "identity", header: "i18n:govoplan-organizations.identity.3252f35f", minWidth: 220, render: (row) => identityDisplay(row.identity_id, identityOptionById) },
{ id: "function", header: "i18n:govoplan-organizations.function.28822f3a", minWidth: 190, render: (row) => optionalLabel(functionById.get(row.function_id)?.name) },
{ id: "unit", header: "i18n:govoplan-organizations.unit.8fe4d595", minWidth: 180, render: (row) => optionalLabel(unitById.get(row.organization_unit_id)?.name) },
{ id: "subunits", header: "i18n:govoplan-organizations.applies_to_subunits.7a15f741", width: 150, render: (row) => activeStatus(row.applies_to_subunits) },
@@ -936,6 +1003,10 @@ export default function OrganizationsPage({
<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>
@@ -1232,15 +1303,52 @@ export default function OrganizationsPage({
}
function renderAssignmentsSection() {
const selectedIdentity = identityOptionById.get(assignmentDraft.identity_id);
return (
<div className="organizations-section-grid">
<Card title="i18n:govoplan-organizations.add_assignment.6682f5f5">
<form className="organizations-form-grid" onSubmit={(event) => void submitAssignment(event)}>
<FormField label="i18n:govoplan-organizations.identity_id.b6ef5010">
<input value={assignmentDraft.identity_id} placeholder="i18n:govoplan-organizations.identity_id_placeholder.298cbd85" disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, identity_id: event.target.value })} />
</FormField>
{identityLookupAvailable ? (
<div className="organizations-identity-picker wide">
<FormField label="i18n:govoplan-organizations.identity_search.eff6eb16">
<input value={identitySearch} placeholder="i18n:govoplan-organizations.search_identities.004afdfd" disabled={!canAssignFunctions || busy} onChange={(event) => setIdentitySearch(event.target.value)} />
</FormField>
<FormField label="i18n:govoplan-organizations.identity.3252f35f">
<select
value={assignmentDraft.identity_id}
disabled={!canAssignFunctions || busy || identityLoading}
onChange={(event) => {
const identity = identityOptionById.get(event.target.value);
setAssignmentDraft({
...assignmentDraft,
identity_id: event.target.value,
account_id: identity?.primary_account_id ?? identity?.account_ids[0] ?? ""
});
}}
>
<option value="">{identityLoading ? "i18n:govoplan-organizations.loading_identities.9c31d2b5" : "i18n:govoplan-organizations.select_identity.510f1134"}</option>
{assignmentDraft.identity_id && !identityOptionById.has(assignmentDraft.identity_id) && <option value={assignmentDraft.identity_id}>{assignmentDraft.identity_id}</option>}
{identityOptions.map((identity) => <option key={identity.id} value={identity.id}>{identityOptionLabel(identity)}</option>)}
</select>
</FormField>
</div>
) : (
<>
<p className="organizations-muted wide">i18n:govoplan-organizations.identity_lookup_unavailable.5a3e77b2</p>
<FormField label="i18n:govoplan-organizations.identity_id.b6ef5010">
<input value={assignmentDraft.identity_id} placeholder="i18n:govoplan-organizations.identity_id_placeholder.298cbd85" disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, identity_id: event.target.value })} />
</FormField>
</>
)}
<FormField label="i18n:govoplan-organizations.optional_account_id.5fcf495c">
<input value={assignmentDraft.account_id} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })} />
{selectedIdentity && selectedIdentity.account_ids.length > 0 ? (
<select value={assignmentDraft.account_id} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })}>
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
{selectedIdentity.account_ids.map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
</select>
) : (
<input value={assignmentDraft.account_id} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })} />
)}
</FormField>
<FormField label="i18n:govoplan-organizations.function.28822f3a">
<select value={assignmentDraft.function_id} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, function_id: event.target.value })}>