@asteby/metacore-runtime-react 18.15.0 → 18.16.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.
@@ -3,16 +3,18 @@
3
3
  // Transport-agnostic: every read/write arrives via props (loaders/mutators),
4
4
  // so each host wires them to its own api client (ops → /api/permissions/*).
5
5
  // The capability universe (modules × actions + general flags) is derived from
6
- // the installed manifests server-side; this component only renders it.
6
+ // the installed manifests + the real sidebar nav server/host-side; this
7
+ // component only renders it.
7
8
  //
8
- // Layout (mirrors the app sidebar so admins recognise what they grant):
9
+ // Layout — a *flat list* that mirrors the app sidebar (NO accordions/folders):
9
10
  // header — title + "Nuevo rol" (primary) + "Guardar permisos" (green).
10
11
  // left — Card "Rol": clean role combobox with inline Editar/Eliminar
11
12
  // 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.
13
+ // — Card "Módulos": a searchable flat list. Each group renders a
14
+ // non-collapsible grey header (uppercase tracking, like "Módulos"
15
+ // / "Sistema" in the sidebar) followed by its modules as clickable
16
+ // rows (icon + label + granted-count badge). Clicking a row selects
17
+ // that module and reveals its action grid on the right.
16
18
  // right — Card "Acciones permitidas": granted counter N/M, mark-all /
17
19
  // clear, checkbox grid (icon + label per action). Clear empty
18
20
  // states for "pick a role" / "pick a module" / loading.
@@ -24,11 +26,9 @@
24
26
  import * as React from 'react'
