@asteby/metacore-runtime-react 18.14.0 → 18.16.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.
@@ -3,16 +3,21 @@
3
3
  // Transport-agnostic: every read/write arrives via props (loaders/mutators),
4
4
  // so each host wires them to its own api client (ops → /api/permissions/*).
5
5
  // The capability universe (modules × actions + general flags) is derived from
6
- // the installed manifests server-side; this component only renders it.
6
+ // the installed manifests + the real sidebar nav server/host-side; this
7
+ // component only renders it.
7
8
  //
8
- // Layout (reference: 7leguas "Permisos y Roles"):
9
+ // Layout a *flat list* that mirrors the app sidebar (NO accordions/folders):
9
10
  // header — title + "Nuevo rol" (primary) + "Guardar permisos" (green).
10
- // left — Card "Rol": searchable role selector with removable chip,
11
- // Editar/Eliminar rol, "Permisos Generales" flag checkboxes.
12
- // — Card "Módulo": searchable module selector grouped by addon,
13
- // 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 flat list. Each group renders a
14
+ // non-collapsible grey header (uppercase tracking, like "Módulos"
15
+ // / "Sistema" in the sidebar) followed by its modules as clickable
16
+ // rows (icon + label + granted-count badge). Clicking a row selects
17
+ // that module and reveals its action grid on the right.
14
18
  // right — Card "Acciones permitidas": granted counter N/M, mark-all /
15
- // clear buttons, checkbox grid (icon + label per action).
19
+ // clear, checkbox grid (icon + label per action). Clear empty
20
+ // states for "pick a role" / "pick a module" / loading.
16
21
  //
17
22
  // Saving calls `syncRolePermissions(roleId, capabilities)` with the FULL
18
23
  // granted set of the active role (baseline + the edits made here). Dirty
@@ -27,9 +32,9 @@ import {
27
32
  Pencil,
28
33
  Plus,
29
34
  Save,
35
+ Search,
30
36
  Shield,
31
37
  Trash2,
32
- X,
33
38
  } from 'lucide-react'
34
39
  import { toast } from 'sonner'
35
40
  import { cn } from '@asteby/metacore-ui/lib'
