@asteby/metacore-runtime-react 45.0.0 → 45.3.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 (177) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/action-modal-dispatcher.d.ts +29 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +115 -6
  5. package/dist/activity-diff.d.ts +2 -0
  6. package/dist/activity-diff.d.ts.map +1 -1
  7. package/dist/activity-diff.js +2 -2
  8. package/dist/approval-events.d.ts +16 -0
  9. package/dist/approval-events.d.ts.map +1 -0
  10. package/dist/approval-events.js +27 -0
  11. package/dist/approval-gate.d.ts +39 -0
  12. package/dist/approval-gate.d.ts.map +1 -0
  13. package/dist/approval-gate.js +110 -0
  14. package/dist/approval-pin-dialog.d.ts +25 -0
  15. package/dist/approval-pin-dialog.d.ts.map +1 -0
  16. package/dist/approval-pin-dialog.js +66 -0
  17. package/dist/business/catalog.d.ts +16 -0
  18. package/dist/business/catalog.d.ts.map +1 -0
  19. package/dist/business/catalog.js +50 -0
  20. package/dist/business/customer-picker.d.ts +39 -0
  21. package/dist/business/customer-picker.d.ts.map +1 -0
  22. package/dist/business/customer-picker.js +51 -0
  23. package/dist/business/feedback.d.ts +72 -0
  24. package/dist/business/feedback.d.ts.map +1 -0
  25. package/dist/business/feedback.js +52 -0
  26. package/dist/business/field-errors.d.ts +26 -0
  27. package/dist/business/field-errors.d.ts.map +1 -0
  28. package/dist/business/field-errors.js +59 -0
  29. package/dist/business/format.d.ts +49 -0
  30. package/dist/business/format.d.ts.map +1 -0
  31. package/dist/business/format.js +128 -0
  32. package/dist/business/index.d.ts +16 -0
  33. package/dist/business/index.d.ts.map +1 -0
  34. package/dist/business/index.js +15 -0
  35. package/dist/business/line-items-editor.d.ts +25 -0
  36. package/dist/business/line-items-editor.d.ts.map +1 -0
  37. package/dist/business/line-items-editor.js +36 -0
  38. package/dist/business/line-items.d.ts +69 -0
  39. package/dist/business/line-items.d.ts.map +1 -0
  40. package/dist/business/line-items.js +133 -0
  41. package/dist/business/payment-capture.d.ts +21 -0
  42. package/dist/business/payment-capture.d.ts.map +1 -0
  43. package/dist/business/payment-capture.js +24 -0
  44. package/dist/business/payment.d.ts +61 -0
  45. package/dist/business/payment.d.ts.map +1 -0
  46. package/dist/business/payment.js +74 -0
  47. package/dist/business/product-picker.d.ts +24 -0
  48. package/dist/business/product-picker.d.ts.map +1 -0
  49. package/dist/business/product-picker.js +48 -0
  50. package/dist/business/product-search.d.ts +70 -0
  51. package/dist/business/product-search.d.ts.map +1 -0
  52. package/dist/business/product-search.js +51 -0
  53. package/dist/business/refund-destination.d.ts +19 -0
  54. package/dist/business/refund-destination.d.ts.map +1 -0
  55. package/dist/business/refund-destination.js +31 -0
  56. package/dist/business/refund.d.ts +29 -0
  57. package/dist/business/refund.d.ts.map +1 -0
  58. package/dist/business/refund.js +44 -0
  59. package/dist/business/return-wizard.d.ts +23 -0
  60. package/dist/business/return-wizard.d.ts.map +1 -0
  61. package/dist/business/return-wizard.js +59 -0
  62. package/dist/business/return.d.ts +90 -0
  63. package/dist/business/return.d.ts.map +1 -0
  64. package/dist/business/return.js +112 -0
  65. package/dist/business/use-async-search.d.ts +10 -0
  66. package/dist/business/use-async-search.d.ts.map +1 -0
  67. package/dist/business/use-async-search.js +38 -0
  68. package/dist/document/action-bar.d.ts +12 -0
  69. package/dist/document/action-bar.d.ts.map +1 -0
  70. package/dist/document/action-bar.js +39 -0
  71. package/dist/document/cancel-with-reason.d.ts +47 -0
  72. package/dist/document/cancel-with-reason.d.ts.map +1 -0
  73. package/dist/document/cancel-with-reason.js +52 -0
  74. package/dist/document/document-header.d.ts +41 -0
  75. package/dist/document/document-header.d.ts.map +1 -0
  76. package/dist/document/document-header.js +30 -0
  77. package/dist/document/document-page.d.ts +37 -0
  78. package/dist/document/document-page.d.ts.map +1 -0
  79. package/dist/document/document-page.js +137 -0
  80. package/dist/document/document-timeline.d.ts +20 -0
  81. package/dist/document/document-timeline.d.ts.map +1 -0
  82. package/dist/document/document-timeline.js +26 -0
  83. package/dist/document/evaluate.d.ts +31 -0
  84. package/dist/document/evaluate.d.ts.map +1 -0
  85. package/dist/document/evaluate.js +170 -0
  86. package/dist/document/format.d.ts +9 -0
  87. package/dist/document/format.d.ts.map +1 -0
  88. package/dist/document/format.js +52 -0
  89. package/dist/document/index.d.ts +13 -0
  90. package/dist/document/index.d.ts.map +1 -0
  91. package/dist/document/index.js +12 -0
  92. package/dist/document/registry.d.ts +20 -0
  93. package/dist/document/registry.d.ts.map +1 -0
  94. package/dist/document/registry.js +20 -0
  95. package/dist/document/smart-buttons.d.ts +16 -0
  96. package/dist/document/smart-buttons.d.ts.map +1 -0
  97. package/dist/document/smart-buttons.js +16 -0
  98. package/dist/document/status-badge.d.ts +16 -0
  99. package/dist/document/status-badge.d.ts.map +1 -0
  100. package/dist/document/status-badge.js +23 -0
  101. package/dist/document/types.d.ts +196 -0
  102. package/dist/document/types.d.ts.map +1 -0
  103. package/dist/document/types.js +1 -0
  104. package/dist/document/use-document-sources.d.ts +14 -0
  105. package/dist/document/use-document-sources.d.ts.map +1 -0
  106. package/dist/document/use-document-sources.js +72 -0
  107. package/dist/dynamic-columns.d.ts.map +1 -1
  108. package/dist/dynamic-columns.js +3 -1
  109. package/dist/dynamic-relation.d.ts +4 -0
  110. package/dist/dynamic-relation.d.ts.map +1 -1
  111. package/dist/dynamic-relation.js +10 -1
  112. package/dist/dynamic-row-actions.d.ts.map +1 -1
  113. package/dist/dynamic-row-actions.js +14 -2
  114. package/dist/dynamic-table.d.ts.map +1 -1
  115. package/dist/dynamic-table.js +28 -3
  116. package/dist/index.d.ts +5 -0
  117. package/dist/index.d.ts.map +1 -1
  118. package/dist/index.js +6 -0
  119. package/dist/reason-prompt.d.ts +59 -0
  120. package/dist/reason-prompt.d.ts.map +1 -0
  121. package/dist/reason-prompt.js +111 -0
  122. package/dist/server-error.d.ts.map +1 -1
  123. package/dist/server-error.js +19 -0
  124. package/dist/use-addon-settings.d.ts +17 -3
  125. package/dist/use-addon-settings.d.ts.map +1 -1
  126. package/dist/use-addon-settings.js +20 -8
  127. package/package.json +1 -1
  128. package/src/__tests__/approval-gate.test.tsx +134 -0
  129. package/src/__tests__/prefill-from-record.test.ts +69 -0
  130. package/src/__tests__/reason-prompt.test.tsx +121 -0
  131. package/src/__tests__/use-addon-settings.test.ts +16 -1
  132. package/src/action-modal-dispatcher.tsx +145 -3
  133. package/src/activity-diff.tsx +10 -1
  134. package/src/approval-events.ts +39 -0
  135. package/src/approval-gate.tsx +180 -0
  136. package/src/approval-pin-dialog.tsx +162 -0
  137. package/src/business/business.test.ts +210 -0
  138. package/src/business/catalog.ts +68 -0
  139. package/src/business/customer-picker.tsx +201 -0
  140. package/src/business/feedback.tsx +141 -0
  141. package/src/business/field-errors.ts +92 -0
  142. package/src/business/format.ts +151 -0
  143. package/src/business/index.ts +15 -0
  144. package/src/business/line-items-editor.tsx +259 -0
  145. package/src/business/line-items.ts +190 -0
  146. package/src/business/payment-capture.tsx +158 -0
  147. package/src/business/payment.ts +125 -0
  148. package/src/business/product-picker.tsx +171 -0
  149. package/src/business/product-search.ts +108 -0
  150. package/src/business/refund-destination.tsx +121 -0
  151. package/src/business/refund.ts +72 -0
  152. package/src/business/return-wizard.tsx +223 -0
  153. package/src/business/return.ts +181 -0
  154. package/src/business/use-async-search.ts +43 -0
  155. package/src/document/__tests__/document-page.test.tsx +107 -0
  156. package/src/document/__tests__/evaluate.test.ts +140 -0
  157. package/src/document/action-bar.tsx +140 -0
  158. package/src/document/cancel-with-reason.tsx +214 -0
  159. package/src/document/document-header.tsx +140 -0
  160. package/src/document/document-page.tsx +268 -0
  161. package/src/document/document-timeline.tsx +66 -0
  162. package/src/document/evaluate.ts +180 -0
  163. package/src/document/format.ts +59 -0
  164. package/src/document/index.ts +28 -0
  165. package/src/document/registry.ts +39 -0
  166. package/src/document/smart-buttons.tsx +54 -0
  167. package/src/document/status-badge.tsx +44 -0
  168. package/src/document/types.ts +203 -0
  169. package/src/document/use-document-sources.ts +91 -0
  170. package/src/dynamic-columns.tsx +5 -1
  171. package/src/dynamic-relation.tsx +14 -1
  172. package/src/dynamic-row-actions.tsx +13 -1
  173. package/src/dynamic-table.tsx +22 -2
  174. package/src/index.ts +33 -0
  175. package/src/reason-prompt.tsx +247 -0
  176. package/src/server-error.ts +20 -0
  177. package/src/use-addon-settings.ts +26 -7
