@asteby/metacore-runtime-react 39.2.7 → 39.2.9

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/api-context.d.ts.map +1 -1
  3. package/dist/api-context.js +50 -2
  4. package/dist/calendar-date.d.ts +4 -0
  5. package/dist/calendar-date.d.ts.map +1 -0
  6. package/dist/calendar-date.js +18 -0
  7. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  8. package/dist/dialogs/dynamic-record.js +6 -1
  9. package/dist/dynamic-columns.d.ts.map +1 -1
  10. package/dist/dynamic-columns.js +3 -1
  11. package/dist/dynamic-date-field.d.ts.map +1 -1
  12. package/dist/dynamic-date-field.js +2 -12
  13. package/dist/dynamic-table.d.ts.map +1 -1
  14. package/dist/dynamic-table.js +8 -2
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -2
  18. package/dist/list-include.d.ts +9 -0
  19. package/dist/list-include.d.ts.map +1 -0
  20. package/dist/list-include.js +36 -0
  21. package/dist/permissions-manager.d.ts +9 -0
  22. package/dist/permissions-manager.d.ts.map +1 -1
  23. package/dist/permissions-manager.js +40 -14
  24. package/dist/query-batch.d.ts +48 -0
  25. package/dist/query-batch.d.ts.map +1 -1
  26. package/dist/query-batch.js +251 -8
  27. package/dist/use-options-resolver.d.ts +6 -0
  28. package/dist/use-options-resolver.d.ts.map +1 -1
  29. package/dist/use-options-resolver.js +16 -2
  30. package/dist/validation-catalog.d.ts.map +1 -1
  31. package/dist/validation-catalog.js +2 -0
  32. package/package.json +3 -3
  33. package/src/__tests__/calendar-date.test.ts +22 -0
  34. package/src/__tests__/options-cache-invalidation.test.tsx +126 -0
  35. package/src/__tests__/permissions-manager.test.tsx +73 -0
  36. package/src/api-context.tsx +64 -2
  37. package/src/calendar-date.ts +16 -0
  38. package/src/dialogs/dynamic-record.tsx +6 -1
  39. package/src/dynamic-columns.tsx +3 -1
  40. package/src/dynamic-date-field.tsx +2 -10
  41. package/src/dynamic-table.tsx +10 -2
  42. package/src/index.ts +10 -0
  43. package/src/list-include.test.ts +33 -0
  44. package/src/list-include.ts +34 -0
  45. package/src/permissions-manager.tsx +58 -19
  46. package/src/query-batch.test.ts +122 -2
  47. package/src/query-batch.ts +274 -7
  48. package/src/use-options-resolver.ts +17 -2
  49. package/src/validation-catalog.ts +2 -0
  50. package/vitest.config.ts +1 -0
  51. package/vitest.setup.ts +29 -0
@@ -400,6 +400,79 @@ describe('PermissionsManager (módulo como combobox agrupado)', () => {
400
400
  })
401
401
  })
402
402
 
