import { useEffect, useMemo, useRef, useState } from "react"; import { Pencil, Plus, Trash2 } from "lucide-react"; import type { ApiSettings, AuthInfo, OrganizationFunctionPickerUiCapability, OrganizationFunctionSelection } from "@govoplan/core-webui"; import { createExternalFunctionRoleMapping, deleteExternalFunctionRoleMapping, fetchExternalFunctionRoleMappingsDelta, fetchRolesDelta, updateExternalFunctionRoleMapping, type ExternalFunctionRoleMappingItem, type RoleSummary } from "../../api/admin"; import { Button } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui"; import { i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { loadDeltaRows } from "./utils/deltaRows"; const emptyDraft = { sourceModule: "organizations", functionId: "", functionLabel: "", organizationUnitLabel: "", roleId: "" }; export default function ExternalFunctionRoleMappingsPanel({ settings, auth, functionPicker, canWrite, onAuthRefresh }: { settings: ApiSettings; auth: AuthInfo; functionPicker: OrganizationFunctionPickerUiCapability; canWrite: boolean; onAuthRefresh: () => Promise; }) { const [mappings, setMappings] = useState([]); const [roles, setRoles] = useState([]); const mappingsRef = useRef([]); const rolesRef = useRef([]); const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const [editing, setEditing] = useState(null); const [draft, setDraft] = useState(emptyDraft); const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft)); const [deleting, setDeleting] = useState(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const tenantId = (auth.active_tenant ?? auth.tenant).id; const dirty = editing !== null && draftKey(draft) !== savedDraftKey; useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: closeEditor }); async function load() { setLoading(true); setError(""); try { const [nextMappings, nextRoles] = await Promise.all([ loadDeltaRows( mappingsRef.current, "access:external-function-role-mappings", getDeltaWatermark, setDeltaWatermark, (since) => fetchExternalFunctionRoleMappingsDelta(settings, { since }), (response) => response.mappings, (mapping) => mapping.id, "access_external_function_role_mapping", sortMappings ), loadDeltaRows( rolesRef.current, "access:roles-for-external-function-mappings", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortRoles ) ]); mappingsRef.current = nextMappings; rolesRef.current = nextRoles; setMappings(nextMappings); setRoles(nextRoles.filter((role) => role.level === "tenant")); } catch (err) { setError(adminErrorMessage(err)); } finally { setLoading(false); } } useEffect(() => { mappingsRef.current = []; rolesRef.current = []; resetDeltaWatermark(); void load(); }, [settings.accessToken, settings.apiBaseUrl, tenantId, resetDeltaWatermark]); const roleById = useMemo(() => new Map(roles.map((role) => [role.id, role])), [roles]); const assignableRoles = useMemo(() => roles.filter((role) => role.is_assignable), [roles]); function openCreate() { const initial = { ...emptyDraft, roleId: assignableRoles[0]?.id ?? "" }; setDraft(initial); setSavedDraftKey(draftKey(initial)); setEditing("new"); setError(""); } function openEdit(mapping: ExternalFunctionRoleMappingItem) { const nextDraft = { sourceModule: mapping.source_module, functionId: mapping.function_id, functionLabel: "", organizationUnitLabel: "", roleId: mapping.role_id }; setDraft(nextDraft); setSavedDraftKey(draftKey(nextDraft)); setEditing(mapping); setError(""); } function closeEditor() { setEditing(null); setDraft(emptyDraft); setSavedDraftKey(draftKey(emptyDraft)); } async function save(): Promise { setBusy(true); setError(""); try { if (editing === "new") { await createExternalFunctionRoleMapping(settings, { source_module: functionPicker.sourceModule, function_id: draft.functionId.trim(), role_id: draft.roleId }); setSuccess(i18nMessage("i18n:govoplan-access.function_role_mapping_created.7a25eb5a")); } else if (editing) { await updateExternalFunctionRoleMapping(settings, editing.id, { role_id: draft.roleId }); setSuccess(i18nMessage("i18n:govoplan-access.function_role_mapping_updated.76020443")); } closeEditor(); await onAuthRefresh(); mappingsRef.current = []; resetDeltaWatermark("access:external-function-role-mappings"); await load(); return true; } catch (err) { setError(adminErrorMessage(err)); return false; } finally { setBusy(false); } } async function remove() { if (!deleting) return; setBusy(true); setError(""); try { await deleteExternalFunctionRoleMapping(settings, deleting.id); setSuccess(i18nMessage("i18n:govoplan-access.function_role_mapping_deleted.fb180786")); setDeleting(null); await onAuthRefresh(); mappingsRef.current = []; resetDeltaWatermark("access:external-function-role-mappings"); await load(); } catch (err) { setError(adminErrorMessage(err)); } finally { setBusy(false); } } const columns = useMemo[]>( () => [ { id: "function", header: "i18n:govoplan-access.function_fact.4f7435e4", width: "minmax(260px, 1fr)", minWidth: 220, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.source_module} ${row.function_id}`, render: (row) => ( <> {functionPicker.renderLabel?.({ settings, auth, sourceModule: row.source_module, functionId: row.function_id, fallback: (
{row.source_module}
{row.function_id}
) }) ?? (
{row.source_module}
{row.function_id}
)} ) }, { id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (row) => roleById.get(row.role_id)?.name ?? row.role_id, render: (row) => { const role = roleById.get(row.role_id); return (
{role?.name ?? row.role_id} {role &&
{role.slug}
}
); } }, { id: "updated", header: "i18n:govoplan-access.updated.f2f8570d", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.updated_at, render: (row) => formatDateTime(row.updated_at) }, { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 130, sticky: "end", resizable: false, align: "right", render: (row) => (
} onClick={() => openEdit(row)} disabled={!canWrite} /> } variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
) } ], [auth, canWrite, functionPicker, roleById, settings] ); return ( <> } variant="primary" onClick={openCreate} disabled={!canWrite || !assignableRoles.length} /> } >
row.id} emptyText="i18n:govoplan-access.no_function_role_mappings_found.f735ff54" />
!busy && closeEditor()} className="admin-dialog" footer={ <> } >
{functionPicker.renderPicker({ settings, auth, disabled: editing !== "new", value: draft.functionId ? draftSelection(draft) : null, onChange: (selection) => setDraft({ ...draft, ...selectionDraft(selection) }) })}

i18n:govoplan-access.function_role_mapping_help.0cf9996a

setDeleting(null)} onConfirm={() => void remove()} /> ); } function draftKey(draft: typeof emptyDraft): string { return JSON.stringify(draft); } function draftSelection(draft: typeof emptyDraft): OrganizationFunctionSelection { return { sourceModule: "organizations", functionId: draft.functionId, label: draft.functionLabel || null, organizationUnitLabel: draft.organizationUnitLabel || null }; } function selectionDraft(selection: OrganizationFunctionSelection | null): Pick { return { sourceModule: selection?.sourceModule ?? "organizations", functionId: selection?.functionId ?? "", functionLabel: selection?.label ?? "", organizationUnitLabel: selection?.organizationUnitLabel ?? "" }; } function sortMappings(left: ExternalFunctionRoleMappingItem, right: ExternalFunctionRoleMappingItem): number { const sourceDelta = left.source_module.localeCompare(right.source_module); return sourceDelta !== 0 ? sourceDelta : left.function_id.localeCompare(right.function_id); } function sortRoles(left: RoleSummary, right: RoleSummary): number { return left.name.localeCompare(right.name); }