@@ -0,0 +1,121 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Mandatory reason (PER-4): the request is tried bare; a 422 errors.reason makes
4
+ // the hook ask once and retry with the reason; cancel = nothing ran.
5
+ import { afterEach, describe, expect, it, vi } from 'vitest'
6
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
7
+
8
+ vi.mock('react-i18next', () => ({
9
+ useTranslation: () => ({
10
+ t: (k: string, opts?: { defaultValue?: string; min?: number }) =>
11
+ (opts?.defaultValue ?? k).replace('{{min}}', String(opts?.min ?? '')),
12
+ }),
13
+ }))
14
+
15
+ import { reasonRequiredInfo, useReasonPrompt } from '../reason-prompt'
16
+
17
+ afterEach(() => {
18
+ cleanup()
19
+ vi.clearAllMocks()
20
+ })
21
+
22
+ const refusal = (code: 'required' | 'min', min?: number) => ({
23
+ response: { status: 422, data: { success: false, errors: { reason: [{ code, params: min ? { min } : undefined }] } } },
24
+ })
25
+
26
+ describe('reasonRequiredInfo', () => {
27
+ it('reads required and min refusals', () => {
28
+ expect(reasonRequiredInfo(refusal('required'))).toEqual({ min: 3, tooShort: false })
29
+ expect(reasonRequiredInfo(refusal('min', 8))).toEqual({ min: 8, tooShort: true })
30
+ })
31
+ it('ignores every other error', () => {
32
+ expect(reasonRequiredInfo(new Error('boom'))).toBeNull()
33
+ expect(reasonRequiredInfo({ response: { data: { errors: { name: [{ code: 'required' }] } } } })).toBeNull()
34
+ expect(reasonRequiredInfo({ response: { data: { errors: { reason: [{ code: 'other' }] } } } })).toBeNull()
35
+ expect(reasonRequiredInfo(undefined)).toBeNull()
36
+ })
37
+ })
38
+
39
+ let result: unknown = 'unset'
40
+ function Probe({ request }: { request: (r?: string) => Promise<string> }) {
41
+ const { run, dialog } = useReasonPrompt()
42
+ return (
43
+ <>
44
+ <button onClick={async () => { result = await run({ title: 'Eliminar', request }) }}>go</button>
45
+ {dialog}
46
+ </>
47
+ )
48
+ }
49
+
50
+ describe('useReasonPrompt', () => {
51
+ it('does not prompt when the server needs no reason', async () => {
52
+ result = 'unset'
53
+ const request = vi.fn().mockResolvedValue('ok')
54
+ render(<Probe request={request} />)
55
+ fireEvent.click(screen.getByText('go'))
56
+ await waitFor(() => expect(result).toBe('ok'))
57
+ expect(request).toHaveBeenCalledTimes(1)
58
+ expect(request).toHaveBeenCalledWith(undefined)
59
+ expect(screen.queryByLabelText('Motivo')).toBeNull()
60
+ })
61
+
62
+ it('asks once on a required refusal and retries with the trimmed reason', async () => {
63
+ result = 'unset'
64
+ const request = vi.fn().mockRejectedValueOnce(refusal('required')).mockResolvedValueOnce('done')
65
+ render(<Probe request={request} />)
66
+ fireEvent.click(screen.getByText('go'))
67
+ const box = await screen.findByLabelText('Motivo')
68
+ const confirm = screen.getByText('Continuar').closest('button') as HTMLButtonElement
69
+ expect(confirm.disabled).toBe(true)
70
+ fireEvent.change(box, { target: { value: ' captura duplicada ' } })
71
+ expect(confirm.disabled).toBe(false)
72
+ fireEvent.click(confirm)
73
+ await waitFor(() => expect(result).toBe('done'))
74
+ expect(request).toHaveBeenNthCalledWith(2, 'captura duplicada')
75
+ })
76
+
77
+ it('stays open with the inline hint when the server wants a longer reason', async () => {
78
+ result = 'unset'
79
+ const request = vi
80
+ .fn()
81
+ .mockRejectedValueOnce(refusal('required'))
82
+ .mockRejectedValueOnce(refusal('min', 10))
83
+ .mockResolvedValueOnce('done')
84
+ render(<Probe request={request} />)
85
+ fireEvent.click(screen.getByText('go'))
86
+ fireEvent.change(await screen.findByLabelText('Motivo'), { target: { value: 'abc' } })
87
+ fireEvent.click(screen.getByText('Continuar'))
88
+ await screen.findByText('Escribe al menos 10 caracteres.')
89
+ fireEvent.change(screen.getByLabelText('Motivo'), { target: { value: 'un motivo bastante largo' } })
90
+ fireEvent.click(screen.getByText('Continuar'))
91
+ await waitFor(() => expect(result).toBe('done'))
92
+ })
93
+
94
+ it('resolves undefined when the operator cancels', async () => {
95
+ result = 'unset'
96
+ const request = vi.fn().mockRejectedValueOnce(refusal('required'))
97
+ render(<Probe request={request} />)
98
+ fireEvent.click(screen.getByText('go'))
99
+ await screen.findByLabelText('Motivo')
100
+ fireEvent.click(screen.getByText('Cancelar'))
101
+ await waitFor(() => expect(result).toBeUndefined())
102
+ expect(request).toHaveBeenCalledTimes(1)
103
+ })
104
+
105
+ it('rethrows a non-reason error', async () => {
106
+ const request = vi.fn().mockRejectedValue(new Error('network'))
107
+ let caught: unknown
108
+ function P2() {
109
+ const { run, dialog } = useReasonPrompt()
110
+ return (
111
+ <>
112
+ <button onClick={() => run({ title: 'x', request }).catch((e) => { caught = e })}>go</button>
113
+ {dialog}
114
+ </>
115
+ )
116
+ }
117
+ render(<P2 />)
118
+ fireEvent.click(screen.getByText('go'))
119
+ await waitFor(() => expect((caught as Error)?.message).toBe('network'))
120
+ })
121
+ })
@@ -4,7 +4,7 @@
4
4
  // itself is a thin react-query wrapper over `useApi`; the load-bearing logic
