@asteby/metacore-runtime-react 18.14.0 → 18.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 18.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cbcedd9: PermissionsManager: rediseño de UX para que la elección de módulo refleje el sidebar.
8
+ - El selector de módulo plano se reemplaza por un **árbol jerárquico** agrupado por `addon_label` (acordeón colapsable, ícono por módulo, badge de acciones otorgadas N/M, búsqueda que filtra el árbol). Los módulos sin addon caen en el grupo "Sistema".
9
+ - El selector de **rol** queda limpio: combobox con acciones de **editar** y **eliminar** inline (íconos lápiz/basurero a la derecha), sin el chip removible separado.
10
+ - Estados claros del panel de acciones: "elige un rol" / "elige un módulo" / skeleton de carga; el grid se habilita en cuanto hay rol + módulo. El panel titula con el módulo activo y su addon.
11
+ - Nuevo campo opcional `icon` en `PermissionModuleDef` (lucide) para mostrar el ícono del módulo en el árbol y el panel.
12
+ - Helpers exportados nuevos: `groupModules`, `filterModuleGroups` (+ tipo `ModuleGroup`).
13
+
14
+ Sin cambios en la firma de props de `PermissionsManager` (solo render interno; `PermissionModuleDef.icon` es aditivo/opcional).
15
+
3
16
  ## 18.14.0
4
17
 
5
18
  ### Minor Changes
@@ -14,9 +14,11 @@ export interface PermissionModuleDef {
14
14
  key: string;
15
15
  /** Localized module label ("Pedidos POS"). */
16
16
  label: string;
17
+ /** Module icon (lucide name) — mirrors the sidebar entry. */
18
+ icon?: string;
17
19
  /** Owning addon key (`pos`). */
18
20
  addon_key?: string;
19
- /** Localized addon label ("Punto de venta") — used to group the selector. */
21
+ /** Localized addon label ("Punto de venta") — used to group the tree. */
20
22
  addon_label?: string;
21
23
  actions: PermissionActionDef[];
22
24
  }
@@ -53,7 +55,7 @@ export interface PermissionsManagerProps {
53
55
  loadRolePermissions: (roleId: string) => Promise<string[]>;
54
56
  /** Persists the FULL granted capability set of a role. */
55
57
  syncRolePermissions: (roleId: string, capabilities: string[]) => Promise<void>;
56
- /** Optional role CRUD — omitting one hides its button. */
58
+ /** Optional role CRUD — omitting one hides its control. */
57
59
  createRole?: (input: RoleInput) => Promise<RoleDef | void>;
58
60
  updateRole?: (roleId: string, input: RoleInput) => Promise<RoleDef | void>;
59
61
  deleteRole?: (roleId: string) => Promise<void>;
@@ -70,5 +72,13 @@ export declare function grantedCountForModule(granted: ReadonlySet<string>, modu
70
72
  export declare function capabilitySetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean;
71
73
  /** Default lucide icon when the manifest action doesn't declare one. */
72
74
  export declare function defaultActionIcon(actionKey: string, kind?: string): string;
75
+ /** [groupLabel, modules] buckets in stable insertion order. */
76
+ export interface ModuleGroup {
77
+ label: string;
78
+ modules: PermissionModuleDef[];
79
+ }
80
+ export declare function groupModules(modules: PermissionModuleDef[]): ModuleGroup[];
81
+ /** Filter the grouped tree by a folded query against module + group labels. */
82
+ export declare function filterModuleGroups(groups: ModuleGroup[], query: string): ModuleGroup[];
73
83
  export declare function PermissionsManager({ loadModules, loadRoles, loadRolePermissions, syncRolePermissions, createRole, updateRole, deleteRole, title, className, }: PermissionsManagerProps): React.JSX.Element;
74
84
  //# sourceMappingURL=permissions-manager.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"permissions-manager.d.ts","sourceRoot":"","sources":["../src/permissions-manager.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"permissions-manager.d.ts","sourceRoot":"","sources":["../src/permissions-manager.tsx"],"names":[],"mappings":"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"}
@@ -6,24 +6,27 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
6
6
  // The capability universe (modules × actions + general flags) is derived from
7
7
  // the installed manifests server-side; this component only renders it.
8
8
  //
9
- // Layout (reference: 7leguas "Permisos y Roles"):
9
+ // Layout (mirrors the app sidebar so admins recognise what they grant):
10
10
  // header — title + "Nuevo rol" (primary) + "Guardar permisos" (green).
11
- // left — Card "Rol": searchable role selector with removable chip,
12
- // Editar/Eliminar rol, "Permisos Generales" flag checkboxes.
13
- // — Card "Módulo": searchable module selector grouped by addon,
14
- // removable chip.
11
+ // left — Card "Rol": clean role combobox with inline Editar/Eliminar
12
+ // icons (no removable chip) + "Permisos Generales" flags.
13
+ // — Card "Módulos": a searchable, accordion *tree* grouped by
14
+ // `addon_label` (modules without one → "Sistema"). Each group
15
+ // lists its modules with their icon + a granted-count badge.
16
+ // Clicking a module selects it and reveals its action grid.
15
17
  // right — Card "Acciones permitidas": granted counter N/M, mark-all /
16
- // clear buttons, checkbox grid (icon + label per action).
18
+ // clear, checkbox grid (icon + label per action). Clear empty
19
+ // states for "pick a role" / "pick a module" / loading.
17
20
  //
18
21
  // Saving calls `syncRolePermissions(roleId, capabilities)` with the FULL
19
22
  // granted set of the active role (baseline + the edits made here). Dirty
20
23
  // state is tracked against the loaded baseline and surfaced next to the
21
24
  // save button.
22
25
  import * as React from 'react';
23
- import { Check, ChevronsUpDown, CheckCheck, Eraser, Pencil, Plus, Save, Shield, Trash2, X, } from 'lucide-react';
26
+ import { Check, ChevronRight, ChevronsUpDown, CheckCheck, Eraser, Folder, Pencil, Plus, Save, Search, Shield, Trash2, } from 'lucide-react';
24
27
  import { toast } from 'sonner';
25
28
  import { cn } from '@asteby/metacore-ui/lib';
26
- 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, } from '@asteby/metacore-ui/primitives';
29
+ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Checkbox, Collapsible, CollapsibleContent, CollapsibleTrigger, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, Popover, PopoverContent, PopoverTrigger, Separator, Skeleton, } from '@asteby/metacore-ui/primitives';
27
30
  import { DynamicIcon } from './dynamic-icon';
