@asteby/metacore-runtime-react 47.1.0 → 49.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 (190) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/action-modal-dispatcher.d.ts +17 -3
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +80 -13
  5. package/dist/action-priority.d.ts +34 -0
  6. package/dist/action-priority.d.ts.map +1 -0
  7. package/dist/action-priority.js +60 -0
  8. package/dist/business/catalog-product-search.d.ts +32 -0
  9. package/dist/business/catalog-product-search.d.ts.map +1 -0
  10. package/dist/business/catalog-product-search.js +71 -0
  11. package/dist/business/catalog.d.ts.map +1 -1
  12. package/dist/business/catalog.js +2 -0
  13. package/dist/business/document-editor-model.d.ts +119 -0
  14. package/dist/business/document-editor-model.d.ts.map +1 -0
  15. package/dist/business/document-editor-model.js +317 -0
  16. package/dist/business/document-editor-panels.d.ts +64 -0
  17. package/dist/business/document-editor-panels.d.ts.map +1 -0
  18. package/dist/business/document-editor-panels.js +58 -0
  19. package/dist/business/document-editor.d.ts +27 -0
  20. package/dist/business/document-editor.d.ts.map +1 -0
  21. package/dist/business/document-editor.js +493 -0
  22. package/dist/business/document-lines.d.ts.map +1 -1
  23. package/dist/business/document-lines.js +5 -1
  24. package/dist/business/index.d.ts +5 -0
  25. package/dist/business/index.d.ts.map +1 -1
  26. package/dist/business/index.js +5 -0
  27. package/dist/business/line-items.d.ts +16 -1
  28. package/dist/business/line-items.d.ts.map +1 -1
  29. package/dist/business/line-items.js +14 -2
  30. package/dist/business/payment-allocator.d.ts +19 -0
  31. package/dist/business/payment-allocator.d.ts.map +1 -0
  32. package/dist/business/payment-allocator.js +31 -0
  33. package/dist/business/product-search.d.ts +4 -0
  34. package/dist/business/product-search.d.ts.map +1 -1
  35. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  36. package/dist/dialogs/dynamic-record.js +14 -7
  37. package/dist/document/document-page.d.ts.map +1 -1
  38. package/dist/document/document-page.js +8 -1
  39. package/dist/document/evaluate.d.ts.map +1 -1
  40. package/dist/document/evaluate.js +17 -1
  41. package/dist/document/index.d.ts +1 -0
  42. package/dist/document/index.d.ts.map +1 -1
  43. package/dist/document/index.js +1 -0
  44. package/dist/document/secondary-bar.d.ts +20 -0
  45. package/dist/document/secondary-bar.d.ts.map +1 -0
  46. package/dist/document/secondary-bar.js +44 -0
  47. package/dist/document/types.d.ts +8 -0
  48. package/dist/document/types.d.ts.map +1 -1
  49. package/dist/document-form-dialog.d.ts +31 -2
  50. package/dist/document-form-dialog.d.ts.map +1 -1
  51. package/dist/document-form-dialog.js +46 -11
  52. package/dist/dynamic-columns-helpers.d.ts +9 -0
  53. package/dist/dynamic-columns-helpers.d.ts.map +1 -1
  54. package/dist/dynamic-columns-helpers.js +19 -0
  55. package/dist/dynamic-columns.d.ts +9 -13
  56. package/dist/dynamic-columns.d.ts.map +1 -1
  57. package/dist/dynamic-columns.js +27 -21
  58. package/dist/dynamic-crud-page.d.ts +18 -0
  59. package/dist/dynamic-crud-page.d.ts.map +1 -1
  60. package/dist/dynamic-crud-page.js +15 -6
  61. package/dist/dynamic-form-schema.d.ts.map +1 -1
  62. package/dist/dynamic-form-schema.js +15 -3
  63. package/dist/dynamic-form.d.ts +16 -1
  64. package/dist/dynamic-form.d.ts.map +1 -1
  65. package/dist/dynamic-form.js +78 -20
  66. package/dist/dynamic-kanban.d.ts +9 -3
  67. package/dist/dynamic-kanban.d.ts.map +1 -1
  68. package/dist/dynamic-kanban.js +31 -12
  69. package/dist/dynamic-relation-helpers.d.ts.map +1 -1
  70. package/dist/dynamic-relation-helpers.js +8 -0
  71. package/dist/dynamic-relation.js +1 -1
  72. package/dist/dynamic-row-actions.d.ts +8 -0
  73. package/dist/dynamic-row-actions.d.ts.map +1 -1
  74. package/dist/dynamic-row-actions.js +12 -2
  75. package/dist/dynamic-table.d.ts.map +1 -1
  76. package/dist/dynamic-table.js +64 -63
  77. package/dist/index.d.ts +7 -2
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +19 -2
  80. package/dist/installed-addons-context.d.ts +15 -0
  81. package/dist/installed-addons-context.d.ts.map +1 -0
  82. package/dist/installed-addons-context.js +39 -0
  83. package/dist/org-runtime-context.d.ts +8 -0
  84. package/dist/org-runtime-context.d.ts.map +1 -1
  85. package/dist/org-runtime-context.js +8 -0
  86. package/dist/org-runtime-provider.d.ts +3 -1
  87. package/dist/org-runtime-provider.d.ts.map +1 -1
  88. package/dist/org-runtime-provider.js +3 -3
  89. package/dist/primitives/aging.d.ts +34 -0
  90. package/dist/primitives/aging.d.ts.map +1 -0
  91. package/dist/primitives/aging.js +48 -0
  92. package/dist/primitives/allocation.d.ts +42 -0
  93. package/dist/primitives/allocation.d.ts.map +1 -0
  94. package/dist/primitives/allocation.js +59 -0
  95. package/dist/primitives/approvals.d.ts +71 -0
  96. package/dist/primitives/approvals.d.ts.map +1 -0
  97. package/dist/primitives/approvals.js +48 -0
  98. package/dist/primitives/contributions.d.ts +93 -0
  99. package/dist/primitives/contributions.d.ts.map +1 -0
  100. package/dist/primitives/contributions.js +163 -0
  101. package/dist/primitives/document-kinds.d.ts +25 -0
  102. package/dist/primitives/document-kinds.d.ts.map +1 -0
  103. package/dist/primitives/document-kinds.js +26 -0
  104. package/dist/primitives/index.d.ts +8 -0
  105. package/dist/primitives/index.d.ts.map +1 -0
  106. package/dist/primitives/index.js +23 -0
  107. package/dist/primitives/panels.d.ts +68 -0
  108. package/dist/primitives/panels.d.ts.map +1 -0
  109. package/dist/primitives/panels.js +3 -0
  110. package/dist/primitives/use-contributions.d.ts +15 -0
  111. package/dist/primitives/use-contributions.d.ts.map +1 -0
  112. package/dist/primitives/use-contributions.js +30 -0
  113. package/dist/record-actions.d.ts +21 -0
  114. package/dist/record-actions.d.ts.map +1 -0
  115. package/dist/record-actions.js +76 -0
  116. package/dist/record-prefill-bar.d.ts +11 -0
  117. package/dist/record-prefill-bar.d.ts.map +1 -0
  118. package/dist/record-prefill-bar.js +33 -0
  119. package/dist/row-actions-menu.d.ts +32 -0
  120. package/dist/row-actions-menu.d.ts.map +1 -0
  121. package/dist/row-actions-menu.js +59 -0
  122. package/dist/slot.d.ts +4 -21
  123. package/dist/slot.d.ts.map +1 -1
  124. package/dist/slot.js +14 -36
  125. package/dist/types.d.ts +134 -0
  126. package/dist/types.d.ts.map +1 -1
  127. package/package.json +3 -3
  128. package/src/__tests__/catalog-product-search.test.ts +61 -0
  129. package/src/__tests__/document-editor-model.test.ts +221 -0
  130. package/src/__tests__/document-editor.test.tsx +340 -0
  131. package/src/__tests__/document-form-dialog.test.tsx +158 -3
  132. package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
  133. package/src/__tests__/dynamic-kanban.test.tsx +24 -3
  134. package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
  135. package/src/__tests__/installed-addons-context.test.tsx +50 -0
  136. package/src/__tests__/line-item-totals.test.ts +28 -0
  137. package/src/__tests__/prefill-scalar-from-record.test.ts +52 -1
  138. package/src/__tests__/primitives-federated.test.tsx +156 -0
  139. package/src/__tests__/primitives.test.ts +224 -0
  140. package/src/__tests__/record-prefill-bar.test.tsx +50 -0
  141. package/src/__tests__/row-action-link-navigation.test.ts +17 -0
  142. package/src/__tests__/secondary-actions.test.tsx +261 -0
  143. package/src/action-modal-dispatcher.tsx +97 -12
  144. package/src/action-priority.ts +82 -0
  145. package/src/business/business.test.ts +1 -1
  146. package/src/business/catalog-product-search.ts +105 -0
  147. package/src/business/catalog.ts +2 -0
  148. package/src/business/document-editor-model.ts +367 -0
  149. package/src/business/document-editor-panels.tsx +164 -0
  150. package/src/business/document-editor.tsx +781 -0
  151. package/src/business/document-lines.ts +5 -1
  152. package/src/business/index.ts +5 -0
  153. package/src/business/line-items.ts +31 -2
  154. package/src/business/payment-allocator.tsx +110 -0
  155. package/src/business/product-search.ts +4 -0
  156. package/src/dialogs/dynamic-record.tsx +29 -6
  157. package/src/document/document-page.tsx +15 -0
  158. package/src/document/evaluate.ts +17 -1
  159. package/src/document/index.ts +1 -0
  160. package/src/document/secondary-bar.tsx +92 -0
  161. package/src/document/types.ts +8 -0
  162. package/src/document-form-dialog.tsx +96 -12
  163. package/src/dynamic-columns-helpers.ts +22 -0
  164. package/src/dynamic-columns.tsx +35 -62
  165. package/src/dynamic-crud-page.tsx +33 -5
  166. package/src/dynamic-form-schema.ts +15 -2
  167. package/src/dynamic-form.tsx +117 -39
  168. package/src/dynamic-kanban.tsx +34 -7
  169. package/src/dynamic-relation-helpers.ts +9 -0
  170. package/src/dynamic-relation.tsx +7 -2
  171. package/src/dynamic-row-actions.tsx +12 -2
  172. package/src/dynamic-table.tsx +3 -0
  173. package/src/index.ts +46 -1
  174. package/src/installed-addons-context.tsx +58 -0
  175. package/src/org-runtime-context.ts +10 -0
  176. package/src/org-runtime-provider.tsx +7 -2
  177. package/src/primitives/aging.ts +76 -0
  178. package/src/primitives/allocation.ts +104 -0
  179. package/src/primitives/approvals.ts +103 -0
  180. package/src/primitives/contributions.ts +255 -0
  181. package/src/primitives/document-kinds.ts +62 -0
  182. package/src/primitives/index.ts +23 -0
  183. package/src/primitives/panels.ts +59 -0
  184. package/src/primitives/use-contributions.ts +42 -0
  185. package/src/record-actions.ts +101 -0
  186. package/src/record-prefill-bar.tsx +62 -0
  187. package/src/row-action-stage-gate.test.ts +33 -0
  188. package/src/row-actions-menu.tsx +159 -0
  189. package/src/slot.tsx +18 -53
  190. package/src/types.ts +129 -0