5
5
  // is this pure merge, so that's what we test directly.
6
6
  import { describe, it, expect } from 'vitest'
7
- import { mergeAddonSettings, addonSettingsKey } from '../use-addon-settings'
7
+ import { mergeAddonSettings, addonSettingsKey, addonSettingsUrl } from '../use-addon-settings'
8
8
 
9
9
  interface PosSettings extends Record<string, unknown> {
10
10
  allowNegativeStock: boolean
@@ -72,3 +72,18 @@ describe('addonSettingsKey', () => {
72
72
  expect(addonSettingsKey('inventory')).toEqual(['addon-settings', 'inventory'])
73
73
  })
74
74
  })
75
+
76
+ describe('branch-scoped settings (POS-1)', () => {
77
+ it('keys and urls carry the branch only when given', () => {
78
+ expect(addonSettingsKey('pos', 'b1')).toEqual(['addon-settings', 'pos', 'b1'])
79
+ expect(addonSettingsKey('pos', null)).toEqual(['addon-settings', 'pos'])
80
+ expect(addonSettingsUrl('pos')).toBe('/api/addons/pos/settings')
81
+ expect(addonSettingsUrl('pos', 'b 1')).toBe('/api/addons/pos/settings?branch_id=b%201')
82
+ })
83
+
84
+ it('an org-key prefix matches every branch read (invalidation)', () => {
85
+ const org = addonSettingsKey('pos')
86
+ const branch = addonSettingsKey('pos', 'b1')
87
+ expect(branch.slice(0, org.length)).toEqual([...org])
88
+ })
89
+ })
@@ -14,7 +14,7 @@
14
14
  //
