chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:16 +02:00
parent 0f2a9beca7
commit 970625edff
43 changed files with 2959 additions and 518 deletions

View File

@@ -17,9 +17,9 @@ import {
type GovernanceAssignment,
type GovernanceTemplateItem,
type PermissionItem,
type TenantAdminItem
} from "../../api/admin";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels } from "@govoplan/core-webui";
type TenantAdminItem } from
"../../api/admin";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
const emptyDraft = {
slug: "",
@@ -35,12 +35,12 @@ export default function GovernanceTemplatesPanel({
kind,
canWrite,
onAuthRefresh
}: {
settings: ApiSettings;
kind: "group" | "role";
canWrite: boolean;
onAuthRefresh: () => Promise<void>;
}) {
}: {settings: ApiSettings;kind: "group" | "role";canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
const [items, setItems] = useState<GovernanceTemplateItem[]>([]);
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
@@ -48,46 +48,63 @@ export default function GovernanceTemplatesPanel({
const [viewing, setViewing] = useState<GovernanceTemplateItem | null>(null);
const [deleting, setDeleting] = useState<GovernanceTemplateItem | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [nextItems, nextTenants, nextPermissions] = await Promise.all([
fetchGovernanceTemplates(settings, kind),
fetchTenants(settings),
kind === "role" ? fetchPermissionCatalog(settings) : Promise.resolve([])
]);
fetchGovernanceTemplates(settings, kind),
fetchTenants(settings),
kind === "role" ? fetchPermissionCatalog(settings) : Promise.resolve([])]
);
setItems(nextItems);
setTenants(nextTenants);
setPermissions(nextPermissions.filter((item) => item.level === "tenant"));
} catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setLoading(false);}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, kind]);
useEffect(() => {void load();}, [settings.accessToken, settings.apiBaseUrl, kind]);
function openCreate() {
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
setEditing("new");
}
function openEdit(item: GovernanceTemplateItem) {
setDraft({
const nextDraft = {
slug: item.slug,
name: item.name,
description: item.description || "",
isActive: item.is_active,
permissions: item.permissions,
assignments: item.assignments
});
};
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(item);
}
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
function assignmentMode(tenantId: string): "none" | "available" | "required" {
return draft.assignments.find((item) => item.tenant_id === tenantId)?.mode ?? "none";
}
@@ -95,13 +112,13 @@ export default function GovernanceTemplatesPanel({
function setAssignment(tenantId: string, mode: "none" | "available" | "required") {
setDraft((current) => ({
...current,
assignments: mode === "none"
? current.assignments.filter((item) => item.tenant_id !== tenantId)
: [...current.assignments.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, mode }]
assignments: mode === "none" ?
current.assignments.filter((item) => item.tenant_id !== tenantId) :
[...current.assignments.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, mode }]
}));
}
async function save() {
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
@@ -115,7 +132,7 @@ export default function GovernanceTemplatesPanel({
is_active: draft.isActive,
assignments: draft.assignments
});
setSuccess(`${kind === "group" ? "Group" : "Role"} template created.`);
setSuccess(i18nMessage("i18n:govoplan-admin.value_template_created.d68a5166", { value0: kind === "group" ? "i18n:govoplan-admin.group.171a0606" : "i18n:govoplan-admin.role.c3f104d1" }));
} else if (editing) {
await updateGovernanceTemplate(settings, editing.id, {
name: draft.name,
@@ -124,13 +141,14 @@ export default function GovernanceTemplatesPanel({
is_active: draft.isActive,
assignments: draft.assignments
});
setSuccess(`${draft.name} updated and synchronized to assigned tenants.`);
setSuccess(i18nMessage("i18n:govoplan-admin.value_updated_and_synchronized_to_assigned_tenan.d136eef2", { value0: draft.name }));
}
setEditing(null);
await load();
await onAuthRefresh();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
return true;
} catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
}
async function remove() {
@@ -139,47 +157,47 @@ export default function GovernanceTemplatesPanel({
setError("");
try {
await deleteGovernanceTemplate(settings, deleting.id);
setSuccess(`${deleting.name} deleted.`);
setSuccess(i18nMessage("i18n:govoplan-admin.value_deleted.3c4bf574", { value0: deleting.name }));
setDeleting(null);
await load();
await onAuthRefresh();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setBusy(false);}
}
const columns = useMemo<DataGridColumn<GovernanceTemplateItem>[]>(() => [
{
id: "template", header: kind === "group" ? "Group template" : "Tenant role", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true,
value: (row) => `${row.name} ${row.slug}`,
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
},
{
id: "tenants", header: "Tenant availability", width: 320, minWidth: 210, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true,
value: (row) => row.assignments.map((assignment) => tenants.find((tenant) => tenant.id === assignment.tenant_id)?.name || assignment.tenant_id).join(", ") || "—",
render: (row) => row.assignments.length ? row.assignments.map((assignment) => {
const tenant = tenants.find((item) => item.id === assignment.tenant_id);
return `${tenant?.name || assignment.tenant_id} (${assignment.mode})`;
}).join(", ") : "—"
},
...(kind === "role" ? [{
id: "permissions", header: "Permissions", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer" as const,
value: (row: GovernanceTemplateItem) => row.effective_permission_count
}] : []),
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{
id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right",
render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} />
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
{
id: "template", header: kind === "group" ? "i18n:govoplan-admin.group_template.973e0fa6" : "i18n:govoplan-admin.tenant_role.6b53115d", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true,
value: (row) => `${row.name} ${row.slug}`,
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
},
{
id: "tenants", header: "i18n:govoplan-admin.tenant_availability.067a4f31", width: 320, minWidth: 210, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true,
value: (row) => row.assignments.map((assignment) => tenants.find((tenant) => tenant.id === assignment.tenant_id)?.name || assignment.tenant_id).join(", ") || "—",
render: (row) => row.assignments.length ? row.assignments.map((assignment) => {
const tenant = tenants.find((item) => item.id === assignment.tenant_id);
return i18nMessage("i18n:govoplan-admin.value_value.c189e8bc", { value0: tenant?.name || assignment.tenant_id, value1: assignment.mode });
}).join(", ") : "—"
},
...(kind === "role" ? [{
id: "permissions", header: "i18n:govoplan-admin.permissions.d06d5557", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer" as const,
value: (row: GovernanceTemplateItem) => row.effective_permission_count
}] : []),
{ id: "status", header: "i18n:govoplan-admin.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{
id: "actions", header: "i18n:govoplan-admin.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right",
render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-admin.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-admin.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} />
<AdminIconButton label={i18nMessage("i18n:govoplan-admin.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
</div>
}
], [canWrite, kind, tenants]);
}],
[canWrite, kind, tenants]);
const title = kind === "group" ? "Central groups" : "Tenant roles";
const description = kind === "group"
? "Centrally defined group identities that are provisioned into selected tenants as available or required definitions. Membership remains tenant-local."
: "Centrally governed tenant roles that are provisioned into selected tenants as available or required definitions.";
const title = kind === "group" ? "i18n:govoplan-admin.central_groups.5c9b5b66" : "i18n:govoplan-admin.tenant_roles.51aca82d";
const description = kind === "group" ?
"i18n:govoplan-admin.centrally_defined_group_identities_that_are_prov.7761fce9" :
"i18n:govoplan-admin.centrally_governed_tenant_roles_that_are_provisi.8739fca3";
return (
<>
@@ -189,41 +207,45 @@ export default function GovernanceTemplatesPanel({
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label={kind === "group" ? "Add group template" : "Add tenant role"} icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}
>
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-admin.reload.cce71553</Button><AdminIconButton label={kind === "group" ? "i18n:govoplan-admin.add_group_template.b74d8f0f" : "i18n:govoplan-admin.add_tenant_role.fcd904ea"} icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}>
<div className="admin-table-surface">
<DataGrid id={`admin-system-${kind}-templates-v3`} rows={items} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText={`No central ${kind} templates found.`} />
<DataGrid id={`admin-system-${kind}-templates-v3`} rows={items} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText={i18nMessage("i18n:govoplan-admin.no_central_value_templates_found.081149fa", { value0: kind })} />
</div>
</AdminPageLayout>
<Dialog
open={editing !== null}
title={editing === "new" ? (kind === "group" ? "Create group template" : "Create tenant role") : (kind === "group" ? "Edit group template" : "Edit tenant role")}
title={editing === "new" ? kind === "group" ? "i18n:govoplan-admin.create_group_template.72407248" : "i18n:govoplan-admin.create_tenant_role.f58db104" : kind === "group" ? "i18n:govoplan-admin.edit_group_template.9bc72d21" : "i18n:govoplan-admin.edit_tenant_role.c15a260d"}
onClose={() => !busy && setEditing(null)}
className="admin-dialog admin-dialog-wide"
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : kind === "group" ? "Save template" : "Save tenant role"}</Button></>}
>
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-admin.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "i18n:govoplan-admin.saving.56a2285c" : kind === "group" ? "i18n:govoplan-admin.save_template.0885fab2" : "i18n:govoplan-admin.save_tenant_role.8fc0d37d"}</Button></>}>
<div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-admin.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-admin.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-admin.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-admin.active.a733b809</option><option value="inactive">i18n:govoplan-admin.inactive.09af574c</option></select></FormField>
<FormField label="i18n:govoplan-admin.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div>
{kind === "role" && <div className="form-field"><span className="form-label">Tenant permissions</span><AdminSelectionList options={permissions.map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))} selected={draft.permissions} onChange={(next) => setDraft({ ...draft, permissions: next })} /></div>}
{kind === "role" && <div className="form-field"><span className="form-label">i18n:govoplan-admin.tenant_permissions.246294bc</span><AdminSelectionList options={permissions.map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))} selected={draft.permissions} onChange={(next) => setDraft({ ...draft, permissions: next })} /></div>}
<div className="form-field">
<span className="form-label">Tenant availability</span>
<span className="form-label">i18n:govoplan-admin.tenant_availability.067a4f31</span>
<div className="admin-selection-list admin-governance-mode">
{tenants.map((tenant) => <div className="admin-tenant-assignment-row" key={tenant.id}><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span><select value={assignmentMode(tenant.id)} onChange={(event) => setAssignment(tenant.id, event.target.value as "none" | "available" | "required")}><option value="none">Not available</option><option value="available">Available</option><option value="required">Required</option></select></div>)}
{tenants.map((tenant) => <div className="admin-tenant-assignment-row" key={tenant.id}><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span><select value={assignmentMode(tenant.id)} onChange={(event) => setAssignment(tenant.id, event.target.value as "none" | "available" | "required")}><option value="none">i18n:govoplan-admin.not_available.d1a17af1</option><option value="available">i18n:govoplan-admin.available.7c62a142</option><option value="required">i18n:govoplan-admin.required.eed6bfb4</option></select></div>)}
</div>
<p className="muted small-note">Required means the definition must remain present and system-controlled. It does not automatically assign users or grant permissions.</p>
<p className="muted small-note">i18n:govoplan-admin.required_means_the_definition_must_remain_presen.8462063c</p>
</div>
</Dialog>
<Dialog open={Boolean(viewing)} title={viewing?.name || "Template details"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
{viewing && <dl className="admin-details-grid"><div><dt>Kind</dt><dd>{viewing.kind}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div><div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Tenants</dt><dd>{viewing.assignments.length || "None"}</dd></div><div><dt>Description</dt><dd>{viewing.description || "—"}</dd></div><div><dt>Permissions</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
<Dialog open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-admin.template_details.d5d75e4d"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-admin.close.bbfa773e</Button>}>
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-admin.kind.e00ac23f</dt><dd>{viewing.kind}</dd></div><div><dt>i18n:govoplan-admin.slug.094da9b9</dt><dd>{viewing.slug}</dd></div><div><dt>i18n:govoplan-admin.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-admin.active.a733b809" : "i18n:govoplan-admin.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-admin.tenants.1f7ae776</dt><dd>{viewing.assignments.length || "i18n:govoplan-admin.none.6eef6648"}</dd></div><div><dt>i18n:govoplan-admin.description.55f8ebc8</dt><dd>{viewing.description || "—"}</dd></div><div><dt>i18n:govoplan-admin.permissions.d06d5557</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
</Dialog>
<ConfirmDialog open={Boolean(deleting)} title={kind === "group" ? "Delete group template" : "Delete tenant role"} message={`Delete ${deleting?.name}? Removal is blocked while a materialized tenant definition still has members or assignments.`} confirmLabel={kind === "group" ? "Delete template" : "Delete tenant role"} tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
</>
);
<ConfirmDialog open={Boolean(deleting)} title={kind === "group" ? "i18n:govoplan-admin.delete_group_template.8745d842" : "i18n:govoplan-admin.delete_tenant_role.4efa0813"} message={i18nMessage("i18n:govoplan-admin.delete_value_removal_is_blocked_while_a_material.d4eba73d", { value0: deleting?.name })} confirmLabel={kind === "group" ? "i18n:govoplan-admin.delete_template.399bf72a" : "i18n:govoplan-admin.delete_tenant_role.4efa0813"} tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
</>);
}
function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft);
}