403
+ describe('PermissionsManager (entradas que comparten modelo)', () => {
404
+ // Two sidebar entries over the same model (QA PIT-001: "Ventas POS" and
405
+ // "Por cobrar" both over sales_orders): picking the second must show the
406
+ // second, not the first entry with that key.
407
+ const shared: GroupedPermissionsCatalog = {
408
+ groups: [
409
+ {
410
+ title: 'Caja',
411
+ modules: [
412
+ {
413
+ key: 'sales_orders',
414
+ label: 'Por cobrar',
415
+ kind: 'model',
416
+ actions: [{ key: 'index', label: 'Listar', kind: 'crud' }],
417
+ },
418
+ ],
419
+ },
420
+ {
421
+ title: 'Ventas',
422
+ modules: [
423
+ {
424
+ key: 'sales_orders',
425
+ label: 'Ventas POS',
426
+ kind: 'model',
427
+ actions: [{ key: 'index', label: 'Listar', kind: 'crud' }],
428
+ },
429
+ ],
430
+ },
431
+ ],
432
+ }
433
+ const moduleTrigger = () => {
434
+ const triggers = screen.getAllByRole('combobox')
435
+ return triggers[triggers.length - 1]
436
+ }
437
+
438
+ it('seleccionar la segunda entrada muestra la segunda', async () => {
439
+ render(<PermissionsManager {...makeProps(shared)} />)
440
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Por cobrar/))
441
+ fireEvent.click(moduleTrigger())
442
+ fireEvent.click(await screen.findByRole('option', { name: /Ventas POS/ }))
443
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Ventas POS/))
444
+ })
445
+ })
446
+
447
+ describe('PermissionsManager (error al crear rol)', () => {
448
+ it('muestra dentro del diálogo el motivo del servidor y lo deja abierto', async () => {
449
+ const dup = Object.assign(new Error('Request failed with status code 422'), {
450
+ response: {
451
+ data: {
452
+ success: false,
453
+ code: 'duplicate',
454
+ message: 'Ya existe un registro con esos datos',
455
+ error: { code: 'duplicate', message: 'Ya existe un registro con esos datos' },
456
+ },
457
+ },
458
+ })
459
+ const props = makeProps(grouped, {
460
+ createRole: vi.fn(async () => {
461
+ throw dup
462
+ }),
463
+ })
464
+ render(<PermissionsManager {...props} />)
465
+ fireEvent.click(await screen.findByRole('button', { name: /Nuevo rol/ }))
466
+ fireEvent.change(await screen.findByLabelText('Nombre del rol'), {
467
+ target: { value: 'Admin' },
468
+ })
469
+ fireEvent.click(screen.getByRole('button', { name: 'Crear rol' }))
470
+ const alert = await screen.findByRole('alert')
471
+ expect(alert.textContent).toBe('Ya existe un registro con esos datos')
472
+ expect(screen.getByRole('dialog')).toBeTruthy()
473
+ })
474
+ })
475
+
403
476
  describe('PermissionsManager (retrocompat shape viejo {modules})', () => {
404
477
  it('renderiza el shape flat legacy sin romper, agrupado por addon', async () => {
405
478
  const props = makeProps(legacy)
@@ -2,7 +2,9 @@
2
2
  // runtime-react components (DynamicTable, dialogs, action dispatcher) can
3
3
  // talk to the backend without a bundler alias to `@/lib/api`. Hosts wrap
4
4
  // their app in <ApiProvider value={axiosInstance}> once at the root.
5
- import React, { createContext, useContext } from 'react'
5
+ import React, { createContext, useContext, useEffect, useMemo } from 'react'
6
+ import { batchGet, invalidateQueryBatchData } from './query-batch'
7
+ import { invalidateOptionsCache } from './use-options-resolver'
6
8
 
7
9
  /** Minimal axios-compatible client shape consumed by runtime-react. */
8
10
  export interface ApiClient {
@@ -20,16 +22,76 @@ export interface ApiProviderProps {
20
22
  }
21
23
 
22
24
  export function ApiProvider({ client, children }: ApiProviderProps) {
25
+ // Hosts also write through their own client (native dialogs, settings
26
+ // screens) without going through useApi(). When the client is axios,
27
+ // every non-GET response drops what the runtime remembered as well.
28
+ useEffect(() => {
29
+ const interceptors = (client as AxiosLike).interceptors?.response
30
+ if (!interceptors?.use) return
31
+ const onSettled = (config: { method?: string; url?: string } | undefined) => {
32
+ if (isWrite(config?.method, config?.url)) forgetRemembered()
33
+ }
34
+ const id = interceptors.use(
35
+ (res: any) => {
36
+ onSettled(res?.config)
37
+ return res
38
+ },
39
+ (err: any) => {
40
+ onSettled(err?.config)
41
+ return Promise.reject(err)
42
+ },
43
+ )
44
+ return () => interceptors.eject?.(id)
45
+ }, [client])
23
46
  return <ApiContext.Provider value={client}>{children}</ApiContext.Provider>
24
47
  }
25
48
 
49
+ interface AxiosLike {
50
+ interceptors?: {
51
+ response?: {
52
+ use?: (onFulfilled: (res: any) => any, onRejected: (err: any) => any) => number
53
+ eject?: (id: number) => void
54
+ }
55
+ }
56
+ }
57
+
58
+ // The read batch is a POST to /q; it reads, so it must not count as a write.
59
+ const BATCH_READ_URL = /(^|\/)q\/?(\?|$)/
60
+
61
+ function isWrite(method: string | undefined, url: string | undefined): boolean {
62
+ const m = (method ?? '').toLowerCase()
63
+ if (m === '' || m === 'get' || m === 'head' || m === 'options') return false
64
+ return !BATCH_READ_URL.test(url ?? '')
65
+ }
66
+
67
+ /** Drops the batch rows and the picker options the runtime remembered. */
68
+ function forgetRemembered(): void {
69
+ invalidateQueryBatchData()
70
+ invalidateOptionsCache()
71
+ }
72
+
73
+ function mutating<T>(request: Promise<T>): Promise<T> {
74
+ forgetRemembered()
75
+ return request.finally(forgetRemembered)
76
+ }
77
+
26
78
  /** Returns the host-injected api client. Throws if no <ApiProvider> is mounted. */
27
79
  export function useApi(): ApiClient {
28
80
  const ctx = useContext(ApiContext)
29
81
  if (!ctx) {
30
82
  throw new Error('useApi() requires an <ApiProvider> ancestor. Hosts must inject an axios-like client via runtime-react ApiProvider.')
31
83
  }
32
- return ctx
84
+ // List, metadata and options GETs share one POST /api/q. Mutations stay
85
+ // on the host client and, settled either way, drop the rows the batch
86
+ // remembered, so the refresh after a save reads the saved row. The
87
+ // wrapper is stable while the host client is.
88
+ return useMemo<ApiClient>(() => ({
89
+ get: (url, config) => batchGet(ctx, url, config),
90
+ post: (url, body, config) =>
91
+ isWrite('post', url) ? mutating(ctx.post(url, body, config)) : ctx.post(url, body, config),
92
+ put: (url, body, config) => mutating(ctx.put(url, body, config)),
93
+ delete: (url, config) => mutating(ctx.delete(url, config)),
94
+ }), [ctx])
33
95
  }
34
96
 
35
97
  /** Optional branch context — hosts that support tenant branches can supply
@@ -0,0 +1,16 @@
1
+ // A `date` column is a calendar day, not an instant. The backend often serves
2
+ // it as UTC midnight ("2026-09-27T00:00:00Z"), which a plain Date parse turns
3
+ // into the previous evening west of UTC (QA Pitsline PIT-025: picked the 27th,
4
+ // the form showed the 26th). Read the YYYY-MM-DD prefix as a LOCAL day instead.
5
+
6
+ /** Parse a calendar day ("YYYY-MM-DD…" or any Date-parseable value) into a
7
+ * local Date at noon, or undefined when empty/invalid. */
8
+ export function parseCalendarDate(v: unknown): Date | undefined {
9
+ if (v === null || v === undefined || v === '') return undefined
10
+ if (v instanceof Date) return isNaN(v.getTime()) ? undefined : v
11
+ const s = String(v)
12
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
13
+ if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)
14
+ const d = new Date(s)
15
+ return isNaN(d.getTime()) ? undefined : d
16
+ }
@@ -49,6 +49,7 @@ import {
49
49
  import { cn } from '@asteby/metacore-ui/lib'
50
50
  import { toast } from 'sonner'
51
51
  import { format, parseISO } from 'date-fns'
52
+ import { parseCalendarDate } from '../calendar-date'
52
53
  import { es } from 'date-fns/locale'
53
54
  import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react'
54
55
  import { BarcodeScanner } from '../barcode-scanner'
@@ -2125,7 +2126,11 @@ export function EditField({ field, value, onChange, record, invalid }: {
2125
2126
  }
2126
2127
 
2127
2128
  if (field.type === 'date' || field.type === 'datetime' || field.type === 'timestamp' || field.type === 'timestamptz') {
2128
- const dateValue = value ? (typeof value === 'string' ? parseISO(value) : new Date(value)) : undefined
2129
+ // A pure `date` is a calendar day: read its YYYY-MM-DD as a local day so
2130
+ // UTC midnight does not render as the day before (PIT-025).
2131
+ const dateValue = field.type === 'date'
2132
+ ? parseCalendarDate(value)
2133
+ : value ? (typeof value === 'string' ? parseISO(value) : new Date(value)) : undefined
2129
2134
  // Treat the Go zero-time (0001-01-01) as empty so an unset date shows the
2130
2135
  // placeholder instead of "31 de diciembre de 1".
2131
2136
  const validDate =
@@ -15,6 +15,7 @@
15
15
  import * as React from 'react'
16
16
  import { ColumnDef } from '@tanstack/react-table'
17
17
  import { format, type Locale } from 'date-fns'
18
+ import { parseCalendarDate } from './calendar-date'
18
19
  import { es, enUS } from 'date-fns/locale'
19
20
  import { Calendar, Check, Copy, Mail, Minus, MoreHorizontal } from 'lucide-react'
20
21
  import {
@@ -507,7 +508,8 @@ export function formatDateCell(
507
508
  title: format(date, 'PPpp', { locale }),
508
509
  }
509
510
  }
510
- return { display: format(date, 'PPP', { locale }) }
511
+ // Calendar day without an org zone: read YYYY-MM-DD as a local day.
512
+ return { display: format(parseCalendarDate(value) ?? date, 'PPP', { locale }) }
511
513
  }
512
514
 
513
515
  /**
@@ -21,6 +21,7 @@ import {
21
21
  } from '@asteby/metacore-ui/primitives'
22
22
  import { CalendarIcon } from 'lucide-react'
23
23
  import type { ActionFieldDef } from './types'
24
+ import { parseCalendarDate } from './calendar-date'
24
25
 
25
26
  export interface DynamicDateFieldProps {
26
27
  field: ActionFieldDef
@@ -28,16 +29,7 @@ export interface DynamicDateFieldProps {
28
29
  onChange: (v: any) => void
29
30
  }
30
31
 
31
- // Parse "YYYY-MM-DD" (or any Date-parseable string) into a local Date, or
32
- // undefined when empty/invalid. Uses noon to dodge timezone day-shift.
33
- function toDate(v: any): Date | undefined {
34
- if (!v) return undefined
35
- const s = String(v)
36
- const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
37
- if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)
38
- const d = new Date(s)
39
- return isNaN(d.getTime()) ? undefined : d
40
- }
32
+ const toDate = parseCalendarDate
41
33
 
42
34
  // Format a Date back to "YYYY-MM-DD" (local, no timezone shift).
43
35
  function toISO(d: Date): string {
@@ -78,6 +78,7 @@ import {
78
78
  import { OptionsContext } from './options-context'
79
79
  import type { TableMetadata, ApiResponse, ColumnDefinition } from './types'
80
80
  import { getSearchableColumnKeys } from './column-visibility'
81
+ import { visibleRelationInclude } from './list-include'
81
82
  import { useDebouncedValue } from './use-debounced-value'
82
83
  import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context'
83
84
  import { useDynamicRowActions } from './dynamic-row-actions'
@@ -808,6 +809,11 @@ export function DynamicTable({
808
809
  return Array.from(set)
809
810
  }, [hiddenColumns, scopeHiddenColumns])
810
811
 
812
+ const relationInclude = useMemo(
813
+ () => visibleRelationInclude(metadata?.columns, effectiveHiddenColumns),
814
+ [metadata, effectiveHiddenColumns],
815
+ )
816
+
811
817
  const buildFilterParams = useCallback(() => {
812
818
  const params: Record<string, any> = {}
813
819
  if (sorting.length > 0) {
@@ -868,6 +874,7 @@ export function DynamicTable({
868
874
  per_page: pagination.pageSize,
869
875
  ...buildFilterParams(),
870
876
  }
877
+ if (relationInclude) params.include = relationInclude
871
878
  const res = await api.get(endpoint || `/data/${model}`, { params }) as { data: ApiResponse<any[]> }
872
879
  setForbidden(false)
873
880
  if (res.data.success) {
@@ -895,7 +902,7 @@ export function DynamicTable({
895
902
  } finally {
896
903
  setLoadingData(false)
897
904
  }
898
- }, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync])
905
+ }, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync, relationInclude])
899
906
 
900
907
  // Columns whose metadata opts into a footer total (display_config.aggregate
901
908
  // → styleConfig.aggregate). When empty, no footer row is rendered and no
@@ -936,6 +943,7 @@ export function DynamicTable({
936
943
  per_page: infPageSize,
937
944
  ...buildFilterParams(),
938
945
  }
946
+ if (relationInclude) params.include = relationInclude
939
947
  const res = (await api.get(endpoint || `/data/${model}`, {
940
948
  params,
941
949
  })) as { data: ApiResponse<any[]> }
@@ -974,7 +982,7 @@ export function DynamicTable({
974
982
  else setLoadingData(false)
975
983
  }
976
984
  },
977
- [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id, enableUrlSync],
985
+ [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id, enableUrlSync, relationInclude],
978
986
  )
979
987
 
980
988
  // Signature of everything that must reset the incremental list to page 1:
package/src/index.ts CHANGED
@@ -393,14 +393,24 @@ export {
393
393
  getSearchableColumnKeys,
394
394
  } from './column-visibility'
395
395
  export {
396
+ batchGet,
396
397
  loadQueryPart,
397
398
  nameBatchTokens,
399
+ narrowInToken,
398
400
  optionsBatchToken,
399
401
  optionsModelFromUrl,
402
+ rememberInRows,
403
+ invalidateQueryBatchData,
404
+ resetQueryBatchCache,
405
+ splitInToken,
406
+ tokenForGet,
407
+ type InListToken,
408
+ type NarrowedIn,
400
409
  type QueryPart,
401
410
  } from './query-batch'
402
411
  export {
403
412
  useOptionsResolver,
413
+ invalidateOptionsCache,
404
414
  projectOption,
405
415
  type ResolvedOption,
406
416
  type OptionsMeta,
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { visibleRelationInclude } from './list-include'
3
+ import type { ColumnDefinition } from './types'
4
+
5
+ function col(partial: Partial<ColumnDefinition> & { key: string }): ColumnDefinition {
6
+ return { label: partial.key, type: 'text', sortable: false, filterable: false, ...partial }
7
+ }
8
+
9
+ describe('visibleRelationInclude', () => {
10
+ it('omits the parameter when the table has no relations', () => {
11
+ expect(visibleRelationInclude([col({ key: 'name' })])).toBeUndefined()
12
+ expect(visibleRelationInclude(undefined)).toBeUndefined()
13
+ })
14
+
15
+ it('lists only the visible relation columns', () => {
16
+ const include = visibleRelationInclude([
17
+ col({ key: 'name' }),
18
+ col({ key: 'customer_id', type: 'relation' }),
19
+ col({ key: 'warehouse_id', ref: 'inventory.Warehouse', hidden: true }),
20
+ col({ key: 'created_by', type: 'creator' }),
21
+ col({ key: 'items', item_fields: [{ key: 'product_id', label: 'Producto' }] }),
22
+ ])
23
+ expect(include).toBe('customer_id,created_by,items')
24
+ })
25
+
26
+ it('drops a column the view hid', () => {
27
+ const include = visibleRelationInclude(
28
+ [col({ key: 'customer_id', type: 'relation' }), col({ key: 'user_id', type: 'user' })],
29
+ ['user_id'],
30
+ )
31
+ expect(include).toBe('customer_id')
32
+ })
33
+ })
@@ -0,0 +1,34 @@
1
+ import type { ColumnDefinition } from './types'
2
+
3
+ /**
4
+ * Columns whose labels come from a relation lookup. The list asks the server
5
+ * to resolve only these, so hidden foreign keys do not each become a query.
6
+ * Returns undefined when nothing relational is visible: the server then keeps
7
+ * resolving everything, which is what hosts do before they send `include=`.
8
+ */
9
+ export function visibleRelationInclude(
10
+ columns: ColumnDefinition[] | undefined,
11
+ hidden: string[] = [],
12
+ ): string | undefined {
13
+ if (!columns?.length) return undefined
14
+ const hide = new Set(hidden)
15
+ const keys: string[] = []
16
+ for (const col of columns) {
17
+ if (!col?.key || col.hidden || hide.has(col.key)) continue
18
+ if (col.visibility === 'modal' || col.visibility === 'list') continue
19
+ if (!columnNeedsRelation(col)) continue
20
+ keys.push(col.key)
21
+ }
22
+ if (keys.length === 0) return undefined
23
+ return keys.join(',')
24
+ }
25
+
26
+ function columnNeedsRelation(col: ColumnDefinition): boolean {
27
+ if (col.type === 'relation' || col.type === 'relation-badge-list' || col.type === 'creator' || col.type === 'user') {
28
+ return true
29
+ }
30
+ if (col.cellStyle === 'relation' || col.cellStyle === 'reference') return true
31
+ if (col.ref) return true
32
+ if (col.itemFields?.length || col.item_fields?.length) return true
33
+ return false
34
+ }
@@ -38,6 +38,7 @@ import {
38
38
  Trash2,
39
39
  } from 'lucide-react'
40
40
  import { toast } from 'sonner'
41
+ import { extractServerError } from './server-error'
41
42
  import { cn } from '@asteby/metacore-ui/lib'
42
43
  import {
43
44
  AlertDialog,
@@ -397,6 +398,29 @@ export function normalizeCatalogGroups(catalog: PermissionsCatalog): ModuleGroup
397
398
  }
398
399
 
399
400
  /** Flat list of every module across groups, in render order. */
401
+ /** Identity of one picker entry: its group index and its index in the group. */
402
+ export function moduleEntryId(groupIndex: number, moduleIndex: number): string {
403
+ return `${groupIndex}:${moduleIndex}`
404
+ }
405
+
406
+ /** The first entry of the catalog, or null when it has no modules. */
407
+ export function firstModuleEntryId(groups: ModuleGroup[]): string | null {
408
+ const gi = groups.findIndex((g) => g.modules.length > 0)
409
+ return gi < 0 ? null : moduleEntryId(gi, 0)
410
+ }
411
+
412
+ /** Resolve an entry id back to its module and group. */
413
+ export function moduleAtEntry(
414
+ groups: ModuleGroup[] | null | undefined,
415
+ id: string | null,
416
+ ): { module: PermissionModuleDef; group: ModuleGroup } | null {
417
+ if (!groups || !id) return null
418
+ const [gi, mi] = id.split(':').map(Number)
419
+ const group = groups[gi!]
420
+ const module = group?.modules[mi!]
421
+ return group && module ? { module, group } : null
422
+ }
423
+
400
424
  export function flattenGroups(groups: ModuleGroup[]): PermissionModuleDef[] {
401
425
  return groups.flatMap((g) => g.modules)
402
426
  }
@@ -546,6 +570,10 @@ export function PermissionsManager({
546
570
  const [loadError, setLoadError] = React.useState(false)
547
571
 
548
572
  const [activeRoleId, setActiveRoleId] = React.useState<string | null>(null)
573
+ // The picker selection is the ENTRY (group index : module index), not the
574
+ // module key: two sidebar entries may govern the same model (e.g. "Ventas
575
+ // POS" and "Por cobrar" both over sales_orders) and a key-based lookup
576
+ // always landed on the first of them.
549
577
  const [activeModuleKey, setActiveModuleKey] = React.useState<string | null>(null)
550
578
 
551
579
  // baseline = capabilities as persisted; draft = baseline + local edits.
@@ -567,6 +595,8 @@ export function PermissionsManager({
567
595
  color: string
568
596
  icon: string
569
597
  grantAll: boolean
598
+ /** Server rejection shown inside the dialog (e.g. duplicate name). */
599
+ error?: string
570
600
  }>({
571
601
  open: false,
572
602
  mode: 'create',
@@ -594,9 +624,7 @@ export function PermissionsManager({
594
624
  setGeneral(cat.general ?? [])
595
625
  setRoles(rs)
596
626
  setActiveRoleId((prev) => prev ?? rs[0]?.id ?? null)
597
- setActiveModuleKey(
598
- (prev) => prev ?? flattenGroups(grouped)[0]?.key ?? null,
599
- )
627
+ setActiveModuleKey((prev) => prev ?? firstModuleEntryId(grouped))
600
628
  })
601
629
  .catch(() => {
602
630
  if (!cancelled) setLoadError(true)
@@ -642,8 +670,8 @@ export function PermissionsManager({
642
670
  [roles, activeRoleId],
643
671
  )
644
672
  const activeModule = React.useMemo(
645
- () => allModules.find((m) => m.key === activeModuleKey) ?? null,
646
- [allModules, activeModuleKey],
673
+ () => moduleAtEntry(groups, activeModuleKey)?.module ?? null,
674
+ [groups, activeModuleKey],
647
675
  )
648
676
 
649
677
  const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft)
@@ -730,6 +758,7 @@ export function PermissionsManager({
730
758
  const label = roleDialog.label.trim()
731
759
  if (!label) return
732
760
  setRoleSaving(true)
761
+ setRoleDialog((d) => ({ ...d, error: undefined }))
733
762
  try {
734
763
  if (roleDialog.mode === 'create' && createRole) {
735
764
  const created = await createRole({
@@ -764,10 +793,15 @@ export function PermissionsManager({
764
793
  toast.success('Rol actualizado')
765
794
  }
766
795
  setRoleDialog((d) => ({ ...d, open: false }))
767
- } catch {
768
- toast.error(
769
- roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol',
770
- )
796
+ } catch (err) {
797
+ // Keep the dialog open WITH the server's reason (422 duplicate name,
798
+ // 403 …): a toast alone can sit behind the modal overlay.
799
+ const fallback =
800
+ roleDialog.mode === 'create' ? 'No se pudo crear el rol' : 'No se pudo actualizar el rol'
801
+ const { title, description } = extractServerError(err, fallback)
802
+ const error = description ? `${title}: ${description}` : title
803
+ setRoleDialog((d) => ({ ...d, error }))
804
+ toast.error(fallback, { description: error })
771
805
  } finally {
772
806
  setRoleSaving(false)
773
807
  }
@@ -806,11 +840,10 @@ export function PermissionsManager({
806
840
  const moduleGranted = activeModule && draft ? grantedCountForModule(draft, activeModule) : 0
807
841
  const moduleTotal = activeModule?.actions.length ?? 0
808
842
  const checksDisabled = !activeRole || !draft || loadingPerms || saving
809
- const activeModuleGroupTitle = React.useMemo(() => {
810
- if (!activeModule || !groups) return ''
811
- const g = groups.find((grp) => grp.modules.some((m) => m.key === activeModule.key))
812
- return g?.title ?? ''
813
- }, [activeModule, groups])
843
+ const activeModuleGroupTitle = React.useMemo(
844
+ () => moduleAtEntry(groups, activeModuleKey)?.group.title ?? '',
845
+ [groups, activeModuleKey],
846
+ )
814
847
 
815
848
  // ---- render --------------------------------------------------------------
816
849
  if (loadError) {
@@ -1094,12 +1127,12 @@ export function PermissionsManager({
1094
1127
  key={group.title || `__untitled_${gi}`}
1095
1128
  heading={group.title || undefined}
1096
1129
  >
1097
- {group.modules.map((mod) => (
1130
+ {group.modules.map((mod, mi) => (
1098
1131
  <CommandItem
1099
- key={mod.key}
1100
- value={`${group.title} ${mod.label} ${mod.key}`}
1132
+ key={moduleEntryId(gi, mi)}
1133
+ value={`${group.title} ${mod.label} ${mod.key} ${moduleEntryId(gi, mi)}`}
1101
1134
  onSelect={() => {
1102
- setActiveModuleKey(mod.key)
1135
+ setActiveModuleKey(moduleEntryId(gi, mi))
1103
1136
  setModuleOpen(false)
1104
1137
  }}
1105
1138
  >
@@ -1129,7 +1162,7 @@ export function PermissionsManager({
1129
1162
  /{mod.actions.length}
1130
1163
  </Badge>
1131
1164
  )}
1132
- {mod.key === activeModuleKey && (
1165
+ {moduleEntryId(gi, mi) === activeModuleKey && (
1133
1166
  <Check className="ml-2 h-4 w-4 shrink-0" />
1134
1167
  )}
1135
1168
  </CommandItem>
@@ -1270,6 +1303,7 @@ export function PermissionsManager({
1270
1303
  setRoleDialog((d) => ({
1271
1304
  ...d,
1272
1305
  label,
1306
+ error: undefined,
1273
1307
  icon:
1274
1308
  d.icon === suggestRoleIcon(d.label) ||
1275
1309
  d.icon === 'Shield' ||
@@ -1330,6 +1364,11 @@ export function PermissionsManager({
1330
1364
  </span>
1331
1365
  </label>
1332
1366
  )}
1367
+ {roleDialog.error && (
1368
+ <p role="alert" className="text-sm text-destructive">
1369
+ {roleDialog.error}
1370
+ </p>
1371
+ )}
1333
1372
  </div>
1334
1373
  <DialogFooter>
1335
1374
  <Button