28
31
  // ---------------------------------------------------------------------------
29
32
  // Pure helpers (exported for hosts/tests)
@@ -67,11 +70,20 @@ export function defaultActionIcon(actionKey, kind) {
67
70
  return kind === 'crud' ? 'List' : 'Zap';
68
71
  }
69
72
  }
70
- function slugify(label) {
71
- return label
73
+ /** Group label fallback when a module has no addon ("Sistema" = core/infra). */
74
+ const SYSTEM_GROUP = 'Sistema';
75
+ function moduleGroupLabel(mod) {
76
+ return mod.addon_label || mod.addon_key || SYSTEM_GROUP;
77
+ }
78
+ /** Accent-insensitive, lowercase fold for tree search. */
79
+ function fold(s) {
80
+ return s
72
81
  .normalize('NFD')
73
- .replace(/[\u0300-\u036f]/g, '')
74
- .toLowerCase()
82
+ .replace(/[̀-ͯ]/g, '')
83
+ .toLowerCase();
84
+ }
85
+ function slugify(label) {
86
+ return fold(label)
75
87
  .trim()
76
88
  .replace(/[^a-z0-9]+/g, '_')
77
89
  .replace(/^_+|_+$/g, '');
@@ -87,6 +99,35 @@ const ROLE_COLORS = [
87
99
  '#ec4899',
88
100
  '#6b7280',
89
101
  ];
102
+ export function groupModules(modules) {
103
+ const order = [];
104
+ const byGroup = new Map();
105
+ for (const mod of modules) {
106
+ const g = moduleGroupLabel(mod);
107
+ if (!byGroup.has(g)) {
108
+ byGroup.set(g, []);
109
+ order.push(g);
110
+ }
111
+ byGroup.get(g).push(mod);
112
+ }
113
+ return order.map((label) => ({ label, modules: byGroup.get(label) }));
114
+ }
115
+ /** Filter the grouped tree by a folded query against module + group labels. */
116
+ export function filterModuleGroups(groups, query) {
117
+ const q = fold(query).trim();
118
+ if (!q)
119
+ return groups;
120
+ const out = [];
121
+ for (const g of groups) {
122
+ const groupMatches = fold(g.label).includes(q);
123
+ const mods = groupMatches
124
+ ? g.modules
125
+ : g.modules.filter((m) => fold(m.label).includes(q) || fold(m.key).includes(q));
126
+ if (mods.length)
127
+ out.push({ label: g.label, modules: mods });
128
+ }
129
+ return out;
130
+ }
90
131
  // ---------------------------------------------------------------------------
91
132
  // Internal sub-components
92
133
  // ---------------------------------------------------------------------------
@@ -101,9 +142,11 @@ function CapabilityCheck({ checked, disabled, onToggle, icon, label, description
101
142
  }
102
143
  }, 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'), children: [_jsx(Checkbox, { checked: checked, "aria-hidden": "true", tabIndex: -1, className: "pointer-events-none mt-0.5 shrink-0" }), icon && (_jsx(DynamicIcon, { name: icon, className: "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" })), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block truncate font-medium text-foreground", children: label }), description && (_jsx("span", { className: "mt-0.5 block text-xs text-muted-foreground", children: description }))] })] }));
103
144
  }
104
- /** Removable selection chip (role / module). */
105
- function SelectionChip({ label, color, onRemove, removeAriaLabel, }) {
106
- return (_jsxs(Badge, { variant: "secondary", className: "gap-1.5 pr-1 text-sm font-medium", children: [color && (_jsx("span", { className: "h-2 w-2 rounded-full", style: { background: color }, "aria-hidden": "true" })), _jsx("span", { className: "max-w-[180px] truncate", children: label }), _jsx("button", { type: "button", "aria-label": removeAriaLabel, onClick: onRemove, className: "rounded-sm p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground", children: _jsx(X, { className: "h-3 w-3" }) })] }));
145
+ /** One module row inside the tree. */
146
+ function ModuleTreeItem({ module, active, granted, total, onSelect, }) {
147
+ return (_jsxs("button", { type: "button", onClick: onSelect, "aria-current": active || undefined, className: cn('group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors', active
148
+ ? 'bg-primary/10 font-medium text-foreground'
149
+ : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'), children: [_jsx(DynamicIcon, { name: module.icon || 'Square', className: cn('h-4 w-4 shrink-0', active ? 'text-primary' : 'text-muted-foreground') }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: module.label }), granted > 0 && (_jsxs(Badge, { variant: granted === total ? 'default' : 'secondary', className: "h-5 shrink-0 px-1.5 text-[10px] tabular-nums", children: [granted, "/", total] }))] }));
107
150
  }
108
151
  // ---------------------------------------------------------------------------
109
152
  // Component
@@ -120,7 +163,9 @@ export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions
120
163
  const [loadingPerms, setLoadingPerms] = React.useState(false);
121
164
  const [saving, setSaving] = React.useState(false);
122
165
  const [roleOpen, setRoleOpen] = React.useState(false);
123
- const [moduleOpen, setModuleOpen] = React.useState(false);
166
+ const [moduleQuery, setModuleQuery] = React.useState('');
167
+ // Groups the user explicitly collapsed (default: every group open).
168
+ const [collapsedGroups, setCollapsedGroups] = React.useState(new Set());
124
169
  // Pending role switch while there are unsaved changes.
125
170
  const [pendingRoleId, setPendingRoleId] = React.useState(null);
126
171
  const [roleDialog, setRoleDialog] = React.useState({ open: false, mode: 'create', label: '', color: ROLE_COLORS[5] });
@@ -184,17 +229,10 @@ export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions
184
229
  const activeRole = React.useMemo(() => roles?.find((r) => r.id === activeRoleId) ?? null, [roles, activeRoleId]);
185
230
  const activeModule = React.useMemo(() => catalog?.modules.find((m) => m.key === activeModuleKey) ?? null, [catalog, activeModuleKey]);
186
231
  const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft);
187
- // Selector groups: modules bucketed by addon label, stable order.
188
- const moduleGroups = React.useMemo(() => {
189
- const groups = new Map();
190
- for (const mod of catalog?.modules ?? []) {
191
- const group = mod.addon_label || mod.addon_key || 'Otros';
192
- const list = groups.get(group) ?? [];
193
- list.push(mod);
194
- groups.set(group, list);
195
- }
196
- return Array.from(groups.entries());
197
- }, [catalog]);
232
+ // Module tree: grouped by addon label, optionally filtered by the search.
233
+ const allGroups = React.useMemo(() => groupModules(catalog?.modules ?? []), [catalog]);
234
+ const visibleGroups = React.useMemo(() => filterModuleGroups(allGroups, moduleQuery), [allGroups, moduleQuery]);
235
+ const searching = moduleQuery.trim().length > 0;
198
236
  // ---- capability edits ---------------------------------------------------
199
237
  const toggleCapability = React.useCallback((cap) => {
200
238
  setDraft((prev) => {
@@ -250,6 +288,14 @@ export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions
250
288
  else
251
289
  setActiveRoleId(roleId);
252
290
  };
291
+ const toggleGroup = (label) => setCollapsedGroups((prev) => {
292
+ const next = new Set(prev);
293
+ if (next.has(label))
294
+ next.delete(label);
295
+ else
296
+ next.add(label);
297
+ return next;
298
+ });
253
299
  // ---- role CRUD -----------------------------------------------------------
254
300
  const refreshRoles = async (selectId) => {
255
301
  const rs = await loadRoles();
@@ -318,6 +364,16 @@ export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions
318
364
  setDeleting(false);
319
365
  }
320
366
  };
367
+ const openEditRole = () => {
368
+ if (!activeRole)
369
+ return;
370
+ setRoleDialog({
371
+ open: true,
372
+ mode: 'edit',
373
+ label: activeRole.label || activeRole.name,
374
+ color: activeRole.color || ROLE_COLORS[5],
375
+ });
376
+ };
321
377
  // ---- derived for the right panel ----------------------------------------
322
378
  const moduleGranted = activeModule && draft ? grantedCountForModule(draft, activeModule) : 0;
323
379
  const moduleTotal = activeModule?.actions.length ?? 0;
@@ -327,20 +383,31 @@ export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions
327
383
  return (_jsxs("div", { className: cn('flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground', className), children: [_jsx(Shield, { className: "h-8 w-8 opacity-40" }), _jsx("p", { className: "text-sm", children: "No se pudo cargar el cat\u00E1logo de permisos." })] }));
328
384
  }
329
385
  if (loading) {
330
- return (_jsxs("div", { className: cn('flex flex-col gap-4', className), children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(Skeleton, { className: "h-8 w-56" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Skeleton, { className: "h-9 w-28" }), _jsx(Skeleton, { className: "h-9 w-40" })] })] }), _jsxs("div", { className: "grid gap-4 lg:grid-cols-[340px_1fr]", children: [_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Skeleton, { className: "h-64 w-full" }), _jsx(Skeleton, { className: "h-28 w-full" })] }), _jsx(Skeleton, { className: "h-96 w-full" })] })] }));
386
+ return (_jsxs("div", { className: cn('flex flex-col gap-4', className), children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(Skeleton, { className: "h-8 w-56" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Skeleton, { className: "h-9 w-28" }), _jsx(Skeleton, { className: "h-9 w-40" })] })] }), _jsxs("div", { className: "grid gap-4 lg:grid-cols-[340px_1fr]", children: [_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Skeleton, { className: "h-40 w-full" }), _jsx(Skeleton, { className: "h-80 w-full" })] }), _jsx(Skeleton, { className: "h-96 w-full" })] })] }));
331
387
  }
