@asteby/metacore-runtime-react 18.13.3 → 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.
- package/CHANGELOG.md +22 -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 +74 -0
- package/dist/permissions-manager.d.ts.map +1 -0
- package/dist/permissions-manager.js +358 -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 +172 -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 +984 -0
|
@@ -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
|
+
}
|