@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,91 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import { useApi } from '../api-context'
3
+ import { buildRelationFilterParams } from '../dynamic-relation-helpers'
4
+ import type { DocumentSourceDef } from './types'
5
+
6
+ const DEFAULT_LIMIT = 50
7
+ // El kernel ≥ 0.139.2 acepta listas `in:` largas; se parte para no exceder la URL.
8
+ const VIA_CHUNK = 40
9
+
10
+ type Rows = Record<string, unknown>[]
11
+
12
+ export interface DocumentSourcesState {
13
+ sources: Record<string, Record<string, unknown>[]>
14
+ loading: boolean
15
+ /** Vuelve a pedir todas las fuentes (tras ejecutar una acción). */
16
+ reload: () => void
17
+ }
18
+
19
+ /**
20
+ * Carga las filas relacionadas con el documento (pagos, REP, notas de crédito…)
21
+ * que alimentan contadores, pestañas y derivados. Un fallo en una fuente deja
22
+ * esa fuente vacía: la página sigue siendo usable.
23
+ */
24
+ export function useDocumentSources(
25
+ defs: DocumentSourceDef[] | undefined,
26
+ recordId: string | number | undefined,
27
+ ): DocumentSourcesState {
28
+ const api = useApi()
29
+ const [sources, setSources] = useState<Record<string, Record<string, unknown>[]>>({})
30
+ const [loading, setLoading] = useState(false)
31
+ const [nonce, setNonce] = useState(0)
32
+ const reload = useCallback(() => setNonce((n) => n + 1), [])
33
+
34
+ useEffect(() => {
35
+ if (!defs || defs.length === 0 || recordId === undefined || recordId === null) {
36
+ setSources({})
37
+ return
38
+ }
39
+ let cancelled = false
40
+ setLoading(true)
41
+ const fetchRows = async (d: DocumentSourceDef, params: Record<string, string>): Promise<Rows> => {
42
+ try {
43
+ const res = await api.get(`/data/${d.model}`, {
44
+ params: { ...params, page: 1, per_page: d.limit ?? DEFAULT_LIMIT },
45
+ })
46
+ const body = res.data
47
+ const rows = (body?.data ?? body) as unknown
48
+ return Array.isArray(rows) ? (rows as Rows) : []
49
+ } catch {
50
+ return []
51
+ }
52
+ }
53
+ const direct = defs.filter((d) => !d.via)
54
+ const hopped = defs.filter((d) => d.via)
55
+ ;(async () => {
56
+ const out: Record<string, Rows> = {}
57
+ await Promise.all(
58
+ direct.map(async (d) => {
59
+ out[d.key] = await fetchRows(d, buildRelationFilterParams(d.foreignKey, recordId, d.where))
60
+ }),
61
+ )
62
+ // Segundo salto: depende de las filas del primero.
63
+ for (const d of hopped) {
64
+ const ids = Array.from(
65
+ new Set((out[d.via!.source] ?? []).map((r) => r[d.via!.field]).filter((v) => v != null && v !== '').map(String)),
66
+ )
67
+ const chunks: string[][] = []
68
+ for (let i = 0; i < ids.length; i += VIA_CHUNK) chunks.push(ids.slice(i, i + VIA_CHUNK))
69
+ const parts = await Promise.all(
70
+ chunks.map((c) => {
71
+ const params: Record<string, string> = { [`f_${d.foreignKey}`]: `in:${c.join(',')}` }
72
+ for (const [k, v] of Object.entries(d.where ?? {})) params[`f_${k}`] = `eq:${v}`
73
+ return fetchRows(d, params)
74
+ }),
75
+ )
76
+ out[d.key] = parts.flat()
77
+ }
78
+ return out
79
+ })()
80
+ .then((out) => {
81
+ if (cancelled) return
82
+ setSources(out)
83
+ setLoading(false)
84
+ })
85
+ return () => {
86
+ cancelled = true
87
+ }
88
+ }, [api, defs, recordId, nonce])
89
+
90
+ return { sources, loading, reload }
91
+ }
@@ -1312,7 +1312,11 @@ export function makeDefaultGetDynamicColumns(
1312
1312
 
1313
1313
  case 'tags': {
1314
1314
  const list: string[] = Array.isArray(value)
1315
- ? value.map(String)
1315
+ ? value.map((v) =>
1316
+ v && typeof v === 'object'
1317
+ ? String((v as { label?: unknown; name?: unknown; key?: unknown }).label ?? (v as { name?: unknown }).name ?? (v as { key?: unknown }).key ?? '')
1318
+ : String(v),
1319
+ ).filter(Boolean)
1316
1320
  : value
1317
1321
  ? String(value)
1318
1322
  .split(',')
@@ -83,6 +83,10 @@ export {
83
83
  export interface DynamicRelationStrings {
84
84
  title: string
85
85
  emptyState: string
86
+ /** Sub-tabla sin permiso de lectura (HTTP 403). Default: "Sin permiso para ver estos registros." */
87
+ forbiddenState?: string
88
+ /** Falla al cargar la sub-tabla (no 403). Default: "No se pudieron cargar los registros relacionados." */
89
+ errorState?: string
86
90
  addLabel: string
87
91
  editLabel: string
88
92
  removeLabel: string
@@ -252,6 +256,9 @@ function OneToManyRelation({
252
256
  const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
253
257
  const [rows, setRows] = useState<any[]>([])
254
258
  const [loading, setLoading] = useState(true)
259
+ // HTTP status of the last failed fetch (null = ok). Without it a 403/500
260
+ // rendered the empty state and read as "no related rows" (PIT-045).
261
+ const [fetchErrorStatus, setFetchErrorStatus] = useState<number | null>(null)
255
262
  // Paginación server-side + scroll infinito, igual que <DynamicTable>.
256
263
  const [total, setTotal] = useState(0)
257
264
  const [page, setPage] = useState(1)
@@ -341,6 +348,7 @@ function OneToManyRelation({
341
348
  cacheMetadata(model, fresh)
342
349
  }
343
350
  const list = (dataRes as { data: ApiResponse<any[]> }).data
351
+ setFetchErrorStatus(null)
344
352
  if (list.success) {
345
353
  const fetched = list.data || []
346
354
  setRows((prev) => (append ? dedupeById(prev, fetched) : fetched))
@@ -351,6 +359,7 @@ function OneToManyRelation({
351
359
  }
352
360
  } catch (err) {
353
361
  console.error('DynamicRelation fetch error', err)
362
+ setFetchErrorStatus((err as { response?: { status?: number } })?.response?.status ?? 0)
354
363
  } finally {
355
364
  if (append) setLoadingMore(false)
356
365
  else setLoading(false)
@@ -555,7 +564,11 @@ function OneToManyRelation({
555
564
  </div>
556
565
  ) : rows.length === 0 ? (
557
566
  <div className="text-center text-sm text-muted-foreground py-8 border rounded-md bg-muted/30">
558
- {labels.emptyState}
567
+ {fetchErrorStatus === 403
568
+ ? (labels.forbiddenState ?? 'Sin permiso para ver estos registros.')
569
+ : fetchErrorStatus !== null
570
+ ? (labels.errorState ?? 'No se pudieron cargar los registros relacionados.')
571
+ : labels.emptyState}
559
572
  </div>
560
573
  ) : (
561
574
  // Real metadata-driven table — same metacore-ui primitives and
@@ -34,6 +34,7 @@ import {
34
34
  AlertDialogTitle,
35
35
  } from '@asteby/metacore-ui/primitives'
36
36
  import { useApi } from './api-context'
37
+ import { useReasonPrompt } from './reason-prompt'
37
38
  import { ActionModalDispatcher } from './action-modal-dispatcher'
38
39
  import { DynamicRecordDialog } from './dialogs/dynamic-record'
39
40
  import { RequiresAddonDialog, resolveRequiresAddon } from './requires-addon'
@@ -102,6 +103,9 @@ export function useDynamicRowActions({
102
103
 
103
104
  const [rowToDelete, setRowToDelete] = useState<any | null>(null)
104
105
  const [isDeleting, setIsDeleting] = useState(false)
106
+ // Models that declare `reason_required.delete` answer a bare DELETE with 422
107
+ // errors.reason; the prompt asks once and retries with `?reason=`.
108
+ const reasonPrompt = useReasonPrompt()
105
109
 
106
110
  const [actionModal, setActionModal] = useState<{
107
111
  open: boolean
@@ -157,7 +161,14 @@ export function useDynamicRowActions({
157
161
  setIsDeleting(true)
158
162
  try {
159
163
  const deleteEndpoint = writeBase ? `${writeBase}/${rowToDelete.id}` : `/data/${model}/${rowToDelete.id}`
160
- const res = await api.delete(deleteEndpoint)
164
+ const res = await reasonPrompt.run({
165
+ title: t('dynamic.delete_reason_title', { defaultValue: 'Eliminar registro' }),
166
+ description: t('dynamic.delete_reason_desc', { defaultValue: 'Este modelo pide el motivo de la eliminación.' }),
167
+ confirmLabel: t('dynamic.delete_confirm', { defaultValue: 'Eliminar' }),
168
+ request: (reason) => api.delete(deleteEndpoint, reason ? { params: { reason } } : undefined),
169
+ })
170
+ // El operador canceló el motivo: no se eliminó nada.
171
+ if (!res) return
161
172
  // CRUD estándar: no usar res.data.message (el endpoint dinámico
162
173
  // devuelve texto en inglés que se filtraría al toast). String localizado.
163
174
  if (res.data.success) { toast.success(t('dynamic.delete_success', { defaultValue: 'Registro eliminado correctamente' })); onRefresh() }
@@ -173,6 +184,7 @@ export function useDynamicRowActions({
173
184
 
174
185
  const dialogs = (
175
186
  <>
187
+ {reasonPrompt.dialog}
176
188
  <AlertDialog open={!!rowToDelete} onOpenChange={(open: boolean) => !open && setRowToDelete(null)}>
177
189
  <AlertDialogContent>
178
190
  <AlertDialogHeader>
@@ -67,6 +67,7 @@ import { toast } from 'sonner'
67
67
  import { Progress } from './dialogs/_primitives'
68
68
  import { useMetadataCache } from './metadata-cache'
69
69
  import { useApi, useCurrentBranch } from './api-context'
70
+ import { useReasonPrompt } from './reason-prompt'
70
71
  import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
71
72
  import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
72
73
  import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
@@ -1123,6 +1124,7 @@ export function DynamicTable({
1123
1124
  onRefresh: handleRefresh,
1124
1125
  })
1125
1126
 
1127
+ const bulkReasonPrompt = useReasonPrompt()
1126
1128
  const confirmBulkDelete = async () => {
1127
1129
  const selectedRows = table.getFilteredSelectedRowModel().rows
1128
1130
  if (selectedRows.length === 0) return
@@ -1130,13 +1132,29 @@ export function DynamicTable({
1130
1132
  setBulkDeleteTotal(selectedRows.length)
1131
1133
  setBulkDeleteProgress(0)
1132
1134
  let successCount = 0, errorCount = 0
1135
+ // One reason for the whole batch: asked the first time a row is refused
1136
+ // for lacking it (model declares reason_required.delete), reused after.
1137
+ let bulkReason: string | undefined
1138
+ let bulkCancelled = false
1133
1139
  for (let i = 0; i < selectedRows.length; i++) {
1134
1140
  const row = selectedRows[i]
1141
+ if (bulkCancelled) { errorCount++; setBulkDeleteProgress(i + 1); continue }
1135
1142
  try {
1136
1143
  const writeBase = mutationEndpoint ?? endpoint
1137
1144
  const deleteEndpoint = writeBase ? `${writeBase}/${row.original.id}` : `/data/${model}/${row.original.id}`
1138
- const res = await api.delete(deleteEndpoint)
1139
- if (res.data.success) successCount++; else errorCount++
1145
+ const res = await bulkReasonPrompt.run({
1146
+ title: t('dynamic.bulk_delete_reason_title', { count: selectedRows.length, defaultValue: 'Eliminar {{count}} registro(s)' }),
1147
+ description: t('dynamic.delete_reason_desc', { defaultValue: 'Este modelo pide el motivo de la eliminación.' }),
1148
+ confirmLabel: t('dynamic.delete_confirm', { defaultValue: 'Eliminar' }),
1149
+ reason: bulkReason,
1150
+ request: (reason) => {
1151
+ if (reason) bulkReason = reason
1152
+ return api.delete(deleteEndpoint, reason ? { params: { reason } } : undefined)
1153
+ },
1154
+ })
1155
+ if (!res) { bulkCancelled = true; errorCount++ }
1156
+ else if (res.data.success) successCount++
1157
+ else errorCount++
1140
1158
  } catch (e) { console.error('Error al eliminar', e); errorCount++ }
1141
1159
  setBulkDeleteProgress(i + 1)
1142
1160
  }
@@ -1863,6 +1881,8 @@ export function DynamicTable({
1863
1881
  </AlertDialogContent>
1864
1882
  </AlertDialog>
1865
1883
 
1884
+ {bulkReasonPrompt.dialog}
1885
+
1866
1886
  {exportEnabled && (
1867
1887
  <ExportDialog open={exportOpen} onOpenChange={setExportOpen} model={model} metadata={metadata} currentFilters={buildFilterParams()} hasActiveFilters={hasActiveFilters} />
1868
1888
  )}
package/src/index.ts CHANGED
@@ -218,6 +218,35 @@ export {
218
218
  type CanFn,
219
219
  type PermissionsProviderProps,
220
220
  } from './permissions-context'
221
+ export {
222
+ ApprovalPinDialog,
223
+ approvalErrorMessage,
224
+ type ApprovalPinDialogProps,
225
+ type ApprovalPinSubmit,
226
+ } from './approval-pin-dialog'
227
+ export {
228
+ ApprovalGateProvider,
229
+ useApprovalGate,
230
+ approvalCapability,
231
+ approvalRequiredInfo,
232
+ approvalGateAvailable,
233
+ APPROVAL_PARKED_EVENT,
234
+ APPROVAL_DECIDED_EVENT,
235
+ type ApprovalGateValue,
236
+ type ApprovalGrant,
237
+ type ApprovalRequiredInfo,
238
+ type RequestApprovalOptions,
239
+ } from './approval-gate'
240
+ export {
241
+ ReasonPromptDialog,
242
+ useReasonPrompt,
243
+ reasonRequiredInfo,
244
+ DEFAULT_REASON_MIN,
245
+ type ReasonPromptDialogProps,
246
+ type ReasonPromptApi,
247
+ type ReasonRequiredInfo,
248
+ type RunWithReasonOptions,
249
+ } from './reason-prompt'
221
250
  export {
222
251
  useDynamicRowActions,
223
252
  type UseDynamicRowActionsParams,
@@ -282,6 +311,7 @@ export {
282
311
  type SidebarLayoutManagerProps,
283
312
  } from './sidebar-layout-manager'
284
313
  export * from './org-runtime-context'
314
+ export * from './business'
285
315
  export * from './org-runtime-provider'
286
316
  export * from './navigation-builder'
287
317
  export * from './i18n-provider'
@@ -581,3 +611,6 @@ export {
581
611
  type AttributeClass,
582
612
  type AttributeClassSection,
583
613
  } from './attribute-classes'
614
+
615
+ // Página de documento (benchmark §5/§6): cabecera, badges, acciones por estado, botones inteligentes.
616
+ export * from './document'
@@ -0,0 +1,247 @@
1
+ // Mandatory reason on destructive operations (PER-4).
2
+ //
3
+ // A manifest model declares `reason_required` (delete and/or actions such as
4
+ // cancel). The kernel then answers a DELETE / action WITHOUT a reason with
5
+ // 422 `errors.reason: [{code: "required" | "min", params: {min}}]`. Instead of
6
+ // every screen carrying its own "motivo" prompt, `useReasonPrompt().run()` wraps
7
+ // the request: it tries it, and when the server says a reason is needed it asks
8
+ // once (a dialog) and retries with the reason attached. Screens that already know
9
+ // they need one can pass `reason` up front through the same helper.
10
+ //
11
+ // The reason is stamped on the canonical event by the kernel, so the activity log
12
+ // (RecordHistory) shows who removed/cancelled what, when, and why.
13
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
14
+ import { useTranslation } from 'react-i18next'
15
+ import { Loader2 } from 'lucide-react'
16
+ import {
17
+ Button,
18
+ Dialog,
19
+ DialogContent,
20
+ DialogDescription,
21
+ DialogFooter,
22
+ DialogHeader,
23
+ DialogTitle,
24
+ Textarea,
25
+ } from '@asteby/metacore-ui/primitives'
26
+
27
+ /** What the server told us about the missing reason. */
28
+ export interface ReasonRequiredInfo {
29
+ /** Minimum trimmed length the policy demands (default 3). */
30
+ min: number
31
+ /** True when a reason WAS sent but was too short. */
32
+ tooShort: boolean
33
+ }
34
+
35
+ export const DEFAULT_REASON_MIN = 3
36
+
37
+ /**
38
+ * Detects the kernel's mandatory-reason refusal in an axios error (or a bare
39
+ * response body): HTTP 422 with `errors.reason[0].code` of `required` | `min`.
40
+ * Returns null for any other error.
41
+ */
42
+ export function reasonRequiredInfo(err: unknown): ReasonRequiredInfo | null {
43
+ const data = (err as { response?: { data?: unknown } } | undefined)?.response?.data ?? err
44
+ const issues = (data as { errors?: { reason?: unknown } } | undefined)?.errors?.reason
45
+ if (!Array.isArray(issues) || issues.length === 0) return null
46
+ const first = issues[0] as { code?: string; params?: { min?: number } } | string
47
+ const code = typeof first === 'string' ? first : first?.code
48
+ if (code !== 'required' && code !== 'min') return null
49
+ const min = typeof first === 'object' && typeof first?.params?.min === 'number' ? first.params.min : DEFAULT_REASON_MIN
50
+ return { min, tooShort: code === 'min' }
51
+ }
52
+
53
+ export interface ReasonPromptDialogProps {
54
+ open: boolean
55
+ title: string
56
+ description?: React.ReactNode
57
+ /** Minimum trimmed length; the submit button stays disabled below it. */
58
+ min?: number
59
+ /** Label of the confirm button (default "Continuar"). */
60
+ confirmLabel?: string
61
+ /** Style the confirm button as destructive (delete / cancel). */
62
+ destructive?: boolean
63
+ /** Inline error from a previous attempt. */
64
+ error?: string | null
65
+ busy?: boolean
66
+ onSubmit: (reason: string) => void
67
+ onCancel: () => void
68
+ }
69
+
70
+ export function ReasonPromptDialog({
71
+ open,
72
+ title,
73
+ description,
74
+ min = DEFAULT_REASON_MIN,
75
+ confirmLabel,
76
+ destructive = true,
77
+ error,
78
+ busy,
79
+ onSubmit,
80
+ onCancel,
81
+ }: ReasonPromptDialogProps) {
82
+ const { t } = useTranslation()
83
+ const [reason, setReason] = useState('')
84
+
85
+ useEffect(() => {
86
+ if (open) setReason('')
87
+ }, [open])
88
+
89
+ const ready = reason.trim().length >= min
90
+
91
+ return (
92
+ <Dialog open={open} onOpenChange={(o: boolean) => !o && !busy && onCancel()}>
93
+ <DialogContent className="sm:max-w-md">
94
+ <DialogHeader>
95
+ <DialogTitle>{title}</DialogTitle>
96
+ {description ? <DialogDescription>{description}</DialogDescription> : null}
97
+ </DialogHeader>
98
+ <div className="space-y-2">
99
+ <label htmlFor="mc-reason" className="text-sm font-medium">
100
+ {t('reason.label', { defaultValue: 'Motivo' })}
101
+ </label>
102
+ <Textarea
103
+ id="mc-reason"
104
+ autoFocus
105
+ rows={3}
106
+ value={reason}
107
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setReason(e.target.value)}
108
+ placeholder={t('reason.placeholder', { defaultValue: 'Explica por qué. Queda en la bitácora.' })}
109
+ />
110
+ {error ? <p className="text-destructive text-sm">{error}</p> : null}
111
+ {!error && reason.length > 0 && !ready ? (
112
+ <p className="text-muted-foreground text-xs">
113
+ {t('reason.min', { min, defaultValue: 'Escribe al menos {{min}} caracteres.' })}
114
+ </p>
115
+ ) : null}
116
+ </div>
117
+ <DialogFooter>
118
+ <Button variant="outline" onClick={onCancel} disabled={busy}>
119
+ {t('common.cancel', { defaultValue: 'Cancelar' })}
120
+ </Button>
121
+ <Button
122
+ variant={destructive ? 'destructive' : 'default'}
123
+ disabled={!ready || busy}
124
+ onClick={() => onSubmit(reason.trim())}
125
+ >
126
+ {busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
127
+ {confirmLabel ?? t('reason.confirm', { defaultValue: 'Continuar' })}
128
+ </Button>
129
+ </DialogFooter>
130
+ </DialogContent>
131
+ </Dialog>
132
+ )
133
+ }
134
+
135
+ export interface RunWithReasonOptions<T> {
136
+ /** Dialog headline, e.g. "Eliminar registro". */
137
+ title: string
138
+ description?: React.ReactNode
139
+ confirmLabel?: string
140
+ destructive?: boolean
141
+ /**
142
+ * Performs the request. Called first WITHOUT a reason; when the server
143
+ * demands one it is called again with the operator's reason. Throw (axios
144
+ * style) on failure.
145
+ */
146
+ request: (reason?: string) => Promise<T>
147
+ /** A reason already collected (bulk operations reuse one for every row). */
148
+ reason?: string
149
+ }
150
+
151
+ export interface ReasonPromptApi {
152
+ /**
153
+ * Resolves the request's result, or `undefined` when the operator cancelled
154
+ * the reason prompt. Any error that is NOT a reason refusal is rethrown.
155
+ */
156
+ run: <T>(opts: RunWithReasonOptions<T>) => Promise<T | undefined>
157
+ /** Render once near the caller: the dialog the helper opens. */
158
+ dialog: React.ReactNode
159
+ }
160
+
161
+ interface PendingReason {
162
+ opts: RunWithReasonOptions<unknown>
163
+ info: ReasonRequiredInfo
164
+ resolve: (v: unknown) => void
165
+ reject: (e: unknown) => void
166
+ error: string | null
167
+ }
168
+
169
+ /** See the module comment. Render `dialog` somewhere in the caller's tree. */
170
+ export function useReasonPrompt(): ReasonPromptApi {
171
+ const { t } = useTranslation()
172
+ const [pending, setPending] = useState<PendingReason | null>(null)
173
+ const [busy, setBusy] = useState(false)
174
+ const pendingRef = useRef<PendingReason | null>(null)
175
+ pendingRef.current = pending
176
+
177
+ const run = useCallback(<T,>(opts: RunWithReasonOptions<T>): Promise<T | undefined> => {
178
+ return new Promise<T | undefined>((resolve, reject) => {
179
+ opts
180
+ .request(opts.reason)
181
+ .then((v) => resolve(v))
182
+ .catch((err) => {
183
+ const info = reasonRequiredInfo(err)
184
+ if (!info) return reject(err)
185
+ setPending({
186
+ opts: opts as RunWithReasonOptions<unknown>,
187
+ info,
188
+ resolve: resolve as (v: unknown) => void,
189
+ reject,
190
+ error: null,
191
+ })
192
+ })
193
+ })
194
+ }, [])
195
+
196
+ const submit = async (reason: string) => {
197
+ const p = pendingRef.current
198
+ if (!p) return
199
+ setBusy(true)
200
+ try {
201
+ const v = await p.opts.request(reason)
202
+ setPending(null)
203
+ p.resolve(v)
204
+ } catch (err) {
205
+ const info = reasonRequiredInfo(err)
206
+ if (info) {
207
+ // Server still not satisfied (a longer minimum): stay open, say why.
208
+ setPending({
209
+ ...p,
210
+ info,
211
+ error: t('reason.min', {
212
+ min: info.min,
213
+ defaultValue: 'Escribe al menos {{min}} caracteres.',
214
+ }),
215
+ })
216
+ } else {
217
+ setPending(null)
218
+ p.reject(err)
219
+ }
220
+ } finally {
221
+ setBusy(false)
222
+ }
223
+ }
224
+
225
+ const cancel = () => {
226
+ const p = pendingRef.current
227
+ setPending(null)
228
+ p?.resolve(undefined)
229
+ }
230
+
231
+ const dialog = (
232
+ <ReasonPromptDialog
233
+ open={pending !== null}
234
+ title={pending?.opts.title ?? ''}
235
+ description={pending?.opts.description}
236
+ min={pending?.info.min}
237
+ confirmLabel={pending?.opts.confirmLabel}
238
+ destructive={pending?.opts.destructive}
239
+ error={pending?.error}
240
+ busy={busy}
241
+ onSubmit={submit}
242
+ onCancel={cancel}
243
+ />
244
+ )
245
+
246
+ return { run, dialog }
247
+ }
@@ -11,6 +11,7 @@
11
11
  // the toast description, in ONE place so every call site behaves identically.
12
12
  import { toast } from 'sonner'
13
13
  import { validationCatalog, validationMessageKey } from './validation-catalog'
14
+ import { APPROVAL_PARKED_EVENT, approvalGateAvailable, approvalRequiredInfo } from './approval-events'
14
15
 
15
16
  /** Structured, display-ready view of an error: a headline + an optional cause. */
16
17
  export interface ExtractedError {
@@ -234,6 +235,25 @@ export function toastServerError(
234
235
  const t = opts?.t
235
236
  const lang = opts?.language
236
237
  const cat = validationCatalog(lang)
238
+ // A write parked for approval is not a failure: say so, and when an
239
+ // <ApprovalGateProvider> is mounted offer to approve it right now with a PIN.
240
+ const parked = approvalRequiredInfo(err)
241
+ if (parked) {
242
+ const title = parked.label || (t ? t('approvals.sent', { defaultValue: 'Enviado a aprobación' }) : 'Enviado a aprobación')
243
+ toast.info(title, {
244
+ description: parked.roles.length
245
+ ? (t ? t('approvals.waiting_for', { defaultValue: 'Lo decide: {{roles}}', roles: parked.roles.join(', ') }) : parked.roles.join(', '))
246
+ : undefined,
247
+ action:
248
+ parked.requestId && approvalGateAvailable()
249
+ ? {
250
+ label: t ? t('approvals.approve_with_pin', { defaultValue: 'Aprobar con PIN' }) : 'Aprobar con PIN',
251
+ onClick: () => window.dispatchEvent(new CustomEvent(APPROVAL_PARKED_EVENT, { detail: parked })),
252
+ }
253
+ : undefined,
254
+ })
255
+ return
256
+ }
237
257
  const map = extractFieldErrors(err)
238
258
  if (map) {
239
259
  const localized = t
@@ -27,9 +27,19 @@ import {
27
27
  } from '@tanstack/react-query'
28
28
  import { useApi } from './api-context'
29
29
 
30
- /** Stable react-query key for an addon's per-org settings. */
31
- export function addonSettingsKey(addonKey: string) {
32
- return ['addon-settings', addonKey] as const
30
+ /** Stable react-query key for an addon's per-org settings (per branch when given). */
31
+ export function addonSettingsKey(addonKey: string, branchId?: string | null) {
32
+ return branchId ? (['addon-settings', addonKey, branchId] as const) : (['addon-settings', addonKey] as const)
33
+ }
34
+
35
+ /**
36
+ * Settings URL. With a branch, the host answers the settings the addon runs
37
+ * with there: org values with each `scope: branch` key replaced by the branch's
38
+ * override (POS-1). Without one, the org values (legacy behaviour).
39
+ */
40
+ export function addonSettingsUrl(addonKey: string, branchId?: string | null): string {
41
+ const base = `/api/addons/${addonKey}/settings`
42
+ return branchId ? `${base}?branch_id=${encodeURIComponent(branchId)}` : base
33
43
  }
34
44
 
35
45
  /**
@@ -63,6 +73,12 @@ export interface UseAddonSettingsOptions<T> {
63
73
  staleTime?: number
64
74
  /** Defer fetching (e.g. until the addon key is known). */
65
75
  enabled?: boolean
76
+ /**
77
+ * Read the settings as one branch sees them (manifest `scope: branch` keys
78
+ * resolve to the branch override, else the org value). Omit for org values.
79
+ * A terminal passes its active branch here.
80
+ */
81
+ branchId?: string | null
66
82
  }
67
83
 
68
84
  export interface UseAddonSettingsResult<T> {
@@ -92,12 +108,12 @@ export function useAddonSettings<T extends Record<string, unknown> = Record<stri
92
108
  opts: UseAddonSettingsOptions<T> = {},
93
109
  ): UseAddonSettingsResult<T> {
94
110
  const api = useApi()
95
- const { defaults, staleTime, enabled } = opts
111
+ const { defaults, staleTime, enabled, branchId } = opts
96
112
 
97
113
  const query = useQuery<Partial<T>>({
98
- queryKey: addonSettingsKey(addonKey),
114
+ queryKey: addonSettingsKey(addonKey, branchId),
99
115
  queryFn: async () => {
100
- const res = await api.get(`/api/addons/${addonKey}/settings`)
116
+ const res = await api.get(addonSettingsUrl(addonKey, branchId))
101
117
  const body = (res as { data: any }).data
102
118
  // Host envelope: { success, data: { <key>: <value> } }.
103
119
  const values = body?.success ? body.data : body
@@ -134,18 +150,21 @@ export function useAddonSettings<T extends Record<string, unknown> = Record<stri
134
150
  */
135
151
  export function useUpdateAddonSettings<T extends Record<string, unknown> = Record<string, unknown>>(
136
152
  addonKey: string,
153
+ /** Write branch overrides (only `scope: branch` keys; `null` clears one) instead of org values. */
154
+ branchId?: string | null,
137
155
  ) {
138
156
  const api = useApi()
139
157
  const qc = useQueryClient()
140
158
 
141
159
  return useMutation<Partial<T>, Error, Partial<T>>({
142
160
  mutationFn: async (values) => {
143
- const res = await api.put(`/api/addons/${addonKey}/settings`, values)
161
+ const res = await api.put(addonSettingsUrl(addonKey, branchId), values)
144
162
  const body = (res as { data: any }).data
145
163
  const saved = body?.success ? body.data : body
146
164
  return (saved ?? values) as Partial<T>
147
165
  },
148
166
  onSuccess: () => {
167
+ // Prefix match: refreshes the org read and every branch read.
149
168
  void qc.invalidateQueries({ queryKey: addonSettingsKey(addonKey) })
150
169
  },
151
170
  })