@asteby/metacore-runtime-react 18.13.2 → 18.14.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,984 @@
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 (reference: 7leguas "Permisos y Roles"):
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.
14
+ // right — Card "Acciones permitidas": granted counter N/M, mark-all /
15
+ // clear buttons, checkbox grid (icon + label per action).
16
+ //
17
+ // Saving calls `syncRolePermissions(roleId, capabilities)` with the FULL
18
+ // granted set of the active role (baseline + the edits made here). Dirty
19
+ // state is tracked against the loaded baseline and surfaced next to the
20
+ // save button.
21
+ import * as React from 'react'
22
+ import {
23
+ Check,
24
+ ChevronsUpDown,
25
+ CheckCheck,
26
+ Eraser,
27
+ Pencil,
28
+ Plus,
29
+ Save,
30
+ Shield,
31
+ Trash2,
32
+ X,
33
+ } from 'lucide-react'
34
+ import { toast } from 'sonner'
35
+ import { cn } from '@asteby/metacore-ui/lib'
36
+ import {
37
+ AlertDialog,
38
+ AlertDialogAction,
39
+ AlertDialogCancel,
40
+ AlertDialogContent,
41
+ AlertDialogDescription,
42
+ AlertDialogFooter,
43
+ AlertDialogHeader,
44
+ AlertDialogTitle,
45
+ Badge,
46
+ Button,
47
+ Card,
48
+ CardContent,
49
+ CardDescription,
50
+ CardHeader,
51
+ CardTitle,
52
+ Checkbox,
53
+ Command,
54
+ CommandEmpty,
55
+ CommandGroup,
56
+ CommandInput,
57
+ CommandItem,
58
+ CommandList,
59
+ Dialog,
60
+ DialogContent,
61
+ DialogFooter,
62
+ DialogHeader,
63
+ DialogTitle,
64
+ Input,
65
+ Label,
66
+ Popover,
67
+ PopoverContent,
68
+ PopoverTrigger,
69
+ Separator,
70
+ Skeleton,
71
+ } from '@asteby/metacore-ui/primitives'
72
+ import { DynamicIcon } from './dynamic-icon'
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Types (mirror of `GET /api/permissions/modules` / `/api/permissions/roles`)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export interface PermissionActionDef {
79
+ /** Canonical action key (`index`, `create`, …, or a custom key like `pagar`). */
80
+ key: string
81
+ /** Localized label ("Listar", "Pagar"). */
82
+ label: string
83
+ /** Lucide icon name from the manifest action (optional). */
84
+ icon?: string
85
+ /** `crud` for the derived CRUD set, `custom` for manifest actions. */
86
+ kind?: 'crud' | 'custom' | string
87
+ }
88
+
89
+ export interface PermissionModuleDef {
90
+ /** Module key = lowercase model table (`pos_orders`). */
91
+ key: string
92
+ /** Localized module label ("Pedidos POS"). */
93
+ label: string
94
+ /** Owning addon key (`pos`). */
95
+ addon_key?: string
96
+ /** Localized addon label ("Punto de venta") — used to group the selector. */
97
+ addon_label?: string
98
+ actions: PermissionActionDef[]
99
+ }
100
+
101
+ export interface GeneralPermissionDef {
102
+ /** Full capability key (`general.work_after_hours`). */
103
+ key: string
104
+ label: string
105
+ description?: string
106
+ }
107
+
108
+ export interface PermissionsCatalog {
109
+ modules: PermissionModuleDef[]
110
+ general: GeneralPermissionDef[]
111
+ }
112
+
113
+ export interface RoleDef {
114
+ id: string
115
+ /** Stable role key ("cashier"). */
116
+ name: string
117
+ /** Human label ("Cajero"). Falls back to `name` when omitted. */
118
+ label?: string
119
+ /** Accent color (hex) for the role chip. */
120
+ color?: string
121
+ }
122
+
123
+ export interface RoleInput {
124
+ name: string
125
+ label?: string
126
+ color?: string
127
+ }
128
+
129
+ export interface PermissionsManagerProps {
130
+ /** Loads the module×action universe + general flags. */
131
+ loadModules: () => Promise<PermissionsCatalog>
132
+ /** Loads every assignable role. */
133
+ loadRoles: () => Promise<RoleDef[]>
134
+ /** Loads the capabilities currently granted to a role. */
135
+ loadRolePermissions: (roleId: string) => Promise<string[]>
136
+ /** Persists the FULL granted capability set of a role. */
137
+ syncRolePermissions: (roleId: string, capabilities: string[]) => Promise<void>
138
+ /** Optional role CRUD — omitting one hides its button. */
139
+ createRole?: (input: RoleInput) => Promise<RoleDef | void>
140
+ updateRole?: (roleId: string, input: RoleInput) => Promise<RoleDef | void>
141
+ deleteRole?: (roleId: string) => Promise<void>
142
+ /** Page heading. Defaults to "Permisos y Roles". */
143
+ title?: string
144
+ className?: string
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Pure helpers (exported for hosts/tests)
149
+ // ---------------------------------------------------------------------------
150
+
151
+ /** Capability for a catalog module action: `lowercase(moduleKey).actionKey`. */
152
+ export function moduleActionCapability(moduleKey: string, actionKey: string): string {
153
+ return `${moduleKey.toLowerCase()}.${actionKey}`
154
+ }
155
+
156
+ /** All capabilities of one module. */
157
+ export function moduleCapabilities(module: PermissionModuleDef): string[] {
158
+ return module.actions.map((a) => moduleActionCapability(module.key, a.key))
159
+ }
160
+
161
+ /** How many of the module's capabilities are in the granted set. */
162
+ export function grantedCountForModule(
163
+ granted: ReadonlySet<string>,
164
+ module: PermissionModuleDef,
165
+ ): number {
166
+ return moduleCapabilities(module).filter((c) => granted.has(c)).length
167
+ }
168
+
169
+ export function capabilitySetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
170
+ if (a.size !== b.size) return false
171
+ for (const v of a) if (!b.has(v)) return false
172
+ return true
173
+ }
174
+
175
+ /** Default lucide icon when the manifest action doesn't declare one. */
176
+ export function defaultActionIcon(actionKey: string, kind?: string): string {
177
+ switch (actionKey) {
178
+ case 'index':
179
+ return 'List'
180
+ case 'create':
181
+ return 'Plus'
182
+ case 'update':
183
+ return 'Pencil'
184
+ case 'delete':
185
+ return 'Trash2'
186
+ case 'export':
187
+ return 'Download'
188
+ case 'import':
189
+ return 'Upload'
190
+ default:
191
+ return kind === 'crud' ? 'List' : 'Zap'
192
+ }
193
+ }
194
+
195
+ function slugify(label: string): string {
196
+ return label
197
+ .normalize('NFD')
198
+ .replace(/[\u0300-\u036f]/g, '')
199
+ .toLowerCase()
200
+ .trim()
201
+ .replace(/[^a-z0-9]+/g, '_')
202
+ .replace(/^_+|_+$/g, '')
203
+ }
204
+
205
+ const ROLE_COLORS = [
206
+ '#ef4444',
207
+ '#f97316',
208
+ '#eab308',
209
+ '#22c55e',
210
+ '#06b6d4',
211
+ '#3b82f6',
212
+ '#8b5cf6',
213
+ '#ec4899',
214
+ '#6b7280',
215
+ ]
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Internal sub-components
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /** Checkbox row used by both the action grid and the general flags. */
222
+ function CapabilityCheck({
223
+ checked,
224
+ disabled,
225
+ onToggle,
226
+ icon,
227
+ label,
228
+ description,
229
+ }: {
230
+ checked: boolean
231
+ disabled?: boolean
232
+ onToggle: () => void
233
+ icon?: string
234
+ label: string
235
+ description?: string
236
+ }) {
237
+ return (
238
+ <div
239
+ role="checkbox"
240
+ aria-checked={checked}
241
+ aria-disabled={disabled || undefined}
242
+ tabIndex={disabled ? -1 : 0}
243
+ onClick={disabled ? undefined : onToggle}
244
+ onKeyDown={
245
+ disabled
246
+ ? undefined
247
+ : (e: React.KeyboardEvent) => {
248
+ if (e.key === ' ' || e.key === 'Enter') {
249
+ e.preventDefault()
250
+ onToggle()
251
+ }
252
+ }
253
+ }
254
+ className={cn(
255
+ 'flex items-start gap-2.5 rounded-md border border-border/60 bg-card px-3 py-2.5 text-sm transition-colors',
256
+ disabled ? 'opacity-50' : 'cursor-pointer hover:bg-muted/40',
257
+ checked && 'border-primary/40 bg-primary/5',
258
+ )}
259
+ >
260
+ <Checkbox
261
+ checked={checked}
262
+ aria-hidden="true"
263
+ tabIndex={-1}
264
+ className="pointer-events-none mt-0.5 shrink-0"
265
+ />
266
+ {icon && (
267
+ <DynamicIcon name={icon} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
268
+ )}
269
+ <span className="min-w-0">
270
+ <span className="block truncate font-medium text-foreground">{label}</span>
271
+ {description && (
272
+ <span className="mt-0.5 block text-xs text-muted-foreground">{description}</span>
273
+ )}
274
+ </span>
275
+ </div>
276
+ )
277
+ }
278
+
279
+ /** Removable selection chip (role / module). */
280
+ function SelectionChip({
281
+ label,
282
+ color,
283
+ onRemove,
284
+ removeAriaLabel,
285
+ }: {
286
+ label: string
287
+ color?: string
288
+ onRemove: () => void
289
+ removeAriaLabel: string
290
+ }) {
291
+ 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
+ />
299
+ )}
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>
310
+ )
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Component
315
+ // ---------------------------------------------------------------------------
316
+
317
+ export function PermissionsManager({
318
+ loadModules,
319
+ loadRoles,
320
+ loadRolePermissions,
321
+ syncRolePermissions,
322
+ createRole,
323
+ updateRole,
324
+ deleteRole,
325
+ title = 'Permisos y Roles',
326
+ className,
327
+ }: PermissionsManagerProps) {
328
+ const [catalog, setCatalog] = React.useState<PermissionsCatalog | null>(null)
329
+ const [roles, setRoles] = React.useState<RoleDef[] | null>(null)
330
+ const [loadError, setLoadError] = React.useState(false)
331
+
332
+ const [activeRoleId, setActiveRoleId] = React.useState<string | null>(null)
333
+ const [activeModuleKey, setActiveModuleKey] = React.useState<string | null>(null)
334
+
335
+ // baseline = capabilities as persisted; draft = baseline + local edits.
336
+ const [baseline, setBaseline] = React.useState<Set<string> | null>(null)
337
+ const [draft, setDraft] = React.useState<Set<string> | null>(null)
338
+ const [loadingPerms, setLoadingPerms] = React.useState(false)
339
+ const [saving, setSaving] = React.useState(false)
340
+
341
+ const [roleOpen, setRoleOpen] = React.useState(false)
342
+ const [moduleOpen, setModuleOpen] = React.useState(false)
343
+
344
+ // Pending role switch while there are unsaved changes.
345
+ const [pendingRoleId, setPendingRoleId] = React.useState<string | null>(null)
346
+
347
+ const [roleDialog, setRoleDialog] = React.useState<{
348
+ open: boolean
349
+ mode: 'create' | 'edit'
350
+ label: string
351
+ color: string
352
+ }>({ open: false, mode: 'create', label: '', color: ROLE_COLORS[5] })
353
+ const [roleSaving, setRoleSaving] = React.useState(false)
354
+ const [deleteOpen, setDeleteOpen] = React.useState(false)
355
+ const [deleting, setDeleting] = React.useState(false)
356
+
357
+ const loading = catalog === null || roles === null
358
+
359
+ // ---- initial load: catalog + roles in parallel -------------------------
360
+ React.useEffect(() => {
361
+ let cancelled = false
362
+ Promise.all([loadModules(), loadRoles()])
363
+ .then(([cat, rs]) => {
364
+ if (cancelled) return
365
+ setCatalog(cat)
366
+ setRoles(rs)
367
+ setActiveRoleId((prev) => prev ?? rs[0]?.id ?? null)
368
+ setActiveModuleKey((prev) => prev ?? cat.modules[0]?.key ?? null)
369
+ })
370
+ .catch(() => {
371
+ if (!cancelled) setLoadError(true)
372
+ })
373
+ return () => {
374
+ cancelled = true
375
+ }
376
+ // eslint-disable-next-line react-hooks/exhaustive-deps
377
+ }, [])
378
+
379
+ // ---- per-role permissions ----------------------------------------------
380
+ React.useEffect(() => {
381
+ if (!activeRoleId) {
382
+ setBaseline(null)
383
+ setDraft(null)
384
+ return
385
+ }
386
+ let cancelled = false
387
+ setLoadingPerms(true)
388
+ loadRolePermissions(activeRoleId)
389
+ .then((caps) => {
390
+ if (cancelled) return
391
+ setBaseline(new Set(caps))
392
+ setDraft(new Set(caps))
393
+ })
394
+ .catch(() => {
395
+ if (cancelled) return
396
+ toast.error('No se pudieron cargar los permisos del rol')
397
+ setBaseline(null)
398
+ setDraft(null)
399
+ })
400
+ .finally(() => {
401
+ if (!cancelled) setLoadingPerms(false)
402
+ })
403
+ return () => {
404
+ cancelled = true
405
+ }
406
+ // eslint-disable-next-line react-hooks/exhaustive-deps
407
+ }, [activeRoleId])
408
+
409
+ const activeRole = React.useMemo(
410
+ () => roles?.find((r) => r.id === activeRoleId) ?? null,
411
+ [roles, activeRoleId],
412
+ )
413
+ const activeModule = React.useMemo(
414
+ () => catalog?.modules.find((m) => m.key === activeModuleKey) ?? null,
415
+ [catalog, activeModuleKey],
416
+ )
417
+
418
+ const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft)
419
+
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])
431
+
432
+ // ---- capability edits ---------------------------------------------------
433
+ const toggleCapability = React.useCallback((cap: string) => {
434
+ setDraft((prev) => {
435
+ if (!prev) return prev
436
+ const next = new Set(prev)
437
+ if (next.has(cap)) next.delete(cap)
438
+ else next.add(cap)
439
+ return next
440
+ })
441
+ }, [])
442
+
443
+ const setModuleAll = React.useCallback(
444
+ (on: boolean) => {
445
+ if (!activeModule) return
446
+ const caps = moduleCapabilities(activeModule)
447
+ setDraft((prev) => {
448
+ if (!prev) return prev
449
+ const next = new Set(prev)
450
+ for (const c of caps) {
451
+ if (on) next.add(c)
452
+ else next.delete(c)
453
+ }
454
+ return next
455
+ })
456
+ },
457
+ [activeModule],
458
+ )
459
+
460
+ const handleSave = async () => {
461
+ if (!activeRoleId || !draft) return
462
+ setSaving(true)
463
+ try {
464
+ await syncRolePermissions(activeRoleId, Array.from(draft).sort())
465
+ setBaseline(new Set(draft))
466
+ toast.success('Permisos guardados')
467
+ } catch {
468
+ toast.error('No se pudieron guardar los permisos')
469
+ } finally {
470
+ setSaving(false)
471
+ }
472
+ }
473
+
474
+ // ---- role switching (dirty guard) ---------------------------------------
475
+ const requestRoleSwitch = (roleId: string | null) => {
476
+ if (roleId === activeRoleId) return
477
+ if (dirty) setPendingRoleId(roleId)
478
+ else setActiveRoleId(roleId)
479
+ }
480
+
481
+ // ---- role CRUD -----------------------------------------------------------
482
+ const refreshRoles = async (selectId?: string | null) => {
483
+ const rs = await loadRoles()
484
+ setRoles(rs)
485
+ if (selectId !== undefined) setActiveRoleId(selectId)
486
+ else if (activeRoleId && !rs.some((r) => r.id === activeRoleId))
487
+ setActiveRoleId(rs[0]?.id ?? null)
488
+ return rs
489
+ }
490
+
491
+ const handleRoleSubmit = async () => {
492
+ const label = roleDialog.label.trim()
493
+ if (!label) return
494
+ setRoleSaving(true)
495
+ try {
496
+ if (roleDialog.mode === 'create' && createRole) {
497
+ const created = await createRole({
498
+ name: slugify(label),
499
+ label,
500
+ color: roleDialog.color,
501
+ })
502
+ const rs = await loadRoles()
503
+ setRoles(rs)
504
+ const createdId =
505
+ (created && 'id' in created && created.id) ||
506
+ rs.find((r) => r.name === slugify(label))?.id ||
507
+ null
508
+ if (createdId) setActiveRoleId(createdId)
509
+ toast.success('Rol creado')
510
+ } else if (roleDialog.mode === 'edit' && updateRole && activeRole) {
511
+ await updateRole(activeRole.id, {
512
+ name: activeRole.name,
513
+ label,
514
+ color: roleDialog.color,
515
+ })
516
+ await refreshRoles(activeRole.id)
517
+ toast.success('Rol actualizado')
518
+ }
519
+ setRoleDialog((d) => ({ ...d, open: false }))
520
+ } catch {
521
+ toast.error(roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol')
522
+ } finally {
523
+ setRoleSaving(false)
524
+ }
525
+ }
526
+
527
+ const handleDeleteRole = async () => {
528
+ if (!deleteRole || !activeRole) return
529
+ setDeleting(true)
530
+ try {
531
+ await deleteRole(activeRole.id)
532
+ const rs = await loadRoles()
533
+ setRoles(rs)
534
+ setActiveRoleId(rs[0]?.id ?? null)
535
+ toast.success('Rol eliminado')
536
+ setDeleteOpen(false)
537
+ } catch {
538
+ toast.error('No se pudo eliminar el rol')
539
+ } finally {
540
+ setDeleting(false)
541
+ }
542
+ }
543
+
544
+ // ---- derived for the right panel ----------------------------------------
545
+ const moduleGranted = activeModule && draft ? grantedCountForModule(draft, activeModule) : 0
546
+ const moduleTotal = activeModule?.actions.length ?? 0
547
+ const checksDisabled = !activeRole || !draft || loadingPerms || saving
548
+
549
+ // ---- render --------------------------------------------------------------
550
+ if (loadError) {
551
+ return (
552
+ <div className={cn('flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground', className)}>
553
+ <Shield className="h-8 w-8 opacity-40" />
554
+ <p className="text-sm">No se pudo cargar el catálogo de permisos.</p>
555
+ </div>
556
+ )
557
+ }
558
+
559
+ if (loading) {
560
+ return (
561
+ <div className={cn('flex flex-col gap-4', className)}>
562
+ <div className="flex items-center justify-between">
563
+ <Skeleton className="h-8 w-56" />
564
+ <div className="flex gap-2">
565
+ <Skeleton className="h-9 w-28" />
566
+ <Skeleton className="h-9 w-40" />
567
+ </div>
568
+ </div>
569
+ <div className="grid gap-4 lg:grid-cols-[340px_1fr]">
570
+ <div className="flex flex-col gap-4">
571
+ <Skeleton className="h-64 w-full" />
572
+ <Skeleton className="h-28 w-full" />
573
+ </div>
574
+ <Skeleton className="h-96 w-full" />
575
+ </div>
576
+ </div>
577
+ )
578
+ }
579
+
580
+ return (
581
+ <div className={cn('flex flex-col gap-4', className)}>
582
+ {/* Header */}
583
+ <div className="flex flex-wrap items-center justify-between gap-3">
584
+ <div>
585
+ <h2 className="text-2xl font-bold tracking-tight">{title}</h2>
586
+ <p className="text-sm text-muted-foreground">
587
+ Define qué puede hacer cada rol en cada módulo.
588
+ </p>
589
+ </div>
590
+ <div className="flex items-center gap-2">
591
+ {dirty && (
592
+ <Badge variant="outline" className="border-amber-500/50 text-amber-600">
593
+ Cambios sin guardar
594
+ </Badge>
595
+ )}
596
+ {createRole && (
597
+ <Button
598
+ onClick={() =>
599
+ setRoleDialog({ open: true, mode: 'create', label: '', color: ROLE_COLORS[5] })
600
+ }
601
+ >
602
+ <Plus className="mr-1.5 h-4 w-4" /> Nuevo rol
603
+ </Button>
604
+ )}
605
+ <Button
606
+ onClick={handleSave}
607
+ disabled={!dirty || saving || !activeRole}
608
+ className="bg-emerald-600 text-white hover:bg-emerald-700"
609
+ >
610
+ <Save className="mr-1.5 h-4 w-4" />
611
+ {saving ? 'Guardando…' : 'Guardar permisos'}
612
+ </Button>
613
+ </div>
614
+ </div>
615
+
616
+ <div className="grid items-start gap-4 lg:grid-cols-[340px_1fr]">
617
+ {/* Left column */}
618
+ <div className="flex flex-col gap-4">
619
+ {/* Card: Rol */}
620
+ <Card>
621
+ <CardHeader>
622
+ <CardTitle className="text-base">Rol</CardTitle>
623
+ <CardDescription>Selecciona el rol a configurar.</CardDescription>
624
+ </CardHeader>
625
+ <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>
672
+ <Button
673
+ variant="outline"
674
+ role="combobox"
675
+ aria-expanded={roleOpen}
676
+ className="w-full justify-between font-normal"
677
+ >
678
+ {activeRole ? activeRole.label || activeRole.name : 'Seleccionar rol…'}
679
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
680
+ </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 && (
715
+ <>
716
+ <Separator />
717
+ <div>
718
+ <h3 className="mb-2 text-sm font-semibold">Permisos Generales</h3>
719
+ <div className="flex flex-col gap-2">
720
+ {catalog!.general.map((g) => (
721
+ <CapabilityCheck
722
+ key={g.key}
723
+ checked={draft?.has(g.key) ?? false}
724
+ disabled={checksDisabled}
725
+ onToggle={() => toggleCapability(g.key)}
726
+ label={g.label}
727
+ description={g.description}
728
+ />
729
+ ))}
730
+ </div>
731
+ </div>
732
+ </>
733
+ )}
734
+ </CardContent>
735
+ </Card>
736
+
737
+ {/* Card: Módulo */}
738
+ <Card>
739
+ <CardHeader>
740
+ <CardTitle className="text-base">Módulo</CardTitle>
741
+ <CardDescription>Elige el módulo cuyas acciones quieres configurar.</CardDescription>
742
+ </CardHeader>
743
+ <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"
749
+ />
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>
788
+ ))}
789
+ </CommandList>
790
+ </Command>
791
+ </PopoverContent>
792
+ </Popover>
793
+ </CardContent>
794
+ </Card>
795
+ </div>
796
+
797
+ {/* Right column: Acciones permitidas */}
798
+ <Card>
799
+ <CardHeader>
800
+ <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>
804
+ </div>
805
+ {activeModule && (
806
+ <div className="flex items-center gap-2">
807
+ <Badge variant="secondary" className="tabular-nums">
808
+ {moduleGranted}/{moduleTotal}
809
+ </Badge>
810
+ <Button
811
+ variant="outline"
812
+ size="sm"
813
+ className="h-8"
814
+ disabled={checksDisabled || moduleGranted === moduleTotal}
815
+ onClick={() => setModuleAll(true)}
816
+ >
817
+ <CheckCheck className="mr-1.5 h-3.5 w-3.5" /> Marcar todo
818
+ </Button>
819
+ <Button
820
+ variant="outline"
821
+ size="sm"
822
+ className="h-8"
823
+ disabled={checksDisabled || moduleGranted === 0}
824
+ onClick={() => setModuleAll(false)}
825
+ >
826
+ <Eraser className="mr-1.5 h-3.5 w-3.5" /> Limpiar
827
+ </Button>
828
+ </div>
829
+ )}
830
+ </div>
831
+ </CardHeader>
832
+ <CardContent>
833
+ {!activeRole ? (
834
+ <EmptyHint text="Selecciona un rol para configurar sus permisos." />
835
+ ) : !activeModule ? (
836
+ <EmptyHint text="Selecciona un módulo para ver sus acciones." />
837
+ ) : loadingPerms ? (
838
+ <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
839
+ {Array.from({ length: 6 }).map((_, i) => (
840
+ <Skeleton key={i} className="h-11 w-full" />
841
+ ))}
842
+ </div>
843
+ ) : (
844
+ <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
845
+ {activeModule.actions.map((action) => {
846
+ const cap = moduleActionCapability(activeModule.key, action.key)
847
+ return (
848
+ <CapabilityCheck
849
+ key={action.key}
850
+ checked={draft?.has(cap) ?? false}
851
+ disabled={checksDisabled}
852
+ onToggle={() => toggleCapability(cap)}
853
+ icon={action.icon || defaultActionIcon(action.key, action.kind)}
854
+ label={action.label}
855
+ />
856
+ )
857
+ })}
858
+ </div>
859
+ )}
860
+ </CardContent>
861
+ </Card>
862
+ </div>
863
+
864
+ {/* Dirty guard when switching roles */}
865
+ <AlertDialog
866
+ open={pendingRoleId !== null}
867
+ onOpenChange={(open: boolean) => !open && setPendingRoleId(null)}
868
+ >
869
+ <AlertDialogContent>
870
+ <AlertDialogHeader>
871
+ <AlertDialogTitle>Cambios sin guardar</AlertDialogTitle>
872
+ <AlertDialogDescription>
873
+ Tienes cambios sin guardar en este rol. Si cambias de rol se descartarán.
874
+ </AlertDialogDescription>
875
+ </AlertDialogHeader>
876
+ <AlertDialogFooter>
877
+ <AlertDialogCancel>Cancelar</AlertDialogCancel>
878
+ <AlertDialogAction
879
+ onClick={() => {
880
+ setActiveRoleId(pendingRoleId)
881
+ setPendingRoleId(null)
882
+ }}
883
+ >
884
+ Descartar y cambiar
885
+ </AlertDialogAction>
886
+ </AlertDialogFooter>
887
+ </AlertDialogContent>
888
+ </AlertDialog>
889
+
890
+ {/* Role create/edit dialog */}
891
+ <Dialog
892
+ open={roleDialog.open}
893
+ onOpenChange={(open: boolean) => setRoleDialog((d) => ({ ...d, open }))}
894
+ >
895
+ <DialogContent className="sm:max-w-md">
896
+ <DialogHeader>
897
+ <DialogTitle>{roleDialog.mode === 'create' ? 'Nuevo rol' : 'Editar rol'}</DialogTitle>
898
+ </DialogHeader>
899
+ <div className="flex flex-col gap-4 py-2">
900
+ <div className="flex flex-col gap-2">
901
+ <Label htmlFor="pm-role-name">Nombre del rol</Label>
902
+ <Input
903
+ id="pm-role-name"
904
+ value={roleDialog.label}
905
+ placeholder="Ej. Cajero"
906
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
907
+ setRoleDialog((d) => ({ ...d, label: e.target.value }))
908
+ }
909
+ />
910
+ </div>
911
+ <div className="flex flex-col gap-2">
912
+ <Label>Color</Label>
913
+ <div className="flex flex-wrap gap-2">
914
+ {ROLE_COLORS.map((c) => (
915
+ <button
916
+ key={c}
917
+ type="button"
918
+ aria-label={`Color ${c}`}
919
+ onClick={() => setRoleDialog((d) => ({ ...d, color: c }))}
920
+ className={cn(
921
+ 'h-7 w-7 rounded-full border-2 transition-transform',
922
+ roleDialog.color === c
923
+ ? 'scale-110 border-foreground'
924
+ : 'border-transparent hover:scale-105',
925
+ )}
926
+ style={{ background: c }}
927
+ />
928
+ ))}
929
+ </div>
930
+ </div>
931
+ </div>
932
+ <DialogFooter>
933
+ <Button
934
+ variant="outline"
935
+ onClick={() => setRoleDialog((d) => ({ ...d, open: false }))}
936
+ disabled={roleSaving}
937
+ >
938
+ Cancelar
939
+ </Button>
940
+ <Button onClick={handleRoleSubmit} disabled={roleSaving || !roleDialog.label.trim()}>
941
+ {roleSaving ? 'Guardando…' : roleDialog.mode === 'create' ? 'Crear rol' : 'Guardar'}
942
+ </Button>
943
+ </DialogFooter>
944
+ </DialogContent>
945
+ </Dialog>
946
+
947
+ {/* Role delete confirm */}
948
+ <AlertDialog open={deleteOpen} onOpenChange={(open: boolean) => !deleting && setDeleteOpen(open)}>
949
+ <AlertDialogContent>
950
+ <AlertDialogHeader>
951
+ <AlertDialogTitle>¿Eliminar el rol?</AlertDialogTitle>
952
+ <AlertDialogDescription>
953
+ 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.
956
+ </AlertDialogDescription>
957
+ </AlertDialogHeader>
958
+ <AlertDialogFooter>
959
+ <AlertDialogCancel disabled={deleting}>Cancelar</AlertDialogCancel>
960
+ <AlertDialogAction
961
+ className="bg-red-600 hover:bg-red-700"
962
+ disabled={deleting}
963
+ onClick={(e: React.MouseEvent) => {
964
+ e.preventDefault()
965
+ handleDeleteRole()
966
+ }}
967
+ >
968
+ {deleting ? 'Eliminando…' : 'Eliminar'}
969
+ </AlertDialogAction>
970
+ </AlertDialogFooter>
971
+ </AlertDialogContent>
972
+ </AlertDialog>
973
+ </div>
974
+ )
975
+ }
976
+
977
+ function EmptyHint({ text }: { text: string }) {
978
+ return (
979
+ <div className="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground">
980
+ <Shield className="h-8 w-8 opacity-40" />
981
+ <p className="text-sm">{text}</p>
982
+ </div>
983
+ )
984
+ }