@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 (
|
|
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":
|
|
11
|
-
//
|
|
12
|
-
// — Card "
|
|
13
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
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(/[
|
|
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
|
-
/**
|
|
280
|
-
function
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
338
|
+
/** One module row inside the tree. */
|
|
339
|
+
function ModuleTreeItem({
|
|
340
|
+
module,
|
|
341
|
+
active,
|
|
342
|
+
granted,
|
|
343
|
+
total,
|
|
344
|
+
onSelect,
|
|
285
345
|
}: {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
346
|
+
module: PermissionModuleDef
|
|
347
|
+
active: boolean
|
|
348
|
+
granted: number
|
|
349
|
+
total: number
|
|
350
|
+
onSelect: () => void
|
|
290
351
|
}) {
|
|
291
352
|
return (
|
|
292
|
-
<
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
301
|
-
<
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
>
|
|
307
|
-
<
|
|
308
|
-
|
|
309
|
-
|
|
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 [
|
|
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
|
-
//
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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(
|
|
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
|
|
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-
|
|
572
|
-
<Skeleton className="h-
|
|
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({
|
|
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
|
-
{
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
<
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
</
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
679
|
-
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
795
|
+
<Pencil className="h-4 w-4" />
|
|
680
796
|
</Button>
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
<
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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:
|
|
835
|
+
{/* Card: Módulos (hierarchical tree, mirrors the sidebar) */}
|
|
738
836
|
<Card>
|
|
739
837
|
<CardHeader>
|
|
740
|
-
<CardTitle className="text-base">
|
|
741
|
-
<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
|
-
|
|
745
|
-
<
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
>
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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">
|
|
803
|
-
|
|
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>
|
|
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
|
|
941
|
-
{
|
|
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
|
|
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
|
|
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>
|