@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.
@@ -5,14 +5,17 @@
5
5
  // The capability universe (modules × actions + general flags) is derived from
6
6
  // the installed manifests server-side; this component only renders it.
7
7
  //
8
- // Layout (reference: 7leguas "Permisos y Roles"):
8
+ // Layout (mirrors the app sidebar so admins recognise what they grant):
9
9
  // 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.
10
+ // left — Card "Rol": clean role combobox with inline Editar/Eliminar
11
+ // icons (no removable chip) + "Permisos Generales" flags.
12
+ // — Card "Módulos": a searchable, accordion *tree* grouped by
13
+ // `addon_label` (modules without one → "Sistema"). Each group
14
+ // lists its modules with their icon + a granted-count badge.
15
+ // Clicking a module selects it and reveals its action grid.
14
16
  // right — Card "Acciones permitidas": granted counter N/M, mark-all /
15
- // clear buttons, checkbox grid (icon + label per action).
17
+ // clear, checkbox grid (icon + label per action). Clear empty
18
+ // states for "pick a role" / "pick a module" / loading.
16
19
  //
17
20
  // Saving calls `syncRolePermissions(roleId, capabilities)` with the FULL
18
21
  // granted set of the active role (baseline + the edits made here). Dirty
@@ -21,15 +24,17 @@
21
24
  import * as React from 'react'
