Files
govoplan-admin/webui/src/features/admin/TenantModuleManagementPanel.tsx
T
zemion a9b840f66d fix(ui): anchor help to headings and document grouped navigation
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:30 +02:00

364 lines
12 KiB
TypeScript

import { MetricGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useState } from "react";
import {
AdminPageLayout,
adminErrorMessage,
Button,
Card,
dispatchPlatformModulesChanged,
DismissibleAlert,
DocumentationHelpLink,
FormField,
MetricCard,
SearchableSelect,
StatusBadge,
ToggleSwitch,
useUnsavedDraftGuard,
type ApiSettings,
type SearchableSelectOption
} from "@govoplan/core-webui";
import { RefreshCw, Save, Undo2 } from "lucide-react";
import {
fetchSystemTenantModules,
fetchTenantModuleTargets,
fetchTenantModules,
updateSystemTenantModules,
updateTenantModules,
type TenantModuleAvailability,
type TenantModuleEntitlementResponse,
type TenantModuleTarget
} from "../../api/admin";
type Props = {
settings: ApiSettings;
scope: "system" | "tenant";
canWrite: boolean;
};
type Draft = {
available: Set<string>;
forced: Set<string>;
enabled: Set<string>;
};
const DOCUMENTATION = {
contextId: "admin.tenant-modules",
documentationType: "admin" as const
};
export default function TenantModuleManagementPanel({ settings, scope, canWrite }: Props) {
const [targets, setTargets] = useState<TenantModuleTarget[]>([]);
const [targetId, setTargetId] = useState("");
const [state, setState] = useState<TenantModuleEntitlementResponse | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const targetOptions = useMemo<SearchableSelectOption[]>(() => targets.map((target) => ({
value: target.id,
label: target.name,
description: `${target.slug}${target.is_active ? "" : " - inactive"}`,
searchText: `${target.name} ${target.slug}`
})), [targets]);
const dirty = Boolean(state && draft && (
!sameSet(draft.available, new Set(state.available_modules))
|| !sameSet(draft.forced, new Set(state.forced_modules))
|| !sameSet(draft.enabled, new Set(state.selected_modules))
));
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: discard
});
useEffect(() => {
void initialize();
}, [scope, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
async function initialize() {
setLoading(true);
setError("");
setSuccess("");
try {
if (scope === "system") {
const loadedTargets = await fetchTenantModuleTargets(settings);
setTargets(loadedTargets);
const nextTarget = loadedTargets.some((item) => item.id === targetId)
? targetId
: loadedTargets[0]?.id ?? "";
setTargetId(nextTarget);
if (nextTarget) {
await load(nextTarget, false);
} else {
setState(null);
setDraft(null);
}
} else {
setTargets([]);
setTargetId("");
await load(undefined, false);
}
} catch (err) {
setError(adminErrorMessage(err));
setState(null);
setDraft(null);
} finally {
setLoading(false);
}
}
async function load(nextTargetId = targetId, manageLoading = true) {
if (scope === "system" && !nextTargetId) return;
if (manageLoading) setLoading(true);
setError("");
setSuccess("");
try {
const loaded = scope === "system"
? await fetchSystemTenantModules(settings, nextTargetId)
: await fetchTenantModules(settings);
setState(loaded);
setDraft(draftFromState(loaded));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
if (manageLoading) setLoading(false);
}
}
async function selectTarget(nextTargetId: string) {
if (!nextTargetId || nextTargetId === targetId) return;
setTargetId(nextTargetId);
await load(nextTargetId);
}
function discard() {
if (state) setDraft(draftFromState(state));
setError("");
setSuccess("");
}
async function save(): Promise<boolean> {
if (!state || !draft || !dirty) return true;
setBusy(true);
setError("");
setSuccess("");
try {
const loaded = scope === "system"
? await updateSystemTenantModules(settings, targetId, {
available_modules: sorted(draft.available),
forced_modules: sorted(draft.forced),
enabled_modules: sorted(draft.enabled),
expected_revision: state.revision
})
: await updateTenantModules(settings, {
enabled_modules: sorted(draft.enabled),
expected_revision: state.revision
});
setState(loaded);
setDraft(draftFromState(loaded));
setSuccess("Tenant module selection saved.");
dispatchPlatformModulesChanged();
return true;
} catch (err) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
}
function setAvailability(moduleId: string, availability: TenantModuleAvailability) {
setDraft((current) => {
if (!current) return current;
const next = cloneDraft(current);
if (availability === "unavailable") {
next.available.delete(moduleId);
next.forced.delete(moduleId);
next.enabled.delete(moduleId);
} else {
next.available.add(moduleId);
if (availability === "forced") next.forced.add(moduleId);
else next.forced.delete(moduleId);
}
return next;
});
}
function setEnabled(moduleId: string, enabled: boolean) {
setDraft((current) => {
if (!current) return current;
const next = cloneDraft(current);
if (enabled) next.enabled.add(moduleId);
else next.enabled.delete(moduleId);
return next;
});
}
const labels = scope === "system"
? {
title: "Tenant modules",
description: "Set a tenant's module ceiling, forced modules, and current selection."
}
: {
title: "Modules",
description: "Enable or disable modules made available to this tenant by system policy."
};
return (
<AdminPageLayout
title={labels.title}
titleHelp={<DocumentationHelpLink reference={DOCUMENTATION} label="Open module governance documentation" />}
description={labels.description}
loading={loading}
error={error}
success={success}
actions={
<>
<Button
title="Reload saved module policy"
aria-label="Reload saved module policy"
onClick={() => void load()}
disabled={loading || busy || (scope === "system" && !targetId)}
>
<RefreshCw size={16} />
</Button>
<Button onClick={discard} disabled={!dirty || busy}>
<Undo2 size={16} /> Discard
</Button>
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}>
<Save size={16} /> {busy ? "Saving..." : "Save"}
</Button>
</>
}
>
{scope === "system" && (
<FormField label="Tenant" documentation={DOCUMENTATION}>
<SearchableSelect
value={targetId}
options={targetOptions}
onChange={(value) => void selectTarget(value)}
placeholder="Select tenant"
searchPlaceholder="Search tenants..."
disabled={loading || busy || targetOptions.length === 0}
/>
</FormField>
)}
{state && draft && (
<>
<MetricGrid columns={5} minimum="compact">
<MetricCard label="Available" value={draft.available.size} tone="info" />
<MetricCard label="Forced" value={draft.forced.size} tone="warning" />
<MetricCard label="Selected" value={draft.enabled.size} />
<MetricCard label="Effective now" value={state.effective_modules.length} tone="good" />
</MetricGrid>
{state.diagnostics.map((diagnostic) => (
<DismissibleAlert key={`${diagnostic.code}:${diagnostic.message}`} tone="warning" compact>
{diagnostic.message}
</DismissibleAlert>
))}
<Card title={scope === "system" ? "Module policy and tenant selection" : "Tenant module selection"}>
<div className="module-management-list">
{state.modules.map((module) => {
const availability = draftAvailability(draft, module.id);
const effectiveSelection = draft.enabled.has(module.id) || availability === "forced" || module.derived_dependency;
const selectionLocked = availability !== "available" || module.derived_dependency;
return (
<div className={`module-management-row${dirtyModule(state, draft, module.id) ? " pending" : ""}`} key={module.id}>
<div className="module-management-main">
<div className="module-management-title">
<strong>{module.name}</strong>
<code>{module.id}</code>
<StatusBadge
status={module.runtime_active ? "success" : "neutral"}
label={module.runtime_active ? "Runtime active" : "Runtime inactive"}
/>
{module.effective && <StatusBadge status="info" label="Effective" />}
</div>
<div className="module-management-details">
<span>{module.dependencies.length ? `Requires ${module.dependencies.join(", ")}` : "No module dependencies"}</span>
{module.reason && <span>{module.reason}</span>}
</div>
</div>
<div className="module-management-toggle">
{scope === "system" && (
<label>
<span>System policy</span>
<select
value={availability}
onChange={(event) => setAvailability(module.id, event.target.value as TenantModuleAvailability)}
disabled={!canWrite || busy || (module.id === "access" || module.id === "admin")}
>
<option value="unavailable">Unavailable</option>
<option value="available">Available</option>
<option value="forced">Forced</option>
</select>
</label>
)}
<ToggleSwitch
label="Enabled for tenant"
checked={effectiveSelection}
onChange={(checked) => setEnabled(module.id, checked)}
disabled={!canWrite || busy || selectionLocked}
help={module.reason || undefined}
/>
</div>
</div>
);
})}
</div>
</Card>
</>
)}
{!loading && !state && (
<DismissibleAlert tone="info" dismissible={false}>
{scope === "system" ? "No tenant is available for module policy." : "Module policy is unavailable."}
</DismissibleAlert>
)}
</AdminPageLayout>
);
}
function draftFromState(state: TenantModuleEntitlementResponse): Draft {
return {
available: new Set(state.available_modules),
forced: new Set(state.forced_modules),
enabled: new Set(state.selected_modules)
};
}
function cloneDraft(draft: Draft): Draft {
return {
available: new Set(draft.available),
forced: new Set(draft.forced),
enabled: new Set(draft.enabled)
};
}
function draftAvailability(draft: Draft, moduleId: string): TenantModuleAvailability {
if (draft.forced.has(moduleId)) return "forced";
if (draft.available.has(moduleId)) return "available";
return "unavailable";
}
function dirtyModule(state: TenantModuleEntitlementResponse, draft: Draft, moduleId: string): boolean {
return state.available_modules.includes(moduleId) !== draft.available.has(moduleId)
|| state.forced_modules.includes(moduleId) !== draft.forced.has(moduleId)
|| state.selected_modules.includes(moduleId) !== draft.enabled.has(moduleId);
}
function sameSet(left: Set<string>, right: Set<string>): boolean {
return left.size === right.size && [...left].every((item) => right.has(item));
}
function sorted(values: Set<string>): string[] {
return [...values].sort();
}