@@ -72,32 +77,52 @@ import {
72
77
  import { DynamicIcon } from './dynamic-icon'
73
78
 
74
79
  // ---------------------------------------------------------------------------
75
- // Types (mirror of `GET /api/permissions/modules` / `/api/permissions/roles`)
80
+ // Types (mirror of `GET /api/permissions/modules` + the host sidebar nav)
76
81
  // ---------------------------------------------------------------------------
77
82
 
78
83
  export interface PermissionActionDef {
79
- /** Canonical action key (`index`, `create`, …, or a custom key like `pagar`). */
84
+ /** Canonical action key (`index`, `create`, …, a custom `pagar`, or `access`). */
80
85
  key: string
81
- /** Localized label ("Listar", "Pagar"). */
86
+ /** Localized label ("Listar", "Pagar", "Acceder"). */
82
87
  label: string
83
88
  /** Lucide icon name from the manifest action (optional). */
84
89
  icon?: string
85
- /** `crud` for the derived CRUD set, `custom` for manifest actions. */
86
- kind?: 'crud' | 'custom' | string
90
+ /**
91
+ * `crud` for the derived CRUD set, `custom` for manifest actions,
92
+ * `screen` for the single `access` action of a non-model screen.
93
+ */
94
+ kind?: 'crud' | 'custom' | 'screen' | string
87
95
  }
88
96
 
89
97
  export interface PermissionModuleDef {
90
- /** Module key = lowercase model table (`pos_orders`). */
98
+ /**
99
+ * Module key.
100
+ * - model: lowercase model table (`pos_orders`).
101
+ * - screen: `screen.<navKey>` (the host prefixes it).
102
+ */
91
103
  key: string
92
- /** Localized module label ("Pedidos POS"). */
104
+ /** Localized module label ("Pedidos POS", "Terminal"). */
93
105
  label: string
94
- /** Owning addon key (`pos`). */
106
+ /** Module icon (lucide name) — mirrors the sidebar entry. */
107
+ icon?: string
108
+ /** Whether this entry is a data model or a non-model screen. */
109
+ kind?: 'model' | 'screen'
110
+ /** Owning addon key (`pos`) — legacy shape only, used for grouping. */
95
111
  addon_key?: string
96
- /** Localized addon label ("Punto de venta") — used to group the selector. */
112
+ /** Localized addon label ("Punto de venta") — legacy shape only. */
97
113
  addon_label?: string
98
114
  actions: PermissionActionDef[]
99
115
  }
100
116
 
117
+ /**
118
+ * A sidebar-style group: a grey (non-collapsible) header + its modules.
119
+ * `title === ''` → no header (e.g. core/infra modules).
120
+ */
121
+ export interface ModuleGroup {
122
+ title: string
123
+ modules: PermissionModuleDef[]
124
+ }
125
+
101
126
  export interface GeneralPermissionDef {
102
127
  /** Full capability key (`general.work_after_hours`). */
103
128
  key: string
@@ -105,11 +130,27 @@ export interface GeneralPermissionDef {
105
130
  description?: string
106
131
  }
107
132
 
108
- export interface PermissionsCatalog {
133
+ /**
134
+ * What `loadModules()` may return.
135
+ *
136
+ * New (preferred) shape — pre-grouped flat list, mirrors the host sidebar:
137
+ * { groups: ModuleGroup[], general }
138
+ *
139
+ * Legacy shape (still accepted, wrapped into a single untitled group) —
140
+ * { modules: PermissionModuleDef[], general }
141
+ */
142
+ export interface GroupedPermissionsCatalog {
143
+ groups: ModuleGroup[]
144
+ general: GeneralPermissionDef[]
145
+ }
146
+
147
+ export interface FlatPermissionsCatalog {
109
148
  modules: PermissionModuleDef[]
110
149
  general: GeneralPermissionDef[]
111
150
  }
112
151
 
152
+ export type PermissionsCatalog = GroupedPermissionsCatalog | FlatPermissionsCatalog
153
+
113
154
  export interface RoleDef {
114
155
  id: string
115
156
  /** Stable role key ("cashier"). */
@@ -127,7 +168,7 @@ export interface RoleInput {
127
168
  }
128
169
 
129
170
  export interface PermissionsManagerProps {
130
- /** Loads the module×action universe + general flags. */
171
+ /** Loads the module×action universe + general flags (grouped or flat). */
131
172
  loadModules: () => Promise<PermissionsCatalog>
132
173
  /** Loads every assignable role. */
133
174
  loadRoles: () => Promise<RoleDef[]>
@@ -135,7 +176,7 @@ export interface PermissionsManagerProps {
135
176
  loadRolePermissions: (roleId: string) => Promise<string[]>
136
177
  /** Persists the FULL granted capability set of a role. */
137
178
  syncRolePermissions: (roleId: string, capabilities: string[]) => Promise<void>
138
- /** Optional role CRUD — omitting one hides its button. */
179
+ /** Optional role CRUD — omitting one hides its control. */
139
180
  createRole?: (input: RoleInput) => Promise<RoleDef | void>
140
181
  updateRole?: (roleId: string, input: RoleInput) => Promise<RoleDef | void>
141
182
  deleteRole?: (roleId: string) => Promise<void>
@@ -187,16 +228,32 @@ export function defaultActionIcon(actionKey: string, kind?: string): string {
187
228
  return 'Download'
188
229
  case 'import':
189
230
  return 'Upload'
231
+ case 'access':
232
+ return 'Eye'
190
233
  default:
191
- return kind === 'crud' ? 'List' : 'Zap'
234
+ if (kind === 'crud') return 'List'
235
+ if (kind === 'screen') return 'Eye'
236
+ return 'Zap'
192
237
  }
193
238
  }
194
239
 
195
- function slugify(label: string): string {
196
- return label
240
+ /** Group label fallback when a legacy module has no addon ("Sistema" = core). */
241
+ const SYSTEM_GROUP = 'Sistema'
242
+
243
+ function legacyGroupLabel(mod: PermissionModuleDef): string {
244
+ return mod.addon_label || mod.addon_key || SYSTEM_GROUP
245
+ }
246
+
247
+ /** Accent-insensitive, lowercase fold for search. */
248
+ function fold(s: string): string {
249
+ return s
197
250
  .normalize('NFD')
198
- .replace(/[\u0300-\u036f]/g, '')
251
+ .replace(/[̀-ͯ]/g, '')
199
252
  .toLowerCase()
253
+ }
254
+
255
+ function slugify(label: string): string {
256
+ return fold(label)
200
257
  .trim()
201
258
  .replace(/[^a-z0-9]+/g, '_')
202
259
  .replace(/^_+|_+$/g, '')
@@ -214,6 +271,63 @@ const ROLE_COLORS = [
214
271
  '#6b7280',
215
272
  ]
216
273
 
274
+ /**
275
+ * Normalize whatever `loadModules` returned into the canonical grouped shape.
276
+ *
277
+ * - New shape (`{ groups }`): passed through (modules default to kind:'model').
278
+ * - Legacy flat shape (`{ modules }`): grouped by `addon_label`/`addon_key`
279
+ * (falling back to "Sistema") so old hosts keep their familiar buckets,
280
+ * every module defaulting to kind:'model'. The grey headers still render,
281
+ * just derived from the addon instead of the sidebar group.
282
+ */
283
+ export function normalizeCatalogGroups(catalog: PermissionsCatalog): ModuleGroup[] {
284
+ const withKind = (m: PermissionModuleDef): PermissionModuleDef => ({
285
+ ...m,
286
+ kind: m.kind ?? 'model',
287
+ })
288
+
289
+ if ('groups' in catalog && Array.isArray(catalog.groups)) {
290
+ return catalog.groups.map((g) => ({
291
+ title: g.title ?? '',
292
+ modules: g.modules.map(withKind),
293
+ }))
294
+ }
295
+
296
+ const modules = ('modules' in catalog && catalog.modules) || []
297
+ const order: string[] = []
298
+ const byGroup = new Map<string, PermissionModuleDef[]>()
299
+ for (const raw of modules) {
300
+ const mod = withKind(raw)
301
+ const g = legacyGroupLabel(mod)
302
+ if (!byGroup.has(g)) {
303
+ byGroup.set(g, [])
304
+ order.push(g)
305
+ }
306
+ byGroup.get(g)!.push(mod)
307
+ }
308
+ return order.map((title) => ({ title, modules: byGroup.get(title)! }))
309
+ }
310
+
311
+ /** Flat list of every module across groups, in render order. */
312
+ export function flattenGroups(groups: ModuleGroup[]): PermissionModuleDef[] {
313
+ return groups.flatMap((g) => g.modules)
314
+ }
315
+
316
+ /** Filter the grouped flat list by a folded query against module + group titles. */
317
+ export function filterModuleGroups(groups: ModuleGroup[], query: string): ModuleGroup[] {
318
+ const q = fold(query).trim()
319
+ if (!q) return groups
320
+ const out: ModuleGroup[] = []
321
+ for (const g of groups) {
322
+ const groupMatches = g.title.length > 0 && fold(g.title).includes(q)
323
+ const mods = groupMatches
324
+ ? g.modules
325
+ : g.modules.filter((m) => fold(m.label).includes(q) || fold(m.key).includes(q))
326
+ if (mods.length) out.push({ title: g.title, modules: mods })
327
+ }
328
+ return out
329
+ }
330
+
217
331
  // ---------------------------------------------------------------------------
218
332
  // Internal sub-components
219
333
  // ---------------------------------------------------------------------------
@@ -276,37 +390,46 @@ function CapabilityCheck({
276
390
  )
277
391
  }
278
392
 
279
- /** Removable selection chip (role / module). */
280
- function SelectionChip({
281
- label,
282
- color,
283
- onRemove,
284
- removeAriaLabel,
393
+ /** One clickable module row in the flat list (mirrors a sidebar item). */
394
+ function ModuleRow({
395
+ module,
396
+ active,
397
+ granted,
398
+ total,
399
+ onSelect,
285
400
  }: {
286
- label: string
287
- color?: string
288
- onRemove: () => void
289
- removeAriaLabel: string
401
+ module: PermissionModuleDef
402
+ active: boolean
403
+ granted: number
404
+ total: number
405
+ onSelect: () => void
290
406
  }) {
291
407
  return (
292
- <Badge variant="secondary" className="gap-1.5 pr-1 text-sm font-medium">
293
- {color && (
294
- <span
295
- className="h-2 w-2 rounded-full"
296
- style={{ background: color }}
297
- aria-hidden="true"
298
- />
408
+ <button
409
+ type="button"
410
+ onClick={onSelect}
411
+ aria-current={active || undefined}
412
+ className={cn(
413
+ 'group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
414
+ active
415
+ ? 'bg-primary/10 font-medium text-foreground'
416
+ : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
299
417
  )}
300
- <span className="max-w-[180px] truncate">{label}</span>
301
- <button
302
- type="button"
303
- aria-label={removeAriaLabel}
304
- onClick={onRemove}
305
- className="rounded-sm p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
306
- >
307
- <X className="h-3 w-3" />
308
- </button>
309
- </Badge>
418
+ >
419
+ <DynamicIcon
420
+ name={module.icon || (module.kind === 'screen' ? 'Eye' : 'Square')}
421
+ className={cn('h-4 w-4 shrink-0', active ? 'text-primary' : 'text-muted-foreground')}
422
+ />
423
+ <span className="min-w-0 flex-1 truncate">{module.label}</span>
424
+ {granted > 0 && (
425
+ <Badge
426
+ variant={granted === total ? 'default' : 'secondary'}
427
+ className="h-5 shrink-0 px-1.5 text-[10px] tabular-nums"
428
+ >
429
+ {granted}/{total}
430
+ </Badge>
431
+ )}
432
+ </button>
310
433
  )
311
434
  }
312
435
 
@@ -325,7 +448,8 @@ export function PermissionsManager({
325
448
  title = 'Permisos y Roles',
326
449
  className,
327
450
  }: PermissionsManagerProps) {
328
- const [catalog, setCatalog] = React.useState<PermissionsCatalog | null>(null)
451
+ const [groups, setGroups] = React.useState<ModuleGroup[] | null>(null)
452
+ const [general, setGeneral] = React.useState<GeneralPermissionDef[] | null>(null)
329
453
  const [roles, setRoles] = React.useState<RoleDef[] | null>(null)
330
454
  const [loadError, setLoadError] = React.useState(false)
331
455
 
@@ -339,7 +463,7 @@ export function PermissionsManager({
339
463
  const [saving, setSaving] = React.useState(false)
340
464
 
341
465
  const [roleOpen, setRoleOpen] = React.useState(false)
342
- const [moduleOpen, setModuleOpen] = React.useState(false)
466
+ const [moduleQuery, setModuleQuery] = React.useState('')
343
467
 
344
468
  // Pending role switch while there are unsaved changes.
345
469
  const [pendingRoleId, setPendingRoleId] = React.useState<string | null>(null)
@@ -354,7 +478,9 @@ export function PermissionsManager({
354
478
  const [deleteOpen, setDeleteOpen] = React.useState(false)
355
479
  const [deleting, setDeleting] = React.useState(false)
356
480
 
357
- const loading = catalog === null || roles === null
481
+ const loading = groups === null || roles === null
482
+
483
+ const allModules = React.useMemo(() => (groups ? flattenGroups(groups) : []), [groups])
358
484
 
359
485
  // ---- initial load: catalog + roles in parallel -------------------------
360
486
  React.useEffect(() => {
@@ -362,10 +488,14 @@ export function PermissionsManager({
362
488
  Promise.all([loadModules(), loadRoles()])
363
489
  .then(([cat, rs]) => {
364
490
  if (cancelled) return
365
- setCatalog(cat)
491
+ const grouped = normalizeCatalogGroups(cat)
492
+ setGroups(grouped)
493
+ setGeneral(cat.general ?? [])
366
494
  setRoles(rs)
367
495
  setActiveRoleId((prev) => prev ?? rs[0]?.id ?? null)
368
- setActiveModuleKey((prev) => prev ?? cat.modules[0]?.key ?? null)
496
+ setActiveModuleKey(
497
+ (prev) => prev ?? flattenGroups(grouped)[0]?.key ?? null,
498
+ )
369
499
  })
370
500
  .catch(() => {
371
501
  if (!cancelled) setLoadError(true)
@@ -411,23 +541,17 @@ export function PermissionsManager({
411
541
  [roles, activeRoleId],
412
542
  )
413
543
  const activeModule = React.useMemo(
414
- () => catalog?.modules.find((m) => m.key === activeModuleKey) ?? null,
415
- [catalog, activeModuleKey],
544
+ () => allModules.find((m) => m.key === activeModuleKey) ?? null,
545
+ [allModules, activeModuleKey],
416
546
  )
417
547
 
418
548
  const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft)
419
549
 
420
- // Selector groups: modules bucketed by addon label, stable order.
421
- const moduleGroups = React.useMemo(() => {
422
- const groups = new Map<string, PermissionModuleDef[]>()
423
- for (const mod of catalog?.modules ?? []) {
424
- const group = mod.addon_label || mod.addon_key || 'Otros'
425
- const list = groups.get(group) ?? []
426
- list.push(mod)
427
- groups.set(group, list)
428
- }
429
- return Array.from(groups.entries())
430
- }, [catalog])
550
+ // Flat module list, optionally filtered by the search.
551
+ const visibleGroups = React.useMemo(
552
+ () => filterModuleGroups(groups ?? [], moduleQuery),
553
+ [groups, moduleQuery],
554
+ )
431
555
 
432
556
  // ---- capability edits ---------------------------------------------------
433
557
  const toggleCapability = React.useCallback((cap: string) => {
@@ -518,7 +642,9 @@ export function PermissionsManager({
518
642
  }
519
643
  setRoleDialog((d) => ({ ...d, open: false }))
520
644
  } catch {
521
- toast.error(roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol')
645
+ toast.error(
646
+ roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol',
647
+ )
522
648
  } finally {
523
649
  setRoleSaving(false)
524
650
  }
@@ -541,15 +667,35 @@ export function PermissionsManager({
541
667
  }
542
668
  }
543
669
 
670
+ const openEditRole = () => {
671
+ if (!activeRole) return
672
+ setRoleDialog({
673
+ open: true,
674
+ mode: 'edit',
675
+ label: activeRole.label || activeRole.name,
676
+ color: activeRole.color || ROLE_COLORS[5],
677
+ })
678
+ }
679
+
544
680
  // ---- derived for the right panel ----------------------------------------
545
681
  const moduleGranted = activeModule && draft ? grantedCountForModule(draft, activeModule) : 0
546
682
  const moduleTotal = activeModule?.actions.length ?? 0
547
683
  const checksDisabled = !activeRole || !draft || loadingPerms || saving
684
+ const activeModuleGroupTitle = React.useMemo(() => {
685
+ if (!activeModule || !groups) return ''
686
+ const g = groups.find((grp) => grp.modules.some((m) => m.key === activeModule.key))
687
+ return g?.title ?? ''
688
+ }, [activeModule, groups])
548
689
 
549
690
  // ---- render --------------------------------------------------------------
550
691
  if (loadError) {
551
692
  return (
552
- <div className={cn('flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground', className)}>
693
+ <div
694
+ className={cn(
695
+ 'flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground',
696
+ className,
697
+ )}
698
+ >
553
699
  <Shield className="h-8 w-8 opacity-40" />
554
700
  <p className="text-sm">No se pudo cargar el catálogo de permisos.</p>
555
701
  </div>
@@ -568,8 +714,8 @@ export function PermissionsManager({
568
714
  </div>
569
715
  <div className="grid gap-4 lg:grid-cols-[340px_1fr]">
570
716
  <div className="flex flex-col gap-4">
571
- <Skeleton className="h-64 w-full" />
572
- <Skeleton className="h-28 w-full" />
717
+ <Skeleton className="h-40 w-full" />
718
+ <Skeleton className="h-80 w-full" />
573
719
  </div>
574
720
  <Skeleton className="h-96 w-full" />
575
721
  </div>
@@ -596,7 +742,12 @@ export function PermissionsManager({
596
742
  {createRole && (
597
743
  <Button
598
744
  onClick={() =>
599
- setRoleDialog({ open: true, mode: 'create', label: '', color: ROLE_COLORS[5] })
745
+ setRoleDialog({
746
+ open: true,
747
+ mode: 'create',
748
+ label: '',
749
+ color: ROLE_COLORS[5],
750
+ })
600
751
  }
601
752
  >
602
753
  <Plus className="mr-1.5 h-4 w-4" /> Nuevo rol
@@ -623,101 +774,103 @@ export function PermissionsManager({
623
774
  <CardDescription>Selecciona el rol a configurar.</CardDescription>
624
775
  </CardHeader>
625
776
  <CardContent className="flex flex-col gap-3">
626
- {activeRole ? (
627
- <div className="flex items-center justify-between gap-2">
628
- <SelectionChip
629
- label={activeRole.label || activeRole.name}
630
- color={activeRole.color}
631
- onRemove={() => requestRoleSwitch(null)}
632
- removeAriaLabel="Quitar rol seleccionado"
633
- />
634
- <div className="flex items-center gap-1">
635
- {updateRole && (
636
- <Button
637
- variant="ghost"
638
- size="sm"
639
- className="h-8 px-2"
640
- aria-label="Editar rol"
641
- onClick={() =>
642
- setRoleDialog({
643
- open: true,
644
- mode: 'edit',
645
- label: activeRole.label || activeRole.name,
646
- color: activeRole.color || ROLE_COLORS[5],
647
- })
648
- }
649
- >
650
- <Pencil className="h-3.5 w-3.5" />
651
- </Button>
652
- )}
653
- {deleteRole && (
654
- <Button
655
- variant="ghost"
656
- size="sm"
657
- className="h-8 px-2 text-destructive hover:text-destructive"
658
- aria-label="Eliminar rol"
659
- onClick={() => setDeleteOpen(true)}
660
- >
661
- <Trash2 className="h-3.5 w-3.5" />
662
- </Button>
663
- )}
664
- </div>
665
- </div>
666
- ) : (
667
- <p className="text-sm text-muted-foreground">Ningún rol seleccionado.</p>
668
- )}
669
-
670
- <Popover open={roleOpen} onOpenChange={setRoleOpen}>
671
- <PopoverTrigger asChild>
777
+ {/* Clean role combobox with inline edit/delete. */}
778
+ <div className="flex items-center gap-1.5">
779
+ <Popover open={roleOpen} onOpenChange={setRoleOpen}>
780
+ <PopoverTrigger asChild>
781
+ <Button
782
+ variant="outline"
783
+ role="combobox"
784
+ aria-expanded={roleOpen}
785
+ className="min-w-0 flex-1 justify-between font-normal"
786
+ >
787
+ <span className="flex min-w-0 items-center gap-2">
788
+ {activeRole && (
789
+ <span
790
+ className="h-2.5 w-2.5 shrink-0 rounded-full"
791
+ style={{
792
+ background: activeRole.color || '#6b7280',
793
+ }}
794
+ aria-hidden="true"
795
+ />
796
+ )}
797
+ <span className="truncate">
798
+ {activeRole
799
+ ? activeRole.label || activeRole.name
800
+ : 'Seleccionar rol…'}
801
+ </span>
802
+ </span>
803
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
804
+ </Button>
805
+ </PopoverTrigger>
806
+ <PopoverContent className="w-[280px] p-0" align="start">
807
+ <Command>
808
+ <CommandInput placeholder="Buscar rol…" />
809
+ <CommandList>
810
+ <CommandEmpty>Sin resultados.</CommandEmpty>
811
+ <CommandGroup>
812
+ {(roles ?? []).map((role) => (
813
+ <CommandItem
814
+ key={role.id}
815
+ value={`${role.label || ''} ${role.name}`}
816
+ onSelect={() => {
817
+ requestRoleSwitch(role.id)
818
+ setRoleOpen(false)
819
+ }}
820
+ >
821
+ <span
822
+ className="mr-2 h-2 w-2 shrink-0 rounded-full"
823
+ style={{
824
+ background: role.color || '#6b7280',
825
+ }}
826
+ aria-hidden="true"
827
+ />
828
+ <span className="truncate">
829
+ {role.label || role.name}
830
+ </span>
831
+ {role.id === activeRoleId && (
832
+ <Check className="ml-auto h-4 w-4" />
833
+ )}
834
+ </CommandItem>
835
+ ))}
836
+ </CommandGroup>
837
+ </CommandList>
838
+ </Command>
839
+ </PopoverContent>
840
+ </Popover>
841
+ {updateRole && (
672
842
  <Button
673
843
  variant="outline"
674
- role="combobox"
675
- aria-expanded={roleOpen}
676
- className="w-full justify-between font-normal"
844
+ size="icon"
845
+ className="h-9 w-9 shrink-0"
846
+ aria-label="Editar rol"
847
+ disabled={!activeRole}
848
+ onClick={openEditRole}
677
849
  >
678
- {activeRole ? activeRole.label || activeRole.name : 'Seleccionar rol…'}
679
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
850
+ <Pencil className="h-4 w-4" />
680
851
  </Button>
681
- </PopoverTrigger>
682
- <PopoverContent className="w-[300px] p-0" align="start">
683
- <Command>
684
- <CommandInput placeholder="Buscar rol…" />
685
- <CommandList>
686
- <CommandEmpty>Sin resultados.</CommandEmpty>
687
- <CommandGroup>
688
- {(roles ?? []).map((role) => (
689
- <CommandItem
690
- key={role.id}
691
- value={`${role.label || ''} ${role.name}`}
692
- onSelect={() => {
693
- requestRoleSwitch(role.id)
694
- setRoleOpen(false)
695
- }}
696
- >
697
- <span
698
- className="mr-2 h-2 w-2 shrink-0 rounded-full"
699
- style={{ background: role.color || '#6b7280' }}
700
- aria-hidden="true"
701
- />
702
- <span className="truncate">{role.label || role.name}</span>
703
- {role.id === activeRoleId && (
704
- <Check className="ml-auto h-4 w-4" />
705
- )}
706
- </CommandItem>
707
- ))}
708
- </CommandGroup>
709
- </CommandList>
710
- </Command>
711
- </PopoverContent>
712
- </Popover>
713
-
714
- {(catalog?.general.length ?? 0) > 0 && (
852
+ )}
853
+ {deleteRole && (
854
+ <Button
855
+ variant="outline"
856
+ size="icon"
857
+ className="h-9 w-9 shrink-0 text-destructive hover:text-destructive"
858
+ aria-label="Eliminar rol"
859
+ disabled={!activeRole}
860
+ onClick={() => setDeleteOpen(true)}
861
+ >
862
+ <Trash2 className="h-4 w-4" />
863
+ </Button>
864
+ )}
865
+ </div>
866
+
867
+ {(general?.length ?? 0) > 0 && (
715
868
  <>
716
869
  <Separator />
717
870
  <div>
718
871
  <h3 className="mb-2 text-sm font-semibold">Permisos Generales</h3>
719
872
  <div className="flex flex-col gap-2">
720
- {catalog!.general.map((g) => (
873
+ {general!.map((g) => (
721
874
  <CapabilityCheck
722
875
  key={g.key}
723
876
  checked={draft?.has(g.key) ?? false}
@@ -734,62 +887,73 @@ export function PermissionsManager({
734
887
  </CardContent>
735
888
  </Card>
736
889
 
737
- {/* Card: Módulo */}
890
+ {/* Card: Módulos (flat list, mirrors the sidebar — no folders) */}
738
891
  <Card>
739
892
  <CardHeader>
740
- <CardTitle className="text-base">Módulo</CardTitle>
741
- <CardDescription>Elige el módulo cuyas acciones quieres configurar.</CardDescription>
893
+ <CardTitle className="text-base">Módulos</CardTitle>
894
+ <CardDescription>
895
+ Elige el módulo cuyas acciones quieres configurar.
896
+ </CardDescription>
742
897
  </CardHeader>
743
898
  <CardContent className="flex flex-col gap-3">
744
- {activeModule ? (
745
- <SelectionChip
746
- label={activeModule.label}
747
- onRemove={() => setActiveModuleKey(null)}
748
- removeAriaLabel="Quitar módulo seleccionado"
899
+ <div className="relative">
900
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
901
+ <Input
902
+ value={moduleQuery}
903
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
904
+ setModuleQuery(e.target.value)
905
+ }
906
+ placeholder="Buscar módulo…"
907
+ aria-label="Buscar módulo"
908
+ className="pl-8"
749
909
  />
750
- ) : (
751
- <p className="text-sm text-muted-foreground">Ningún módulo seleccionado.</p>
752
- )}
753
- <Popover open={moduleOpen} onOpenChange={setModuleOpen}>
754
- <PopoverTrigger asChild>
755
- <Button
756
- variant="outline"
757
- role="combobox"
758
- aria-expanded={moduleOpen}
759
- className="w-full justify-between font-normal"
760
- >
761
- {activeModule ? activeModule.label : 'Seleccionar módulo…'}
762
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
763
- </Button>
764
- </PopoverTrigger>
765
- <PopoverContent className="w-[300px] p-0" align="start">
766
- <Command>
767
- <CommandInput placeholder="Buscar módulo…" />
768
- <CommandList>
769
- <CommandEmpty>Sin resultados.</CommandEmpty>
770
- {moduleGroups.map(([group, mods]) => (
771
- <CommandGroup key={group} heading={group}>
772
- {mods.map((mod) => (
773
- <CommandItem
774
- key={mod.key}
775
- value={`${mod.label} ${mod.key} ${group}`}
776
- onSelect={() => {
777
- setActiveModuleKey(mod.key)
778
- setModuleOpen(false)
779
- }}
780
- >
781
- <span className="truncate">{mod.label}</span>
782
- {mod.key === activeModuleKey && (
783
- <Check className="ml-auto h-4 w-4" />
784
- )}
785
- </CommandItem>
786
- ))}
787
- </CommandGroup>
910
+ </div>
911
+
912
+ <div
913
+ role="list"
914
+ aria-label="Módulos"
915
+ className="-mx-1 flex max-h-[460px] flex-col gap-0.5 overflow-y-auto px-1"
916
+ >
917
+ {visibleGroups.length === 0 ? (
918
+ <p className="px-2 py-6 text-center text-sm text-muted-foreground">
919
+ Sin módulos.
920
+ </p>
921
+ ) : (
922
+ visibleGroups.map((group, gi) => (
923
+ <div
924
+ key={group.title || `__untitled_${gi}`}
925
+ className="flex flex-col gap-0.5"
926
+ >
927
+ {group.title && (
928
+ <div
929
+ role="heading"
930
+ aria-level={3}
931
+ className={cn(
932
+ 'px-2 pb-1 pt-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground',
933
+ gi === 0 && 'pt-1',
934
+ )}
935
+ >
936
+ {group.title}
937
+ </div>
938
+ )}
939
+ {group.modules.map((mod) => (
940
+ <ModuleRow
941
+ key={mod.key}
942
+ module={mod}
943
+ active={mod.key === activeModuleKey}
944
+ granted={
945
+ draft
946
+ ? grantedCountForModule(draft, mod)
947
+ : 0
948
+ }
949
+ total={mod.actions.length}
950
+ onSelect={() => setActiveModuleKey(mod.key)}
951
+ />
788
952
  ))}
789
- </CommandList>
790
- </Command>
791
- </PopoverContent>
792
- </Popover>
953
+ </div>
954
+ ))
955
+ )}
956
+ </div>
793
957
  </CardContent>
794
958
  </Card>
795
959
  </div>
@@ -798,11 +962,30 @@ export function PermissionsManager({
798
962
  <Card>
799
963
  <CardHeader>
800
964
  <div className="flex flex-wrap items-start justify-between gap-2">
801
- <div>
802
- <CardTitle className="text-base">Acciones permitidas</CardTitle>
803
- <CardDescription>Configura los permisos para este módulo.</CardDescription>
965
+ <div className="min-w-0">
966
+ <CardTitle className="flex items-center gap-2 text-base">
967
+ {activeModule && (
968
+ <DynamicIcon
969
+ name={
970
+ activeModule.icon ||
971
+ (activeModule.kind === 'screen' ? 'Eye' : 'Square')
972
+ }
973
+ className="h-4 w-4 shrink-0 text-primary"
974
+ />
975
+ )}
976
+ <span className="truncate">
977
+ {activeModule ? activeModule.label : 'Acciones permitidas'}
978
+ </span>
979
+ </CardTitle>
980
+ <CardDescription>
981
+ {activeModule
982
+ ? `${
983
+ activeModuleGroupTitle || 'Sistema'
984
+ } · configura las acciones permitidas`
985
+ : 'Configura los permisos del módulo seleccionado.'}
986
+ </CardDescription>
804
987
  </div>
805
- {activeModule && (
988
+ {activeRole && activeModule && (
806
989
  <div className="flex items-center gap-2">
807
990
  <Badge variant="secondary" className="tabular-nums">
808
991
  {moduleGranted}/{moduleTotal}
@@ -832,14 +1015,14 @@ export function PermissionsManager({
832
1015
  <CardContent>
833
1016
  {!activeRole ? (
834
1017
  <EmptyHint text="Selecciona un rol para configurar sus permisos." />
835
- ) : !activeModule ? (
836
- <EmptyHint text="Selecciona un módulo para ver sus acciones." />
837
1018
  ) : loadingPerms ? (
838
1019
  <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
839
1020
  {Array.from({ length: 6 }).map((_, i) => (
840
1021
  <Skeleton key={i} className="h-11 w-full" />
841
1022
  ))}
842
1023
  </div>
1024
+ ) : !activeModule ? (
1025
+ <EmptyHint text="Selecciona un módulo de la lista para ver sus acciones." />
843
1026
  ) : (
844
1027
  <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
845
1028
  {activeModule.actions.map((action) => {
@@ -894,7 +1077,9 @@ export function PermissionsManager({
894
1077
  >
895
1078
  <DialogContent className="sm:max-w-md">
896
1079
  <DialogHeader>
897
- <DialogTitle>{roleDialog.mode === 'create' ? 'Nuevo rol' : 'Editar rol'}</DialogTitle>
1080
+ <DialogTitle>
1081
+ {roleDialog.mode === 'create' ? 'Nuevo rol' : 'Editar rol'}
1082
+ </DialogTitle>
898
1083
  </DialogHeader>
899
1084
  <div className="flex flex-col gap-4 py-2">
900
1085
  <div className="flex flex-col gap-2">
@@ -937,22 +1122,32 @@ export function PermissionsManager({
937
1122
  >
938
1123
  Cancelar
939
1124
  </Button>
940
- <Button onClick={handleRoleSubmit} disabled={roleSaving || !roleDialog.label.trim()}>
941
- {roleSaving ? 'Guardando…' : roleDialog.mode === 'create' ? 'Crear rol' : 'Guardar'}
1125
+ <Button
1126
+ onClick={handleRoleSubmit}
1127
+ disabled={roleSaving || !roleDialog.label.trim()}
1128
+ >
1129
+ {roleSaving
1130
+ ? 'Guardando…'
1131
+ : roleDialog.mode === 'create'
1132
+ ? 'Crear rol'
1133
+ : 'Guardar'}
942
1134
  </Button>
943
1135
  </DialogFooter>
944
1136
  </DialogContent>
945
1137
  </Dialog>
946
1138
 
947
1139
  {/* Role delete confirm */}
948
- <AlertDialog open={deleteOpen} onOpenChange={(open: boolean) => !deleting && setDeleteOpen(open)}>
1140
+ <AlertDialog
1141
+ open={deleteOpen}
1142
+ onOpenChange={(open: boolean) => !deleting && setDeleteOpen(open)}
1143
+ >
949
1144
  <AlertDialogContent>
950
1145
  <AlertDialogHeader>
951
1146
  <AlertDialogTitle>¿Eliminar el rol?</AlertDialogTitle>
952
1147
  <AlertDialogDescription>
953
1148
  Se eliminará el rol{' '}
954
- <strong>{activeRole ? activeRole.label || activeRole.name : ''}</strong> y sus
955
- asignaciones de permisos. Esta acción no se puede deshacer.
1149
+ <strong>{activeRole ? activeRole.label || activeRole.name : ''}</strong> y
1150
+ sus asignaciones de permisos. Esta acción no se puede deshacer.
956
1151
  </AlertDialogDescription>
957
1152
  </AlertDialogHeader>
958
1153
  <AlertDialogFooter>