@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.
- package/CHANGELOG.md +35 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +8 -3
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +20 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +6 -1
- package/dist/permissions-context.d.ts +48 -0
- package/dist/permissions-context.d.ts.map +1 -0
- package/dist/permissions-context.js +124 -0
- package/dist/permissions-manager.d.ts +84 -0
- package/dist/permissions-manager.d.ts.map +1 -0
- package/dist/permissions-manager.js +429 -0
- package/package.json +11 -9
- package/src/__tests__/dynamic-table-permissions.test.tsx +147 -0
- package/src/__tests__/permissions-context.test.tsx +102 -0
- package/src/__tests__/permissions-manager.test.tsx +258 -0
- package/src/dynamic-crud-page.tsx +8 -3
- package/src/dynamic-table.tsx +22 -9
- package/src/index.ts +26 -0
- package/src/model-action-toolbar.tsx +9 -1
- package/src/permissions-context.tsx +158 -0
- package/src/permissions-manager.tsx +1143 -0
|
@@ -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
|
+
}
|