22
25
  import {
23
26
  Check,
27
+ ChevronRight,
24
28
  ChevronsUpDown,
25
29
  CheckCheck,
26
30
  Eraser,
31
+ Folder,
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'
@@ -50,6 +55,9 @@ import {
50
55
  CardHeader,
51
56
  CardTitle,
52
57
  Checkbox,
58
+ Collapsible,
59
+ CollapsibleContent,
60
+ CollapsibleTrigger,
53
61
  Command,
54
62
  CommandEmpty,
55
63
  CommandGroup,
@@ -91,9 +99,11 @@ export interface PermissionModuleDef {
91
99
  key: string
92
100
  /** Localized module label ("Pedidos POS"). */
93
101
  label: string
102
+ /** Module icon (lucide name) — mirrors the sidebar entry. */
103
+ icon?: string
94
104
  /** Owning addon key (`pos`). */
95
105
  addon_key?: string
96
- /** Localized addon label ("Punto de venta") — used to group the selector. */
106
+ /** Localized addon label ("Punto de venta") — used to group the tree. */
97
107
  addon_label?: string
98
108
  actions: PermissionActionDef[]
99
109
  }
@@ -135,7 +145,7 @@ export interface PermissionsManagerProps {
135
145
  loadRolePermissions: (roleId: string) => Promise<string[]>
136
146
  /** Persists the FULL granted capability set of a role. */
137
147
  syncRolePermissions: (roleId: string, capabilities: string[]) => Promise<void>
138
- /** Optional role CRUD — omitting one hides its button. */
148
+ /** Optional role CRUD — omitting one hides its control. */
139
149
  createRole?: (input: RoleInput) => Promise<RoleDef | void>
140
150
  updateRole?: (roleId: string, input: RoleInput) => Promise<RoleDef | void>
141
151
  deleteRole?: (roleId: string) => Promise<void>
@@ -192,11 +202,23 @@ export function defaultActionIcon(actionKey: string, kind?: string): string {
192
202
  }
193
203
  }
194
204
 
195
- function slugify(label: string): string {
196
- return label
205
+ /** Group label fallback when a module has no addon ("Sistema" = core/infra). */
206
+ const SYSTEM_GROUP = 'Sistema'
207
+
208
+ function moduleGroupLabel(mod: PermissionModuleDef): string {
209
+ return mod.addon_label || mod.addon_key || SYSTEM_GROUP
210
+ }
211
+
212
+ /** Accent-insensitive, lowercase fold for tree search. */
213
+ function fold(s: string): string {
214
+ return s
197
215
  .normalize('NFD')
198
- .replace(/[\u0300-\u036f]/g, '')
216
+ .replace(/[̀-ͯ]/g, '')
199
217
  .toLowerCase()
218
+ }
219
+
220
+ function slugify(label: string): string {
221
+ return fold(label)
200
222
  .trim()
201
223
  .replace(/[^a-z0-9]+/g, '_')
202
224
  .replace(/^_+|_+$/g, '')
@@ -214,6 +236,43 @@ const ROLE_COLORS = [
214
236
  '#6b7280',
215
237
  ]
216
238
 
239
+ /** [groupLabel, modules] buckets in stable insertion order. */
240
+ export interface ModuleGroup {
241
+ label: string
242
+ modules: PermissionModuleDef[]
243
+ }
244
+
245
+ export function groupModules(modules: PermissionModuleDef[]): ModuleGroup[] {
246
+ const order: string[] = []
247
+ const byGroup = new Map<string, PermissionModuleDef[]>()
248
+ for (const mod of modules) {
249
+ const g = moduleGroupLabel(mod)
250
+ if (!byGroup.has(g)) {
251
+ byGroup.set(g, [])
252
+ order.push(g)
253
+ }
254
+ byGroup.get(g)!.push(mod)
255
+ }
256
+ return order.map((label) => ({ label, modules: byGroup.get(label)! }))
257
+ }
258
+
259
+ /** Filter the grouped tree by a folded query against module + group labels. */
260
+ export function filterModuleGroups(groups: ModuleGroup[], query: string): ModuleGroup[] {
261
+ const q = fold(query).trim()
262
+ if (!q) return groups
263
+ const out: ModuleGroup[] = []
264
+ for (const g of groups) {
265
+ const groupMatches = fold(g.label).includes(q)
266
+ const mods = groupMatches
267
+ ? g.modules
268
+ : g.modules.filter(
269
+ (m) => fold(m.label).includes(q) || fold(m.key).includes(q),
270
+ )
271
+ if (mods.length) out.push({ label: g.label, modules: mods })
272
+ }
273
+ return out
274
+ }
275
+
217
276
  // ---------------------------------------------------------------------------
218
277
  // Internal sub-components
219
278
  // ---------------------------------------------------------------------------
@@ -276,37 +335,46 @@ function CapabilityCheck({
276
335
  )
277
336
  }
278
337
 
279
- /** Removable selection chip (role / module). */
280
- function SelectionChip({
281
- label,
282
- color,
283
- onRemove,
284
- removeAriaLabel,
338
+ /** One module row inside the tree. */
339
+ function ModuleTreeItem({
340
+ module,
341
+ active,
342
+ granted,
343
+ total,
344
+ onSelect,
285
345
  }: {
286
- label: string
287
- color?: string
288
- onRemove: () => void
289
- removeAriaLabel: string
346
+ module: PermissionModuleDef
347
+ active: boolean
348
+ granted: number
349
+ total: number
350
+ onSelect: () => void
290
351
  }) {
291
352
  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
- />
353
+ <button
354
+ type="button"
355
+ onClick={onSelect}
356
+ aria-current={active || undefined}
357
+ className={cn(
358
+ 'group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
359
+ active
360
+ ? 'bg-primary/10 font-medium text-foreground'
361
+ : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
299
362
  )}
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>
363
+ >
364
+ <DynamicIcon
365
+ name={module.icon || 'Square'}
366
+ className={cn('h-4 w-4 shrink-0', active ? 'text-primary' : 'text-muted-foreground')}
367
+ />
368
+ <span className="min-w-0 flex-1 truncate">{module.label}</span>
369
+ {granted > 0 && (
370
+ <Badge
371
+ variant={granted === total ? 'default' : 'secondary'}
372
+ className="h-5 shrink-0 px-1.5 text-[10px] tabular-nums"
373
+ >
374
+ {granted}/{total}
375
+ </Badge>
376
+ )}
377
+ </button>
310
378
  )
311
379
  }
312
380
 
@@ -339,7 +407,9 @@ export function PermissionsManager({
339
407
  const [saving, setSaving] = React.useState(false)
340
408
 
341
409
  const [roleOpen, setRoleOpen] = React.useState(false)
342
- const [moduleOpen, setModuleOpen] = React.useState(false)
410
+ const [moduleQuery, setModuleQuery] = React.useState('')
411
+ // Groups the user explicitly collapsed (default: every group open).
412
+ const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set())
343
413
 
344
414
  // Pending role switch while there are unsaved changes.
345
415
  const [pendingRoleId, setPendingRoleId] = React.useState<string | null>(null)
@@ -417,17 +487,13 @@ export function PermissionsManager({
417
487
 
418
488
  const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft)
419
489
 
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])
490
+ // Module tree: grouped by addon label, optionally filtered by the search.
491
+ const allGroups = React.useMemo(() => groupModules(catalog?.modules ?? []), [catalog])
492
+ const visibleGroups = React.useMemo(
493
+ () => filterModuleGroups(allGroups, moduleQuery),
494
+ [allGroups, moduleQuery],
495
+ )
496
+ const searching = moduleQuery.trim().length > 0
431
497
 
432
498
  // ---- capability edits ---------------------------------------------------
433
499
  const toggleCapability = React.useCallback((cap: string) => {
@@ -478,6 +544,14 @@ export function PermissionsManager({
478
544
  else setActiveRoleId(roleId)
479
545
  }
480
546
 
547
+ const toggleGroup = (label: string) =>
548
+ setCollapsedGroups((prev) => {
549
+ const next = new Set(prev)
550
+ if (next.has(label)) next.delete(label)
551
+ else next.add(label)
552
+ return next
553
+ })
554
+
481
555
  // ---- role CRUD -----------------------------------------------------------
482
556
  const refreshRoles = async (selectId?: string | null) => {
483
557
  const rs = await loadRoles()
@@ -518,7 +592,9 @@ export function PermissionsManager({
518
592
  }
519
593
  setRoleDialog((d) => ({ ...d, open: false }))
520
594
  } catch {
521
- toast.error(roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol')
595
+ toast.error(
596
+ roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol',
597
+ )
522
598
  } finally {
523
599
  setRoleSaving(false)
524
600
  }
@@ -541,6 +617,16 @@ export function PermissionsManager({
541
617
  }
542
618
  }
543
619
 
620
+ const openEditRole = () => {
621
+ if (!activeRole) return
622
+ setRoleDialog({
623
+ open: true,
624
+ mode: 'edit',
625
+ label: activeRole.label || activeRole.name,
626
+ color: activeRole.color || ROLE_COLORS[5],
627
+ })
628
+ }
629
+
544
630
  // ---- derived for the right panel ----------------------------------------
545
631
  const moduleGranted = activeModule && draft ? grantedCountForModule(draft, activeModule) : 0
546
632
  const moduleTotal = activeModule?.actions.length ?? 0
@@ -549,7 +635,12 @@ export function PermissionsManager({
549
635
  // ---- render --------------------------------------------------------------
550
636
  if (loadError) {
551
637
  return (
552
- <div className={cn('flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground', className)}>
638
+ <div
639
+ className={cn(
640
+ 'flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground',
641
+ className,
642
+ )}
643
+ >
553
644
  <Shield className="h-8 w-8 opacity-40" />
554
645
  <p className="text-sm">No se pudo cargar el catálogo de permisos.</p>
555
646
  </div>
@@ -568,8 +659,8 @@ export function PermissionsManager({
568
659
  </div>
569
660
  <div className="grid gap-4 lg:grid-cols-[340px_1fr]">
570
661
  <div className="flex flex-col gap-4">
571
- <Skeleton className="h-64 w-full" />
572
- <Skeleton className="h-28 w-full" />
662
+ <Skeleton className="h-40 w-full" />
663
+ <Skeleton className="h-80 w-full" />
573
664
  </div>
574
665
  <Skeleton className="h-96 w-full" />
575
666
  </div>
@@ -596,7 +687,12 @@ export function PermissionsManager({
596
687
  {createRole && (
597
688
  <Button
598
689
  onClick={() =>
599
- setRoleDialog({ open: true, mode: 'create', label: '', color: ROLE_COLORS[5] })
690
+ setRoleDialog({
691
+ open: true,
692
+ mode: 'create',
693
+ label: '',
694
+ color: ROLE_COLORS[5],
695
+ })
600
696
  }
601
697
  >
602
698
  <Plus className="mr-1.5 h-4 w-4" /> Nuevo rol
@@ -623,93 +719,95 @@ export function PermissionsManager({
623
719
  <CardDescription>Selecciona el rol a configurar.</CardDescription>
624
720
  </CardHeader>
625
721
  <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>
722
+ {/* Clean role combobox with inline edit/delete. */}
723
+ <div className="flex items-center gap-1.5">
724
+ <Popover open={roleOpen} onOpenChange={setRoleOpen}>
725
+ <PopoverTrigger asChild>
726
+ <Button
727
+ variant="outline"
728
+ role="combobox"
729
+ aria-expanded={roleOpen}
730
+ className="min-w-0 flex-1 justify-between font-normal"
731
+ >
732
+ <span className="flex min-w-0 items-center gap-2">
733
+ {activeRole && (
734
+ <span
735
+ className="h-2.5 w-2.5 shrink-0 rounded-full"
736
+ style={{
737
+ background: activeRole.color || '#6b7280',
738
+ }}
739
+ aria-hidden="true"
740
+ />
741
+ )}
742
+ <span className="truncate">
743
+ {activeRole
744
+ ? activeRole.label || activeRole.name
745
+ : 'Seleccionar rol…'}
746
+ </span>
747
+ </span>
748
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
749
+ </Button>
750
+ </PopoverTrigger>
751
+ <PopoverContent className="w-[280px] p-0" align="start">
752
+ <Command>
753
+ <CommandInput placeholder="Buscar rol…" />
754
+ <CommandList>
755
+ <CommandEmpty>Sin resultados.</CommandEmpty>
756
+ <CommandGroup>
757
+ {(roles ?? []).map((role) => (
758
+ <CommandItem
759
+ key={role.id}
760
+ value={`${role.label || ''} ${role.name}`}
761
+ onSelect={() => {
762
+ requestRoleSwitch(role.id)
763
+ setRoleOpen(false)
764
+ }}
765
+ >
766
+ <span
767
+ className="mr-2 h-2 w-2 shrink-0 rounded-full"
768
+ style={{
769
+ background: role.color || '#6b7280',
770
+ }}
771
+ aria-hidden="true"
772
+ />
773
+ <span className="truncate">
774
+ {role.label || role.name}
775
+ </span>
776
+ {role.id === activeRoleId && (
777
+ <Check className="ml-auto h-4 w-4" />
778
+ )}
779
+ </CommandItem>
780
+ ))}
781
+ </CommandGroup>
782
+ </CommandList>
783
+ </Command>
784
+ </PopoverContent>
785
+ </Popover>
786
+ {updateRole && (
672
787
  <Button
673
788
  variant="outline"
674
- role="combobox"
675
- aria-expanded={roleOpen}
676
- className="w-full justify-between font-normal"
789
+ size="icon"
790
+ className="h-9 w-9 shrink-0"
791
+ aria-label="Editar rol"
792
+ disabled={!activeRole}
793
+ onClick={openEditRole}
677
794
  >
678
- {activeRole ? activeRole.label || activeRole.name : 'Seleccionar rol…'}
679
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
795
+ <Pencil className="h-4 w-4" />
680
796
  </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>
797
+ )}
798
+ {deleteRole && (
799
+ <Button
800
+ variant="outline"
801
+ size="icon"
802
+ className="h-9 w-9 shrink-0 text-destructive hover:text-destructive"
803
+ aria-label="Eliminar rol"
804
+ disabled={!activeRole}
805
+ onClick={() => setDeleteOpen(true)}
806
+ >
807
+ <Trash2 className="h-4 w-4" />
808
+ </Button>
809
+ )}
810
+ </div>
713
811
 
714
812
  {(catalog?.general.length ?? 0) > 0 && (
715
813
  <>
@@ -734,62 +832,97 @@ export function PermissionsManager({
734
832
  </CardContent>
735
833
  </Card>
736
834
 
737
- {/* Card: Módulo */}
835
+ {/* Card: Módulos (hierarchical tree, mirrors the sidebar) */}
738
836
  <Card>
739
837
  <CardHeader>
740
- <CardTitle className="text-base">Módulo</CardTitle>
741
- <CardDescription>Elige el módulo cuyas acciones quieres configurar.</CardDescription>
838
+ <CardTitle className="text-base">Módulos</CardTitle>
839
+ <CardDescription>
840
+ Elige el módulo cuyas acciones quieres configurar.
841
+ </CardDescription>
742
842
  </CardHeader>
743
843
  <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"
844
+ <div className="relative">
845
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
846
+ <Input
847
+ value={moduleQuery}
848
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
849
+ setModuleQuery(e.target.value)
850
+ }
851
+ placeholder="Buscar módulo…"
852
+ aria-label="Buscar módulo"
853
+ className="pl-8"
749
854
  />
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" />
855
+ </div>
856
+
857
+ <div
858
+ role="tree"
859
+ aria-label="Módulos"
860
+ className="-mx-1 max-h-[460px] overflow-y-auto px-1"
861
+ >
862
+ {visibleGroups.length === 0 ? (
863
+ <p className="px-2 py-6 text-center text-sm text-muted-foreground">
864
+ Sin módulos.
865
+ </p>
866
+ ) : (
867
+ visibleGroups.map((group) => {
868
+ // While searching, force every matching group open.
869
+ const open = searching || !collapsedGroups.has(group.label)
870
+ return (
871
+ <Collapsible
872
+ key={group.label}
873
+ open={open}
874
+ onOpenChange={() =>
875
+ !searching && toggleGroup(group.label)
876
+ }
877
+ >
878
+ <CollapsibleTrigger asChild>
879
+ <button
880
+ type="button"
881
+ 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"
882
+ >
883
+ <ChevronRight
884
+ className={cn(
885
+ 'h-3.5 w-3.5 shrink-0 transition-transform',
886
+ open && 'rotate-90',
784
887
  )}
785
- </CommandItem>
786
- ))}
787
- </CommandGroup>
788
- ))}
789
- </CommandList>
790
- </Command>
791
- </PopoverContent>
792
- </Popover>
888
+ />
889
+ <Folder className="h-3.5 w-3.5 shrink-0" />
890
+ <span className="min-w-0 flex-1 truncate normal-case">
891
+ {group.label}
892
+ </span>
893
+ <span className="shrink-0 text-[10px] tabular-nums opacity-70">
894
+ {group.modules.length}
895
+ </span>
896
+ </button>
897
+ </CollapsibleTrigger>
898
+ <CollapsibleContent>
899
+ <div className="ml-3 flex flex-col gap-0.5 border-l border-border/60 pl-1.5">
900
+ {group.modules.map((mod) => (
901
+ <ModuleTreeItem
902
+ key={mod.key}
903
+ module={mod}
904
+ active={mod.key === activeModuleKey}
905
+ granted={
906
+ draft
907
+ ? grantedCountForModule(
908
+ draft,
909
+ mod,
910
+ )
911
+ : 0
912
+ }
913
+ total={mod.actions.length}
914
+ onSelect={() =>
915
+ setActiveModuleKey(mod.key)
916
+ }
917
+ />
918
+ ))}
919
+ </div>
920
+ </CollapsibleContent>
921
+ </Collapsible>
922
+ )
923
+ })
924
+ )}
925
+ </div>
793
926
  </CardContent>
794
927
  </Card>
795
928
  </div>
@@ -798,11 +931,25 @@ export function PermissionsManager({
798
931
  <Card>
799
932
  <CardHeader>
800
933
  <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>
934
+ <div className="min-w-0">
935
+ <CardTitle className="flex items-center gap-2 text-base">
936
+ {activeModule && (
937
+ <DynamicIcon
938
+ name={activeModule.icon || 'Square'}
939
+ className="h-4 w-4 shrink-0 text-primary"
940
+ />
941
+ )}
942
+ <span className="truncate">
943
+ {activeModule ? activeModule.label : 'Acciones permitidas'}
944
+ </span>
945
+ </CardTitle>
946
+ <CardDescription>
947
+ {activeModule
948
+ ? `${moduleGroupLabel(activeModule)} · configura las acciones permitidas`
949
+ : 'Configura los permisos del módulo seleccionado.'}
950
+ </CardDescription>
804
951
  </div>
805
- {activeModule && (
952
+ {activeRole && activeModule && (
806
953
  <div className="flex items-center gap-2">
807
954
  <Badge variant="secondary" className="tabular-nums">
808
955
  {moduleGranted}/{moduleTotal}
@@ -832,14 +979,14 @@ export function PermissionsManager({
832
979
  <CardContent>
833
980
  {!activeRole ? (
834
981
  <EmptyHint text="Selecciona un rol para configurar sus permisos." />
835
- ) : !activeModule ? (
836
- <EmptyHint text="Selecciona un módulo para ver sus acciones." />
837
982
  ) : loadingPerms ? (
838
983
  <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
839
984
  {Array.from({ length: 6 }).map((_, i) => (
840
985
  <Skeleton key={i} className="h-11 w-full" />
841
986
  ))}
842
987
  </div>
988
+ ) : !activeModule ? (
989
+ <EmptyHint text="Selecciona un módulo del árbol para ver sus acciones." />
843
990
  ) : (
844
991
  <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
845
992
  {activeModule.actions.map((action) => {
@@ -894,7 +1041,9 @@ export function PermissionsManager({
894
1041
  >
895
1042
  <DialogContent className="sm:max-w-md">
896
1043
  <DialogHeader>
897
- <DialogTitle>{roleDialog.mode === 'create' ? 'Nuevo rol' : 'Editar rol'}</DialogTitle>
1044
+ <DialogTitle>
1045
+ {roleDialog.mode === 'create' ? 'Nuevo rol' : 'Editar rol'}
1046
+ </DialogTitle>
898
1047
  </DialogHeader>
899
1048
  <div className="flex flex-col gap-4 py-2">
900
1049
  <div className="flex flex-col gap-2">
@@ -937,22 +1086,32 @@ export function PermissionsManager({
937
1086
  >
938
1087
  Cancelar
939
1088
  </Button>
940
- <Button onClick={handleRoleSubmit} disabled={roleSaving || !roleDialog.label.trim()}>
941
- {roleSaving ? 'Guardando…' : roleDialog.mode === 'create' ? 'Crear rol' : 'Guardar'}
1089
+ <Button
1090
+ onClick={handleRoleSubmit}
1091
+ disabled={roleSaving || !roleDialog.label.trim()}
1092
+ >
1093
+ {roleSaving
1094
+ ? 'Guardando…'
1095
+ : roleDialog.mode === 'create'
1096
+ ? 'Crear rol'
1097
+ : 'Guardar'}
942
1098
  </Button>
943
1099
  </DialogFooter>
944
1100
  </DialogContent>
945
1101
  </Dialog>
946
1102
 
947
1103
  {/* Role delete confirm */}
948
- <AlertDialog open={deleteOpen} onOpenChange={(open: boolean) => !deleting && setDeleteOpen(open)}>
1104
+ <AlertDialog
1105
+ open={deleteOpen}
1106
+ onOpenChange={(open: boolean) => !deleting && setDeleteOpen(open)}
1107
+ >
949
1108
  <AlertDialogContent>
950
1109
  <AlertDialogHeader>
951
1110
  <AlertDialogTitle>¿Eliminar el rol?</AlertDialogTitle>
952
1111
  <AlertDialogDescription>
953
1112
  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.
1113
+ <strong>{activeRole ? activeRole.label || activeRole.name : ''}</strong> y
1114
+ sus asignaciones de permisos. Esta acción no se puede deshacer.
956
1115
  </AlertDialogDescription>
957
1116
  </AlertDialogHeader>
958
1117
  <AlertDialogFooter>