25
27
  import {
26
28
  Check,
27
- ChevronRight,
28
29
  ChevronsUpDown,
29
30
  CheckCheck,
30
31
  Eraser,
31
- Folder,
32
32
  Pencil,
33
33
  Plus,
34
34
  Save,
@@ -55,9 +55,6 @@ import {
55
55
  CardHeader,
56
56
  CardTitle,
57
57
  Checkbox,
58
- Collapsible,
59
- CollapsibleContent,
60
- CollapsibleTrigger,
61
58
  Command,
62
59
  CommandEmpty,
63
60
  CommandGroup,
@@ -80,34 +77,52 @@ import {
80
77
  import { DynamicIcon } from './dynamic-icon'
81
78
 
82
79
  // ---------------------------------------------------------------------------
83
- // Types (mirror of `GET /api/permissions/modules` / `/api/permissions/roles`)
80
+ // Types (mirror of `GET /api/permissions/modules` + the host sidebar nav)
84
81
  // ---------------------------------------------------------------------------
85
82
 
86
83
  export interface PermissionActionDef {
87
- /** Canonical action key (`index`, `create`, …, or a custom key like `pagar`). */
84
+ /** Canonical action key (`index`, `create`, …, a custom `pagar`, or `access`). */
88
85
  key: string
89
- /** Localized label ("Listar", "Pagar"). */
86
+ /** Localized label ("Listar", "Pagar", "Acceder"). */
90
87
  label: string
91
88
  /** Lucide icon name from the manifest action (optional). */
92
89
  icon?: string
93
- /** `crud` for the derived CRUD set, `custom` for manifest actions. */
94
- kind?: 'crud' | 'custom' | string
90
+ /**
91
+ * `crud` for the derived CRUD set, `custom` for manifest actions,
92
+ * `screen` for the single `access` action of a non-model screen.
93
+ */
94
+ kind?: 'crud' | 'custom' | 'screen' | string
95
95
  }
96
96
 
97
97
  export interface PermissionModuleDef {
98
- /** Module key = lowercase model table (`pos_orders`). */
98
+ /**
99
+ * Module key.
100
+ * - model: lowercase model table (`pos_orders`).
101
+ * - screen: `screen.<navKey>` (the host prefixes it).
102
+ */
99
103
  key: string
100
- /** Localized module label ("Pedidos POS"). */
104
+ /** Localized module label ("Pedidos POS", "Terminal"). */
101
105
  label: string
102
106
  /** Module icon (lucide name) — mirrors the sidebar entry. */
103
107
  icon?: string
104
- /** Owning addon key (`pos`). */
108
+ /** Whether this entry is a data model or a non-model screen. */
109
+ kind?: 'model' | 'screen'
110
+ /** Owning addon key (`pos`) — legacy shape only, used for grouping. */
105
111
  addon_key?: string
106
- /** Localized addon label ("Punto de venta") — used to group the tree. */
112
+ /** Localized addon label ("Punto de venta") — legacy shape only. */
107
113
  addon_label?: string
108
114
  actions: PermissionActionDef[]
109
115
  }
110
116
 
117
+ /**
118
+ * A sidebar-style group: a grey (non-collapsible) header + its modules.
119
+ * `title === ''` → no header (e.g. core/infra modules).
120
+ */
121
+ export interface ModuleGroup {
122
+ title: string
123
+ modules: PermissionModuleDef[]
124
+ }
125
+
111
126
  export interface GeneralPermissionDef {
112
127
  /** Full capability key (`general.work_after_hours`). */
113
128
  key: string
@@ -115,11 +130,27 @@ export interface GeneralPermissionDef {
115
130
  description?: string
116
131
  }
117
132
 
118
- export interface PermissionsCatalog {
133
+ /**
134
+ * What `loadModules()` may return.
135
+ *
136
+ * New (preferred) shape — pre-grouped flat list, mirrors the host sidebar:
137
+ * { groups: ModuleGroup[], general }
138
+ *
139
+ * Legacy shape (still accepted, wrapped into a single untitled group) —
140
+ * { modules: PermissionModuleDef[], general }
141
+ */
142
+ export interface GroupedPermissionsCatalog {
143
+ groups: ModuleGroup[]
144
+ general: GeneralPermissionDef[]
145
+ }
146
+
147
+ export interface FlatPermissionsCatalog {
119
148
  modules: PermissionModuleDef[]
120
149
  general: GeneralPermissionDef[]
121
150
  }
122
151
 
152
+ export type PermissionsCatalog = GroupedPermissionsCatalog | FlatPermissionsCatalog
153
+
123
154
  export interface RoleDef {
124
155
  id: string
125
156
  /** Stable role key ("cashier"). */
@@ -137,7 +168,7 @@ export interface RoleInput {
137
168
  }
138
169
 
139
170
  export interface PermissionsManagerProps {
140
- /** Loads the module×action universe + general flags. */
171
+ /** Loads the module×action universe + general flags (grouped or flat). */
141
172
  loadModules: () => Promise<PermissionsCatalog>
142
173
  /** Loads every assignable role. */
143
174
  loadRoles: () => Promise<RoleDef[]>
@@ -197,19 +228,23 @@ export function defaultActionIcon(actionKey: string, kind?: string): string {
197
228
  return 'Download'
198
229
  case 'import':
199
230
  return 'Upload'
231
+ case 'access':
232
+ return 'Eye'
200
233
  default:
201
- return kind === 'crud' ? 'List' : 'Zap'
234
+ if (kind === 'crud') return 'List'
235
+ if (kind === 'screen') return 'Eye'
236
+ return 'Zap'
202
237
  }
203
238
  }
204
239
 
205
- /** Group label fallback when a module has no addon ("Sistema" = core/infra). */
240
+ /** Group label fallback when a legacy module has no addon ("Sistema" = core). */
206
241
  const SYSTEM_GROUP = 'Sistema'
207
242
 
208
- function moduleGroupLabel(mod: PermissionModuleDef): string {
243
+ function legacyGroupLabel(mod: PermissionModuleDef): string {
209
244
  return mod.addon_label || mod.addon_key || SYSTEM_GROUP
210
245
  }
211
246
 
212
- /** Accent-insensitive, lowercase fold for tree search. */
247
+ /** Accent-insensitive, lowercase fold for search. */
213
248
  function fold(s: string): string {
214
249
  return s
215
250
  .normalize('NFD')
@@ -236,39 +271,59 @@ const ROLE_COLORS = [
236
271
  '#6b7280',
237
272
  ]
238
273
 
239
- /** [groupLabel, modules] buckets in stable insertion order. */
240
- export interface ModuleGroup {
241
- label: string
242
- modules: PermissionModuleDef[]
243
- }
274
+ /**
275
+ * Normalize whatever `loadModules` returned into the canonical grouped shape.
276
+ *
277
+ * - New shape (`{ groups }`): passed through (modules default to kind:'model').
278
+ * - Legacy flat shape (`{ modules }`): grouped by `addon_label`/`addon_key`
279
+ * (falling back to "Sistema") so old hosts keep their familiar buckets,
280
+ * every module defaulting to kind:'model'. The grey headers still render,
281
+ * just derived from the addon instead of the sidebar group.
282
+ */
283
+ export function normalizeCatalogGroups(catalog: PermissionsCatalog): ModuleGroup[] {
284
+ const withKind = (m: PermissionModuleDef): PermissionModuleDef => ({
285
+ ...m,
286
+ kind: m.kind ?? 'model',
287
+ })
288
+
289
+ if ('groups' in catalog && Array.isArray(catalog.groups)) {
290
+ return catalog.groups.map((g) => ({
291
+ title: g.title ?? '',
292
+ modules: g.modules.map(withKind),
293
+ }))
294
+ }
244
295
 
245
- export function groupModules(modules: PermissionModuleDef[]): ModuleGroup[] {
296
+ const modules = ('modules' in catalog && catalog.modules) || []
246
297
  const order: string[] = []
247
298
  const byGroup = new Map<string, PermissionModuleDef[]>()
248
- for (const mod of modules) {
249
- const g = moduleGroupLabel(mod)
299
+ for (const raw of modules) {
300
+ const mod = withKind(raw)
301
+ const g = legacyGroupLabel(mod)
250
302
  if (!byGroup.has(g)) {
251
303
  byGroup.set(g, [])
252
304
  order.push(g)
253
305
  }
254
306
  byGroup.get(g)!.push(mod)
255
307
  }
256
- return order.map((label) => ({ label, modules: byGroup.get(label)! }))
308
+ return order.map((title) => ({ title, modules: byGroup.get(title)! }))
257
309
  }
258
310
 
259
- /** Filter the grouped tree by a folded query against module + group labels. */
311
+ /** Flat list of every module across groups, in render order. */
312
+ export function flattenGroups(groups: ModuleGroup[]): PermissionModuleDef[] {
313
+ return groups.flatMap((g) => g.modules)
314
+ }
315
+
316
+ /** Filter the grouped flat list by a folded query against module + group titles. */
260
317
  export function filterModuleGroups(groups: ModuleGroup[], query: string): ModuleGroup[] {
261
318
  const q = fold(query).trim()
262
319
  if (!q) return groups
263
320
  const out: ModuleGroup[] = []
264
321
  for (const g of groups) {
265
- const groupMatches = fold(g.label).includes(q)
322
+ const groupMatches = g.title.length > 0 && fold(g.title).includes(q)
266
323
  const mods = groupMatches
267
324
  ? 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 })
325
+ : g.modules.filter((m) => fold(m.label).includes(q) || fold(m.key).includes(q))
326
+ if (mods.length) out.push({ title: g.title, modules: mods })
272
327
  }
273
328
  return out
274
329
  }
@@ -335,8 +390,8 @@ function CapabilityCheck({
335
390
  )
336
391
  }
337
392
 
338
- /** One module row inside the tree. */
339
- function ModuleTreeItem({
393
+ /** One clickable module row in the flat list (mirrors a sidebar item). */
394
+ function ModuleRow({
340
395
  module,
341
396
  active,
342
397
  granted,
@@ -362,7 +417,7 @@ function ModuleTreeItem({
362
417
  )}
363
418
  >
364
419
  <DynamicIcon
365
- name={module.icon || 'Square'}
420
+ name={module.icon || (module.kind === 'screen' ? 'Eye' : 'Square')}
366
421
  className={cn('h-4 w-4 shrink-0', active ? 'text-primary' : 'text-muted-foreground')}
367
422
  />
368
423
  <span className="min-w-0 flex-1 truncate">{module.label}</span>
@@ -393,7 +448,8 @@ export function PermissionsManager({
393
448
  title = 'Permisos y Roles',
394
449
  className,
395
450
  }: PermissionsManagerProps) {
396
- const [catalog, setCatalog] = React.useState<PermissionsCatalog | null>(null)
451
+ const [groups, setGroups] = React.useState<ModuleGroup[] | null>(null)
452
+ const [general, setGeneral] = React.useState<GeneralPermissionDef[] | null>(null)
397
453
  const [roles, setRoles] = React.useState<RoleDef[] | null>(null)
398
454
  const [loadError, setLoadError] = React.useState(false)
399
455
 
@@ -408,8 +464,6 @@ export function PermissionsManager({
408
464
 
409
465
  const [roleOpen, setRoleOpen] = React.useState(false)
410
466
  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
467
 
414
468
  // Pending role switch while there are unsaved changes.
415
469
  const [pendingRoleId, setPendingRoleId] = React.useState<string | null>(null)
@@ -424,7 +478,9 @@ export function PermissionsManager({
424
478
  const [deleteOpen, setDeleteOpen] = React.useState(false)
425
479
  const [deleting, setDeleting] = React.useState(false)
426
480
 
427
- const loading = catalog === null || roles === null
481
+ const loading = groups === null || roles === null
482
+
483
+ const allModules = React.useMemo(() => (groups ? flattenGroups(groups) : []), [groups])
428
484
 
429
485
  // ---- initial load: catalog + roles in parallel -------------------------
430
486
  React.useEffect(() => {
@@ -432,10 +488,14 @@ export function PermissionsManager({
432
488
  Promise.all([loadModules(), loadRoles()])
433
489
  .then(([cat, rs]) => {
434
490
  if (cancelled) return
435
- setCatalog(cat)
491
+ const grouped = normalizeCatalogGroups(cat)
492
+ setGroups(grouped)
493
+ setGeneral(cat.general ?? [])
436
494
  setRoles(rs)
437
495
  setActiveRoleId((prev) => prev ?? rs[0]?.id ?? null)
438
- setActiveModuleKey((prev) => prev ?? cat.modules[0]?.key ?? null)
496
+ setActiveModuleKey(
497
+ (prev) => prev ?? flattenGroups(grouped)[0]?.key ?? null,
498
+ )
439
499
  })
440
500
  .catch(() => {
441
501
  if (!cancelled) setLoadError(true)
@@ -481,19 +541,17 @@ export function PermissionsManager({
481
541
  [roles, activeRoleId],
482
542
  )
483
543
  const activeModule = React.useMemo(
484
- () => catalog?.modules.find((m) => m.key === activeModuleKey) ?? null,
485
- [catalog, activeModuleKey],
544
+ () => allModules.find((m) => m.key === activeModuleKey) ?? null,
545
+ [allModules, activeModuleKey],
486
546
  )
487
547
 
488
548
  const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft)
489
549
 
490
- // Module tree: grouped by addon label, optionally filtered by the search.
491
- const allGroups = React.useMemo(() => groupModules(catalog?.modules ?? []), [catalog])
550
+ // Flat module list, optionally filtered by the search.
492
551
  const visibleGroups = React.useMemo(
493
- () => filterModuleGroups(allGroups, moduleQuery),
494
- [allGroups, moduleQuery],
552
+ () => filterModuleGroups(groups ?? [], moduleQuery),
553
+ [groups, moduleQuery],
495
554
  )
496
- const searching = moduleQuery.trim().length > 0
497
555
 
498
556
  // ---- capability edits ---------------------------------------------------
499
557
  const toggleCapability = React.useCallback((cap: string) => {
@@ -544,14 +602,6 @@ export function PermissionsManager({
544
602
  else setActiveRoleId(roleId)
545
603
  }
546
604
 
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
605
  // ---- role CRUD -----------------------------------------------------------
556
606
  const refreshRoles = async (selectId?: string | null) => {
557
607
  const rs = await loadRoles()
@@ -631,6 +681,11 @@ export function PermissionsManager({
631
681
  const moduleGranted = activeModule && draft ? grantedCountForModule(draft, activeModule) : 0
632
682
  const moduleTotal = activeModule?.actions.length ?? 0
633
683
  const checksDisabled = !activeRole || !draft || loadingPerms || saving
684
+ const activeModuleGroupTitle = React.useMemo(() => {
685
+ if (!activeModule || !groups) return ''
686
+ const g = groups.find((grp) => grp.modules.some((m) => m.key === activeModule.key))
687
+ return g?.title ?? ''
688
+ }, [activeModule, groups])
634
689
 
635
690
  // ---- render --------------------------------------------------------------
636
691
  if (loadError) {
@@ -809,13 +864,13 @@ export function PermissionsManager({
809
864
  )}
810
865
  </div>
811
866
 
812
- {(catalog?.general.length ?? 0) > 0 && (
867
+ {(general?.length ?? 0) > 0 && (
813
868
  <>
814
869
  <Separator />
815
870
  <div>
816
871
  <h3 className="mb-2 text-sm font-semibold">Permisos Generales</h3>
817
872
  <div className="flex flex-col gap-2">
818
- {catalog!.general.map((g) => (
873
+ {general!.map((g) => (
819
874
  <CapabilityCheck
820
875
  key={g.key}
821
876
  checked={draft?.has(g.key) ?? false}
@@ -832,7 +887,7 @@ export function PermissionsManager({
832
887
  </CardContent>
833
888
  </Card>
834
889
 
835
- {/* Card: Módulos (hierarchical tree, mirrors the sidebar) */}
890
+ {/* Card: Módulos (flat list, mirrors the sidebar — no folders) */}
836
891
  <Card>
837
892
  <CardHeader>
838
893
  <CardTitle className="text-base">Módulos</CardTitle>
@@ -855,72 +910,48 @@ export function PermissionsManager({
855
910
  </div>
856
911
 
857
912
  <div
858
- role="tree"
913
+ role="list"
859
914
  aria-label="Módulos"
860
- className="-mx-1 max-h-[460px] overflow-y-auto px-1"
915
+ className="-mx-1 flex max-h-[460px] flex-col gap-0.5 overflow-y-auto px-1"
861
916
  >
862
917
  {visibleGroups.length === 0 ? (
863
918
  <p className="px-2 py-6 text-center text-sm text-muted-foreground">
864
919
  Sin módulos.
865
920
  </p>
866
921
  ) : (
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
- })
922
+ visibleGroups.map((group, gi) => (
923
+ <div
924
+ key={group.title || `__untitled_${gi}`}
925
+ className="flex flex-col gap-0.5"
926
+ >
927
+ {group.title && (
928
+ <div
929
+ role="heading"
930
+ aria-level={3}
931
+ className={cn(
932
+ 'px-2 pb-1 pt-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground',
933
+ gi === 0 && 'pt-1',
934
+ )}
935
+ >
936
+ {group.title}
937
+ </div>
938
+ )}
939
+ {group.modules.map((mod) => (
940
+ <ModuleRow
941
+ key={mod.key}
942
+ module={mod}
943
+ active={mod.key === activeModuleKey}
944
+ granted={
945
+ draft
946
+ ? grantedCountForModule(draft, mod)
947
+ : 0
948
+ }
949
+ total={mod.actions.length}
950
+ onSelect={() => setActiveModuleKey(mod.key)}
951
+ />
952
+ ))}
953
+ </div>
954
+ ))
924
955
  )}
925
956
  </div>
926
957
  </CardContent>
@@ -935,7 +966,10 @@ export function PermissionsManager({
935
966
  <CardTitle className="flex items-center gap-2 text-base">
936
967
  {activeModule && (
937
968
  <DynamicIcon
938
- name={activeModule.icon || 'Square'}
969
+ name={
970
+ activeModule.icon ||
971
+ (activeModule.kind === 'screen' ? 'Eye' : 'Square')
972
+ }
939
973
  className="h-4 w-4 shrink-0 text-primary"
940
974
  />
941
975
  )}
@@ -945,7 +979,9 @@ export function PermissionsManager({
945
979
  </CardTitle>
946
980
  <CardDescription>
947
981
  {activeModule
948
- ? `${moduleGroupLabel(activeModule)} · configura las acciones permitidas`
982
+ ? `${
983
+ activeModuleGroupTitle || 'Sistema'
984
+ } · configura las acciones permitidas`
949
985
  : 'Configura los permisos del módulo seleccionado.'}
950
986
  </CardDescription>
951
987
  </div>
@@ -986,7 +1022,7 @@ export function PermissionsManager({
986
1022
  ))}
987
1023
  </div>
988
1024
  ) : !activeModule ? (
989
- <EmptyHint text="Selecciona un módulo del árbol para ver sus acciones." />
1025
+ <EmptyHint text="Selecciona un módulo de la lista para ver sus acciones." />
990
1026
  ) : (
991
1027
  <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
992
1028
  {activeModule.actions.map((action) => {