@asteby/metacore-runtime-react 18.13.3 → 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.
@@ -0,0 +1,1143 @@
1
+ // permissions-manager — "Permisos y Roles" pro view (rol × módulo × acción).
2
+ //
3
+ // Transport-agnostic: every read/write arrives via props (loaders/mutators),
4
+ // so each host wires them to its own api client (ops → /api/permissions/*).
5
+ // The capability universe (modules × actions + general flags) is derived from
6
+ // the installed manifests server-side; this component only renders it.
7
+ //
8
+ // Layout (mirrors the app sidebar so admins recognise what they grant):
9
+ // header — title + "Nuevo rol" (primary) + "Guardar permisos" (green).
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.
16
+ // right — Card "Acciones permitidas": granted counter N/M, mark-all /
17
+ // clear, checkbox grid (icon + label per action). Clear empty
18
+ // states for "pick a role" / "pick a module" / loading.
19
+ //
20
+ // Saving calls `syncRolePermissions(roleId, capabilities)` with the FULL
21
+ // granted set of the active role (baseline + the edits made here). Dirty
22
+ // state is tracked against the loaded baseline and surfaced next to the
23
+ // save button.
24
+ import * as React from 'react'
25
+ import {
26
+ Check,
27
+ ChevronRight,
28
+ ChevronsUpDown,
29
+ CheckCheck,
30
+ Eraser,
31
+ Folder,
32
+ Pencil,
33
+ Plus,
34
+ Save,
35
+ Search,
36
+ Shield,
37
+ Trash2,
38
+ } from 'lucide-react'
39
+ import { toast } from 'sonner'
40
+ import { cn } from '@asteby/metacore-ui/lib'
41
+ import {
42
+ AlertDialog,
43
+ AlertDialogAction,
44
+ AlertDialogCancel,
45
+ AlertDialogContent,
46
+ AlertDialogDescription,
47
+ AlertDialogFooter,
48
+ AlertDialogHeader,
49
+ AlertDialogTitle,
50
+ Badge,
51
+ Button,
52
+ Card,
53
+ CardContent,
54
+ CardDescription,
55
+ CardHeader,
56
+ CardTitle,
57
+ Checkbox,
58
+ Collapsible,
59
+ CollapsibleContent,
60
+ CollapsibleTrigger,
61
+ Command,
62
+ CommandEmpty,
63
+ CommandGroup,
64
+ CommandInput,
65
+ CommandItem,
66
+ CommandList,
67
+ Dialog,
68
+ DialogContent,
69
+ DialogFooter,
70
+ DialogHeader,
71
+ DialogTitle,
72
+ Input,
73
+ Label,
74
+ Popover,
75
+ PopoverContent,
76
+ PopoverTrigger,
77
+ Separator,
78
+ Skeleton,
79
+ } from '@asteby/metacore-ui/primitives'
80
+ import { DynamicIcon } from './dynamic-icon'
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Types (mirror of `GET /api/permissions/modules` / `/api/permissions/roles`)
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export interface PermissionActionDef {
87
+ /** Canonical action key (`index`, `create`, …, or a custom key like `pagar`). */
88
+ key: string
89
+ /** Localized label ("Listar", "Pagar"). */
90
+ label: string
91
+ /** Lucide icon name from the manifest action (optional). */
92
+ icon?: string
93
+ /** `crud` for the derived CRUD set, `custom` for manifest actions. */
94
+ kind?: 'crud' | 'custom' | string
95
+ }
96
+
97
+ export interface PermissionModuleDef {
98
+ /** Module key = lowercase model table (`pos_orders`). */
99
+ key: string
100
+ /** Localized module label ("Pedidos POS"). */
101
+ label: string
102
+ /** Module icon (lucide name) — mirrors the sidebar entry. */
103
+ icon?: string
104
+ /** Owning addon key (`pos`). */
105
+ addon_key?: string
106
+ /** Localized addon label ("Punto de venta") — used to group the tree. */
107
+ addon_label?: string
108
+ actions: PermissionActionDef[]
109
+ }
110
+
111
+ export interface GeneralPermissionDef {
112
+ /** Full capability key (`general.work_after_hours`). */
113
+ key: string
114
+ label: string
115
+ description?: string
116
+ }
117
+
118
+ export interface PermissionsCatalog {
119
+ modules: PermissionModuleDef[]
120
+ general: GeneralPermissionDef[]
121
+ }
122
+
123
+ export interface RoleDef {
124
+ id: string
125
+ /** Stable role key ("cashier"). */
126
+ name: string
127
+ /** Human label ("Cajero"). Falls back to `name` when omitted. */
128
+ label?: string
129
+ /** Accent color (hex) for the role chip. */
130
+ color?: string
131
+ }
132
+
133
+ export interface RoleInput {
134
+ name: string
135
+ label?: string
136
+ color?: string
137
+ }
138
+
139
+ export interface PermissionsManagerProps {
140
+ /** Loads the module×action universe + general flags. */
141
+ loadModules: () => Promise<PermissionsCatalog>
142
+ /** Loads every assignable role. */
143
+ loadRoles: () => Promise<RoleDef[]>
144
+ /** Loads the capabilities currently granted to a role. */
145
+ loadRolePermissions: (roleId: string) => Promise<string[]>
146
+ /** Persists the FULL granted capability set of a role. */
147
+ syncRolePermissions: (roleId: string, capabilities: string[]) => Promise<void>
148
+ /** Optional role CRUD — omitting one hides its control. */
149
+ createRole?: (input: RoleInput) => Promise<RoleDef | void>
150
+ updateRole?: (roleId: string, input: RoleInput) => Promise<RoleDef | void>
151
+ deleteRole?: (roleId: string) => Promise<void>
152
+ /** Page heading. Defaults to "Permisos y Roles". */
153
+ title?: string
154
+ className?: string
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Pure helpers (exported for hosts/tests)
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /** Capability for a catalog module action: `lowercase(moduleKey).actionKey`. */
162
+ export function moduleActionCapability(moduleKey: string, actionKey: string): string {
163
+ return `${moduleKey.toLowerCase()}.${actionKey}`
164
+ }
165
+
166
+ /** All capabilities of one module. */
167
+ export function moduleCapabilities(module: PermissionModuleDef): string[] {
168
+ return module.actions.map((a) => moduleActionCapability(module.key, a.key))
169
+ }
170
+
171
+ /** How many of the module's capabilities are in the granted set. */
172
+ export function grantedCountForModule(
173
+ granted: ReadonlySet<string>,
174
+ module: PermissionModuleDef,
175
+ ): number {
176
+ return moduleCapabilities(module).filter((c) => granted.has(c)).length
177
+ }
178
+
179
+ export function capabilitySetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
180
+ if (a.size !== b.size) return false
181
+ for (const v of a) if (!b.has(v)) return false
182
+ return true
183
+ }
184
+
185
+ /** Default lucide icon when the manifest action doesn't declare one. */
186
+ export function defaultActionIcon(actionKey: string, kind?: string): string {
187
+ switch (actionKey) {
188
+ case 'index':
189
+ return 'List'
190
+ case 'create':
191
+ return 'Plus'
192
+ case 'update':
193
+ return 'Pencil'
194
+ case 'delete':
195
+ return 'Trash2'
196
+ case 'export':
197
+ return 'Download'
198
+ case 'import':
199
+ return 'Upload'
200
+ default:
201
+ return kind === 'crud' ? 'List' : 'Zap'
202
+ }
203
+ }
204
+
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
215
+ .normalize('NFD')
216
+ .replace(/[̀-ͯ]/g, '')
217
+ .toLowerCase()
218
+ }
219
+
220
+ function slugify(label: string): string {
221
+ return fold(label)
222
+ .trim()
223
+ .replace(/[^a-z0-9]+/g, '_')
224
+ .replace(/^_+|_+$/g, '')
225
+ }
226
+
227
+ const ROLE_COLORS = [
228
+ '#ef4444',
229
+ '#f97316',
230
+ '#eab308',
231
+ '#22c55e',
232
+ '#06b6d4',
233
+ '#3b82f6',
234
+ '#8b5cf6',
235
+ '#ec4899',
236
+ '#6b7280',
237
+ ]
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
+
276
+ // ---------------------------------------------------------------------------
277
+ // Internal sub-components
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /** Checkbox row used by both the action grid and the general flags. */
281
+ function CapabilityCheck({
282
+ checked,
283
+ disabled,
284
+ onToggle,
285
+ icon,
286
+ label,
287
+ description,
288
+ }: {
289
+ checked: boolean
290
+ disabled?: boolean
291
+ onToggle: () => void
292
+ icon?: string
293
+ label: string
294
+ description?: string
295
+ }) {
296
+ return (
297
+ <div
298
+ role="checkbox"
299
+ aria-checked={checked}
300
+ aria-disabled={disabled || undefined}
301
+ tabIndex={disabled ? -1 : 0}
302
+ onClick={disabled ? undefined : onToggle}
303
+ onKeyDown={
304
+ disabled
305
+ ? undefined
306
+ : (e: React.KeyboardEvent) => {
307
+ if (e.key === ' ' || e.key === 'Enter') {
308
+ e.preventDefault()
309
+ onToggle()
310
+ }
311
+ }
312
+ }
313
+ className={cn(
314
+ 'flex items-start gap-2.5 rounded-md border border-border/60 bg-card px-3 py-2.5 text-sm transition-colors',
315
+ disabled ? 'opacity-50' : 'cursor-pointer hover:bg-muted/40',
316
+ checked && 'border-primary/40 bg-primary/5',
317
+ )}
318
+ >
319
+ <Checkbox
320
+ checked={checked}
321
+ aria-hidden="true"
322
+ tabIndex={-1}
323
+ className="pointer-events-none mt-0.5 shrink-0"
324
+ />
325
+ {icon && (
326
+ <DynamicIcon name={icon} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
327
+ )}
328
+ <span className="min-w-0">
329
+ <span className="block truncate font-medium text-foreground">{label}</span>
330
+ {description && (
331
+ <span className="mt-0.5 block text-xs text-muted-foreground">{description}</span>
332
+ )}
333
+ </span>
334
+ </div>
335
+ )
336
+ }
337
+
338
+ /** One module row inside the tree. */
339
+ function ModuleTreeItem({
340
+ module,
341
+ active,
342
+ granted,
343
+ total,
344
+ onSelect,
345
+ }: {
346
+ module: PermissionModuleDef
347
+ active: boolean
348
+ granted: number
349
+ total: number
350
+ onSelect: () => void
351
+ }) {
352
+ return (
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',
362
+ )}
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>
378
+ )
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Component
383
+ // ---------------------------------------------------------------------------
384
+
385
+ export function PermissionsManager({
386
+ loadModules,
387
+ loadRoles,
388
+ loadRolePermissions,
389
+ syncRolePermissions,
390
+ createRole,
391
+ updateRole,
392
+ deleteRole,
393
+ title = 'Permisos y Roles',
394
+ className,
395
+ }: PermissionsManagerProps) {
396
+ const [catalog, setCatalog] = React.useState<PermissionsCatalog | null>(null)
397
+ const [roles, setRoles] = React.useState<RoleDef[] | null>(null)
398
+ const [loadError, setLoadError] = React.useState(false)
399
+
400
+ const [activeRoleId, setActiveRoleId] = React.useState<string | null>(null)
401
+ const [activeModuleKey, setActiveModuleKey] = React.useState<string | null>(null)
402
+
403
+ // baseline = capabilities as persisted; draft = baseline + local edits.
404
+ const [baseline, setBaseline] = React.useState<Set<string> | null>(null)
405
+ const [draft, setDraft] = React.useState<Set<string> | null>(null)
406
+ const [loadingPerms, setLoadingPerms] = React.useState(false)
407
+ const [saving, setSaving] = React.useState(false)
408
+
409
+ const [roleOpen, setRoleOpen] = 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())
413
+
414
+ // Pending role switch while there are unsaved changes.
415
+ const [pendingRoleId, setPendingRoleId] = React.useState<string | null>(null)
416
+
417
+ const [roleDialog, setRoleDialog] = React.useState<{
418
+ open: boolean
419
+ mode: 'create' | 'edit'
420
+ label: string
421
+ color: string
422
+ }>({ open: false, mode: 'create', label: '', color: ROLE_COLORS[5] })
423
+ const [roleSaving, setRoleSaving] = React.useState(false)
424
+ const [deleteOpen, setDeleteOpen] = React.useState(false)
425
+ const [deleting, setDeleting] = React.useState(false)
426
+
427
+ const loading = catalog === null || roles === null
428
+
429
+ // ---- initial load: catalog + roles in parallel -------------------------
430
+ React.useEffect(() => {
431
+ let cancelled = false
432
+ Promise.all([loadModules(), loadRoles()])
433
+ .then(([cat, rs]) => {
434
+ if (cancelled) return
435
+ setCatalog(cat)
436
+ setRoles(rs)
437
+ setActiveRoleId((prev) => prev ?? rs[0]?.id ?? null)
438
+ setActiveModuleKey((prev) => prev ?? cat.modules[0]?.key ?? null)
439
+ })
440
+ .catch(() => {
441
+ if (!cancelled) setLoadError(true)
442
+ })
443
+ return () => {
444
+ cancelled = true
445
+ }
446
+ // eslint-disable-next-line react-hooks/exhaustive-deps
447
+ }, [])
448
+
449
+ // ---- per-role permissions ----------------------------------------------
450
+ React.useEffect(() => {
451
+ if (!activeRoleId) {
452
+ setBaseline(null)
453
+ setDraft(null)
454
+ return
455
+ }
456
+ let cancelled = false
457
+ setLoadingPerms(true)
458
+ loadRolePermissions(activeRoleId)
459
+ .then((caps) => {
460
+ if (cancelled) return
461
+ setBaseline(new Set(caps))
462
+ setDraft(new Set(caps))
463
+ })
464
+ .catch(() => {
465
+ if (cancelled) return
466
+ toast.error('No se pudieron cargar los permisos del rol')
467
+ setBaseline(null)
468
+ setDraft(null)
469
+ })
470
+ .finally(() => {
471
+ if (!cancelled) setLoadingPerms(false)
472
+ })
473
+ return () => {
474
+ cancelled = true
475
+ }
476
+ // eslint-disable-next-line react-hooks/exhaustive-deps
477
+ }, [activeRoleId])
478
+
479
+ const activeRole = React.useMemo(
480
+ () => roles?.find((r) => r.id === activeRoleId) ?? null,
481
+ [roles, activeRoleId],
482
+ )
483
+ const activeModule = React.useMemo(
484
+ () => catalog?.modules.find((m) => m.key === activeModuleKey) ?? null,
485
+ [catalog, activeModuleKey],
486
+ )
487
+
488
+ const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft)
489
+
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
497
+
498
+ // ---- capability edits ---------------------------------------------------
499
+ const toggleCapability = React.useCallback((cap: string) => {
500
+ setDraft((prev) => {
501
+ if (!prev) return prev
502
+ const next = new Set(prev)
503
+ if (next.has(cap)) next.delete(cap)
504
+ else next.add(cap)
505
+ return next
506
+ })
507
+ }, [])
508
+
509
+ const setModuleAll = React.useCallback(
510
+ (on: boolean) => {
511
+ if (!activeModule) return
512
+ const caps = moduleCapabilities(activeModule)
513
+ setDraft((prev) => {
514
+ if (!prev) return prev
515
+ const next = new Set(prev)
516
+ for (const c of caps) {
517
+ if (on) next.add(c)
518
+ else next.delete(c)
519
+ }
520
+ return next
521
+ })
522
+ },
523
+ [activeModule],
524
+ )
525
+
526
+ const handleSave = async () => {
527
+ if (!activeRoleId || !draft) return
528
+ setSaving(true)
529
+ try {
530
+ await syncRolePermissions(activeRoleId, Array.from(draft).sort())
531
+ setBaseline(new Set(draft))
532
+ toast.success('Permisos guardados')
533
+ } catch {
534
+ toast.error('No se pudieron guardar los permisos')
535
+ } finally {
536
+ setSaving(false)
537
+ }
538
+ }
539
+
540
+ // ---- role switching (dirty guard) ---------------------------------------
541
+ const requestRoleSwitch = (roleId: string | null) => {
542
+ if (roleId === activeRoleId) return
543
+ if (dirty) setPendingRoleId(roleId)
544
+ else setActiveRoleId(roleId)
545
+ }
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
+
555
+ // ---- role CRUD -----------------------------------------------------------
556
+ const refreshRoles = async (selectId?: string | null) => {
557
+ const rs = await loadRoles()
558
+ setRoles(rs)
559
+ if (selectId !== undefined) setActiveRoleId(selectId)
560
+ else if (activeRoleId && !rs.some((r) => r.id === activeRoleId))
561
+ setActiveRoleId(rs[0]?.id ?? null)
562
+ return rs
563
+ }
564
+
565
+ const handleRoleSubmit = async () => {
566
+ const label = roleDialog.label.trim()
567
+ if (!label) return
568
+ setRoleSaving(true)
569
+ try {
570
+ if (roleDialog.mode === 'create' && createRole) {
571
+ const created = await createRole({
572
+ name: slugify(label),
573
+ label,
574
+ color: roleDialog.color,
575
+ })
576
+ const rs = await loadRoles()
577
+ setRoles(rs)
578
+ const createdId =
579
+ (created && 'id' in created && created.id) ||
580
+ rs.find((r) => r.name === slugify(label))?.id ||
581
+ null
582
+ if (createdId) setActiveRoleId(createdId)
583
+ toast.success('Rol creado')
584
+ } else if (roleDialog.mode === 'edit' && updateRole && activeRole) {
585
+ await updateRole(activeRole.id, {
586
+ name: activeRole.name,
587
+ label,
588
+ color: roleDialog.color,
589
+ })
590
+ await refreshRoles(activeRole.id)
591
+ toast.success('Rol actualizado')
592
+ }
593
+ setRoleDialog((d) => ({ ...d, open: false }))
594
+ } catch {
595
+ toast.error(
596
+ roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol',
597
+ )
598
+ } finally {
599
+ setRoleSaving(false)
600
+ }
601
+ }
602
+
603
+ const handleDeleteRole = async () => {
604
+ if (!deleteRole || !activeRole) return
605
+ setDeleting(true)
606
+ try {
607
+ await deleteRole(activeRole.id)
608
+ const rs = await loadRoles()
609
+ setRoles(rs)
610
+ setActiveRoleId(rs[0]?.id ?? null)
611
+ toast.success('Rol eliminado')
612
+ setDeleteOpen(false)
613
+ } catch {
614
+ toast.error('No se pudo eliminar el rol')
615
+ } finally {
616
+ setDeleting(false)
617
+ }
618
+ }
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
+
630
+ // ---- derived for the right panel ----------------------------------------
631
+ const moduleGranted = activeModule && draft ? grantedCountForModule(draft, activeModule) : 0
632
+ const moduleTotal = activeModule?.actions.length ?? 0
633
+ const checksDisabled = !activeRole || !draft || loadingPerms || saving
634
+
635
+ // ---- render --------------------------------------------------------------
636
+ if (loadError) {
637
+ return (
638
+ <div
639
+ className={cn(
640
+ 'flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground',
641
+ className,
642
+ )}
643
+ >
644
+ <Shield className="h-8 w-8 opacity-40" />
645
+ <p className="text-sm">No se pudo cargar el catálogo de permisos.</p>
646
+ </div>
647
+ )
648
+ }
649
+
650
+ if (loading) {
651
+ return (
652
+ <div className={cn('flex flex-col gap-4', className)}>
653
+ <div className="flex items-center justify-between">
654
+ <Skeleton className="h-8 w-56" />
655
+ <div className="flex gap-2">
656
+ <Skeleton className="h-9 w-28" />
657
+ <Skeleton className="h-9 w-40" />
658
+ </div>
659
+ </div>
660
+ <div className="grid gap-4 lg:grid-cols-[340px_1fr]">
661
+ <div className="flex flex-col gap-4">
662
+ <Skeleton className="h-40 w-full" />
663
+ <Skeleton className="h-80 w-full" />
664
+ </div>
665
+ <Skeleton className="h-96 w-full" />
666
+ </div>
667
+ </div>
668
+ )
669
+ }
670
+
671
+ return (
672
+ <div className={cn('flex flex-col gap-4', className)}>
673
+ {/* Header */}
674
+ <div className="flex flex-wrap items-center justify-between gap-3">
675
+ <div>
676
+ <h2 className="text-2xl font-bold tracking-tight">{title}</h2>
677
+ <p className="text-sm text-muted-foreground">
678
+ Define qué puede hacer cada rol en cada módulo.
679
+ </p>
680
+ </div>
681
+ <div className="flex items-center gap-2">
682
+ {dirty && (
683
+ <Badge variant="outline" className="border-amber-500/50 text-amber-600">
684
+ Cambios sin guardar
685
+ </Badge>
686
+ )}
687
+ {createRole && (
688
+ <Button
689
+ onClick={() =>
690
+ setRoleDialog({
691
+ open: true,
692
+ mode: 'create',
693
+ label: '',
694
+ color: ROLE_COLORS[5],
695
+ })
696
+ }
697
+ >
698
+ <Plus className="mr-1.5 h-4 w-4" /> Nuevo rol
699
+ </Button>
700
+ )}
701
+ <Button
702
+ onClick={handleSave}
703
+ disabled={!dirty || saving || !activeRole}
704
+ className="bg-emerald-600 text-white hover:bg-emerald-700"
705
+ >
706
+ <Save className="mr-1.5 h-4 w-4" />
707
+ {saving ? 'Guardando…' : 'Guardar permisos'}
708
+ </Button>
709
+ </div>
710
+ </div>
711
+
712
+ <div className="grid items-start gap-4 lg:grid-cols-[340px_1fr]">
713
+ {/* Left column */}
714
+ <div className="flex flex-col gap-4">
715
+ {/* Card: Rol */}
716
+ <Card>
717
+ <CardHeader>
718
+ <CardTitle className="text-base">Rol</CardTitle>
719
+ <CardDescription>Selecciona el rol a configurar.</CardDescription>
720
+ </CardHeader>
721
+ <CardContent className="flex flex-col gap-3">
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 && (
787
+ <Button
788
+ variant="outline"
789
+ size="icon"
790
+ className="h-9 w-9 shrink-0"
791
+ aria-label="Editar rol"
792
+ disabled={!activeRole}
793
+ onClick={openEditRole}
794
+ >
795
+ <Pencil className="h-4 w-4" />
796
+ </Button>
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>
811
+
812
+ {(catalog?.general.length ?? 0) > 0 && (
813
+ <>
814
+ <Separator />
815
+ <div>
816
+ <h3 className="mb-2 text-sm font-semibold">Permisos Generales</h3>
817
+ <div className="flex flex-col gap-2">
818
+ {catalog!.general.map((g) => (
819
+ <CapabilityCheck
820
+ key={g.key}
821
+ checked={draft?.has(g.key) ?? false}
822
+ disabled={checksDisabled}
823
+ onToggle={() => toggleCapability(g.key)}
824
+ label={g.label}
825
+ description={g.description}
826
+ />
827
+ ))}
828
+ </div>
829
+ </div>
830
+ </>
831
+ )}
832
+ </CardContent>
833
+ </Card>
834
+
835
+ {/* Card: Módulos (hierarchical tree, mirrors the sidebar) */}
836
+ <Card>
837
+ <CardHeader>
838
+ <CardTitle className="text-base">Módulos</CardTitle>
839
+ <CardDescription>
840
+ Elige el módulo cuyas acciones quieres configurar.
841
+ </CardDescription>
842
+ </CardHeader>
843
+ <CardContent className="flex flex-col gap-3">
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"
854
+ />
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',
887
+ )}
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>
926
+ </CardContent>
927
+ </Card>
928
+ </div>
929
+
930
+ {/* Right column: Acciones permitidas */}
931
+ <Card>
932
+ <CardHeader>
933
+ <div className="flex flex-wrap items-start justify-between gap-2">
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>
951
+ </div>
952
+ {activeRole && activeModule && (
953
+ <div className="flex items-center gap-2">
954
+ <Badge variant="secondary" className="tabular-nums">
955
+ {moduleGranted}/{moduleTotal}
956
+ </Badge>
957
+ <Button
958
+ variant="outline"
959
+ size="sm"
960
+ className="h-8"
961
+ disabled={checksDisabled || moduleGranted === moduleTotal}
962
+ onClick={() => setModuleAll(true)}
963
+ >
964
+ <CheckCheck className="mr-1.5 h-3.5 w-3.5" /> Marcar todo
965
+ </Button>
966
+ <Button
967
+ variant="outline"
968
+ size="sm"
969
+ className="h-8"
970
+ disabled={checksDisabled || moduleGranted === 0}
971
+ onClick={() => setModuleAll(false)}
972
+ >
973
+ <Eraser className="mr-1.5 h-3.5 w-3.5" /> Limpiar
974
+ </Button>
975
+ </div>
976
+ )}
977
+ </div>
978
+ </CardHeader>
979
+ <CardContent>
980
+ {!activeRole ? (
981
+ <EmptyHint text="Selecciona un rol para configurar sus permisos." />
982
+ ) : loadingPerms ? (
983
+ <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
984
+ {Array.from({ length: 6 }).map((_, i) => (
985
+ <Skeleton key={i} className="h-11 w-full" />
986
+ ))}
987
+ </div>
988
+ ) : !activeModule ? (
989
+ <EmptyHint text="Selecciona un módulo del árbol para ver sus acciones." />
990
+ ) : (
991
+ <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
992
+ {activeModule.actions.map((action) => {
993
+ const cap = moduleActionCapability(activeModule.key, action.key)
994
+ return (
995
+ <CapabilityCheck
996
+ key={action.key}
997
+ checked={draft?.has(cap) ?? false}
998
+ disabled={checksDisabled}
999
+ onToggle={() => toggleCapability(cap)}
1000
+ icon={action.icon || defaultActionIcon(action.key, action.kind)}
1001
+ label={action.label}
1002
+ />
1003
+ )
1004
+ })}
1005
+ </div>
1006
+ )}
1007
+ </CardContent>
1008
+ </Card>
1009
+ </div>
1010
+
1011
+ {/* Dirty guard when switching roles */}
1012
+ <AlertDialog
1013
+ open={pendingRoleId !== null}
1014
+ onOpenChange={(open: boolean) => !open && setPendingRoleId(null)}
1015
+ >
1016
+ <AlertDialogContent>
1017
+ <AlertDialogHeader>
1018
+ <AlertDialogTitle>Cambios sin guardar</AlertDialogTitle>
1019
+ <AlertDialogDescription>
1020
+ Tienes cambios sin guardar en este rol. Si cambias de rol se descartarán.
1021
+ </AlertDialogDescription>
1022
+ </AlertDialogHeader>
1023
+ <AlertDialogFooter>
1024
+ <AlertDialogCancel>Cancelar</AlertDialogCancel>
1025
+ <AlertDialogAction
1026
+ onClick={() => {
1027
+ setActiveRoleId(pendingRoleId)
1028
+ setPendingRoleId(null)
1029
+ }}
1030
+ >
1031
+ Descartar y cambiar
1032
+ </AlertDialogAction>
1033
+ </AlertDialogFooter>
1034
+ </AlertDialogContent>
1035
+ </AlertDialog>
1036
+
1037
+ {/* Role create/edit dialog */}
1038
+ <Dialog
1039
+ open={roleDialog.open}
1040
+ onOpenChange={(open: boolean) => setRoleDialog((d) => ({ ...d, open }))}
1041
+ >
1042
+ <DialogContent className="sm:max-w-md">
1043
+ <DialogHeader>
1044
+ <DialogTitle>
1045
+ {roleDialog.mode === 'create' ? 'Nuevo rol' : 'Editar rol'}
1046
+ </DialogTitle>
1047
+ </DialogHeader>
1048
+ <div className="flex flex-col gap-4 py-2">
1049
+ <div className="flex flex-col gap-2">
1050
+ <Label htmlFor="pm-role-name">Nombre del rol</Label>
1051
+ <Input
1052
+ id="pm-role-name"
1053
+ value={roleDialog.label}
1054
+ placeholder="Ej. Cajero"
1055
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
1056
+ setRoleDialog((d) => ({ ...d, label: e.target.value }))
1057
+ }
1058
+ />
1059
+ </div>
1060
+ <div className="flex flex-col gap-2">
1061
+ <Label>Color</Label>
1062
+ <div className="flex flex-wrap gap-2">
1063
+ {ROLE_COLORS.map((c) => (
1064
+ <button
1065
+ key={c}
1066
+ type="button"
1067
+ aria-label={`Color ${c}`}
1068
+ onClick={() => setRoleDialog((d) => ({ ...d, color: c }))}
1069
+ className={cn(
1070
+ 'h-7 w-7 rounded-full border-2 transition-transform',
1071
+ roleDialog.color === c
1072
+ ? 'scale-110 border-foreground'
1073
+ : 'border-transparent hover:scale-105',
1074
+ )}
1075
+ style={{ background: c }}
1076
+ />
1077
+ ))}
1078
+ </div>
1079
+ </div>
1080
+ </div>
1081
+ <DialogFooter>
1082
+ <Button
1083
+ variant="outline"
1084
+ onClick={() => setRoleDialog((d) => ({ ...d, open: false }))}
1085
+ disabled={roleSaving}
1086
+ >
1087
+ Cancelar
1088
+ </Button>
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'}
1098
+ </Button>
1099
+ </DialogFooter>
1100
+ </DialogContent>
1101
+ </Dialog>
1102
+
1103
+ {/* Role delete confirm */}
1104
+ <AlertDialog
1105
+ open={deleteOpen}
1106
+ onOpenChange={(open: boolean) => !deleting && setDeleteOpen(open)}
1107
+ >
1108
+ <AlertDialogContent>
1109
+ <AlertDialogHeader>
1110
+ <AlertDialogTitle>¿Eliminar el rol?</AlertDialogTitle>
1111
+ <AlertDialogDescription>
1112
+ Se eliminará el rol{' '}
1113
+ <strong>{activeRole ? activeRole.label || activeRole.name : ''}</strong> y
1114
+ sus asignaciones de permisos. Esta acción no se puede deshacer.
1115
+ </AlertDialogDescription>
1116
+ </AlertDialogHeader>
1117
+ <AlertDialogFooter>
1118
+ <AlertDialogCancel disabled={deleting}>Cancelar</AlertDialogCancel>
1119
+ <AlertDialogAction
1120
+ className="bg-red-600 hover:bg-red-700"
1121
+ disabled={deleting}
1122
+ onClick={(e: React.MouseEvent) => {
1123
+ e.preventDefault()
1124
+ handleDeleteRole()
1125
+ }}
1126
+ >
1127
+ {deleting ? 'Eliminando…' : 'Eliminar'}
1128
+ </AlertDialogAction>
1129
+ </AlertDialogFooter>
1130
+ </AlertDialogContent>
1131
+ </AlertDialog>
1132
+ </div>
1133
+ )
1134
+ }
1135
+
1136
+ function EmptyHint({ text }: { text: string }) {
1137
+ return (
1138
+ <div className="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground">
1139
+ <Shield className="h-8 w-8 opacity-40" />
1140
+ <p className="text-sm">{text}</p>
1141
+ </div>
1142
+ )
1143
+ }