15
15
  // The host injects its axios-like client via <ApiProvider>; we no longer
16
16
  // depend on a bundler alias to `@/lib/api`.
17
- import { useState, useEffect, useMemo, useSyncExternalStore } from 'react'
17
+ import { useState, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
18
18
  import { useTranslation } from 'react-i18next'
19
19
  import {
20
20
  Dialog,
@@ -57,6 +57,7 @@ import {
57
57
  } from './field-validation-ui'
58
58
  import { validationCatalog } from './validation-catalog'
59
59
  import { useApi } from './api-context'
60
+ import { useReasonPrompt } from './reason-prompt'
60
61
  import { buildRelationFilterParams } from './dynamic-relation-helpers'
61
62
  import { DynamicIcon } from './dynamic-icon'
62
63
  import { DynamicLineItems } from './dynamic-line-items'
@@ -112,6 +113,16 @@ export interface PrefillSpec {
112
113
  * flow (which carries no prefill spec); only the prefilled action locks them.
113
114
  */
114
115
  lock?: string[]
116
+ /**
117
+ * Key of a SIBLING action field (a dynamic_select with a `ref`) whose selected
118
+ * value is the id of the document the rows are seeded from. Without it the
119
+ * rows come from the record the action was opened on (row actions); with it
120
+ * a `create`-placed action — which has no record — seeds its grid as soon as
121
+ * the user picks the source document (e.g. a goods receipt picking its
122
+ * purchase order). `$prefillFromRecord` then names a one_to_many relation of
123
+ * the REFERENCED model. Clearing the selection empties the grid.
124
+ */
125
+ fromField?: string
115
126
  }
116
127
 
117
128
  export function isPrefillSpec(v: unknown): v is PrefillSpec {
@@ -222,6 +233,64 @@ export function prefillRelationRequests(
222
233
  return out
223
234
  }
224
235
 
236
+ // ---- prefill lines from a document picked in a sibling field ----------------
237
+ //
238
+ // A create-placed action has no record, so `$prefillFromRecord` alone can only
239
+ // seed an empty grid. With `fromField` the spec follows a sibling ref field:
240
+ // when the user selects the source document the modal loads its relation rows
241
+ // and seeds the grid (see PrefillSpec.fromField).
242
+
243
+ export interface PrefillFromFieldRequest {
244
+ fieldKey: string
245
+ spec: PrefillSpec
246
+ /** Model key the sibling field references (its `ref`). */
247
+ refModel: string
248
+ /** Selected document id; '' when nothing is selected (grid must clear). */
249
+ refId: string
250
+ }
251
+
252
+ export function prefillFromFieldRequests(
253
+ fields: ActionFieldDef[] | undefined,
254
+ formData: Record<string, any>,
255
+ ): PrefillFromFieldRequest[] {
256
+ const out: PrefillFromFieldRequest[] = []
257
+ if (!fields) return out
258
+ for (const field of fields) {
259
+ if (!isLineItemsField(field)) continue
260
+ const spec = lineItemsDefault(field)
261
+ if (!isPrefillSpec(spec) || !spec.fromField) continue
262
+ const source = fields.find((f) => f.key === spec.fromField)
263
+ const refModel = source ? getFieldRef(source) : undefined
264
+ if (!refModel) continue
265
+ const raw = unwrapRecordScalar(formData?.[spec.fromField])
266
+ const refId = raw === undefined || raw === null ? '' : String(raw)
267
+ out.push({ fieldKey: field.key, spec, refModel, refId })
268
+ }
269
+ return out
270
+ }
271
+
272
+ // prefillFromFieldRelationRequest resolves the child-rows request for a
273
+ // selected document, from the referenced model's declared relations. Null when
274
+ // the relation is missing or not a plain one_to_many.
275
+ export function prefillFromFieldRelationRequest(
276
+ req: PrefillFromFieldRequest,
277
+ relations: Array<{ name?: string; kind?: string; through?: string; foreign_key?: string; scope?: Record<string, unknown> }>,
278
+ ): { endpoint: string; params: Record<string, string | number> } | null {
279
+ if (!req.refId) return null
280
+ const rel = (relations || []).find(
281
+ (r) => r?.name === req.spec.$prefillFromRecord && (r.kind ?? 'one_to_many') === 'one_to_many',
282
+ )
283
+ if (!rel?.through || !rel.foreign_key) return null
284
+ const scope: Record<string, string> = {}
285
+ for (const [k, v] of Object.entries(rel.scope ?? {})) {
286
+ if (v !== undefined && v !== null) scope[k] = String(v)
287
+ }
288
+ return {
289
+ endpoint: `/data/${rel.through}`,
290
+ params: { ...buildRelationFilterParams(rel.foreign_key, req.refId, scope), per_page: 200 },
291
+ }
292
+ }
293
+
225
294
  // ---- scalar prefill from the acted-on record --------------------------------
226
295
  //
227
296
  // Row actions (stamp / refactura / cancel-with-reason) should open with the
@@ -728,6 +797,9 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
728
797
  const { t, i18n } = useTranslation()
729
798
  const api = useApi()
730
799
  const [executing, setExecuting] = useState(false)
800
+ // Actions listed in the model's `reason_required.actions` (cancel, void…)
801
+ // answer 422 errors.reason without a motive; the prompt asks and retries.
802
+ const reasonPrompt = useReasonPrompt()
731
803
  // `action.label` is an addon-contributed i18n key; its locale bundle loads
732
804
  // asynchronously, so translate at render (defaultValue keeps an already
733
805
  // localized label unchanged).
@@ -737,7 +809,13 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
737
809
  setExecuting(true)
738
810
  try {
739
811
  const url = buildActionUrl(endpoint, model, record.id, action.key)
740
- const res = await api.post(url, {})
812
+ const res = await reasonPrompt.run({
813
+ title: label,
814
+ description: t('actions.reason_desc', { defaultValue: 'Esta acción pide el motivo. Queda en la bitácora del documento.' }),
815
+ request: (reason) => api.post(url, reason ? { reason } : {}),
816
+ })
817
+ // Operator cancelled the reason prompt: nothing ran.
818
+ if (!res) return
741
819
  if (res.data.success) {
742
820
  toastServerSuccess(res.data, { t })
743
821
  onOpenChange(false)
@@ -753,6 +831,8 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
753
831
  }
754
832
 
755
833
  return (
834
+ <>
835
+ {reasonPrompt.dialog}
756
836
  <AlertDialog open={open} onOpenChange={onOpenChange}>
757
837
  <AlertDialogContent>
758
838
  <AlertDialogHeader>
@@ -778,6 +858,7 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
778
858
  </AlertDialogFooter>
779
859
  </AlertDialogContent>
780
860
  </AlertDialog>
861
+ </>
781
862
  )
782
863
  }
783
864
 
@@ -788,6 +869,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
788
869
  // defaultValue keeps an already-localized string unchanged.
789
870
  const tl = (s: string) => t(s, { defaultValue: s })
790
871
  const api = useApi()
872
+ const reasonPrompt = useReasonPrompt()
791
873
  const branchGate = useBranchCreateGate()
792
874
  const [formData, setFormData] = useState<Record<string, any>>({})
793
875
  const [executing, setExecuting] = useState(false)
@@ -850,6 +932,60 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
850
932
  }
851
933
  }, [open, action.fields, record, relations, api])
