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; forced: Set; enabled: Set; }; const DOCUMENTATION = { contextId: "admin.tenant-modules", documentationType: "admin" as const }; export default function TenantModuleManagementPanel({ settings, scope, canWrite }: Props) { const [targets, setTargets] = useState([]); const [targetId, setTargetId] = useState(""); const [state, setState] = useState(null); const [draft, setDraft] = useState(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const targetOptions = useMemo(() => 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 { 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 ( } description={labels.description} loading={loading} error={error} success={success} actions={ <> } > {scope === "system" && ( void selectTarget(value)} placeholder="Select tenant" searchPlaceholder="Search tenants..." disabled={loading || busy || targetOptions.length === 0} /> )} {state && draft && ( <> {state.diagnostics.map((diagnostic) => ( {diagnostic.message} ))}
{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 (
{module.name} {module.id} {module.effective && }
{module.dependencies.length ? `Requires ${module.dependencies.join(", ")}` : "No module dependencies"} {module.reason && {module.reason}}
{scope === "system" && ( )} setEnabled(module.id, checked)} disabled={!canWrite || busy || selectionLocked} help={module.reason || undefined} />
); })}
)} {!loading && !state && ( {scope === "system" ? "No tenant is available for module policy." : "Module policy is unavailable."} )}
); } 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, right: Set): boolean { return left.size === right.size && [...left].every((item) => right.has(item)); } function sorted(values: Set): string[] { return [...values].sort(); }