@@ -0,0 +1,781 @@
1
+ // DocumentEditor — alta de un documento comercial en UNA pantalla por
2
+ // secciones: factura, NC, cobro/REP, cotización, OC… Sustituye al wizard
3
+ // «Datos → Siguiente → Renglones» de DocumentFormDialog cuando el tipo declara
4
+ // `layout: "editor"` en `document_forms` (lo sirve el kernel ≥ v0.190.0).
5
+ //
6
+ // Contraparte + lo esencial (PartyCard: datos clave y crédito)
7
+ // Cargar desde… (sources: el renglón/encabezado se precarga)
8
+ // Renglones | Reparto del pago (DocumentLinesGrid con catálogo | PaymentAllocator)
9
+ // Totales
10
+ // ▸ Opciones fiscales (plegada: extensiones fiscal_data.*, catálogos
11
+ // con default y contribuciones header.fields)
12
+ // Secciones de otros addons (body.sections / side.panels)
13
+ // ▸ Vista previa (plegada; acción `preview` con analyze: true)
14
+ // [Cancelar] [Acción primaria única]
15
+ //
16
+ // Pensado para quien hace varios roles: a la vista solo lo que hay que decidir;
17
+ // lo fiscal trae defaults (de la contraparte y del manifest) y se revisa solo si
18
+ // hace falta. Genérico y sin país: fiscal_mexico aporta sus campos como
19
+ // extensiones o contribuciones; otra localización hace lo mismo.
20
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
21
+ import { useTranslation } from 'react-i18next'
22
+ import { Button } from '@asteby/metacore-ui/primitives'
23
+ import { Loader2 } from 'lucide-react'
24
+ import { toast } from 'sonner'
25
+ import { useApi } from '../api-context'
26
+ import { useOrgTaxRate } from '../org-runtime-context'
27
+ import { FieldCell, FieldGrid, FieldLabel } from '../field-grid'
28
+ import { FormErrorBanner } from './feedback'
29
+ import { DocumentLinesGrid, type DocumentLinesGridProps, type LineItemsColumn } from './line-items-editor'
30
+ import { computeTotals, serializeLineItems, taxBreakdown, type LineItem } from './line-items'
31
+ import { roundMoney, toAmount, useFormatter } from './format'
32
+ import { createCatalogProductSearch } from './catalog-product-search'
33
+ import {
34
+ allocationAmountField,
35
+ allocationIssueMessages,
36
+ allocationPayload,
37
+ creditStatus,
38
+ editorLinesConfig,
39
+ linesFromSource,
40
+ localIssues,
41
+ sourceLoadSummary,
42
+ sourceTracksRemaining,
43
+ partyDefaults,
44
+ partySummaryRows,
45
+ splitEditorFields,
46
+ toOpenDocuments,
47
+ withFriendlyOptions,
48
+ type EditorIssue,
49
+ } from './document-editor-model'
50
+ import {
51
+ CollapsibleSection,
52
+ EditorSection,
53
+ PartyCard,
54
+ PreviewPanel,
55
+ TotalsPanel,
56
+ ValidationChecklist,
57
+ type PreviewContent,
58
+ type TotalsRow,
59
+ } from './document-editor-panels'
60
+ import { PaymentAllocator, type PaymentAllocatorValue } from './payment-allocator'
61
+ import { actionErrorBannerMessage, buildFieldDefaults, prefillFromFieldRelationRequest, renderField } from '../action-modal-dispatcher'
62
+ import { extractFieldErrors, localizeFieldErrorMap } from '../server-error'
63
+ import { validateValues, bagHasErrors } from '../validator'
64
+ import { clearFieldErrorTree } from '../field-validation-ui'
65
+ import { emitRecordMutation } from '../record-mutation-events'
66
+ import { evaluateVisibleWhen, getVisibleWhen } from '../dynamic-form-schema'
67
+ import { useInstalledAddons } from '../installed-addons-context'
68
+ import { allocatePayment, validateAllocation, type OpenDocument } from '../primitives/allocation'
69
+ import { useDocumentContributions } from '../primitives/use-contributions'
70
+ import type { DocumentContributionProps, DocumentEditorContext } from '../primitives/contributions'
71
+ import type { ActionFieldDef, DocumentFormSource, DocumentFormType, DocumentFormsManifest } from '../types'
72
+
73
+ export interface DocumentEditorProps {
74
+ model: string
75
+ /** Endpoint de alta del modelo. Default `/dynamic/<model>`. */
76
+ endpoint?: string
77
+ forms: DocumentFormsManifest
78
+ type: DocumentFormType
79
+ /** Registro desde el que se abrió (acción de fila): siembra `default_from_record`. */
80
+ record?: Record<string, any>
81
+ /** Documento origen a precargar al abrir (`sources[].key` + id). */
82
+ initialSource?: { key: string; id: string }
83
+ onSaved?: (record?: unknown) => void
84
+ onCancel: () => void
85
+ searchProducts?: DocumentLinesGridProps['search']
86
+ /** Modelo de catálogo del buscador por defecto. Default `products.Product`. */
87
+ productModel?: string
88
+ defaultTaxRate?: number
89
+ currency?: string
90
+ /** Hoy (días de atraso del reparto; inyectable en tests). */
91
+ today?: Date
92
+ }
93
+
94
+ type Busy = 'save' | 'preview' | 'source' | null
95
+
96
+ export function DocumentEditor({
97
+ model,
98
+ endpoint,
99
+ forms,
100
+ type,
101
+ record,
102
+ initialSource,
103
+ onSaved,
104
+ onCancel,
105
+ searchProducts,
106
+ productModel,
107
+ defaultTaxRate,
108
+ currency,
109
+ today,
110
+ }: DocumentEditorProps) {
111
+ const { t, i18n } = useTranslation()
112
+ const lang = i18n?.language
113
+ const api = useApi()
114
+ const fmt = useFormatter({ currency })
115
+ const installed = useInstalledAddons()
116
+ const orgTaxRate = useOrgTaxRate()
117
+ const tl = useCallback((s: string) => t(s, { defaultValue: s }), [t])
118
+
119
+ const lineCfg = useMemo(() => editorLinesConfig(type, forms), [type, forms])
120
+ const kind = lineCfg?.kind ?? 'sale'
121
+ const isAllocation = kind === 'allocation' && !!lineCfg?.open_documents
122
+ const amountKey = isAllocation ? allocationAmountField(type.fields) : undefined
123
+
124
+ const [header, setHeader] = useState<Record<string, any>>(() => ({
125
+ ...buildFieldDefaults(type.fields, record),
126
+ ...(type.defaults ?? {}),
127
+ }))
128
+ const [lines, setLines] = useState<LineItem[]>([])
129
+ const [linesFromSourceDoc, setLinesFromSourceDoc] = useState(false)
130
+ const [extFields, setExtFields] = useState<ActionFieldDef[]>([])
131
+ const [party, setParty] = useState<Record<string, any> | null>(null)
132
+ const [openDocs, setOpenDocs] = useState<OpenDocument[]>([])
133
+ const [docsLoading, setDocsLoading] = useState(false)
134
+ const [alloc, setAlloc] = useState<PaymentAllocatorValue>({ strategy: 'oldest_due_first', manual: {} })
135
+ const [draftId, setDraftId] = useState<string | null>(null)
136
+ const [serverIssues, setServerIssues] = useState<EditorIssue[]>([])
137
+ const [contributed, setContributed] = useState<Record<string, EditorIssue>>({})
138
+ const [preview, setPreview] = useState<PreviewContent | null>(null)
139
+ const [previewOpen, setPreviewOpen] = useState(false)
140
+ const [advancedOpen, setAdvancedOpen] = useState(false)
141
+ const [busy, setBusy] = useState<Busy>(null)
142
+ const [formError, setFormError] = useState<string | undefined>()
143
+ const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
144
+ const [attempted, setAttempted] = useState(false)
145
+ const [sourceKey, setSourceKey] = useState<string>(initialSource?.key ?? '')
146
+ const [sourceId, setSourceId] = useState<string>(initialSource?.id ?? '')
147
+ // Fuente de la que salieron los renglones: su `line_link_field` viaja en cada
148
+ // renglón para que el servidor descuente lo ya facturado/devuelto y valide.
149
+ const [loadedSource, setLoadedSource] = useState<DocumentFormSource | null>(null)
150
+ const [sourceNote, setSourceNote] = useState<string | undefined>()
151
+ const relCache = useRef(new Map<string, Promise<any[]>>())
152
+
153
+ const taxRate = defaultTaxRate ?? orgTaxRate
154
+ const search = useMemo(
155
+ () => searchProducts ?? createCatalogProductSearch(api, { model: productModel, defaultTaxRate: taxRate }),
156
+ [searchProducts, api, productModel, taxRate],
157
+ )
158
+
159
+ const isInstalled = useCallback((k?: string) => !k || !installed || installed.addons.has(k), [installed])
160
+ const sources = useMemo(() => (type.sources ?? []).filter((s) => isInstalled(s.requires_addon)), [type.sources, isInstalled])
161
+ const previewEnabled = !!type.preview && !type.submit_action && isInstalled(type.preview.requires_addon)
162
+
163
+ // Campos de extensión del modelo (fiscal_data.* que declaran otros addons): van plegados.
164
+ useEffect(() => {
165
+ let cancelled = false
166
+ api.get(`/metadata/modal/${model}`)
167
+ .then((res: any) => {
168
+ const fields: any[] = res?.data?.data?.fields ?? res?.data?.fields ?? []
169
+ const declared = new Set(type.fields.map((f) => f.key))
170
+ const ext = fields.filter(
171
+ (f) => typeof f?.key === 'string' && f.key.startsWith('fiscal_data.') && !declared.has(f.key) && !f.readonly && !f.readOnly,
172
+ )
173
+ if (!cancelled) setExtFields(ext as ActionFieldDef[])
174
+ })
175
+ .catch(() => {})
176
+ return () => {
177
+ cancelled = true
178
+ }
179
+ }, [api, model, type.fields])
180
+
181
+ const groups = useMemo(
182
+ () =>
183
+ splitEditorFields(type.fields, {
184
+ partyField: type.party?.field,
185
+ essentialKeys: [amountKey, ...sources.map((s) => s.link_field)].filter((k): k is string => !!k),
186
+ extensionFields: extFields,
187
+ }),
188
+ [type.fields, type.party?.field, amountKey, sources, extFields],
189
+ )
190
+ const visible = useCallback((f: ActionFieldDef) => evaluateVisibleWhen(getVisibleWhen(f), header), [header])
191
+ const allFields = useMemo(
192
+ () => [...(groups.party ? [groups.party] : []), ...groups.essential, ...groups.advanced, ...groups.notes],
193
+ [groups],
194
+ )
195
+
196
+ const fieldKeys = useRef<string[]>([])
197
+ fieldKeys.current = allFields.map((f) => f.key)
198
+
199
+ // Contraparte: tarjeta + defaults de las extensiones con la misma clave.
200
+ const partyId = type.party ? header[type.party.field] : undefined
201
+ useEffect(() => {
202
+ if (!type.party || !partyId) {
203
+ setParty(null)
204
+ return
205
+ }
206
+ let cancelled = false
207
+ const ep = type.party.endpoint ?? `/data/${type.party.model}`
208
+ api.get(ep, { params: { f_id: `eq:${partyId}`, per_page: 1 } })
209
+ .then((res: any) => {
210
+ const rows = res?.data?.data ?? []
211
+ const rec = Array.isArray(rows) ? rows[0] ?? null : rows ?? null
212
+ if (cancelled) return
213
+ setParty(rec)
214
+ setHeader((h) => ({ ...h, ...partyDefaults(rec, fieldKeys.current, h) }))
215
+ })
216
+ .catch(() => !cancelled && setParty(null))
217
+ return () => {
218
+ cancelled = true
219
+ }
220
+ }, [api, type.party, partyId])
221
+
222
+ // Cobro: documentos abiertos de la contraparte.
223
+ const openCfg = lineCfg?.open_documents
224
+ const allocPartyId = openCfg ? header[openCfg.party_field] ?? partyId : undefined
225
+ useEffect(() => {
226
+ if (!isAllocation || !openCfg || !allocPartyId) {
227
+ setOpenDocs([])
228
+ return
229
+ }
230
+ let cancelled = false
231
+ setDocsLoading(true)
232
+ api.get(`/data/${openCfg.model}`, { params: { [`f_${openCfg.party_field}`]: `eq:${allocPartyId}`, per_page: 200 } })
233
+ .then((res: any) => {
234
+ if (!cancelled) setOpenDocs(toOpenDocuments(res?.data?.data, openCfg, fmt.currency))
235
+ })
236
+ .catch(() => !cancelled && setOpenDocs([]))
237
+ .finally(() => !cancelled && setDocsLoading(false))
238
+ return () => {
239
+ cancelled = true
240
+ }
241
+ }, [api, isAllocation, openCfg, allocPartyId, fmt.currency])
242
+
243
+ const amount = amountKey ? toAmount(header[amountKey]) : 0
244
+ const allocation = useMemo(() => allocatePayment(amount, openDocs, alloc.strategy, alloc.manual), [amount, openDocs, alloc])
245
+ const openBalance = useMemo(() => roundMoney(openDocs.reduce((s, d) => s + d.balance, 0)), [openDocs])
246
+
247
+ const totals = useMemo(() => computeTotals(lines), [lines])
248
+ const taxes = useMemo(() => taxBreakdown(lines), [lines])
249
+ const documentTotal = isAllocation ? amount : totals.total
250
+ const credit = useMemo(
251
+ () => (isAllocation ? null : creditStatus(party, type.party?.credit, documentTotal)),
252
+ [isAllocation, party, type.party?.credit, documentTotal],
253
+ )
254
+
255
+ const issues = useMemo<EditorIssue[]>(() => {
256
+ const out: EditorIssue[] = []
257
+ if (isAllocation && openCfg) {
258
+ const found = validateAllocation(amount, fmt.currency, openDocs, allocation.allocations, { allowUnapplied: true })
259
+ out.push(...allocationIssueMessages(found, openDocs))
260
+ } else if (lineCfg) {
261
+ out.push(...localIssues(lines, { requireLines: lineCfg.required ?? true }))
262
+ }
263
+ if (credit?.level === 'block') {
264
+ out.push({
265
+ field: type.party?.field,
266
+ severity: 'error',
267
+ message: credit.holdReason
268
+ ? `${t('documentEditor.credit_hold', { defaultValue: 'Crédito retenido' })}: ${credit.holdReason}`
269
+ : t('documentEditor.credit_exceeded', { defaultValue: 'El total supera el crédito disponible.' }),
270
+ })
271
+ }
272
+ out.push(...Object.values(contributed), ...serverIssues)
273
+ return out
274
+ }, [isAllocation, openCfg, amount, fmt.currency, openDocs, allocation, lineCfg, lines, credit, type.party?.field, contributed, serverIssues, t])
275
+ const blocking = issues.some((i) => i.severity === 'error')
276
+ const shownIssues = attempted ? issues : issues.filter((i) => i.severity === 'warning')
277
+
278
+ const payload = useCallback(() => {
279
+ const p: Record<string, unknown> = { ...(type.defaults ?? {}), ...header }
280
+ if (forms.type_field && !type.submit_action) p[forms.type_field] = type.value ?? type.key
281
+ if (lineCfg) {
282
+ p[lineCfg.field] =
283
+ isAllocation && openCfg
284
+ ? allocationPayload(allocation.allocations, openCfg)
285
+ : serializeLineItems(lines, { sourceLineField: loadedSource?.line_link_field })
286
+ }
287
+ return p
288
+ }, [type, header, forms.type_field, lineCfg, isAllocation, openCfg, allocation, lines, loadedSource])
289
+
290
+ const baseUrl = type.endpoint ?? endpoint ?? `/dynamic/${model}`
291
+
292
+ /** Crea o actualiza el BORRADOR (idempotente por draftId). La vista previa lo necesita. */
293
+ const saveDraft = useCallback(async (): Promise<Record<string, any>> => {
294
+ const res = draftId ? await api.put(`${baseUrl}/${draftId}`, payload()) : await api.post(baseUrl, payload())
295
+ if (res.data?.success === false) throw { response: { data: res.data } }
296
+ const rec = (res.data?.data ?? res.data ?? {}) as Record<string, any>
297
+ const id = draftId ?? (rec.id != null ? String(rec.id) : null)
298
+ if (id) setDraftId(id)
299
+ emitRecordMutation(model, draftId ? 'update' : 'create')
300
+ return { ...rec, id: id ?? rec.id }
301
+ }, [api, baseUrl, draftId, model, payload])
302
+
303
+ const handleError = (err: unknown) => {
304
+ setFormError(actionErrorBannerMessage(err, allFields, t, lang))
305
+ const map = extractFieldErrors(err)
306
+ if (map) {
307
+ const labels: Record<string, string> = {}
308
+ for (const f of allFields) labels[f.key] = tl(f.label)
309
+ const localized = localizeFieldErrorMap(map, t, { labels, language: lang })
310
+ setFieldErrors(localized)
311
+ if (groups.advanced.some((f) => localized[f.key])) setAdvancedOpen(true)
312
+ }
313
+ }
314
+
315
+ // ---- Cargar desde… --------------------------------------------------------
316
+ /**
317
+ * Renglones del origen. Con lo pendiente declarado (`line_link_field`,
318
+ * `remaining_qty_field` o `remaining_endpoint`) los sirve el host ya con
319
+ * `remaining_quantity` (cantidad − lo ya facturado/devuelto, calculado en el
320
+ * servidor); si el host aún no tiene `source-lines` (404) o la fuente no lo
321
+ * declara, se leen de la relación como antes.
322
+ */
323
+ const fetchSourceRows = useCallback(
324
+ async (src: DocumentFormSource, id: string): Promise<unknown[]> => {
325
+ if (src.remaining_endpoint) {
326
+ const res = await api.get(src.remaining_endpoint, { params: { id } })
327
+ return res?.data?.data ?? []
328
+ }
329
+ if (sourceTracksRemaining(src)) {
330
+ try {
331
+ const res = await api.get(`${baseUrl}/source-lines`, {
332
+ params: { source: src.key, id, ...(draftId ? { exclude: draftId } : {}) },
333
+ })
334
+ return res?.data?.data ?? []
335
+ } catch (err: any) {
336
+ if (err?.response?.status !== 404) throw err
337
+ }
338
+ }
339
+ let rels = relCache.current.get(src.model)
340
+ if (!rels) {
341
+ rels = api
342
+ .get(`/metadata/table/${src.model}`)
343
+ .then((r: any) => r?.data?.data?.relations ?? r?.data?.relations ?? [])
344
+ relCache.current.set(src.model, rels)
345
+ }
346
+ const call = prefillFromFieldRelationRequest(
347
+ { fieldKey: lineCfg?.field ?? 'lines', refModel: src.model, refId: id, spec: { $prefillFromRecord: src.lines } },
348
+ await rels,
349
+ )
350
+ if (!call) throw new Error(`${src.model}: falta la relación «${src.lines}»`)
351
+ const res = await api.get(call.endpoint, { params: call.params })
352
+ return res?.data?.data ?? []
353
+ },
354
+ [api, baseUrl, draftId, lineCfg?.field],
355
+ )
356
+
357
+ const loadFromSource = useCallback(
358
+ async (src: DocumentFormSource, id: string) => {
359
+ if (!lineCfg || !id) return
360
+ setBusy('source')
361
+ try {
362
+ if (!isAllocation) {
363
+ const rows = await fetchSourceRows(src, id)
364
+ const loaded = linesFromSource(rows, src.map, { kind, discountMode: lineCfg.discount_mode })
365
+ const { covered } = sourceLoadSummary(rows)
366
+ setLines(loaded)
367
+ setLoadedSource(src)
368
+ setLinesFromSourceDoc(kind === 'credit')
369
+ setSourceNote(
370
+ loaded.length === 0 && covered > 0
371
+ ? t('documentEditor.source_all_covered', {
372
+ defaultValue: 'Todo el documento ya está registrado en otros documentos; no queda nada pendiente.',
373
+ })
374
+ : covered > 0
375
+ ? t('documentEditor.source_loaded_partial', {
376
+ defaultValue: '{{count}} renglones con lo pendiente · {{covered}} ya cubiertos se omitieron',
377
+ count: loaded.length,
378
+ covered,
379
+ })
380
+ : t('documentEditor.source_loaded', { defaultValue: '{{count}} renglones cargados', count: loaded.length }),
381
+ )
382
+ }
383
+ if (src.header || src.link_field) {
384
+ const h = await api.get(`/data/${src.model}`, { params: { f_id: `eq:${id}`, per_page: 1 } })
385
+ const rec = ((h?.data?.data ?? [])[0] ?? {}) as Record<string, any>
386
+ setHeader((prev) => {
387
+ const next = { ...prev }
388
+ for (const [to, from] of Object.entries(src.header ?? {})) if (rec[from] != null) next[to] = rec[from]
389
+ if (src.link_field) next[src.link_field] = id
390
+ return next
391
+ })
392
+ }
393
+ setServerIssues([])
394
+ } catch (err) {
395
+ toast.error(t('documentEditor.source_failed', { defaultValue: 'No se pudo cargar el documento' }), {
396
+ description: err instanceof Error ? err.message : undefined,
397
+ })
398
+ } finally {
399
+ setBusy(null)
400
+ }
401
+ },
402
+ [api, lineCfg, isAllocation, kind, t, fetchSourceRows],
403
+ )
404
+
405
+ // Prefill al abrir desde un documento origen (acción de fila «Crear NC», «Facturar venta»…).
406
+ const autoLoaded = useRef(false)
407
+ useEffect(() => {
408
+ if (autoLoaded.current || !initialSource) return
409
+ const src = sources.find((s) => s.key === initialSource.key)
410
+ if (!src) return
411
+ autoLoaded.current = true
412
+ void loadFromSource(src, initialSource.id)
413
+ }, [initialSource, sources, loadFromSource])
414
+
415
+ // ---- Vista previa -----------------------------------------------------------
416
+ const runPreview = async () => {
417
+ if (!type.preview) return
418
+ setBusy('preview')
419
+ setFormError(undefined)
420
+ try {
421
+ const rec = await saveDraft()
422
+ if (!rec.id) return
423
+ const res = await api.post(`${baseUrl}/${rec.id}/action/${type.preview.action}`, { analyze: true })
424
+ const d = res.data?.data ?? res.data ?? {}
425
+ const checks: any[] = Array.isArray(d.checks) ? d.checks : Array.isArray(d.issues) ? d.issues : []
426
+ setServerIssues(
427
+ checks
428
+ .filter((c) => c && c.severity !== 'ok' && c.message)
429
+ .map((c) => ({ field: c.field, severity: c.severity === 'error' ? 'error' : 'warning', message: String(c.message) })),
430
+ )
431
+ setPreview({ xml: d.xml, html: d.html, pdf_url: d.pdf_url, pdf_base64: d.pdf_base64 })
432
+ } catch (err) {
433
+ handleError(err)
434
+ } finally {
435
+ setBusy(null)
436
+ }
437
+ }
438
+
439
+ const togglePreview = (open: boolean) => {
440
+ setPreviewOpen(open)
441
+ if (open && !preview && busy === null) void runPreview()
442
+ }
443
+
444
+ // ---- Guardar (acción primaria única) ----------------------------------------
445
+ const validateHeader = (): boolean => {
446
+ const fields = allFields.filter(visible)
447
+ const bag = validateValues(fields, header)
448
+ if (!bagHasErrors(bag)) {
449
+ setFieldErrors({})
450
+ return true
451
+ }
452
+ const labels: Record<string, string> = {}
453
+ for (const f of fields) labels[f.key] = tl(f.label)
454
+ const localized = localizeFieldErrorMap(bag, t, { labels, language: lang })
455
+ setFieldErrors(localized)
456
+ if (groups.advanced.some((f) => localized[f.key])) setAdvancedOpen(true)
457
+ return false
458
+ }
459
+
460
+ const submit = async () => {
461
+ setAttempted(true)
462
+ const headerOk = validateHeader()
463
+ if (!headerOk || blocking) {
464
+ setFormError(t('documentEditor.fix_issues', { defaultValue: 'Corrige los puntos marcados antes de guardar.' }))
465
+ return
466
+ }
467
+ setBusy('save')
468
+ setFormError(undefined)
469
+ try {
470
+ let saved: Record<string, any> | undefined
471
+ if (type.submit_action) {
472
+ const res = await api.post(`${baseUrl}/action/${type.submit_action}`, payload())
473
+ if (res.data?.success === false) throw { response: { data: res.data } }
474
+ saved = res.data?.data ?? res.data ?? undefined
475
+ emitRecordMutation(model, 'create')
476
+ } else {
477
+ saved = await saveDraft()
478
+ }
479
+ toast.success(t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' }))
480
+ onSaved?.(saved)
481
+ } catch (err) {
482
+ handleError(err)
483
+ } finally {
484
+ setBusy(null)
485
+ }
486
+ }
487
+
488
+ // ---- Contribuciones de otros addons -----------------------------------------
489
+ const ctx = useMemo<DocumentEditorContext>(() => ({ kind: type.key, model, values: header, mode: 'create' }), [type.key, model, header])
490
+ const setValue = useCallback((key: string, value: unknown) => setHeader((h) => (h[key] === value ? h : { ...h, [key]: value })), [])
491
+ const report = useCallback((key: string, issue: { severity: 'error' | 'warning'; message: string } | null) => {
492
+ setContributed((prev) => {
493
+ const cur = prev[key]
494
+ if (!issue) {
495
+ if (!cur) return prev
496
+ const { [key]: _drop, ...rest } = prev
497
+ return rest
498
+ }
499
+ if (cur && cur.severity === issue.severity && cur.message === issue.message) return prev
500
+ return { ...prev, [key]: { ...issue, field: key } }
501
+ })
502
+ }, [])
503
+ const contribProps = useMemo<DocumentContributionProps>(
504
+ () => ({ ctx, setValue, issues: { report: report as DocumentContributionProps['issues']['report'] } }),
505
+ [ctx, setValue, report],
506
+ )
507
+ const headerContribs = useDocumentContributions(ctx, 'header.fields')
508
+ const partyContribs = useDocumentContributions(ctx, 'party.card')
509
+ const bodyContribs = useDocumentContributions(ctx, 'body.sections')
510
+ const sideContribs = useDocumentContributions(ctx, 'side.panels')
511
+ const footerContribs = useDocumentContributions(ctx, 'footer.actions')
512
+
513
+ // ---- Render -------------------------------------------------------------------
514
+ const updateField = (key: string, value: any) => {
515
+ setHeader((h) => ({ ...h, [key]: value }))
516
+ setFieldErrors((prev) => clearFieldErrorTree(prev, key))
517
+ }
518
+
519
+ const fieldCell = (field: ActionFieldDef, fullWidth = false) =>
520
+ visible(field) ? (
521
+ <FieldCell key={field.key} fullWidth={fullWidth}>
522
+ <FieldLabel htmlFor={field.key} required={field.required}>
523
+ {tl(field.label)}
524
+ </FieldLabel>
525
+ {renderField(withFriendlyOptions(field), header[field.key], (v: any) => updateField(field.key, v), header, undefined, fieldErrors)}
526
+ {fieldErrors[field.key] && <p className="mt-1 text-xs text-destructive">{fieldErrors[field.key]}</p>}
527
+ </FieldCell>
528
+ ) : null
529
+
530
+ const summaryRows = partySummaryRows(
531
+ party,
532
+ type.party?.summary,
533
+ Object.fromEntries(allFields.map((f) => [f.key, tl(f.label)])),
534
+ )
535
+ const advancedSummary = groups.advanced
536
+ .filter(visible)
537
+ .map((f) => {
538
+ const v = header[f.key]
539
+ if (v == null || v === '') return null
540
+ const opt = f.options?.find((o) => String(o.value) === String(v))
541
+ return opt ? String(opt.value) : String(v)
542
+ })
543
+ .filter(Boolean)
544
+ .slice(0, 4)
545
+ .join(' · ')
546
+
547
+ const totalsRows: TotalsRow[] = isAllocation
548
+ ? [
549
+ { label: t('documentEditor.received', { defaultValue: 'Recibido' }), value: fmt.money(amount) },
550
+ { label: t('documentEditor.applied', { defaultValue: 'Aplicado' }), value: fmt.money(allocation.applied) },
551
+ ...(allocation.unapplied > 0
552
+ ? [{ label: t('documentEditor.unapplied', { defaultValue: 'Sin aplicar (saldo a favor)' }), value: fmt.money(allocation.unapplied) }]
553
+ : []),
554
+ ]
555
+ : [
556
+ { label: t('documentEditor.subtotal', { defaultValue: 'Subtotal' }), value: fmt.money(totals.subtotal) },
557
+ ...(totals.discount > 0 ? [{ label: t('documentEditor.discount', { defaultValue: 'Descuento' }), value: `− ${fmt.money(totals.discount)}` }] : []),
558
+ ...taxes.map((x) => ({
559
+ label: `${t('documentEditor.tax', { defaultValue: 'Impuesto' })} ${roundMoney(x.rate * 100)} %`,
560
+ value: fmt.money(x.tax),
561
+ })),
562
+ { label: t('documentEditor.total', { defaultValue: 'Total' }), value: fmt.money(totals.total), emphasis: true },
563
+ ]
564
+
565
+ const activeSource = sources.find((s) => s.key === sourceKey)
566
+ const linesTitle = lineCfg?.title
567
+ ? tl(lineCfg.title)
568
+ : isAllocation
569
+ ? t('documentEditor.allocation_title', { defaultValue: 'Documentos a pagar' })
570
+ : t('documentEditor.lines_title', { defaultValue: 'Conceptos' })
571
+
572
+ return (
573
+ <div className="flex min-h-0 flex-1 flex-col" data-slot="document-editor" data-kind={kind}>
574
+ <div className="-mx-1 min-h-0 flex-1 space-y-8 overflow-y-auto px-1 py-2">
575
+ <FormErrorBanner message={formError} />
576
+
577
+ <EditorSection slot="editor-essentials">
578
+ <FieldGrid>
579
+ {groups.party && fieldCell(groups.party)}
580
+ {groups.essential.map((f) => fieldCell(f))}
581
+ </FieldGrid>
582
+ {party && (
583
+ <PartyCard name={party.name ?? party.legal_name} rows={summaryRows} credit={credit} fmt={fmt}>
584
+ {partyContribs.map(({ id, component: C }) => (
585
+ <C key={id} {...contribProps} />
586
+ ))}
587
+ </PartyCard>
588
+ )}
589
+ </EditorSection>
590
+
591
+ {sources.length > 0 && !isAllocation && (
592
+ <EditorSection
593
+ slot="load-from-source"
594
+ title={t('documentEditor.load_from', { defaultValue: 'Cargar desde…' })}
595
+ hint={busy === 'source' ? t('documentEditor.loading_source', { defaultValue: 'Cargando…' }) : sourceNote}
596
+ >
597
+ <div className="flex flex-wrap items-center gap-2">
598
+ {sources.length > 1 && (
599
+ <select
600
+ aria-label={t('documentEditor.source_kind', { defaultValue: 'Tipo de documento origen' })}
601
+ className="h-9 rounded-md border bg-background px-2 text-sm"
602
+ value={sourceKey}
603
+ onChange={(e) => {
604
+ setSourceKey(e.target.value)
605
+ setSourceId('')
606
+ }}
607
+ >
608
+ <option value="">{t('documentEditor.choose_source', { defaultValue: 'Elige…' })}</option>
609
+ {sources.map((s) => (
610
+ <option key={s.key} value={s.key}>
611
+ {tl(s.label)}
612
+ </option>
613
+ ))}
614
+ </select>
615
+ )}
616
+ {(activeSource ?? (sources.length === 1 ? sources[0] : undefined)) && (
617
+ <SourcePicker
618
+ source={(activeSource ?? sources[0])!}
619
+ value={sourceId}
620
+ header={header}
621
+ label={tl((activeSource ?? sources[0])!.label)}
622
+ onPick={(id) => {
623
+ if (!activeSource) setSourceKey(sources[0].key)
624
+ setSourceId(id)
625
+ const src = activeSource ?? sources[0]
626
+ if (id) void loadFromSource(src, id)
627
+ }}
628
+ />
629
+ )}
630
+ </div>
631
+ </EditorSection>
632
+ )}
633
+
634
+ {lineCfg && (
635
+ <EditorSection
636
+ slot={isAllocation ? 'editor-allocation' : 'editor-lines'}
637
+ title={linesTitle}
638
+ hint={
639
+ isAllocation && openBalance > 0 && amountKey ? (
640
+ <button
641
+ type="button"
642
+ className="underline-offset-2 hover:underline"
643
+ onClick={() => updateField(amountKey, openBalance)}
644
+ >
645
+ {t('documentEditor.pay_all', { defaultValue: 'Cobrar todo ({{amount}})', amount: fmt.money(openBalance) })}
646
+ </button>
647
+ ) : undefined
648
+ }
649
+ >
650
+ {isAllocation ? (
651
+ allocPartyId ? (
652
+ <PaymentAllocator
653
+ documents={openDocs}
654
+ result={allocation}
655
+ value={alloc}
656
+ onChange={setAlloc}
657
+ currency={currency}
658
+ loading={docsLoading}
659
+ today={today}
660
+ />
661
+ ) : (
662
+ <p className="text-sm text-muted-foreground">
663
+ {t('documentEditor.pick_party_first', { defaultValue: 'Elige el cliente para ver sus documentos con saldo.' })}
664
+ </p>
665
+ )
666
+ ) : (
667
+ <DocumentLinesGrid
668
+ value={lines}
669
+ onChange={(next) => {
670
+ setLines(next)
671
+ setServerIssues([])
672
+ }}
673
+ columns={(lineCfg.columns as LineItemsColumn[] | undefined) ?? ['discount', 'tax']}
674
+ priceSource={lineCfg.price_source ?? (kind === 'purchase' ? 'cost' : 'sale')}
675
+ discountMode={lineCfg.discount_mode}
676
+ mode={linesFromSourceDoc ? 'from_source' : 'free'}
677
+ policy={kind === 'credit' ? { allowZeroQuantity: linesFromSourceDoc } : undefined}
678
+ search={search}
679
+ currency={currency}
680
+ />
681
+ )}
682
+ <TotalsPanel rows={totalsRows} />
683
+ </EditorSection>
684
+ )}
685
+
686
+ {(groups.advanced.length > 0 || headerContribs.length > 0) && (
687
+ <CollapsibleSection
688
+ slot="advanced-options"
689
+ title={t('documentEditor.advanced', { defaultValue: 'Opciones fiscales' })}
690
+ summary={advancedSummary || t('documentEditor.advanced_defaults', { defaultValue: 'Valores sugeridos' })}
691
+ open={advancedOpen}
692
+ onOpenChange={setAdvancedOpen}
693
+ >
694
+ {groups.advanced.length > 0 && <FieldGrid>{groups.advanced.map((f) => fieldCell(f))}</FieldGrid>}
695
+ {headerContribs.map(({ id, component: C }) => (
696
+ <C key={id} {...contribProps} />
697
+ ))}
698
+ </CollapsibleSection>
699
+ )}
700
+
701
+ {[...bodyContribs, ...sideContribs].map(({ id, component: C }) => (
702
+ <section key={id} data-contribution={id}>
703
+ <C {...contribProps} />
704
+ </section>
705
+ ))}
706
+
707
+ {groups.notes.length > 0 && <FieldGrid>{groups.notes.map((f) => fieldCell(f, true))}</FieldGrid>}
708
+
709
+ {previewEnabled && (
710
+ <CollapsibleSection
711
+ slot="preview"
712
+ title={tl(type.preview!.label ?? 'Vista previa')}
713
+ open={previewOpen}
714
+ onOpenChange={togglePreview}
715
+ >
716
+ {busy === 'preview' ? (
717
+ <p className="flex items-center gap-2 text-sm text-muted-foreground">
718
+ <Loader2 className="size-4 animate-spin" aria-hidden />
719
+ {t('documentEditor.preview_loading', { defaultValue: 'Generando vista previa…' })}
720
+ </p>
721
+ ) : (
722
+ <>
723
+ <PreviewPanel preview={preview} />
724
+ <Button type="button" variant="ghost" size="sm" onClick={() => void runPreview()} disabled={busy !== null}>
725
+ {t('documentEditor.preview_refresh', { defaultValue: 'Actualizar vista previa' })}
726
+ </Button>
727
+ </>
728
+ )}
729
+ </CollapsibleSection>
730
+ )}
731
+
732
+ <ValidationChecklist issues={shownIssues} title={t('documentEditor.review', { defaultValue: 'Revisa antes de guardar' })} />
733
+ </div>
734
+
735
+ <footer className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t pt-4" data-slot="editor-actions">
736
+ {footerContribs.map(({ id, component: C }) => (
737
+ <C key={id} {...contribProps} />
738
+ ))}
739
+ <Button type="button" variant="ghost" onClick={onCancel} disabled={busy === 'save'}>
740
+ {t('common.cancel', { defaultValue: 'Cancelar' })}
741
+ </Button>
742
+ <Button type="button" data-primary="true" onClick={() => void submit()} disabled={busy !== null}>
743
+ {busy === 'save' && <Loader2 className="mr-2 size-4 animate-spin" aria-hidden />}
744
+ {type.submit_label ? tl(type.submit_label) : t('common.save', { defaultValue: 'Guardar' })}
745
+ </Button>
746
+ </footer>
747
+ </div>
748
+ )
749
+ }
750
+
751
+ /** Selector del documento origen (mismo widget de relación que el resto de formularios). */
752
+ function SourcePicker({
753
+ source,
754
+ value,
755
+ header,
756
+ label,
757
+ onPick,
758
+ }: {
759
+ source: DocumentFormSource
760
+ value: string
761
+ header: Record<string, any>
762
+ label: string
763
+ onPick: (id: string) => void
764
+ }) {
765
+ const field = useMemo(
766
+ () =>
767
+ ({
768
+ key: `__source_${source.key}`,
769
+ label,
770
+ type: 'dynamic_select',
771
+ ref: source.model,
772
+ option_filter: source.option_filter,
773
+ }) as ActionFieldDef,
774
+ [source, label],
775
+ )
776
+ return (
777
+ <div className="min-w-[240px] flex-1" data-source={source.key}>
778
+ {renderField(field, value, (v: any) => onPick(v == null || v === '' ? '' : String(v)), header)}
779
+ </div>
780
+ )
781
+ }