852
934
 
935
+ // Lines seeded from a document picked in a sibling field (PrefillSpec.fromField).
936
+ // Re-runs only when the picked id changes; each pick loads the referenced
937
+ // model's relations (cached per model for the life of the modal) and then
938
+ // the child rows.
939
+ const fromFieldReqs = useMemo(
940
+ () => prefillFromFieldRequests(action.fields, formData),
941
+ [action.fields, formData],
942
+ )
943
+ const fromFieldSig = fromFieldReqs.map((r) => `${r.fieldKey}=${r.refModel}:${r.refId}`).join('|')
944
+ const refRelationsCache = useRef<Map<string, Promise<any[]>>>(new Map())
945
+ useEffect(() => {
946
+ if (!open || fromFieldReqs.length === 0) return
947
+ let cancelled = false
948
+ for (const req of fromFieldReqs) {
949
+ if (!req.refId) {
950
+ setFormData((prev: Record<string, any>) =>
951
+ Array.isArray(prev[req.fieldKey]) && prev[req.fieldKey].length === 0
952
+ ? prev
953
+ : { ...prev, [req.fieldKey]: [] },
954
+ )
955
+ continue
956
+ }
957
+ let relsPromise = refRelationsCache.current.get(req.refModel)
958
+ if (!relsPromise) {
959
+ relsPromise = api
960
+ .get(`/metadata/table/${req.refModel}`)
961
+ .then((res: any) => {
962
+ const rels = res?.data?.relations ?? res?.data?.data?.relations ?? []
963
+ return Array.isArray(rels) ? rels : []
964
+ })
965
+ .catch(() => [] as any[])
966
+ refRelationsCache.current.set(req.refModel, relsPromise)
967
+ }
968
+ relsPromise
969
+ .then((rels) => {
970
+ const call = prefillFromFieldRelationRequest(req, rels)
971
+ if (!call) return
972
+ return api.get(call.endpoint, { params: call.params }).then((res: any) => {
973
+ if (cancelled) return
974
+ const rows = res?.data?.data ?? res?.data ?? []
975
+ if (!Array.isArray(rows)) return
976
+ const built = buildPrefillRows(req.spec, { [req.spec.$prefillFromRecord]: rows })
977
+ setFormData((prev: Record<string, any>) => ({ ...prev, [req.fieldKey]: built }))
978
+ })
979
+ })
980
+ .catch(() => {})
981
+ }
982
+ return () => {
983
+ cancelled = true
984
+ }
985
+ // fromFieldSig captures every dependency of fromFieldReqs that matters.
986
+ // eslint-disable-next-line react-hooks/exhaustive-deps
987
+ }, [open, fromFieldSig, api])
988
+
853
989
  const updateField = (key: string, value: any) => {
854
990
  setFormData((prev: Record<string, any>) => ({ ...prev, [key]: value }))
855
991
  setFieldErrors((prev) => clearFieldErrorTree(prev, key))
@@ -914,7 +1050,12 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
914
1050
  if (branchId) payload = { ...payload, branch_id: branchId }
915
1051
  }