332
- return (_jsxs("div", { className: cn('flex flex-col gap-4', className), children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-2xl font-bold tracking-tight", children: title }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Define qu\u00E9 puede hacer cada rol en cada m\u00F3dulo." })] }), _jsxs("div", { className: "flex items-center gap-2", children: [dirty && (_jsx(Badge, { variant: "outline", className: "border-amber-500/50 text-amber-600", children: "Cambios sin guardar" })), createRole && (_jsxs(Button, { onClick: () => setRoleDialog({ open: true, mode: 'create', label: '', color: ROLE_COLORS[5] }), children: [_jsx(Plus, { className: "mr-1.5 h-4 w-4" }), " Nuevo rol"] })), _jsxs(Button, { onClick: handleSave, disabled: !dirty || saving || !activeRole, className: "bg-emerald-600 text-white hover:bg-emerald-700", children: [_jsx(Save, { className: "mr-1.5 h-4 w-4" }), saving ? 'Guardando…' : 'Guardar permisos'] })] })] }), _jsxs("div", { className: "grid items-start gap-4 lg:grid-cols-[340px_1fr]", children: [_jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { className: "text-base", children: "Rol" }), _jsx(CardDescription, { children: "Selecciona el rol a configurar." })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [activeRole ? (_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx(SelectionChip, { label: activeRole.label || activeRole.name, color: activeRole.color, onRemove: () => requestRoleSwitch(null), removeAriaLabel: "Quitar rol seleccionado" }), _jsxs("div", { className: "flex items-center gap-1", children: [updateRole && (_jsx(Button, { variant: "ghost", size: "sm", className: "h-8 px-2", "aria-label": "Editar rol", onClick: () => setRoleDialog({
333
- open: true,
334
- mode: 'edit',
335
- label: activeRole.label || activeRole.name,
336
- color: activeRole.color || ROLE_COLORS[5],
337
- }), children: _jsx(Pencil, { className: "h-3.5 w-3.5" }) })), deleteRole && (_jsx(Button, { variant: "ghost", size: "sm", className: "h-8 px-2 text-destructive hover:text-destructive", "aria-label": "Eliminar rol", onClick: () => setDeleteOpen(true), children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }))] })] })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: "Ning\u00FAn rol seleccionado." })), _jsxs(Popover, { open: roleOpen, onOpenChange: setRoleOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", "aria-expanded": roleOpen, className: "w-full justify-between font-normal", children: [activeRole ? activeRole.label || activeRole.name : 'Seleccionar rol…', _jsx(ChevronsUpDown, { className: "ml-2 h-4 w-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "w-[300px] p-0", align: "start", children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: "Buscar rol\u2026" }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: "Sin resultados." }), _jsx(CommandGroup, { children: (roles ?? []).map((role) => (_jsxs(CommandItem, { value: `${role.label || ''} ${role.name}`, onSelect: () => {
338
- requestRoleSwitch(role.id);
339
- setRoleOpen(false);
340
- }, children: [_jsx("span", { className: "mr-2 h-2 w-2 shrink-0 rounded-full", style: { background: role.color || '#6b7280' }, "aria-hidden": "true" }), _jsx("span", { className: "truncate", children: role.label || role.name }), role.id === activeRoleId && (_jsx(Check, { className: "ml-auto h-4 w-4" }))] }, role.id))) })] })] }) })] }), (catalog?.general.length ?? 0) > 0 && (_jsxs(_Fragment, { children: [_jsx(Separator, {}), _jsxs("div", { children: [_jsx("h3", { className: "mb-2 text-sm font-semibold", children: "Permisos Generales" }), _jsx("div", { className: "flex flex-col gap-2", children: catalog.general.map((g) => (_jsx(CapabilityCheck, { checked: draft?.has(g.key) ?? false, disabled: checksDisabled, onToggle: () => toggleCapability(g.key), label: g.label, description: g.description }, g.key))) })] })] }))] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { className: "text-base", children: "M\u00F3dulo" }), _jsx(CardDescription, { children: "Elige el m\u00F3dulo cuyas acciones quieres configurar." })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [activeModule ? (_jsx(SelectionChip, { label: activeModule.label, onRemove: () => setActiveModuleKey(null), removeAriaLabel: "Quitar m\u00F3dulo seleccionado" })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: "Ning\u00FAn m\u00F3dulo seleccionado." })), _jsxs(Popover, { open: moduleOpen, onOpenChange: setModuleOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", "aria-expanded": moduleOpen, className: "w-full justify-between font-normal", children: [activeModule ? activeModule.label : 'Seleccionar módulo…', _jsx(ChevronsUpDown, { className: "ml-2 h-4 w-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "w-[300px] p-0", align: "start", children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: "Buscar m\u00F3dulo\u2026" }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: "Sin resultados." }), moduleGroups.map(([group, mods]) => (_jsx(CommandGroup, { heading: group, children: mods.map((mod) => (_jsxs(CommandItem, { value: `${mod.label} ${mod.key} ${group}`, onSelect: () => {
341
- setActiveModuleKey(mod.key);
342
- setModuleOpen(false);
343
- }, children: [_jsx("span", { className: "truncate", children: mod.label }), mod.key === activeModuleKey && (_jsx(Check, { className: "ml-auto h-4 w-4" }))] }, mod.key))) }, group)))] })] }) })] })] })] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs("div", { className: "flex flex-wrap items-start justify-between gap-2", children: [_jsxs("div", { children: [_jsx(CardTitle, { className: "text-base", children: "Acciones permitidas" }), _jsx(CardDescription, { children: "Configura los permisos para este m\u00F3dulo." })] }), activeModule && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs(Badge, { variant: "secondary", className: "tabular-nums", children: [moduleGranted, "/", moduleTotal] }), _jsxs(Button, { variant: "outline", size: "sm", className: "h-8", disabled: checksDisabled || moduleGranted === moduleTotal, onClick: () => setModuleAll(true), children: [_jsx(CheckCheck, { className: "mr-1.5 h-3.5 w-3.5" }), " Marcar todo"] }), _jsxs(Button, { variant: "outline", size: "sm", className: "h-8", disabled: checksDisabled || moduleGranted === 0, onClick: () => setModuleAll(false), children: [_jsx(Eraser, { className: "mr-1.5 h-3.5 w-3.5" }), " Limpiar"] })] }))] }) }), _jsx(CardContent, { children: !activeRole ? (_jsx(EmptyHint, { text: "Selecciona un rol para configurar sus permisos." })) : !activeModule ? (_jsx(EmptyHint, { text: "Selecciona un m\u00F3dulo para ver sus acciones." })) : loadingPerms ? (_jsx("div", { className: "grid gap-2 sm:grid-cols-2 xl:grid-cols-3", children: Array.from({ length: 6 }).map((_, i) => (_jsx(Skeleton, { className: "h-11 w-full" }, i))) })) : (_jsx("div", { className: "grid gap-2 sm:grid-cols-2 xl:grid-cols-3", children: activeModule.actions.map((action) => {
388
+ return (_jsxs("div", { className: cn('flex flex-col gap-4', className), children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-2xl font-bold tracking-tight", children: title }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Define qu\u00E9 puede hacer cada rol en cada m\u00F3dulo." })] }), _jsxs("div", { className: "flex items-center gap-2", children: [dirty && (_jsx(Badge, { variant: "outline", className: "border-amber-500/50 text-amber-600", children: "Cambios sin guardar" })), createRole && (_jsxs(Button, { onClick: () => setRoleDialog({
389
+ open: true,
390
+ mode: 'create',
391
+ label: '',
392
+ color: ROLE_COLORS[5],
393
+ }), children: [_jsx(Plus, { className: "mr-1.5 h-4 w-4" }), " Nuevo rol"] })), _jsxs(Button, { onClick: handleSave, disabled: !dirty || saving || !activeRole, className: "bg-emerald-600 text-white hover:bg-emerald-700", children: [_jsx(Save, { className: "mr-1.5 h-4 w-4" }), saving ? 'Guardando…' : 'Guardar permisos'] })] })] }), _jsxs("div", { className: "grid items-start gap-4 lg:grid-cols-[340px_1fr]", children: [_jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { className: "text-base", children: "Rol" }), _jsx(CardDescription, { children: "Selecciona el rol a configurar." })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs(Popover, { open: roleOpen, onOpenChange: setRoleOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", "aria-expanded": roleOpen, className: "min-w-0 flex-1 justify-between font-normal", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [activeRole && (_jsx("span", { className: "h-2.5 w-2.5 shrink-0 rounded-full", style: {
394
+ background: activeRole.color || '#6b7280',
395
+ }, "aria-hidden": "true" })), _jsx("span", { className: "truncate", children: activeRole
396
+ ? activeRole.label || activeRole.name
397
+ : 'Seleccionar rol…' })] }), _jsx(ChevronsUpDown, { className: "ml-2 h-4 w-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "w-[280px] p-0", align: "start", children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: "Buscar rol\u2026" }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: "Sin resultados." }), _jsx(CommandGroup, { children: (roles ?? []).map((role) => (_jsxs(CommandItem, { value: `${role.label || ''} ${role.name}`, onSelect: () => {
398
+ requestRoleSwitch(role.id);
399
+ setRoleOpen(false);
400
+ }, children: [_jsx("span", { className: "mr-2 h-2 w-2 shrink-0 rounded-full", style: {
401
+ background: role.color || '#6b7280',
402
+ }, "aria-hidden": "true" }), _jsx("span", { className: "truncate", children: role.label || role.name }), role.id === activeRoleId && (_jsx(Check, { className: "ml-auto h-4 w-4" }))] }, role.id))) })] })] }) })] }), updateRole && (_jsx(Button, { variant: "outline", size: "icon", className: "h-9 w-9 shrink-0", "aria-label": "Editar rol", disabled: !activeRole, onClick: openEditRole, children: _jsx(Pencil, { className: "h-4 w-4" }) })), deleteRole && (_jsx(Button, { variant: "outline", size: "icon", className: "h-9 w-9 shrink-0 text-destructive hover:text-destructive", "aria-label": "Eliminar rol", disabled: !activeRole, onClick: () => setDeleteOpen(true), children: _jsx(Trash2, { className: "h-4 w-4" }) }))] }), (catalog?.general.length ?? 0) > 0 && (_jsxs(_Fragment, { children: [_jsx(Separator, {}), _jsxs("div", { children: [_jsx("h3", { className: "mb-2 text-sm font-semibold", children: "Permisos Generales" }), _jsx("div", { className: "flex flex-col gap-2", children: catalog.general.map((g) => (_jsx(CapabilityCheck, { checked: draft?.has(g.key) ?? false, disabled: checksDisabled, onToggle: () => toggleCapability(g.key), label: g.label, description: g.description }, g.key))) })] })] }))] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { className: "text-base", children: "M\u00F3dulos" }), _jsx(CardDescription, { children: "Elige el m\u00F3dulo cuyas acciones quieres configurar." })] }), _jsxs(CardContent, { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: moduleQuery, onChange: (e) => setModuleQuery(e.target.value), placeholder: "Buscar m\u00F3dulo\u2026", "aria-label": "Buscar m\u00F3dulo", className: "pl-8" })] }), _jsx("div", { role: "tree", "aria-label": "M\u00F3dulos", className: "-mx-1 max-h-[460px] overflow-y-auto px-1", children: visibleGroups.length === 0 ? (_jsx("p", { className: "px-2 py-6 text-center text-sm text-muted-foreground", children: "Sin m\u00F3dulos." })) : (visibleGroups.map((group) => {
403
+ // While searching, force every matching group open.
404
+ const open = searching || !collapsedGroups.has(group.label);
405
+ return (_jsxs(Collapsible, { open: open, onOpenChange: () => !searching && toggleGroup(group.label), children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: "flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted/40", children: [_jsx(ChevronRight, { className: cn('h-3.5 w-3.5 shrink-0 transition-transform', open && 'rotate-90') }), _jsx(Folder, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "min-w-0 flex-1 truncate normal-case", children: group.label }), _jsx("span", { className: "shrink-0 text-[10px] tabular-nums opacity-70", children: group.modules.length })] }) }), _jsx(CollapsibleContent, { children: _jsx("div", { className: "ml-3 flex flex-col gap-0.5 border-l border-border/60 pl-1.5", children: group.modules.map((mod) => (_jsx(ModuleTreeItem, { module: mod, active: mod.key === activeModuleKey, granted: draft
406
+ ? grantedCountForModule(draft, mod)
407
+ : 0, total: mod.actions.length, onSelect: () => setActiveModuleKey(mod.key) }, mod.key))) }) })] }, group.label));
408
+ })) })] })] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsxs("div", { className: "flex flex-wrap items-start justify-between gap-2", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs(CardTitle, { className: "flex items-center gap-2 text-base", children: [activeModule && (_jsx(DynamicIcon, { name: activeModule.icon || 'Square', className: "h-4 w-4 shrink-0 text-primary" })), _jsx("span", { className: "truncate", children: activeModule ? activeModule.label : 'Acciones permitidas' })] }), _jsx(CardDescription, { children: activeModule
409
+ ? `${moduleGroupLabel(activeModule)} · configura las acciones permitidas`
410
+ : 'Configura los permisos del módulo seleccionado.' })] }), activeRole && activeModule && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs(Badge, { variant: "secondary", className: "tabular-nums", children: [moduleGranted, "/", moduleTotal] }), _jsxs(Button, { variant: "outline", size: "sm", className: "h-8", disabled: checksDisabled || moduleGranted === moduleTotal, onClick: () => setModuleAll(true), children: [_jsx(CheckCheck, { className: "mr-1.5 h-3.5 w-3.5" }), " Marcar todo"] }), _jsxs(Button, { variant: "outline", size: "sm", className: "h-8", disabled: checksDisabled || moduleGranted === 0, onClick: () => setModuleAll(false), children: [_jsx(Eraser, { className: "mr-1.5 h-3.5 w-3.5" }), " Limpiar"] })] }))] }) }), _jsx(CardContent, { children: !activeRole ? (_jsx(EmptyHint, { text: "Selecciona un rol para configurar sus permisos." })) : loadingPerms ? (_jsx("div", { className: "grid gap-2 sm:grid-cols-2 xl:grid-cols-3", children: Array.from({ length: 6 }).map((_, i) => (_jsx(Skeleton, { className: "h-11 w-full" }, i))) })) : !activeModule ? (_jsx(EmptyHint, { text: "Selecciona un m\u00F3dulo del \u00E1rbol para ver sus acciones." })) : (_jsx("div", { className: "grid gap-2 sm:grid-cols-2 xl:grid-cols-3", children: activeModule.actions.map((action) => {
344
411
  const cap = moduleActionCapability(activeModule.key, action.key);
345
412
  return (_jsx(CapabilityCheck, { checked: draft?.has(cap) ?? false, disabled: checksDisabled, onToggle: () => toggleCapability(cap), icon: action.icon || defaultActionIcon(action.key, action.kind), label: action.label }, action.key));
346
413
  }) })) })] })] }), _jsx(AlertDialog, { open: pendingRoleId !== null, onOpenChange: (open) => !open && setPendingRoleId(null), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: "Cambios sin guardar" }), _jsx(AlertDialogDescription, { children: "Tienes cambios sin guardar en este rol. Si cambias de rol se descartar\u00E1n." })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: "Cancelar" }), _jsx(AlertDialogAction, { onClick: () => {
@@ -348,7 +415,11 @@ export function PermissionsManager({ loadModules, loadRoles, loadRolePermissions
348
415
  setPendingRoleId(null);
349
416
  }, children: "Descartar y cambiar" })] })] }) }), _jsx(Dialog, { open: roleDialog.open, onOpenChange: (open) => setRoleDialog((d) => ({ ...d, open })), children: _jsxs(DialogContent, { className: "sm:max-w-md", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: roleDialog.mode === 'create' ? 'Nuevo rol' : 'Editar rol' }) }), _jsxs("div", { className: "flex flex-col gap-4 py-2", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "pm-role-name", children: "Nombre del rol" }), _jsx(Input, { id: "pm-role-name", value: roleDialog.label, placeholder: "Ej. Cajero", onChange: (e) => setRoleDialog((d) => ({ ...d, label: e.target.value })) })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: "Color" }), _jsx("div", { className: "flex flex-wrap gap-2", children: ROLE_COLORS.map((c) => (_jsx("button", { type: "button", "aria-label": `Color ${c}`, onClick: () => setRoleDialog((d) => ({ ...d, color: c })), className: cn('h-7 w-7 rounded-full border-2 transition-transform', roleDialog.color === c
350
417
  ? 'scale-110 border-foreground'
351
- : 'border-transparent hover:scale-105'), style: { background: c } }, c))) })] })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setRoleDialog((d) => ({ ...d, open: false })), disabled: roleSaving, children: "Cancelar" }), _jsx(Button, { onClick: handleRoleSubmit, disabled: roleSaving || !roleDialog.label.trim(), children: roleSaving ? 'Guardando…' : roleDialog.mode === 'create' ? 'Crear rol' : 'Guardar' })] })] }) }), _jsx(AlertDialog, { open: deleteOpen, onOpenChange: (open) => !deleting && setDeleteOpen(open), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: "\u00BFEliminar el rol?" }), _jsxs(AlertDialogDescription, { children: ["Se eliminar\u00E1 el rol", ' ', _jsx("strong", { children: activeRole ? activeRole.label || activeRole.name : '' }), " y sus asignaciones de permisos. Esta acci\u00F3n no se puede deshacer."] })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { disabled: deleting, children: "Cancelar" }), _jsx(AlertDialogAction, { className: "bg-red-600 hover:bg-red-700", disabled: deleting, onClick: (e) => {
418
+ : 'border-transparent hover:scale-105'), style: { background: c } }, c))) })] })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setRoleDialog((d) => ({ ...d, open: false })), disabled: roleSaving, children: "Cancelar" }), _jsx(Button, { onClick: handleRoleSubmit, disabled: roleSaving || !roleDialog.label.trim(), children: roleSaving
419
+ ? 'Guardando…'
420
+ : roleDialog.mode === 'create'
421
+ ? 'Crear rol'
422
+ : 'Guardar' })] })] }) }), _jsx(AlertDialog, { open: deleteOpen, onOpenChange: (open) => !deleting && setDeleteOpen(open), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: "\u00BFEliminar el rol?" }), _jsxs(AlertDialogDescription, { children: ["Se eliminar\u00E1 el rol", ' ', _jsx("strong", { children: activeRole ? activeRole.label || activeRole.name : '' }), " y sus asignaciones de permisos. Esta acci\u00F3n no se puede deshacer."] })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { disabled: deleting, children: "Cancelar" }), _jsx(AlertDialogAction, { className: "bg-red-600 hover:bg-red-700", disabled: deleting, onClick: (e) => {
352
423
  e.preventDefault();
353
424
  handleDeleteRole();
354
425
  }, children: deleting ? 'Eliminando…' : 'Eliminar' })] })] }) })] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "18.14.0",
