@asteby/metacore-runtime-react 46.4.0 → 47.0.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/action-modal-dispatcher.d.ts +10 -1
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +107 -19
  5. package/dist/business/catalog.d.ts.map +1 -1
  6. package/dist/business/catalog.js +24 -0
  7. package/dist/business/index.d.ts +3 -0
  8. package/dist/business/index.d.ts.map +1 -1
  9. package/dist/business/index.js +3 -0
  10. package/dist/business/print-send-dialog.d.ts +27 -0
  11. package/dist/business/print-send-dialog.d.ts.map +1 -0
  12. package/dist/business/print-send-dialog.js +55 -0
  13. package/dist/business/relate-documents.d.ts +41 -0
  14. package/dist/business/relate-documents.d.ts.map +1 -0
  15. package/dist/business/relate-documents.js +42 -0
  16. package/dist/business/vehicle-picker.d.ts +36 -0
  17. package/dist/business/vehicle-picker.d.ts.map +1 -0
  18. package/dist/business/vehicle-picker.js +61 -0
  19. package/dist/cfdi-stamp-panel.d.ts +29 -0
  20. package/dist/cfdi-stamp-panel.d.ts.map +1 -0
  21. package/dist/cfdi-stamp-panel.js +53 -0
  22. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  23. package/dist/dialogs/dynamic-record.js +5 -0
  24. package/dist/document-form-dialog.d.ts +23 -0
  25. package/dist/document-form-dialog.d.ts.map +1 -0
  26. package/dist/document-form-dialog.js +196 -0
  27. package/dist/dynamic-crud-page.d.ts +6 -0
  28. package/dist/dynamic-crud-page.d.ts.map +1 -1
  29. package/dist/dynamic-crud-page.js +4 -2
  30. package/dist/dynamic-kanban.d.ts.map +1 -1
  31. package/dist/dynamic-kanban.js +3 -1
  32. package/dist/dynamic-table.d.ts.map +1 -1
  33. package/dist/dynamic-table.js +6 -2
  34. package/dist/index.d.ts +5 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +5 -0
  37. package/dist/nav-permissions.d.ts +19 -0
  38. package/dist/nav-permissions.d.ts.map +1 -0
  39. package/dist/nav-permissions.js +51 -0
  40. package/dist/navigation-builder.d.ts +6 -0
  41. package/dist/navigation-builder.d.ts.map +1 -1
  42. package/dist/navigation-builder.js +26 -1
  43. package/dist/permissions-manager.d.ts +9 -0
  44. package/dist/permissions-manager.d.ts.map +1 -1
  45. package/dist/permissions-manager.js +50 -17
  46. package/dist/print-document-button.d.ts.map +1 -1
  47. package/dist/print-document-button.js +9 -1
  48. package/dist/record-mutation-events.d.ts +14 -0
  49. package/dist/record-mutation-events.d.ts.map +1 -0
  50. package/dist/record-mutation-events.js +37 -0
  51. package/dist/types.d.ts +46 -0
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/use-print-document.d.ts +15 -1
  54. package/dist/use-print-document.d.ts.map +1 -1
  55. package/dist/use-print-document.js +52 -1
  56. package/dist/use-record-mutation-tick.d.ts +6 -0
  57. package/dist/use-record-mutation-tick.d.ts.map +1 -0
  58. package/dist/use-record-mutation-tick.js +11 -0
  59. package/package.json +3 -3
  60. package/src/__tests__/action-modal-feedback.test.tsx +116 -0
  61. package/src/__tests__/business-pickers.test.tsx +157 -0
  62. package/src/__tests__/document-form-dialog.test.tsx +190 -0
  63. package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
  64. package/src/__tests__/nav-permissions.test.tsx +76 -0
  65. package/src/__tests__/permissions-manager.test.tsx +21 -4
  66. package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
  67. package/src/action-modal-dispatcher.tsx +124 -6
  68. package/src/business/catalog.ts +24 -0
  69. package/src/business/index.ts +3 -0
  70. package/src/business/print-send-dialog.tsx +175 -0
  71. package/src/business/relate-documents.tsx +182 -0
  72. package/src/business/vehicle-picker.tsx +188 -0
  73. package/src/cfdi-stamp-panel.tsx +146 -0
  74. package/src/dialogs/dynamic-record.tsx +5 -0
  75. package/src/document-form-dialog.tsx +357 -0
  76. package/src/dynamic-crud-page.tsx +21 -2
  77. package/src/dynamic-kanban.tsx +3 -1
  78. package/src/dynamic-table.tsx +6 -2
  79. package/src/index.ts +28 -0
  80. package/src/nav-permissions.ts +58 -0
  81. package/src/navigation-builder.tsx +27 -1
  82. package/src/permissions-manager.tsx +39 -26
  83. package/src/print-document-button.tsx +9 -1
  84. package/src/record-mutation-events.ts +50 -0
  85. package/src/types.ts +49 -0
  86. package/src/use-print-document.ts +66 -3
  87. package/src/use-record-mutation-tick.ts +12 -0