916
1052
  const url = buildActionUrl(endpoint, model, record?.id, action.key)
917
- const res = await api.post(url, payload)
1053
+ const res = await reasonPrompt.run({
1054
+ title: tl(action.label),
1055
+ description: t('actions.reason_desc', { defaultValue: 'Esta acción pide el motivo. Queda en la bitácora del documento.' }),
1056
+ request: (reason) => api.post(url, reason ? { ...payload, reason } : payload),
1057
+ })
1058
+ if (!res) return
918
1059
  if (res.data.success) {
919
1060
  toastServerSuccess(res.data, { t })
920
1061
  onOpenChange(false)
@@ -1022,6 +1163,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1022
1163
  {tl(action.label)}
1023
1164
  </Button>
1024
1165
  </DialogFooter>
1166
+ {reasonPrompt.dialog}
1025
1167
  </DialogContent>
1026
1168
  </Dialog>
1027
1169
  )
@@ -50,6 +50,8 @@ export interface ActivityEvent {
50
50
  */
51
51
  changes?: Record<string, { from: unknown; to: unknown }> | null
52
52
  summary?: string | null
53
+ /** Motive the operator stated for a delete / reason_required action (PER-4). */
54
+ reason?: string | null
53
55
  occurred_at: string
54
56
  }
55
57
 