3
+ "version": "18.15.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,6 +10,8 @@ import {
10
10
  moduleCapabilities,
11
11
  grantedCountForModule,
12
12
  capabilitySetsEqual,
13
+ groupModules,
14
+ filterModuleGroups,
13
15
  type PermissionsCatalog,
14
16
  type RoleDef,
15
17
  } from '../permissions-manager'
@@ -19,6 +21,7 @@ const catalog: PermissionsCatalog = {
19
21
  {
20
22
  key: 'pos_orders',
21
23
  label: 'Pedidos POS',
24
+ icon: 'ShoppingCart',
22
25
  addon_key: 'pos',
23
26
  addon_label: 'Punto de venta',
24
27
  actions: [
@@ -27,6 +30,20 @@ const catalog: PermissionsCatalog = {
27
30
  { key: 'pagar', label: 'Pagar', icon: 'CreditCard', kind: 'custom' },
28
31
  ],
29
32
  },
33
+ {
34
+ key: 'pos_sessions',
35
+ label: 'Sesiones POS',
36
+ icon: 'Clock',
37
+ addon_key: 'pos',
38
+ addon_label: 'Punto de venta',
39
+ actions: [{ key: 'index', label: 'Listar', icon: 'List', kind: 'crud' }],
40
+ },
41
+ {
42
+ key: 'users',
43
+ label: 'Usuarios',
44
+ icon: 'Users',
45
+ actions: [{ key: 'index', label: 'Listar', icon: 'List', kind: 'crud' }],
46
+ },
30
47
  ],
31
48
  general: [
32
49
  {
@@ -68,6 +85,28 @@ describe('helpers puros', () => {
68
85
  expect(capabilitySetsEqual(new Set(['a', 'b']), new Set(['b', 'a']))).toBe(true)
69
86
  expect(capabilitySetsEqual(new Set(['a']), new Set(['a', 'b']))).toBe(false)
70
87
  })
88
+
89
+ it('groupModules agrupa por addon_label y manda los sin addon a "Sistema"', () => {
90
+ const groups = groupModules(catalog.modules)
91
+ expect(groups.map((g) => g.label)).toEqual(['Punto de venta', 'Sistema'])
92
+ expect(groups[0].modules.map((m) => m.key)).toEqual(['pos_orders', 'pos_sessions'])
93
+ expect(groups[1].modules.map((m) => m.key)).toEqual(['users'])
94
+ })
95
+
96
+ it('filterModuleGroups busca por módulo (accent/case-insensitive) o por grupo', () => {
97
+ const groups = groupModules(catalog.modules)
98
+ // Por nombre de módulo.
99
+ const bySession = filterModuleGroups(groups, 'sesiones')
100
+ expect(bySession).toHaveLength(1)
101
+ expect(bySession[0].modules.map((m) => m.key)).toEqual(['pos_sessions'])
102
+ // Por nombre de grupo trae todos sus módulos.
103
+ const byGroup = filterModuleGroups(groups, 'venta')
104
+ expect(byGroup[0].modules).toHaveLength(2)
105
+ // Query vacía = pasa todo.
106
+ expect(filterModuleGroups(groups, ' ')).toEqual(groups)
107
+ // Sin match = vacío.
108
+ expect(filterModuleGroups(groups, 'zzz')).toEqual([])
109
+ })
71
110
  })
72
111
 
73
112
  describe('PermissionsManager', () => {
@@ -76,9 +115,11 @@ describe('PermissionsManager', () => {
76
115
  render(<PermissionsManager {...props} />)
77
116
 
78
117
  // Auto-selección: primer rol + primer módulo, grid con las acciones.
79
- expect(await screen.findByText('Acciones permitidas')).toBeTruthy()
118
+ // El panel derecho titula con el módulo activo ("Pedidos POS").
119
+ expect(await screen.findAllByText('Pedidos POS')).toBeTruthy()
80
120
  expect(await screen.findByText('Pagar')).toBeTruthy()
81
- expect(screen.getByText('1/3')).toBeTruthy()
121
+ // El contador N/M del panel está presente (también lo refleja el árbol).
122
+ expect(screen.getAllByText('1/3').length).toBeGreaterThan(0)
82
123
  expect(props.loadRolePermissions).toHaveBeenCalledWith('r1')
83
124
 
84
125
  // Generales presentes con descripción.
@@ -125,7 +166,8 @@ describe('PermissionsManager', () => {
125
166
  await screen.findByText('Pagar')
126
167
 
127
168
  fireEvent.click(screen.getByRole('button', { name: /Marcar todo/ }))
128
- expect(screen.getByText('3/3')).toBeTruthy()
169
+ // 3/3 aparece en el panel y en el badge del árbol.
170
+ expect(screen.getAllByText('3/3').length).toBeGreaterThan(0)
129
171
 
130
172
  fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
131
173
  await waitFor(() =>
@@ -157,6 +199,50 @@ describe('PermissionsManager', () => {
157
199
  expect(screen.queryByRole('button', { name: 'Eliminar rol' })).toBeNull()
158
200
  })
159
201
 
202
+ it('renderiza el árbol agrupado y permite seleccionar un módulo de otro grupo', async () => {
203
+ const props = makeProps()
204
+ render(<PermissionsManager {...props} />)
205
+ await screen.findByText('Pagar')
206
+
207
+ // Grupos visibles (encabezados del árbol).
208
+ expect(screen.getByText('Punto de venta')).toBeTruthy()
209
+ expect(screen.getByText('Sistema')).toBeTruthy()
210
+
211
+ // Click en "Usuarios" (grupo Sistema) cambia el grid de acciones.
212
+ fireEvent.click(screen.getByRole('button', { name: /Usuarios/ }))
213
+ // El grid ahora muestra solo la acción de users; "Pagar" (de pos_orders) ya no.
214
+ await waitFor(() => expect(screen.queryByText('Pagar')).toBeNull())
215
+ // "Usuarios" titula el panel derecho además del árbol.
216
+ expect(screen.getAllByText('Usuarios').length).toBeGreaterThan(0)
217
+ })
218
+
219
+ it('la búsqueda filtra el árbol de módulos', async () => {
220
+ const props = makeProps()
221
+ render(<PermissionsManager {...props} />)
222
+ await screen.findByText('Pagar')
223
+
224
+ fireEvent.change(screen.getByLabelText('Buscar módulo'), {
225
+ target: { value: 'sesiones' },
226
+ })
227
+ // Solo el grupo con match permanece.
228
+ expect(screen.getByText('Sesiones POS')).toBeTruthy()
229
+ expect(screen.queryByText('Usuarios')).toBeNull()
230
+ })
231
+
232
+ it('selector de rol limpio: edit/delete inline, sin chip removible', async () => {
233
+ const props = makeProps({
234
+ updateRole: vi.fn(async () => {}),
235
+ deleteRole: vi.fn(async () => {}),
236
+ })
237
+ render(<PermissionsManager {...props} />)
238
+ await screen.findByText('Pagar')
239
+ // No existe el botón de quitar rol del chip antiguo.
240
+ expect(screen.queryByRole('button', { name: 'Quitar rol seleccionado' })).toBeNull()
241
+ // Iconos inline presentes.
242
+ expect(screen.getByRole('button', { name: 'Editar rol' })).toBeTruthy()
243
+ expect(screen.getByRole('button', { name: 'Eliminar rol' })).toBeTruthy()
244
+ })
245
+
160
246
  it('muestra los CRUD de rol cuando los mutators existen', async () => {
161
247
  const props = makeProps({
162
248
  createRole: vi.fn(async () => {}),