@@ -73,7 +73,6 @@ import {
73
73
  Popover,
74
74
  PopoverContent,
75
75
  PopoverTrigger,
76
- Separator,
77
76
  Skeleton,
78
77
  Collapsible,
79
78
  CollapsibleContent,
@@ -360,6 +359,31 @@ export function suggestRoleIcon(label: string): string {
360
359
  return 'Shield'
361
360
  }
362
361
 
362
+ /** Label of the synthetic module that hosts the general (non-model) flags. */
363
+ export const GENERAL_MODULE_LABEL = 'Permisos generales'
364
+
365
+ /**
366
+ * The general flags (`general.work_after_hours`, …) as a synthetic module, so
367
+ * they are picked from the module combobox and edited in the same two-column
368
+ * action grid as any module's actions (#1021). Each flag keeps its full
369
+ * capability key through `PermissionActionDef.capability`.
370
+ */
371
+ export function generalPermissionsModule(general: GeneralPermissionDef[]): PermissionModuleDef {
372
+ return {
373
+ key: 'general',
374
+ label: GENERAL_MODULE_LABEL,
375
+ icon: 'SlidersHorizontal',
376
+ kind: 'model',
377
+ actions: general.map((g) => ({
378
+ key: g.key.replace(/^general\./, ''),
379
+ label: g.label,
380
+ description: g.description,
381
+ capability: g.key,
382
+ kind: 'custom',
383
+ })),
384
+ }
385
+ }
386
+
363
387
  /**
364
388
  * Normalize whatever `loadModules` returned into the canonical grouped shape.
365
389
  *
@@ -613,6 +637,15 @@ export function PermissionsManager({
613
637
 
614
638
  const allModules = React.useMemo(() => (groups ? flattenGroups(groups) : []), [groups])
615
639
 
640
+ // What the module picker lists: the catalog groups plus a trailing synthetic
641
+ // group for the general flags. `allModules` / "Otorgar todo" keep using the
642
+ // plain catalog, so that button's scope does not change.
643
+ const pickerGroups = React.useMemo<ModuleGroup[] | null>(() => {
644
+ if (!groups) return groups
645
+ if (!general || general.length === 0) return groups
646
+ return [...groups, { title: '', modules: [generalPermissionsModule(general)] }]
647
+ }, [groups, general])
648
+
616
649
  // ---- initial load: catalog + roles in parallel -------------------------
617
650
  React.useEffect(() => {
618
651
  let cancelled = false
@@ -670,8 +703,8 @@ export function PermissionsManager({
670
703
  [roles, activeRoleId],
671
704
  )
672
705
  const activeModule = React.useMemo(
673
- () => moduleAtEntry(groups, activeModuleKey)?.module ?? null,
674
- [groups, activeModuleKey],
706
+ () => moduleAtEntry(pickerGroups, activeModuleKey)?.module ?? null,
707
+ [pickerGroups, activeModuleKey],
675
708
  )
676
709
 
677
710
  const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft)
@@ -841,8 +874,8 @@ export function PermissionsManager({
841
874
  const moduleTotal = activeModule?.actions.length ?? 0
842
875
  const checksDisabled = !activeRole || !draft || loadingPerms || saving
843
876
  const activeModuleGroupTitle = React.useMemo(
844
- () => moduleAtEntry(groups, activeModuleKey)?.group.title ?? '',
845
- [groups, activeModuleKey],
877
+ () => moduleAtEntry(pickerGroups, activeModuleKey)?.group.title ?? '',
878
+ [pickerGroups, activeModuleKey],
846
879
  )
847
880
 
848
881
  // ---- render --------------------------------------------------------------
@@ -1052,26 +1085,6 @@ export function PermissionsManager({
1052
1085
  )}
1053
1086
  </div>
1054
1087
 
1055
- {(general?.length ?? 0) > 0 && (
1056
- <>
1057
- <Separator />
1058
- <div>
1059
- <h3 className="mb-2 text-sm font-semibold">Permisos Generales</h3>
1060
- <div className="flex flex-col gap-2">
1061
- {general!.map((g) => (
1062
- <CapabilityCheck
1063
- key={g.key}
1064
- checked={draft?.has(g.key) ?? false}
1065
- disabled={checksDisabled}
1066
- onToggle={() => toggleCapability(g.key)}
1067
- label={g.label}
1068
- description={g.description}
1069
- />
1070
- ))}
1071
- </div>
1072
- </div>
1073
- </>
1074
- )}
1075
1088
  </CardContent>
1076
1089
  </Card>
1077
1090
 
@@ -1122,7 +1135,7 @@ export function PermissionsManager({
1122
1135
  <CommandInput placeholder="Buscar módulo…" />
1123
1136
  <CommandList className="max-h-[360px]">
1124
1137
  <CommandEmpty>Sin módulos.</CommandEmpty>
1125
- {(groups ?? []).map((group, gi) => (
1138
+ {(pickerGroups ?? []).map((group, gi) => (
1126
1139
  <CommandGroup
1127
1140
  key={group.title || `__untitled_${gi}`}
1128
1141
  heading={group.title || undefined}
@@ -51,7 +51,15 @@ export function PrintDocumentButton({
51
51
  if (e.defaultPrevented) return
52
52
  setBusy(true)
53
53
  try {
54
- await printDocument({ model, id, key: documentKey, mode, filename })
54
+ // A caller that handles errors itself keeps ownership of the error toast.
55
+ await printDocument({
56
+ model,
57
+ id,
58
+ key: documentKey,
59
+ mode,
60
+ filename,
61
+ feedback: onError ? { progress: true, success: true, error: false } : true,
62
+ })
55
63
  } catch (err) {
56
64
  onError?.(err)
57
65
  } finally {
@@ -0,0 +1,50 @@
1
+ // record-mutation-events — a tiny window-level bus that tells every mounted
2
+ // list (DynamicTable, DynamicKanban) "a record of model X just changed".
3
+ //
4
+ // Why: the lists only reloaded when the *caller* bumped `refreshTrigger`. A
5
+ // create/edit/delete done from a dialog the shell opens (the dynamic record
6
+ // dialog, a federated action modal, a wizard, a confirmation) never reached
7
+ // the table, so the row stayed stale until a manual refresh (#1020). Emitting
8
+ // here decouples the mutation site from the list that displays the model.
9
+
10
+ export type RecordMutationKind = 'create' | 'update' | 'delete'
11
+
12
+ export const RECORD_MUTATION_EVENT = 'metacore:record-mutated'
13
+
14
+ export interface RecordMutationDetail {
15
+ /** Model key as the emitter knows it (matched case-insensitively). */
16
+ model: string
17
+ kind: RecordMutationKind
18
+ }
19
+
20
+ const norm = (m: string | undefined | null) => (m ?? '').trim().toLowerCase()
21
+
22
+ /** Announce a successful mutation. Safe to call outside a browser (no-op). */
23
+ export function emitRecordMutation(model: string | undefined | null, kind: RecordMutationKind): void {
24
+ if (!model || typeof window === 'undefined' || typeof CustomEvent === 'undefined') return
25
+ try {
26
+ window.dispatchEvent(
27
+ new CustomEvent<RecordMutationDetail>(RECORD_MUTATION_EVENT, { detail: { model, kind } }),
28
+ )
29
+ } catch {
30
+ /* a listener must never break the save flow */
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Listen for mutations of `model` (case-insensitive). Returns an unsubscribe.
36
+ */
37
+ export function subscribeRecordMutations(
38
+ model: string | undefined | null,
39
+ cb: (detail: RecordMutationDetail) => void,
40
+ ): () => void {
41
+ if (typeof window === 'undefined') return () => {}
42
+ const want = norm(model)
43
+ const handler = (e: Event) => {
44
+ const d = (e as CustomEvent<RecordMutationDetail>).detail
45
+ if (!d || !want || norm(d.model) !== want) return
46
+ cb(d)
47
+ }
48
+ window.addEventListener(RECORD_MUTATION_EVENT, handler)
49
+ return () => window.removeEventListener(RECORD_MUTATION_EVENT, handler)
50
+ }
package/src/types.ts CHANGED
@@ -22,6 +22,48 @@ export interface ImportSpecMeta {
22
22
  instructions?: string[]
23
23
  }
24
24
 
25
+ /** Paso de renglones de un tipo de documento (usa el editor de renglones del SDK). */
26
+ export interface DocumentFormLines {
27
+ /** Campo del payload que recibe los renglones. Default: `lines` (o `lines_field` del manifest). */
28
+ field?: string
29
+ /** Columnas opcionales del editor (`discount`, `tax`, `unit`…). */
30
+ columns?: string[]
31
+ /** `sale` (precio de lista) o `cost` (compras). Default `sale`. */
32
+ price_source?: 'sale' | 'cost'
33
+ /** Al menos un renglón para guardar. Default true. */
34
+ required?: boolean
35
+ /** Título del paso. Default «Renglones». */
36
+ title?: string
37
+ }
38
+
39
+ /** Un tipo de documento (Factura, Nota de crédito, REP, Global, Traslado…). */
40
+ export interface DocumentFormType {
41
+ key: string
42
+ label: string
43
+ description?: string
44
+ icon?: string
45
+ /** Valor que se escribe en `type_field` (p. ej. `"E"`). Default: `key`. */
46
+ value?: string
47
+ /** Campos del tipo, en el mismo vocabulario que los campos de una acción. */
48
+ fields: ActionFieldDef[]
49
+ /** Valores fijos que viajan en el alta de este tipo. */
50
+ defaults?: Record<string, unknown>
51
+ /** Con esto el tipo tiene paso de renglones. */
52
+ lines?: DocumentFormLines | boolean
53
+ /** Endpoint de alta propio de este tipo. Default: el del modelo. */
54
+ endpoint?: string
55
+ /** Texto del botón final. Default «Crear». */
56
+ submit_label?: string
57
+ }
58
+
59
+ export interface DocumentFormsManifest {
60
+ /** Columna discriminadora del modelo (p. ej. `"type"`). Sin ella no se escribe el tipo. */
61
+ type_field?: string
62
+ /** Campo por defecto de los renglones. Default `lines`. */
63
+ lines_field?: string
64
+ types: DocumentFormType[]
65
+ }
66
+
25
67
  export interface TableMetadata {
26
68
  title: string
27
69
  endpoint: string
@@ -36,6 +78,13 @@ export interface TableMetadata {
36
78
  canExport?: boolean
37
79
  canImport?: boolean
38
80
  canCreate?: boolean
81
+ /**
82
+ * Formularios de alta guiados por tipo de documento (FAC-12). Cuando trae
83
+ * al menos un tipo, `DynamicCRUDPage` abre `DocumentFormDialog` en lugar del
84
+ * formulario genérico del modelo. Lo sirve el kernel o el addon; el SDK no
85
+ * conoce campos fiscales.
86
+ */
87
+ document_forms?: DocumentFormsManifest
39
88
  /**
40
89
  * Cómo se pinta el alta genérica. `generic` (o ausente) es el botón Crear.
41
90
  * `hidden` lo quita. `action` lo quita porque el módulo trae la suya
@@ -17,6 +17,7 @@
17
17
  // The ApiClient is a PEER via <ApiProvider> (same one useAddonSettings uses), so
18
18
  // this hook constructs no client of its own.
19
19
  import { useCallback } from 'react'
20
+ import { toast } from 'sonner'
20
21
  import { useApi } from './api-context'
21
22
 
22
23
  export interface PrintDocumentArgs {
@@ -40,6 +41,42 @@ export interface PrintDocumentArgs {
40
41
  * that is how downloads end up literally named with mustache braces.
41
42
  */
42
43
  filename?: string
44
+ /**
45
+ * User feedback. Default: a toast while the file is prepared, a success toast
46
+ * when it is ready and an error toast with the server's reason when it fails
47
+ * (a download used to give NO sign of life — PIT «Descargar Factura CFDI»).
48
+ * `false` silences all of it; `{ error: false }` leaves errors to the caller
49
+ * (the promise still rejects either way).
50
+ */
51
+ feedback?: boolean | { progress?: boolean; success?: boolean; error?: boolean }
52
+ }
53
+
54
+ const noun = (mode: 'print' | 'download' | 'open') =>
55
+ mode === 'download' ? 'Descargando documento…' : mode === 'open' ? 'Abriendo documento…' : 'Preparando impresión…'
56
+
57
+ const readyMessage = (mode: 'print' | 'download' | 'open') =>
58
+ mode === 'download' ? 'Documento descargado' : mode === 'open' ? 'Documento abierto' : 'Documento listo para imprimir'
59
+
60
+ /** Best-effort message from an error whose body is a Blob (responseType: 'blob'). */
61
+ export async function documentErrorMessage(err: unknown): Promise<string> {
62
+ const fallback = 'No se pudo generar el documento. Inténtalo de nuevo.'
63
+ const e = err as { response?: { data?: unknown; status?: number }; message?: unknown } | undefined
64
+ let data = e?.response?.data
65
+ try {
66
+ if (typeof Blob !== 'undefined' && data instanceof Blob) {
67
+ data = JSON.parse(await data.text())
68
+ }
69
+ } catch {
70
+ data = undefined
71
+ }
72
+ const d = data as { message?: unknown; details?: unknown; error?: unknown } | string | undefined
73
+ if (typeof d === 'string' && d.trim()) return d.trim().slice(0, 200)
74
+ if (d && typeof d === 'object') {
75
+ const msg = [d.message, d.details, d.error].find((x) => typeof x === 'string' && x.trim()) as string | undefined
76
+ if (msg) return msg.trim()
77
+ }
78
+ if (e?.response?.status === 404) return 'El documento no está disponible todavía.'
79
+ return fallback
43
80
  }
44
81
 
45
82
  /** Parse filename from Content-Disposition (RFC 5987 / quoted). */
@@ -71,14 +108,14 @@ export function looksLikeFilenameTemplate(name: string | undefined): boolean {
71
108
  */
72
109
  export function usePrintDocument() {
73
110
  const api = useApi()
74
- return useCallback(
111
+ const run = useCallback(
75
112
  async ({
76
113
  model,
77
114
  id,
78
115
  key,
79
- mode = 'print',
116
+ mode,
80
117
  filename,
81
- }: PrintDocumentArgs): Promise<string> => {
118
+ }: Required<Pick<PrintDocumentArgs, 'model' | 'id' | 'key' | 'mode'>> & Pick<PrintDocumentArgs, 'filename'>): Promise<string> => {
82
119
  const url = `/data/${encodeURIComponent(model)}/${encodeURIComponent(
83
120
  id,
84
121
  )}/documents/${encodeURIComponent(key)}.pdf`
@@ -145,4 +182,30 @@ export function usePrintDocument() {
145
182
  },
146
183
  [api],
147
184
  )
185
+ return useCallback(
186
+ async ({
187
+ model,
188
+ id,
189
+ key,
190
+ mode = 'print',
191
+ filename,
192
+ feedback = true,
193
+ }: PrintDocumentArgs): Promise<string> => {
194
+ const fb = feedback === false ? {} : feedback === true ? { progress: true, success: true, error: true } : feedback
195
+ const toastId = fb.progress ? toast.loading(noun(mode)) : undefined
196
+ try {
197
+ const blobUrl = await run({ model, id, key, mode, filename })
198
+ if (toastId !== undefined) toast.dismiss(toastId)
199
+ if (fb.success) toast.success(readyMessage(mode))
200
+ return blobUrl
201
+ } catch (err) {
202
+ if (toastId !== undefined) toast.dismiss(toastId)
203
+ if (fb.error) toast.error(await documentErrorMessage(err))
204
+ throw err
205
+ } finally {
206
+ if (toastId !== undefined) toast.dismiss(toastId)
207
+ }
208
+ },
209
+ [run],
210
+ )
148
211
  }
@@ -0,0 +1,12 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { subscribeRecordMutations } from './record-mutation-events'
3
+
4
+ /**
5
+ * A counter that increments whenever a record of `model` is created, updated
6
+ * or deleted anywhere in the app. Fold it into a list's refetch deps.
7
+ */
8
+ export function useRecordMutationTick(model: string | undefined | null): number {
9
+ const [tick, setTick] = useState(0)
10
+ useEffect(() => subscribeRecordMutations(model, () => setTick((n) => n + 1)), [model])
11
+ return tick
12
+ }