@@ -204,7 +206,7 @@ export const ActivityDiff: React.FC<ActivityDiffProps> = ({
204
206
 
205
207
  const variantBadge = VARIANT_BADGE[variant] ?? VARIANT_BADGE.other
206
208
 
207
- if (allKeys.length === 0 && !event.summary) {
209
+ if (allKeys.length === 0 && !event.summary && !event.reason) {
208
210
  return (
209
211
  <div className={cn('text-sm text-muted-foreground italic py-1', className)}>
210
212
  Sin campos registrados.
@@ -235,6 +237,13 @@ export const ActivityDiff: React.FC<ActivityDiffProps> = ({
235
237
  )}
236
238
  </div>
237
239
 
240
+ {/* Mandatory-reason motive (delete / cancel with reason_required) */}
241
+ {event.reason && (
242
+ <p className="text-sm" data-testid="activity-reason">
243
+ <span className="font-medium">Motivo:</span> {event.reason}
244
+ </p>
245
+ )}
246
+
238
247
  {/* Summary line (if backend provided one) */}
239
248
  {event.summary && (
240
249
  <p className="text-sm text-muted-foreground italic">{event.summary}</p>
@@ -0,0 +1,39 @@
1
+ // approval-events — dependency-free glue between server-error.ts (which sees a
2
+ // parked `approval_required` write) and <ApprovalGateProvider> (which owns the
3
+ // PIN dialog). Kept apart so the error toaster never imports React/UI code.
4
+
5
+ /** Window event toastServerError fires for a parked `approval_required` write. */
6
+ export const APPROVAL_PARKED_EVENT = 'metacore:approval-parked'
7
+ /** Fired after a parked request was approved (and replayed) with a PIN. */
8
+ export const APPROVAL_DECIDED_EVENT = 'metacore:approval-decided'
9
+
10
+ let gateMounted = 0
11
+ /** @internal provider bookkeeping. */
12
+ export function gateMountedDelta(d: number): void {
13
+ gateMounted += d
14
+ }
15
+
16
+ /** True while an <ApprovalGateProvider> is mounted (toastServerError offers the PIN action only then). */
17
+ export function approvalGateAvailable(): boolean {
18
+ return gateMounted > 0
19
+ }
20
+
21
+ export interface ApprovalRequiredInfo {
22
+ requestId: string
23
+ label?: string
24
+ roles: string[]
25
+ }
26
+
27
+ /** Extracts the parked-request info from an axios error / response body, or null. */
28
+ export function approvalRequiredInfo(err: unknown): ApprovalRequiredInfo | null {
29
+ const data = ((err as { response?: { data?: unknown } })?.response?.data ?? err) as
30
+ | { error?: { code?: string }; meta?: { approval_request_id?: string; approval_label?: string; approval_roles?: string[] } }
31
+ | undefined
32
+ if (data?.error?.code !== 'approval_required') return null
33
+ return {
34
+ requestId: data.meta?.approval_request_id ?? '',
35
+ label: data.meta?.approval_label,
36
+ roles: data.meta?.approval_roles ?? [],
37
+ }
38
+ }
39
+
@@ -0,0 +1,180 @@
1
+ // ApprovalGate — one place for "this needs a supervisor" in hosts that mount an
2
+ // ApiProvider. It answers two situations with the same PIN dialog:
3
+ //
4
+ // 1. INLINE policy (POS oversell, discount over the cap, refund…): the client
5
+ // calls `requestApproval({policy, …})`. A user who already holds the policy's
6
+ // `general.approve_<policy>` capability passes straight through (`bypassed`);
7
+ // everyone else gets the dialog, which posts `/approvals/pin-grant`.
8
+ // 2. PARKED request: a write answered 422 `approval_required`. toastServerError
9
+ // shows "Enviado a aprobación" with an "Aprobar con PIN" action that opens the
10
+ // dialog against `/approvals/:id/approve-pin` (kernel approvals_pin.go).
11
+ //
12
+ // Federated addons with their own HTTP client use <ApprovalPinDialog> directly
13
+ // (approval-pin-dialog.tsx) and post to the same endpoints.
14
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
15
+ import { useTranslation } from 'react-i18next'
16
+ import { toast } from 'sonner'
17
+ import { useApi } from './api-context'
18
+ import { useCan } from './permissions-context'
19
+ import { ApprovalPinDialog, type ApprovalPinSubmit } from './approval-pin-dialog'
20
+ import {
21
+ APPROVAL_DECIDED_EVENT,
22
+ APPROVAL_PARKED_EVENT,
23
+ gateMountedDelta,
24
+ type ApprovalRequiredInfo,
25
+ } from './approval-events'
26
+
27
+ export {
28
+ APPROVAL_DECIDED_EVENT,
29
+ APPROVAL_PARKED_EVENT,
30
+ approvalGateAvailable,
31
+ approvalRequiredInfo,
32
+ type ApprovalRequiredInfo,
33
+ } from './approval-events'
34
+
35
+ /** Capability that authorizes `policy` (and lets its holder skip the prompt). */
36
+ export function approvalCapability(policy: string): string {
37
+ return `general.approve_${policy}`
38
+ }
39
+
40
+ export interface ApprovalGrant {
41
+ /** Audit row id (ApprovalRequest.kind=pin); null when the caller bypassed. */
42
+ id: string | null
43
+ policy: string
44
+ bypassed: boolean
45
+ reason?: string
46
+ }
47
+
48
+ export interface RequestApprovalOptions {
49
+ /** Policy key: oversell | discount | price_below_min | refund | cancel_cfdi | inventory_adjust | <addon-declared>. */
50
+ policy: string
51
+ label: string
52
+ description?: React.ReactNode
53
+ /** Free-form audit data stored with the grant (product, qty, before → after…). */
54
+ context?: Record<string, unknown>
55
+ model?: string
56
+ recordId?: string
57
+ addonKey?: string
58
+ }
59
+
60
+ export interface ApprovalGateValue {
61
+ /** Resolves the grant, or null when the operator cancelled. */
62
+ requestApproval: (opts: RequestApprovalOptions) => Promise<ApprovalGrant | null>
63
+ /** True when the current user may do `policy` without asking anyone. */
64
+ canSelfApprove: (policy: string) => boolean
65
+ }
66
+
67
+ const ApprovalGateContext = createContext<ApprovalGateValue | null>(null)
68
+
69
+ type Pending =
70
+ | { kind: 'inline'; opts: RequestApprovalOptions; resolve: (g: ApprovalGrant | null) => void }
71
+ | { kind: 'parked'; info: ApprovalRequiredInfo }
72
+
73
+ export function ApprovalGateProvider({ children }: { children: React.ReactNode }) {
74
+ const { t } = useTranslation()
75
+ const api = useApi()
76
+ const can = useCan()
77
+ const [pending, setPending] = useState<Pending | null>(null)
78
+ const pendingRef = useRef<Pending | null>(null)
79
+ pendingRef.current = pending
80
+
81
+ useEffect(() => {
82
+ gateMountedDelta(1)
83
+ const onParked = (e: Event) => {
84
+ const info = (e as CustomEvent<ApprovalRequiredInfo>).detail
85
+ if (info?.requestId) setPending({ kind: 'parked', info })
86
+ }
87
+ window.addEventListener(APPROVAL_PARKED_EVENT, onParked)
88
+ return () => {
89
+ gateMountedDelta(-1)
90
+ window.removeEventListener(APPROVAL_PARKED_EVENT, onParked)
91
+ }
92
+ }, [])
93
+
94
+ const canSelfApprove = useCallback((policy: string) => can(approvalCapability(policy)), [can])
95
+
96
+ const requestApproval = useCallback(
97
+ (opts: RequestApprovalOptions) => {
98
+ if (can(approvalCapability(opts.policy))) {
99
+ return Promise.resolve<ApprovalGrant>({ id: null, policy: opts.policy, bypassed: true })
100
+ }
101
+ return new Promise<ApprovalGrant | null>((resolve) => {
102
+ pendingRef.current?.kind === 'inline' && pendingRef.current.resolve(null)
103
+ setPending({ kind: 'inline', opts, resolve })
104
+ })
105
+ },
106
+ [can],
107
+ )
108
+
109
+ const close = () => {
110
+ const p = pendingRef.current
111
+ if (p?.kind === 'inline') p.resolve(null)
112
+ setPending(null)
113
+ }
114
+
115
+ const submit = async ({ pin, reason }: ApprovalPinSubmit) => {
116
+ const p = pendingRef.current
117
+ if (!p) return
118
+ if (p.kind === 'inline') {
119
+ const res = await api.post('/approvals/pin-grant', {
120
+ policy: p.opts.policy,
121
+ label: p.opts.label,
122
+ pin,
123
+ reason,
124
+ context: p.opts.context,
125
+ model: p.opts.model,
126
+ record_id: p.opts.recordId,
127
+ addon_key: p.opts.addonKey,
128
+ })
129
+ const row = res.data?.data
130
+ p.resolve({ id: row?.id ?? null, policy: p.opts.policy, bypassed: false, reason })
131
+ } else {
132
+ await api.post(`/approvals/${p.info.requestId}/approve-pin`, { pin, reason })
133
+ toast.success(t('approvals.approved_applied', { defaultValue: 'Aprobado y aplicado' }))
134
+ window.dispatchEvent(new CustomEvent(APPROVAL_DECIDED_EVENT, { detail: p.info }))
135
+ }
136
+ setPending(null)
137
+ }
138
+
139
+ const value = useMemo<ApprovalGateValue>(() => ({ requestApproval, canSelfApprove }), [requestApproval, canSelfApprove])
140
+
141
+ const title =
142
+ pending?.kind === 'inline'
143
+ ? pending.opts.label
144
+ : pending?.kind === 'parked'
145
+ ? pending.info.label || t('approvals.parked_title', { defaultValue: 'Requiere aprobación' })
146
+ : ''
147
+
148
+ return (
149
+ <ApprovalGateContext.Provider value={value}>
150
+ {children}
151
+ <ApprovalPinDialog
152
+ open={pending !== null}
153
+ title={title}
154
+ description={pending?.kind === 'inline' ? pending.opts.description : undefined}
155
+ onSubmit={submit}
156
+ onCancel={close}
157
+ />
158
+ </ApprovalGateContext.Provider>
159
+ )
160
+ }
161
+
162
+ /**
163
+ * Access the gate. Without a provider the gate fails CLOSED for inline requests
164
+ * (resolves null) — never silently authorizes — but honours the capability
165
+ * bypass, and `canSelfApprove` follows `useCan()` (always-true with no
166
+ * <PermissionsProvider>, i.e. legacy hosts keep today's behaviour).
167
+ */
168
+ export function useApprovalGate(): ApprovalGateValue {
169
+ const ctx = useContext(ApprovalGateContext)
170
+ const can = useCan()
171
+ return useMemo<ApprovalGateValue>(
172
+ () =>
173
+ ctx ?? {
174
+ canSelfApprove: (policy) => can(approvalCapability(policy)),
175
+ requestApproval: async (opts) =>
176
+ can(approvalCapability(opts.policy)) ? { id: null, policy: opts.policy, bypassed: true } : null,
177
+ },
178
+ [ctx, can],
179
+ )
180
+ }