// permissions-manager — "Permisos y Roles" pro view (rol × módulo × acción). // // Transport-agnostic: every read/write arrives via props (loaders/mutators), // so each host wires them to its own api client (ops → /api/permissions/*). // The capability universe (modules × actions + general flags) is derived from // the installed manifests + the real sidebar nav server/host-side; this // component only renders it. // // Layout — a *flat list* that mirrors the app sidebar (NO accordions/folders): // header — title + "Nuevo rol" (primary) + "Guardar permisos" (green). // left — Card "Rol": clean role combobox with inline Editar/Eliminar // icons (no removable chip) + "Permisos Generales" flags. // — Card "Módulos": a searchable flat list. Each group renders a // non-collapsible grey header (uppercase tracking, like "Módulos" // / "Sistema" in the sidebar) followed by its modules as clickable // rows (icon + label + granted-count badge). Clicking a row selects // that module and reveals its action grid on the right. // right — Card "Acciones permitidas": granted counter N/M, mark-all / // clear, checkbox grid (icon + label per action). Clear empty // states for "pick a role" / "pick a module" / loading. // // Saving calls `syncRolePermissions(roleId, capabilities)` with the FULL // granted set of the active role (baseline + the edits made here). Dirty // state is tracked against the loaded baseline and surfaced next to the // save button. import * as React from 'react' import { Check, ChevronsUpDown, CheckCheck, ChevronDown, Eraser, Pencil, Plus, Save, Search, Shield, Trash2, } from 'lucide-react' import { toast } from 'sonner' import { cn } from '@asteby/metacore-ui/lib' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Checkbox, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, Popover, PopoverContent, PopoverTrigger, Separator, Skeleton, Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@asteby/metacore-ui/primitives' import { DynamicIcon } from './dynamic-icon' import { IconPickerField } from './icon-picker-field' import { ColorPickerField, DEFAULT_ROLE_COLOR } from './color-picker-field' import type { ActionFieldDef } from './types' // --------------------------------------------------------------------------- // Types (mirror of `GET /api/permissions/modules` + the host sidebar nav) // --------------------------------------------------------------------------- export interface PermissionActionDef { /** Canonical action key (`index`, `create`, …, a custom `pagar`, or `access`). */ key: string /** Localized label ("Listar", "Pagar", "Acceder"). */ label: string /** * Optional secondary line under the label (e.g. action name when the label * is the module — used by screen → API shortcut checkboxes). */ description?: string /** Lucide icon name from the manifest action (optional). */ icon?: string /** * `crud` for the derived CRUD set, `custom` for manifest actions, * `screen` for the single `access` action of a non-model screen. * `dependency` for a shortcut to another module's capability (see * {@link capability}). */ kind?: 'crud' | 'custom' | 'screen' | 'dependency' | string /** * Full capability string override. When set, the grant uses this exact key * instead of `${moduleKey}.${key}` — hosts use it so a screen (Terminal) * can show checkboxes for other modules' caps (`product.index`, …) as * shortcuts without inventing `screen….product.index`. */ capability?: string /** * Optional section title for the action grid (e.g. "Catálogo", "Ventas"). * When any action on a module sets `group`, the right-hand panel renders * collapsible sections instead of one flat checkbox grid. */ group?: string } export interface PermissionModuleDef { /** * Module key. * - model: lowercase model table (`pos_orders`). * - screen: `screen.` (the host prefixes it). */ key: string /** Localized module label ("Pedidos POS", "Terminal"). */ label: string /** Module icon (lucide name) — mirrors the sidebar entry. */ icon?: string /** Whether this entry is a data model or a non-model screen. */ kind?: 'model' | 'screen' /** Owning addon key (`pos`) — legacy shape only, used for grouping. */ addon_key?: string /** Localized addon label ("Punto de venta") — legacy shape only. */ addon_label?: string actions: PermissionActionDef[] } /** * A sidebar-style group: a grey (non-collapsible) header + its modules. * `title === ''` → no header (e.g. core/infra modules). */ export interface ModuleGroup { title: string modules: PermissionModuleDef[] } export interface GeneralPermissionDef { /** Full capability key (`general.work_after_hours`). */ key: string label: string description?: string } /** * What `loadModules()` may return. * * New (preferred) shape — pre-grouped flat list, mirrors the host sidebar: * { groups: ModuleGroup[], general } * * Legacy shape (still accepted, wrapped into a single untitled group) — * { modules: PermissionModuleDef[], general } */ export interface GroupedPermissionsCatalog { groups: ModuleGroup[] general: GeneralPermissionDef[] } export interface FlatPermissionsCatalog { modules: PermissionModuleDef[] general: GeneralPermissionDef[] } export type PermissionsCatalog = GroupedPermissionsCatalog | FlatPermissionsCatalog export interface RoleDef { id: string /** Stable role key ("cashier"). */ name: string /** Human label ("Cajero"). Falls back to `name` when omitted. */ label?: string /** Accent color (hex) for the role chip. */ color?: string /** Lucide PascalCase name (or image path) for the role chip. */ icon?: string } export interface RoleInput { name: string label?: string color?: string icon?: string } export interface PermissionsManagerProps { /** Loads the module×action universe + general flags (grouped or flat). */ loadModules: () => Promise /** Loads every assignable role. */ loadRoles: () => Promise /** Loads the capabilities currently granted to a role. */ loadRolePermissions: (roleId: string) => Promise /** Persists the FULL granted capability set of a role. */ syncRolePermissions: (roleId: string, capabilities: string[]) => Promise /** Optional role CRUD — omitting one hides its control. */ createRole?: (input: RoleInput) => Promise updateRole?: (roleId: string, input: RoleInput) => Promise deleteRole?: (roleId: string) => Promise /** Page heading. Defaults to "Permisos y Roles". */ title?: string className?: string } // --------------------------------------------------------------------------- // Pure helpers (exported for hosts/tests) // --------------------------------------------------------------------------- /** Capability for a catalog module action: `lowercase(moduleKey).actionKey`, * or {@link PermissionActionDef.capability} when the action is a cross-module * shortcut. */ export function moduleActionCapability( moduleKey: string, actionKey: string, capability?: string, ): string { if (capability) return capability return `${moduleKey.toLowerCase()}.${actionKey}` } /** All capabilities of one module. */ export function moduleCapabilities(module: PermissionModuleDef): string[] { return module.actions.map((a) => moduleActionCapability(module.key, a.key, a.capability), ) } export interface ActionGroupSection { /** Empty string = ungrouped actions rendered above the collapsibles. */ title: string actions: PermissionActionDef[] } /** * Split a module's actions into an optional ungrouped prefix + named sections * (first-seen order of `action.group`). Modules without any `group` yield a * single untitled section (flat grid, backward compatible). */ export function groupModuleActions(actions: PermissionActionDef[]): ActionGroupSection[] { const ungrouped: PermissionActionDef[] = [] const byTitle = new Map() const order: string[] = [] for (const action of actions) { const title = (action.group ?? '').trim() if (!title) { ungrouped.push(action) continue } let list = byTitle.get(title) if (!list) { list = [] byTitle.set(title, list) order.push(title) } list.push(action) } const out: ActionGroupSection[] = [] if (ungrouped.length > 0) out.push({ title: '', actions: ungrouped }) for (const title of order) { out.push({ title, actions: byTitle.get(title)! }) } return out } /** How many of the module's capabilities are in the granted set. */ export function grantedCountForModule( granted: ReadonlySet, module: PermissionModuleDef, ): number { return moduleCapabilities(module).filter((c) => granted.has(c)).length } export function capabilitySetsEqual(a: ReadonlySet, b: ReadonlySet): boolean { if (a.size !== b.size) return false for (const v of a) if (!b.has(v)) return false return true } /** Default lucide icon when the manifest action doesn't declare one. */ export function defaultActionIcon(actionKey: string, kind?: string): string { switch (actionKey) { case 'index': return 'List' case 'create': return 'Plus' case 'update': return 'Pencil' case 'delete': return 'Trash2' case 'export': return 'Download' case 'import': return 'Upload' case 'access': return 'Eye' default: if (kind === 'crud') return 'List' if (kind === 'screen') return 'Eye' if (kind === 'dependency') return 'Link2' return 'Zap' } } /** Group label fallback when a legacy module has no addon ("Sistema" = core). */ const SYSTEM_GROUP = 'Sistema' function legacyGroupLabel(mod: PermissionModuleDef): string { return mod.addon_label || mod.addon_key || SYSTEM_GROUP } /** Accent-insensitive, lowercase fold for search. */ function fold(s: string): string { return s .normalize('NFD') .replace(/[̀-ͯ]/g, '') .toLowerCase() } function slugify(label: string): string { return fold(label) .trim() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') } /** Suggest a Lucide icon from the role label (create-dialog default). */ export function suggestRoleIcon(label: string): string { const s = fold(label) const rules: Array<[RegExp, string]> = [ [/cajer|cash|teller|bank/, 'Banknote'], [/vend|seller|sales|shop|mostrador/, 'ShoppingBag'], [/mecanic|technic|wrench|taller/, 'Wrench'], [/jefe|lead|manager|gerente|supervisor/, 'HardHat'], [/almacen|ware|stock|invent/, 'Warehouse'], [/compr|purch|buyer/, 'Truck'], [/contad|account|ledger/, 'Calculator'], [/nomina|payroll|sueldo/, 'Wallet'], [/rh|hr|human|emplead|people/, 'Users'], [/fleet|flota|vehic/, 'Car'], [/admin|dueño|owner/, 'ShieldCheck'], [/view|observa|lectura|read/, 'Eye'], ] for (const [re, icon] of rules) { if (re.test(s)) return icon } return 'Shield' } /** * Normalize whatever `loadModules` returned into the canonical grouped shape. * * - New shape (`{ groups }`): passed through (modules default to kind:'model'). * - Legacy flat shape (`{ modules }`): grouped by `addon_label`/`addon_key` * (falling back to "Sistema") so old hosts keep their familiar buckets, * every module defaulting to kind:'model'. The grey headers still render, * just derived from the addon instead of the sidebar group. */ export function normalizeCatalogGroups(catalog: PermissionsCatalog): ModuleGroup[] { const withKind = (m: PermissionModuleDef): PermissionModuleDef => ({ ...m, kind: m.kind ?? 'model', }) if ('groups' in catalog && Array.isArray(catalog.groups)) { return catalog.groups.map((g) => ({ title: g.title ?? '', modules: g.modules.map(withKind), })) } const modules = ('modules' in catalog && catalog.modules) || [] const order: string[] = [] const byGroup = new Map() for (const raw of modules) { const mod = withKind(raw) const g = legacyGroupLabel(mod) if (!byGroup.has(g)) { byGroup.set(g, []) order.push(g) } byGroup.get(g)!.push(mod) } return order.map((title) => ({ title, modules: byGroup.get(title)! })) } /** Flat list of every module across groups, in render order. */ export function flattenGroups(groups: ModuleGroup[]): PermissionModuleDef[] { return groups.flatMap((g) => g.modules) } /** Filter the grouped flat list by a folded query against module + group titles. */ export function filterModuleGroups(groups: ModuleGroup[], query: string): ModuleGroup[] { const q = fold(query).trim() if (!q) return groups const out: ModuleGroup[] = [] for (const g of groups) { const groupMatches = g.title.length > 0 && fold(g.title).includes(q) const mods = groupMatches ? g.modules : g.modules.filter((m) => fold(m.label).includes(q) || fold(m.key).includes(q)) if (mods.length) out.push({ title: g.title, modules: mods }) } return out } // --------------------------------------------------------------------------- // Internal sub-components // --------------------------------------------------------------------------- /** Checkbox row used by both the action grid and the general flags. */ function CapabilityCheck({ checked, disabled, onToggle, icon, label, description, }: { checked: boolean disabled?: boolean onToggle: () => void icon?: string label: string description?: string }) { return (
{ if (e.key === ' ' || e.key === 'Enter') { e.preventDefault() onToggle() } } } className={cn( 'flex items-start gap-2.5 rounded-md border border-border/60 bg-card px-3 py-2.5 text-sm transition-colors', disabled ? 'opacity-50' : 'cursor-pointer hover:bg-muted/40', checked && 'border-primary/40 bg-primary/5', )} >
) } /** One clickable module row in the flat list (mirrors a sidebar item). */ function ModuleRow({ module, active, granted, total, onSelect, }: { module: PermissionModuleDef active: boolean granted: number total: number onSelect: () => void }) { return ( ) } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions, syncRolePermissions, createRole, updateRole, deleteRole, title = 'Permisos y Roles', className, }: PermissionsManagerProps) { const [groups, setGroups] = React.useState(null) const [general, setGeneral] = React.useState(null) const [roles, setRoles] = React.useState(null) const [loadError, setLoadError] = React.useState(false) const [activeRoleId, setActiveRoleId] = React.useState(null) const [activeModuleKey, setActiveModuleKey] = React.useState(null) // baseline = capabilities as persisted; draft = baseline + local edits. const [baseline, setBaseline] = React.useState | null>(null) const [draft, setDraft] = React.useState | null>(null) const [loadingPerms, setLoadingPerms] = React.useState(false) const [saving, setSaving] = React.useState(false) const [roleOpen, setRoleOpen] = React.useState(false) const [moduleOpen, setModuleOpen] = React.useState(false) // Pending role switch while there are unsaved changes. const [pendingRoleId, setPendingRoleId] = React.useState(null) const [roleDialog, setRoleDialog] = React.useState<{ open: boolean mode: 'create' | 'edit' label: string color: string icon: string grantAll: boolean }>({ open: false, mode: 'create', label: '', color: DEFAULT_ROLE_COLOR, icon: 'Shield', grantAll: false, }) const [roleSaving, setRoleSaving] = React.useState(false) const [deleteOpen, setDeleteOpen] = React.useState(false) const [deleting, setDeleting] = React.useState(false) const loading = groups === null || roles === null const allModules = React.useMemo(() => (groups ? flattenGroups(groups) : []), [groups]) // ---- initial load: catalog + roles in parallel ------------------------- React.useEffect(() => { let cancelled = false Promise.all([loadModules(), loadRoles()]) .then(([cat, rs]) => { if (cancelled) return const grouped = normalizeCatalogGroups(cat) setGroups(grouped) setGeneral(cat.general ?? []) setRoles(rs) setActiveRoleId((prev) => prev ?? rs[0]?.id ?? null) setActiveModuleKey( (prev) => prev ?? flattenGroups(grouped)[0]?.key ?? null, ) }) .catch(() => { if (!cancelled) setLoadError(true) }) return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // ---- per-role permissions ---------------------------------------------- React.useEffect(() => { if (!activeRoleId) { setBaseline(null) setDraft(null) return } let cancelled = false setLoadingPerms(true) loadRolePermissions(activeRoleId) .then((caps) => { if (cancelled) return setBaseline(new Set(caps)) setDraft(new Set(caps)) }) .catch(() => { if (cancelled) return toast.error('No se pudieron cargar los permisos del rol') setBaseline(null) setDraft(null) }) .finally(() => { if (!cancelled) setLoadingPerms(false) }) return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeRoleId]) const activeRole = React.useMemo( () => roles?.find((r) => r.id === activeRoleId) ?? null, [roles, activeRoleId], ) const activeModule = React.useMemo( () => allModules.find((m) => m.key === activeModuleKey) ?? null, [allModules, activeModuleKey], ) const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft) // ---- capability edits --------------------------------------------------- const toggleCapability = React.useCallback((cap: string) => { setDraft((prev) => { if (!prev) return prev const next = new Set(prev) if (next.has(cap)) next.delete(cap) else next.add(cap) return next }) }, []) const setModuleAll = React.useCallback( (on: boolean) => { if (!activeModule) return const caps = moduleCapabilities(activeModule) setDraft((prev) => { if (!prev) return prev const next = new Set(prev) for (const c of caps) { if (on) next.add(c) else next.delete(c) } return next }) }, [activeModule], ) // Every capability across every module in the catalog — the "Otorgar // todo" button's target set (as opposed to setModuleAll, which is // scoped to the currently open module). const allCatalogCapabilities = React.useMemo( () => allModules.flatMap((m) => moduleCapabilities(m)), [allModules], ) const setAllPermissions = React.useCallback( (on: boolean) => { if (on) { setDraft(new Set(allCatalogCapabilities)) } else { setDraft(new Set()) } }, [allCatalogCapabilities], ) const handleSave = async () => { if (!activeRoleId || !draft) return setSaving(true) try { await syncRolePermissions(activeRoleId, Array.from(draft).sort()) setBaseline(new Set(draft)) toast.success('Permisos guardados') } catch { toast.error('No se pudieron guardar los permisos') } finally { setSaving(false) } } // ---- role switching (dirty guard) --------------------------------------- const requestRoleSwitch = (roleId: string | null) => { if (roleId === activeRoleId) return if (dirty) setPendingRoleId(roleId) else setActiveRoleId(roleId) } // ---- role CRUD ----------------------------------------------------------- const refreshRoles = async (selectId?: string | null) => { const rs = await loadRoles() setRoles(rs) if (selectId !== undefined) setActiveRoleId(selectId) else if (activeRoleId && !rs.some((r) => r.id === activeRoleId)) setActiveRoleId(rs[0]?.id ?? null) return rs } const handleRoleSubmit = async () => { const label = roleDialog.label.trim() if (!label) return setRoleSaving(true) try { if (roleDialog.mode === 'create' && createRole) { const created = await createRole({ name: slugify(label), label, color: roleDialog.color, icon: roleDialog.icon || suggestRoleIcon(label), }) const rs = await loadRoles() setRoles(rs) const createdId = (created && 'id' in created && created.id) || rs.find((r) => r.name === slugify(label))?.id || null if (createdId) setActiveRoleId(createdId) if (roleDialog.grantAll && createdId && allCatalogCapabilities.length) { await syncRolePermissions(createdId, [...allCatalogCapabilities].sort()) setBaseline(new Set(allCatalogCapabilities)) setDraft(new Set(allCatalogCapabilities)) toast.success('Rol creado con todos los permisos') } else { toast.success('Rol creado') } } else if (roleDialog.mode === 'edit' && updateRole && activeRole) { await updateRole(activeRole.id, { name: activeRole.name, label, color: roleDialog.color, icon: roleDialog.icon || suggestRoleIcon(label), }) await refreshRoles(activeRole.id) toast.success('Rol actualizado') } setRoleDialog((d) => ({ ...d, open: false })) } catch { toast.error( roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol', ) } finally { setRoleSaving(false) } } const handleDeleteRole = async () => { if (!deleteRole || !activeRole) return setDeleting(true) try { await deleteRole(activeRole.id) const rs = await loadRoles() setRoles(rs) setActiveRoleId(rs[0]?.id ?? null) toast.success('Rol eliminado') setDeleteOpen(false) } catch { toast.error('No se pudo eliminar el rol') } finally { setDeleting(false) } } const openEditRole = () => { if (!activeRole) return setRoleDialog({ open: true, mode: 'edit', label: activeRole.label || activeRole.name, color: activeRole.color || DEFAULT_ROLE_COLOR, icon: activeRole.icon || suggestRoleIcon(activeRole.label || activeRole.name), grantAll: false, }) } // ---- derived for the right panel ---------------------------------------- const moduleGranted = activeModule && draft ? grantedCountForModule(draft, activeModule) : 0 const moduleTotal = activeModule?.actions.length ?? 0 const checksDisabled = !activeRole || !draft || loadingPerms || saving const activeModuleGroupTitle = React.useMemo(() => { if (!activeModule || !groups) return '' const g = groups.find((grp) => grp.modules.some((m) => m.key === activeModule.key)) return g?.title ?? '' }, [activeModule, groups]) // ---- render -------------------------------------------------------------- if (loadError) { return (

No se pudo cargar el catálogo de permisos.

) } if (loading) { return (
) } return (
{/* Header */}

{title}

Define qué puede hacer cada rol en cada módulo.

{dirty && ( Cambios sin guardar )} {createRole && ( )}
{/* Left column */}
{/* Card: Rol */} Rol Selecciona el rol a configurar. {/* Clean role combobox with inline edit/delete. */}
Sin resultados. {(roles ?? []).map((role) => ( { requestRoleSwitch(role.id) setRoleOpen(false) }} > {role.label || role.name} {role.id === activeRoleId && ( )} ))} {updateRole && ( )} {deleteRole && ( )}
{(general?.length ?? 0) > 0 && ( <>

Permisos Generales

{general!.map((g) => ( toggleCapability(g.key)} label={g.label} description={g.description} /> ))}
)}
{/* Card: Módulo — a grouped combobox, same pattern as the role selector above (compact; the long flat list felt heavy). */} Módulo Elige el módulo cuyas acciones quieres configurar. Sin módulos. {(groups ?? []).map((group, gi) => ( {group.modules.map((mod) => ( { setActiveModuleKey(mod.key) setModuleOpen(false) }} > {mod.label} {draft && grantedCountForModule(draft, mod) > 0 && ( {grantedCountForModule( draft, mod, )} /{mod.actions.length} )} {mod.key === activeModuleKey && ( )} ))} ))}
{/* Right column: Acciones permitidas */}
{activeModule && ( )} {activeModule ? activeModule.label : 'Acciones permitidas'} {activeModule ? `${ activeModuleGroupTitle || 'Sistema' } · configura las acciones permitidas` : 'Configura los permisos del módulo seleccionado.'}
{activeRole && activeModule && (
{moduleGranted}/{moduleTotal}
)}
{!activeRole ? ( ) : loadingPerms ? (
{Array.from({ length: 6 }).map((_, i) => ( ))}
) : !activeModule ? ( ) : ( )}
{/* Dirty guard when switching roles */} !open && setPendingRoleId(null)} > Cambios sin guardar Tienes cambios sin guardar en este rol. Si cambias de rol se descartarán. Cancelar { setActiveRoleId(pendingRoleId) setPendingRoleId(null) }} > Descartar y cambiar {/* Role create/edit dialog */} setRoleDialog((d) => ({ ...d, open }))} > {roleDialog.mode === 'create' ? 'Nuevo rol' : 'Editar rol'}
) => { const label = e.target.value setRoleDialog((d) => ({ ...d, label, icon: d.icon === suggestRoleIcon(d.label) || d.icon === 'Shield' || !d.icon ? suggestRoleIcon(label) : d.icon, })) }} />
setRoleDialog((d) => ({ ...d, icon: typeof v === 'string' && v ? v : 'Shield', })) } />
setRoleDialog((d) => ({ ...d, color }))} />
{roleDialog.mode === 'create' && ( )}
{/* Role delete confirm */} !deleting && setDeleteOpen(open)} > ¿Eliminar el rol? Se eliminará el rol{' '} {activeRole ? activeRole.label || activeRole.name : ''} y sus asignaciones de permisos. Esta acción no se puede deshacer. Cancelar { e.preventDefault() handleDeleteRole() }} > {deleting ? 'Eliminando…' : 'Eliminar'}
) } function ActionCheckGrid({ moduleKey, actions, draft, checksDisabled, onToggle, }: { moduleKey: string actions: PermissionActionDef[] draft: ReadonlySet | null | undefined checksDisabled: boolean onToggle: (cap: string) => void }) { return (
{actions.map((action) => { const cap = moduleActionCapability(moduleKey, action.key, action.capability) return ( onToggle(cap)} icon={action.icon || defaultActionIcon(action.key, action.kind)} label={action.label} description={action.description} /> ) })}
) } /** Flat grid, or collapsible sections when actions declare `group`. */ function ModuleActionsPanel({ module, draft, checksDisabled, onToggle, }: { module: PermissionModuleDef draft: ReadonlySet | null | undefined checksDisabled: boolean onToggle: (cap: string) => void }) { const sections = React.useMemo( () => groupModuleActions(module.actions), [module.actions], ) const hasNamedGroups = sections.some((s) => s.title.length > 0) if (!hasNamedGroups) { return ( ) } return (
{sections.map((section) => { if (!section.title) { return ( ) } const granted = section.actions.filter((a) => draft?.has(moduleActionCapability(module.key, a.key, a.capability)), ).length return ( {section.title} {granted}/{section.actions.length} ) })}
) } function EmptyHint({ text }: { text: string }) { return (

{text}

) }