@asteby/metacore-runtime-react 47.0.0 → 48.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 (178) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  3. package/dist/action-modal-dispatcher.js +24 -4
  4. package/dist/audit-info.d.ts +54 -0
  5. package/dist/audit-info.d.ts.map +1 -0
  6. package/dist/audit-info.js +142 -0
  7. package/dist/business/catalog-product-search.d.ts +25 -0
  8. package/dist/business/catalog-product-search.d.ts.map +1 -0
  9. package/dist/business/catalog-product-search.js +57 -0
  10. package/dist/business/catalog.d.ts.map +1 -1
  11. package/dist/business/catalog.js +2 -0
  12. package/dist/business/document-editor-model.d.ts +107 -0
  13. package/dist/business/document-editor-model.d.ts.map +1 -0
  14. package/dist/business/document-editor-model.js +279 -0
  15. package/dist/business/document-editor-panels.d.ts +64 -0
  16. package/dist/business/document-editor-panels.d.ts.map +1 -0
  17. package/dist/business/document-editor-panels.js +58 -0
  18. package/dist/business/document-editor.d.ts +27 -0
  19. package/dist/business/document-editor.d.ts.map +1 -0
  20. package/dist/business/document-editor.js +445 -0
  21. package/dist/business/document-lines.d.ts.map +1 -1
  22. package/dist/business/document-lines.js +5 -1
  23. package/dist/business/index.d.ts +5 -0
  24. package/dist/business/index.d.ts.map +1 -1
  25. package/dist/business/index.js +5 -0
  26. package/dist/business/line-items.d.ts +7 -0
  27. package/dist/business/line-items.d.ts.map +1 -1
  28. package/dist/business/line-items.js +6 -1
  29. package/dist/business/payment-allocator.d.ts +19 -0
  30. package/dist/business/payment-allocator.d.ts.map +1 -0
  31. package/dist/business/payment-allocator.js +31 -0
  32. package/dist/business/product-search.d.ts +4 -0
  33. package/dist/business/product-search.d.ts.map +1 -1
  34. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  35. package/dist/dialogs/dynamic-record.js +19 -8
  36. package/dist/document-form-dialog.d.ts +31 -2
  37. package/dist/document-form-dialog.d.ts.map +1 -1
  38. package/dist/document-form-dialog.js +43 -11
  39. package/dist/dynamic-columns-helpers.d.ts +9 -0
  40. package/dist/dynamic-columns-helpers.d.ts.map +1 -1
  41. package/dist/dynamic-columns-helpers.js +19 -0
  42. package/dist/dynamic-columns.d.ts +8 -2
  43. package/dist/dynamic-columns.d.ts.map +1 -1
  44. package/dist/dynamic-columns.js +21 -5
  45. package/dist/dynamic-crud-page.d.ts +6 -0
  46. package/dist/dynamic-crud-page.d.ts.map +1 -1
  47. package/dist/dynamic-crud-page.js +14 -5
  48. package/dist/dynamic-form-schema.d.ts.map +1 -1
  49. package/dist/dynamic-form-schema.js +15 -3
  50. package/dist/dynamic-form.d.ts +16 -1
  51. package/dist/dynamic-form.d.ts.map +1 -1
  52. package/dist/dynamic-form.js +81 -20
  53. package/dist/dynamic-kanban.d.ts +9 -3
  54. package/dist/dynamic-kanban.d.ts.map +1 -1
  55. package/dist/dynamic-kanban.js +39 -13
  56. package/dist/dynamic-line-items.d.ts.map +1 -1
  57. package/dist/dynamic-line-items.js +3 -0
  58. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  59. package/dist/dynamic-multi-select-field.js +2 -0
  60. package/dist/dynamic-relation-helpers.d.ts.map +1 -1
  61. package/dist/dynamic-relation-helpers.js +8 -0
  62. package/dist/dynamic-relation.js +1 -1
  63. package/dist/dynamic-select-field.d.ts.map +1 -1
  64. package/dist/dynamic-select-field.js +4 -0
  65. package/dist/index.d.ts +8 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +17 -2
  68. package/dist/installed-addons-context.d.ts +15 -0
  69. package/dist/installed-addons-context.d.ts.map +1 -0
  70. package/dist/installed-addons-context.js +39 -0
  71. package/dist/option-filter.d.ts +31 -0
  72. package/dist/option-filter.d.ts.map +1 -0
  73. package/dist/option-filter.js +49 -0
  74. package/dist/org-runtime-context.d.ts +8 -0
  75. package/dist/org-runtime-context.d.ts.map +1 -1
  76. package/dist/org-runtime-context.js +8 -0
  77. package/dist/org-runtime-provider.d.ts +3 -1
  78. package/dist/org-runtime-provider.d.ts.map +1 -1
  79. package/dist/org-runtime-provider.js +3 -3
  80. package/dist/primitives/aging.d.ts +34 -0
  81. package/dist/primitives/aging.d.ts.map +1 -0
  82. package/dist/primitives/aging.js +48 -0
  83. package/dist/primitives/allocation.d.ts +42 -0
  84. package/dist/primitives/allocation.d.ts.map +1 -0
  85. package/dist/primitives/allocation.js +59 -0
  86. package/dist/primitives/approvals.d.ts +71 -0
  87. package/dist/primitives/approvals.d.ts.map +1 -0
  88. package/dist/primitives/approvals.js +48 -0
  89. package/dist/primitives/contributions.d.ts +82 -0
  90. package/dist/primitives/contributions.d.ts.map +1 -0
  91. package/dist/primitives/contributions.js +126 -0
  92. package/dist/primitives/document-kinds.d.ts +25 -0
  93. package/dist/primitives/document-kinds.d.ts.map +1 -0
  94. package/dist/primitives/document-kinds.js +26 -0
  95. package/dist/primitives/index.d.ts +8 -0
  96. package/dist/primitives/index.d.ts.map +1 -0
  97. package/dist/primitives/index.js +23 -0
  98. package/dist/primitives/panels.d.ts +68 -0
  99. package/dist/primitives/panels.d.ts.map +1 -0
  100. package/dist/primitives/panels.js +3 -0
  101. package/dist/primitives/use-contributions.d.ts +15 -0
  102. package/dist/primitives/use-contributions.d.ts.map +1 -0
  103. package/dist/primitives/use-contributions.js +30 -0
  104. package/dist/record-prefill-bar.d.ts +11 -0
  105. package/dist/record-prefill-bar.d.ts.map +1 -0
  106. package/dist/record-prefill-bar.js +33 -0
  107. package/dist/types.d.ts +142 -0
  108. package/dist/types.d.ts.map +1 -1
  109. package/dist/use-options-resolver.d.ts +8 -0
  110. package/dist/use-options-resolver.d.ts.map +1 -1
  111. package/dist/use-options-resolver.js +8 -2
  112. package/package.json +5 -5
  113. package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
  114. package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
  115. package/src/__tests__/audit-info.test.tsx +103 -0
  116. package/src/__tests__/catalog-product-search.test.ts +61 -0
  117. package/src/__tests__/dependent-options.test.tsx +12 -5
  118. package/src/__tests__/document-editor-model.test.ts +167 -0
  119. package/src/__tests__/document-editor.test.tsx +261 -0
  120. package/src/__tests__/document-form-dialog.test.tsx +118 -3
  121. package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
  122. package/src/__tests__/dynamic-kanban.test.tsx +24 -3
  123. package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
  124. package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
  125. package/src/__tests__/installed-addons-context.test.tsx +50 -0
  126. package/src/__tests__/line-item-totals.test.ts +28 -0
  127. package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
  128. package/src/__tests__/option-filter.test.tsx +115 -0
  129. package/src/__tests__/primitives-federated.test.tsx +156 -0
  130. package/src/__tests__/primitives.test.ts +224 -0
  131. package/src/__tests__/record-prefill-bar.test.tsx +50 -0
  132. package/src/__tests__/structured-view-value.test.tsx +5 -1
  133. package/src/__tests__/wizard-action-modal.test.tsx +1 -1
  134. package/src/action-modal-dispatcher.tsx +47 -4
  135. package/src/audit-info.tsx +219 -0
  136. package/src/business/catalog-product-search.ts +87 -0
  137. package/src/business/catalog.ts +2 -0
  138. package/src/business/document-editor-model.ts +325 -0
  139. package/src/business/document-editor-panels.tsx +164 -0
  140. package/src/business/document-editor.tsx +727 -0
  141. package/src/business/document-lines.ts +5 -1
  142. package/src/business/index.ts +5 -0
  143. package/src/business/line-items.ts +15 -1
  144. package/src/business/payment-allocator.tsx +110 -0
  145. package/src/business/product-search.ts +4 -0
  146. package/src/dialogs/dynamic-record.tsx +42 -6
  147. package/src/document-form-dialog.tsx +92 -12
  148. package/src/dropdown-menu-entry.test.ts +23 -0
  149. package/src/dynamic-columns-helpers.ts +22 -0
  150. package/src/dynamic-columns.tsx +23 -6
  151. package/src/dynamic-crud-page.tsx +22 -3
  152. package/src/dynamic-form-schema.ts +15 -2
  153. package/src/dynamic-form.tsx +120 -39
  154. package/src/dynamic-kanban.tsx +41 -10
  155. package/src/dynamic-line-items.tsx +3 -0
  156. package/src/dynamic-multi-select-field.tsx +2 -0
  157. package/src/dynamic-relation-helpers.ts +9 -0
  158. package/src/dynamic-relation.tsx +7 -2
  159. package/src/dynamic-select-field.tsx +4 -0
  160. package/src/index.ts +34 -1
  161. package/src/installed-addons-context.tsx +58 -0
  162. package/src/option-filter.ts +88 -0
  163. package/src/org-runtime-context.ts +10 -0
  164. package/src/org-runtime-provider.tsx +7 -2
  165. package/src/primitives/aging.ts +76 -0
  166. package/src/primitives/allocation.ts +104 -0
  167. package/src/primitives/approvals.ts +103 -0
  168. package/src/primitives/contributions.ts +208 -0
  169. package/src/primitives/document-kinds.ts +62 -0
  170. package/src/primitives/index.ts +23 -0
  171. package/src/primitives/panels.ts +59 -0
  172. package/src/primitives/use-contributions.ts +42 -0
  173. package/src/record-prefill-bar.tsx +62 -0
  174. package/src/row-action-stage-gate.test.ts +33 -0
  175. package/src/types.ts +142 -0
  176. package/src/use-options-resolver.ts +19 -1
  177. package/vitest.config.ts +1 -0
  178. package/vitest.setup.ts +7 -0
@@ -31,17 +31,21 @@ export function applyProductToLine(
31
31
  const list = variant?.price ?? product.price
32
32
  const cost = variant?.cost ?? product.cost
33
33
  const catalog = source === 'cost' ? (cost ?? list) : list
34
+ // Nunca cantidad 0: un renglón vacío (o con la celda borrada) entra con 1.
35
+ const qty = opts.quantity ?? toAmount(line.quantity)
34
36
  return {
35
37
  ...line,
36
38
  kind: 'item',
37
39
  product_id: variant?.id ?? product.id,
38
40
  sku: variant?.sku ?? product.sku,
39
41
  description: variant ? `${product.name} · ${variant.label}` : product.name,
40
- quantity: opts.quantity ?? (toAmount(line.quantity) || 1),
42
+ quantity: qty !== 0 ? qty : 1,
41
43
  unit_price: priceFromProduct(product, variant, source),
42
44
  catalog_price: catalog,
43
45
  cost,
44
46
  tax_rate: product.tax_rate ?? line.tax_rate ?? 0,
47
+ unit: product.unit ?? line.unit,
48
+ extensions: product.extensions ? { ...(line.extensions ?? {}), ...product.extensions } : line.extensions,
45
49
  available: availableStock(src, opts.warehouseId) ?? availableStock(product, opts.warehouseId),
46
50
  }
47
51
  }
@@ -19,3 +19,8 @@ export * from './inspection-checklist'
19
19
  export * from './vehicle-picker'
20
20
  export * from './relate-documents'
21
21
  export * from './print-send-dialog'
22
+ export * from './catalog-product-search'
23
+ export * from './document-editor-model'
24
+ export * from './document-editor-panels'
25
+ export * from './payment-allocator'
26
+ export * from './document-editor'
@@ -37,6 +37,13 @@ export interface LineItem {
37
37
  dot?: string
38
38
  /** Existencia disponible conocida (para la política de sobreventa). */
39
39
  available?: number
40
+ /**
41
+ * Campos de extensión del renglón que aportan OTROS addons (p. ej.
42
+ * fiscal_mexico: `mx_clave_prod_serv`, `mx_clave_unidad`), copiados del
43
+ * producto al elegirlo. Viajan en `fiscal_data` (la bolsa de ModelExtension
44
+ * del host) — el SDK no conoce sus claves.
45
+ */
46
+ extensions?: Record<string, string>
40
47
  }
41
48
 
42
49
  /** Qué hacer si `quantity > available`. */
@@ -214,6 +221,7 @@ export function serializeLineItems(lines: LineItem[]): Array<Record<string, unkn
214
221
  const v = l[k]
215
222
  if (typeof v === 'string' && v.trim()) out[k] = v.trim()
216
223
  }
224
+ if (l.extensions && Object.keys(l.extensions).length > 0) out.fiscal_data = { ...l.extensions }
217
225
  }
218
226
  return out
219
227
  })
@@ -231,7 +239,7 @@ export function parseLineItems(rows: unknown): LineItem[] {
231
239
  product_id: str(o.product_id),
232
240
  sku: str(o.sku),
233
241
  description: typeof o.description === 'string' ? o.description : '',
234
- quantity: o.quantity == null ? 1 : toAmount(o.quantity),
242
+ quantity: o.quantity == null || o.quantity === '' ? 1 : toAmount(o.quantity),
235
243
  unit_price: toAmount(o.unit_price),
236
244
  discount: toAmount(o.discount),
237
245
  discount_kind: o.discount_kind === 'amount' ? 'amount' : undefined,
@@ -243,6 +251,12 @@ export function parseLineItems(rows: unknown): LineItem[] {
243
251
  technician_id: str(o.technician_id),
244
252
  lot: str(o.lot),
245
253
  dot: str(o.dot),
254
+ extensions:
255
+ o.fiscal_data && typeof o.fiscal_data === 'object'
256
+ ? (Object.fromEntries(
257
+ Object.entries(o.fiscal_data as Record<string, unknown>).filter(([, v]) => typeof v === 'string'),
258
+ ) as Record<string, string>)
259
+ : undefined,
246
260
  })
247
261
  })
248
262
  }
@@ -0,0 +1,110 @@
1
+ // PaymentAllocator — UI del reparto de un pago entre los documentos abiertos de
2
+ // la contraparte (cobro multi-factura / REP, CxP «Aplicar pago»). Las reglas
3
+ // son las de primitives/allocation (allocatePayment / validateAllocation); aquí
4
+ // solo se pintan y se editan. Por defecto reparte solo, del más vencido al más
5
+ // reciente; al teclear un monto pasa a manual sin perder lo ya repartido.
6
+ import { useTranslation } from 'react-i18next'
7
+ import { Badge, Button } from '@asteby/metacore-ui/primitives'
8
+ import { Input } from '@asteby/metacore-ui'
9
+ import type { AllocationResult, AllocationStrategy, OpenDocument } from '../primitives/allocation'
10
+ import { manualFromAllocations, overdueDays } from './document-editor-model'
11
+ import { useFormatter } from './format'
12
+
13
+ export interface PaymentAllocatorValue {
14
+ strategy: AllocationStrategy
15
+ manual: Record<string, number>
16
+ }
17
+
18
+ export interface PaymentAllocatorProps {
19
+ documents: OpenDocument[]
20
+ /** Resultado de `allocatePayment(amount, documents, value.strategy, value.manual)`. */
21
+ result: AllocationResult
22
+ value: PaymentAllocatorValue
23
+ onChange: (next: PaymentAllocatorValue) => void
24
+ currency?: string
25
+ loading?: boolean
26
+ readOnly?: boolean
27
+ /** Hoy (para los días de atraso; inyectable en tests). */
28
+ today?: Date
29
+ }
30
+
31
+ const num = (s: string): number => (s.trim() === '' ? 0 : Number(s.replace(',', '.')) || 0)
32
+
33
+ export function PaymentAllocator({ documents, result, value, onChange, currency, loading, readOnly, today }: PaymentAllocatorProps) {
34
+ const { t } = useTranslation()
35
+ const fmt = useFormatter({ currency })
36
+ const byDoc = new Map(result.allocations.map((a) => [a.document_id, a]))
37
+
38
+ if (loading) {
39
+ return <p className="text-sm text-muted-foreground">{t('paymentAllocator.loading', { defaultValue: 'Buscando documentos con saldo…' })}</p>
40
+ }
41
+ if (documents.length === 0) {
42
+ return (
43
+ <p className="text-sm text-muted-foreground" data-slot="payment-allocator-empty">
44
+ {t('paymentAllocator.empty', { defaultValue: 'No hay documentos con saldo pendiente.' })}
45
+ </p>
46
+ )
47
+ }
48
+
49
+ const setAmount = (id: string, amount: number) =>
50
+ onChange({ strategy: 'manual', manual: { ...manualFromAllocations(result.allocations), [id]: amount } })
51
+
52
+ return (
53
+ <div className="space-y-2" data-slot="payment-allocator">
54
+ <div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
55
+ <span>
56
+ {value.strategy === 'manual'
57
+ ? t('paymentAllocator.manual', { defaultValue: 'Reparto manual' })
58
+ : t('paymentAllocator.auto', { defaultValue: 'Se aplica primero a lo más vencido' })}
59
+ </span>
60
+ {!readOnly && value.strategy === 'manual' && (
61
+ <Button type="button" variant="ghost" size="sm" onClick={() => onChange({ strategy: 'oldest_due_first', manual: {} })}>
62
+ {t('paymentAllocator.autoapply', { defaultValue: 'Autoaplicar' })}
63
+ </Button>
64
+ )}
65
+ </div>
66
+ <ul className="divide-y rounded-md border">
67
+ {documents.map((d) => {
68
+ const a = byDoc.get(d.id)
69
+ const late = overdueDays(d, today)
70
+ return (
71
+ <li key={d.id} className="flex flex-wrap items-center gap-3 px-3 py-2 text-sm" data-document={d.id}>
72
+ <div className="min-w-0 flex-1">
73
+ <div className="flex items-center gap-2">
74
+ <span className="font-medium">{d.number}</span>
75
+ {late > 0 && (
76
+ <Badge variant="secondary">{t('paymentAllocator.overdue', { defaultValue: 'Vencida · {{days}} d', days: late })}</Badge>
77
+ )}
78
+ {d.payment_method && <span className="text-xs text-muted-foreground">{d.payment_method}</span>}
79
+ </div>
80
+ <div className="text-xs text-muted-foreground">
81
+ {t('paymentAllocator.balance', { defaultValue: 'Saldo' })} {fmt.money(d.balance, d.currency)}
82
+ {a && a.balance_after > 0 && (
83
+ <>
84
+ {' · '}
85
+ {t('paymentAllocator.installment', { defaultValue: 'Parcialidad {{n}}', n: a.installment })}
86
+ {' · '}
87
+ {t('paymentAllocator.remaining', { defaultValue: 'Queda' })} {fmt.money(a.balance_after, d.currency)}
88
+ </>
89
+ )}
90
+ </div>
91
+ </div>
92
+ <Input
93
+ type="number"
94
+ inputMode="decimal"
95
+ min={0}
96
+ step="0.01"
97
+ aria-label={t('paymentAllocator.apply_to', { defaultValue: 'Aplicar a {{number}}', number: d.number })}
98
+ className="h-9 w-32 text-right tabular-nums"
99
+ value={a ? String(a.amount) : ''}
100
+ placeholder="0.00"
101
+ disabled={readOnly}
102
+ onChange={(e) => setAmount(d.id, num(e.target.value))}
103
+ />
104
+ </li>
105
+ )
106
+ })}
107
+ </ul>
108
+ </div>
109
+ )
110
+ }
@@ -80,6 +80,10 @@ export interface ProductResult {
80
80
  stock?: StockByWarehouse[]
81
81
  /** Paquetes automáticos: ids/nombres que se sugieren junto al producto. */
82
82
  bundle_items?: Array<{ product_id: string; name: string; quantity: number }>
83
+ /** Unidad de venta del catálogo (pza, juego, kg…). */
84
+ unit?: string
85
+ /** Campos de extensión del producto (fiscal_data.*) que se copian al renglón. */
86
+ extensions?: Record<string, string>
83
87
  }
84
88
 
85
89
  /** Existencia disponible: de la variante o del producto, en un almacén o en total. */
@@ -10,7 +10,7 @@
10
10
  // the image-url resolver and the org IANA timezone — are passed as props so the
11
11
  // SDK stays transport- and host-agnostic.
12
12
  import { emitRecordMutation } from '../record-mutation-events'
13
- import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from 'react'
13
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
14
14
  import { useTranslation } from 'react-i18next'
15
15
  import type { ModelSchema } from './types'
16
16
 
@@ -60,6 +60,8 @@ import { toastServerError, extractFieldErrors, localizeFieldIssue, localizeField
60
60
  import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
61
61
  import { DynamicMultiSelectField } from '../dynamic-multi-select-field'
62
62
  import { DynamicRelations } from '../dynamic-relations'
63
+ import { AuditInfo, readAuditMeta } from '../audit-info'
64
+ import type { AuditMeta } from '../types'
63
65
  import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
64
66
  import { getFieldRef, getVisibleWhen, evaluateVisibleWhen, ATTRIBUTE_CLASSES_KEY } from '../dynamic-form-schema'
65
67
  import { useAttributeClasses, type AttributeClass } from '../attribute-classes'
@@ -67,13 +69,14 @@ import type { VisibleWhen } from '../types'
67
69
  import { groupFieldsBySection, type FormLayout } from '../form-layout'
68
70
  import { FieldSection, WizardProgress } from '../form-layout-ui'
69
71
  import { AssistInterview } from '../assist-interview'
72
+ import { RecordPrefillBar } from '../record-prefill-bar'
70
73
  import { FieldCell } from '../field-grid'
71
74
  import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
72
75
  import { normalizeRefFieldsForSubmit } from './normalize-submit'
73
76
  import { validateValues, bagHasErrors, exemptUnchangedRuleIssues } from '../validator'
74
77
  import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
75
78
  import { IconPickerField } from '../icon-picker-field'
76
- import { humanizeToken } from '../dynamic-columns-helpers'
79
+ import { humanizeToken, localizeFieldLabel } from '../dynamic-columns-helpers'
77
80
  import { formatDateCell } from '../dynamic-columns'
78
81
  import {
79
82
  ImageStack,
@@ -643,6 +646,7 @@ export function DynamicRecordDialog({
643
646
  schema ? (schema as ModalMetadata) : null,
644
647
  )
645
648
  const [relations, setRelations] = useState<RelationMeta[]>([])
649
+ const [audit, setAudit] = useState<AuditMeta | undefined>(undefined)
646
650
  const [record, setRecord] = useState<any | null>(null)
647
651
  const [formValues, setFormValues] = useState<Record<string, any>>({})
648
652
  // Classes of the record's category (plus those its saved data carries), for
@@ -664,6 +668,17 @@ export function DynamicRecordDialog({
664
668
  const isCreate = mode === 'create'
665
669
  const isView = mode === 'view'
666
670
  const isEditable = mode === 'create' || mode === 'edit'
671
+ // Claves que un ayudante de captura (RecordPrefillBar) puede escribir: sólo
672
+ // los campos que este formulario declara (incluye fiscal_data.* de las
673
+ // ModelExtension). Readonly fuera: un prefill no salta un candado.
674
+ const prefillAllowedKeys = useMemo(
675
+ () => new Set<string>((modalMeta?.fields ?? []).filter((f: any) => !f.readonly).map((f: any) => f.key)),
676
+ [modalMeta],
677
+ )
678
+ const applyPrefill = useCallback(
679
+ (patch: Record<string, any>) => setFormValues(prev => ({ ...prev, ...patch })),
680
+ [],
681
+ )
667
682
  const config = MODE_CONFIG[mode]
668
683
 
669
684
  // ── Fetch metadata + record when dialog opens ──────────────────────────
@@ -784,6 +799,7 @@ export function DynamicRecordDialog({
784
799
  useEffect(() => {
785
800
  if (!open || mode === 'create' || !recordId) {
786
801
  setRelations([])
802
+ setAudit(undefined)
787
803
  return
788
804
  }
789
805
  let cancelled = false
@@ -792,6 +808,7 @@ export function DynamicRecordDialog({
792
808
  if (cancelled) return
793
809
  const meta = res.data?.data ?? res.data
794
810
  const rels: RelationMeta[] = Array.isArray(meta?.relations) ? meta.relations : []
811
+ setAudit(readAuditMeta(meta?.audit))
795
812
  // Localize each panel header: the backend serves `label` as an
796
813
  // i18n key (addon bundle, loaded live) and the SDK renders it verbatim.
797
814
  setRelations(
@@ -846,7 +863,7 @@ export function DynamicRecordDialog({
846
863
  // with no matching form field).
847
864
  const labelForKey = (key: string): string => {
848
865
  const f = (modalMeta?.fields ?? []).find(x => x.key === key)
849
- if (f?.label) return f.label
866
+ if (f?.label) return localizeFieldLabel(f.label, t)
850
867
  return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
851
868
  }
852
869
 
@@ -898,7 +915,7 @@ export function DynamicRecordDialog({
898
915
  }
899
916
  if (bagHasErrors(bag)) {
900
917
  const labels: Record<string, string> = {}
901
- for (const f of visible) labels[f.key] = f.label
918
+ for (const f of visible) labels[f.key] = localizeFieldLabel(f.label, t)
902
919
  const next = localizeFieldErrorMap(bag, t, { labels })
903
920
  setFieldErrors(next)
904
921
  const description = Object.entries(next)
@@ -1072,7 +1089,7 @@ export function DynamicRecordDialog({
1072
1089
  const bag = validateValues(stepFields as ActionFieldDef[], formValues)
1073
1090
  if (bagHasErrors(bag)) {
1074
1091
  const labels: Record<string, string> = {}
1075
- for (const f of stepFields) labels[f.key] = f.label
1092
+ for (const f of stepFields) labels[f.key] = localizeFieldLabel(f.label, t)
1076
1093
  setFieldErrors(localizeFieldErrorMap(bag, t, { labels }))
1077
1094
  toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
1078
1095
  return
@@ -1106,6 +1123,16 @@ export function DynamicRecordDialog({
1106
1123
  two-column grid, unchanged. FieldCell gives each
1107
1124
  cell `min-w-0` so a long select/input value can't
1108
1125
  blow the two columns past the dialog width. */}
1126
+ {isEditable && (
1127
+ <RecordPrefillBar
1128
+ model={model}
1129
+ mode={isCreate ? 'create' : 'edit'}
1130
+ recordId={recordId}
1131
+ values={formValues}
1132
+ allowedKeys={prefillAllowedKeys}
1133
+ onApply={applyPrefill}
1134
+ />
1135
+ )}
1109
1136
  <form
1110
1137
  id={formId}
1111
1138
  // Stable Aby / guidance contract: unique id={formId}
@@ -1189,6 +1216,14 @@ export function DynamicRecordDialog({
1189
1216
  />
1190
1217
  </div>
1191
1218
  )}
1219
+ {!isCreate && record && audit && (
1220
+ <AuditInfo
1221
+ record={record}
1222
+ audit={audit}
1223
+ timeZone={timeZone}
1224
+ className="mt-6 border-t pt-3"
1225
+ />
1226
+ )}
1192
1227
  </CurrencyContext.Provider>
1193
1228
  </TimeZoneContext.Provider>
1194
1229
  </ImageUrlContext.Provider>
@@ -1300,11 +1335,12 @@ function FieldRow({ field, record, value, mode, onChange, error, locked }: Field
1300
1335
  // the rich read-only renderer. `locked` forces the same disabled rendering on
1301
1336
  // CREATE for a caller-specified field (see `lockedFields`).
1302
1337
  const isEditReadonly = (mode === 'edit' && !!field.readonly) || !!locked
1338
+ const { t } = useTranslation()
1303
1339
 
1304
1340
  return (
1305
1341
  <div className="flex flex-col gap-1.5" data-aby-field={field.key}>
1306
1342
  <Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
1307
- {field.label}
1343
+ {localizeFieldLabel(field.label, t)}
1308
1344
  {field.required && mode !== 'view' && !isEditReadonly && (
1309
1345
  <span className="text-destructive ml-0.5">*</span>
1310
1346
  )}
@@ -23,6 +23,7 @@ import {
23
23
  import { Loader2 } from 'lucide-react'
24
24
  import { toast } from 'sonner'
25
25
  import { useApi } from './api-context'
26
+ import { useOrgTaxRate } from './org-runtime-context'
26
27
  import { DynamicIcon } from './dynamic-icon'
27
28
  import { FieldCell, FieldGrid, FieldLabel } from './field-grid'
28
29
  import { FormErrorBanner } from './business/feedback'
@@ -41,12 +42,13 @@ import { extractFieldErrors, localizeFieldErrorMap } from './server-error'
41
42
  import { validateValues, bagHasErrors } from './validator'
42
43
  import { clearFieldErrorTree } from './field-validation-ui'
43
44
  import { emitRecordMutation } from './record-mutation-events'
44
- import { isLineItemsField, resolveWidget } from './dynamic-form-schema'
45
+ import { isLineItemsField, resolveWidget, scopeValueFromFilterToken } from './dynamic-form-schema'
46
+ import { createCatalogProductSearch } from './business/catalog-product-search'
47
+ import { DocumentEditor } from './business/document-editor'
48
+ import { editorLinesConfig, isEditorLayout } from './business/document-editor-model'
45
49
  import type {
46
50
  ActionFieldDef,
47
- DocumentFormLines,
48
51
  DocumentFormsManifest,
49
- DocumentFormType,
50
52
  TableMetadata,
51
53
  } from './types'
52
54
 
@@ -62,10 +64,24 @@ export interface DocumentFormDialogProps {
62
64
  initialType?: string
63
65
  /** Se llama con el registro creado. */
64
66
  onSaved?: (record?: unknown) => void
65
- /** Buscador de productos para el editor de renglones (mismo contrato que ProductPicker). */
67
+ /**
68
+ * Buscador de productos para el editor de renglones (mismo contrato que
69
+ * ProductPicker). Sin él se busca en el catálogo (`productModel`) vía /data.
70
+ */
66
71
  searchProducts?: DocumentLinesGridProps['search']
72
+ /** Modelo de catálogo del buscador por defecto. Default `products.Product`. */
73
+ productModel?: string
74
+ /** Tasa de impuesto (0.16) si el producto no trae la suya. Default: la de la org (OrgRuntimeProvider). */
75
+ defaultTaxRate?: number
67
76
  /** Moneda ISO para el editor de renglones (default: la de la org). */
68
77
  currency?: string
78
+ /**
79
+ * Registro desde el que se abre (acción de fila «Crear NC», «Registrar
80
+ * cobro»…): siembra los campos con `default_from_record`.
81
+ */
82
+ record?: Record<string, any>
83
+ /** Documento origen a precargar en el DocumentEditor (`sources[].key` + id). */
84
+ initialSource?: { key: string; id: string }
69
85
  }
70
86
 
71
87
  /** Manifest efectivo: la prop gana sobre el metadata; sin tipos devuelve undefined. */
@@ -77,10 +93,25 @@ export function resolveDocumentForms(
77
93
  return m && Array.isArray(m.types) && m.types.length > 0 ? m : undefined
78
94
  }
79
95
 
80
- function linesConfig(type: DocumentFormType, forms: DocumentFormsManifest): (DocumentFormLines & { field: string }) | undefined {
81
- if (!type.lines) return undefined
82
- const cfg: DocumentFormLines = type.lines === true ? {} : type.lines
83
- return { ...cfg, field: cfg.field ?? forms.lines_field ?? 'lines' }
96
+ /**
97
+ * Acota el alta guiada a la vista filtrada. Un nav que filtra por la columna
98
+ * discriminadora (`type_field`) lista UN tipo de documento: «Crear» debe abrir
99
+ * ese tipo directo y, si el manifest no trae formulario para él, no ofrecerse
100
+ * (retest r3: «Documentos fiscales → Facturas → Crear» abría el REP, el único
101
+ * tipo declarado). Sin manifest, sin `type_field` o sin filtro sobre él, todo
102
+ * queda igual.
103
+ */
104
+ export function scopeDocumentFormsToFilter(
105
+ forms: DocumentFormsManifest | undefined,
106
+ filter?: Record<string, unknown> | null,
107
+ ): { forms: DocumentFormsManifest | undefined; hideCreate: boolean } {
108
+ const field = forms?.type_field
109
+ if (!forms || !field || !filter || !(field in filter)) return { forms, hideCreate: false }
110
+ const want = scopeValueFromFilterToken(filter[field]).trim().toLowerCase()
111
+ if (want === '') return { forms, hideCreate: false }
112
+ const types = forms.types.filter((x) => String(x.value ?? x.key).trim().toLowerCase() === want)
113
+ if (types.length === 0) return { forms: undefined, hideCreate: true }
114
+ return { forms: types.length === forms.types.length ? forms : { ...forms, types }, hideCreate: false }
84
115
  }
85
116
 
86
117
  type Step = 'type' | 'fields' | 'lines'
@@ -94,10 +125,15 @@ export function DocumentFormDialog({
94
125
  initialType,
95
126
  onSaved,
96
127
  searchProducts,
128
+ productModel,
129
+ defaultTaxRate,
97
130
  currency,
131
+ record,
132
+ initialSource,
98
133
  }: DocumentFormDialogProps) {
99
134
  const { t, i18n } = useTranslation()
100
135
  const api = useApi()
136
+ const orgTaxRate = useOrgTaxRate()
101
137
  const types = forms.types
102
138
  const single = types.length === 1 ? types[0] : undefined
103
139
 
@@ -111,7 +147,15 @@ export function DocumentFormDialog({
111
147
  const [saving, setSaving] = useState(false)
112
148
 
113
149
  const type = useMemo(() => types.find((x) => x.key === typeKey) ?? null, [types, typeKey])
114
- const lineCfg = type ? linesConfig(type, forms) : undefined
150
+ // Sin buscador inyectado, el paso de renglones solo ofrecía «Renglón libre»
151
+ // y la línea entraba sin precio (DynamicCRUDPage no pasa searchProducts).
152
+ // Default: el catálogo de productos de la org.
153
+ const taxRate = defaultTaxRate ?? orgTaxRate
154
+ const lineSearch = useMemo(
155
+ () => searchProducts ?? createCatalogProductSearch(api, { model: productModel, defaultTaxRate: taxRate }),
156
+ [searchProducts, api, productModel, taxRate],
157
+ )
158
+ const lineCfg = type ? editorLinesConfig(type, forms) : undefined
115
159
 
116
160
  // Reset every time the dialog (re)opens.
117
161
  useEffect(() => {
@@ -128,10 +172,10 @@ export function DocumentFormDialog({
128
172
  // Seed field defaults for the chosen type.
129
173
  useEffect(() => {
130
174
  if (!type) return
131
- setFormData({ ...buildFieldDefaults(type.fields, undefined), ...(type.defaults ?? {}) })
175
+ setFormData({ ...buildFieldDefaults(type.fields, record), ...(type.defaults ?? {}) })
132
176
  setFieldErrors({})
133
177
  setFormError(undefined)
134
- }, [type])
178
+ }, [type, record])
135
179
 
136
180
  const updateField = (key: string, value: any) => {
137
181
  setFormData((prev) => ({ ...prev, [key]: value }))
@@ -232,6 +276,41 @@ export function DocumentFormDialog({
232
276
  ? t('documentForm.step_data', { defaultValue: 'Datos' })
233
277
  : tl(lineCfg?.title ?? 'Renglones')
234
278
 
279
+ // layout "editor": una sola pantalla por secciones (DocumentEditor). Sin él
280
+ // sigue el wizard de siempre (retrocompatible).
281
+ if (open && type && step !== 'type' && isEditorLayout(type)) {
282
+ return (
283
+ <Dialog open={open} onOpenChange={onOpenChange}>
284
+ <DialogContent
285
+ className="flex max-h-[92dvh] flex-col overflow-hidden"
286
+ style={{ maxHeight: '92dvh', maxWidth: '960px', width: '95vw' }}
287
+ data-slot="document-editor-dialog"
288
+ >
289
+ <DialogHeader className="shrink-0">
290
+ <DialogTitle>{tl(type.label)}</DialogTitle>
291
+ {type.description && <DialogDescription>{tl(type.description)}</DialogDescription>}
292
+ </DialogHeader>
293
+ <DocumentEditor
294
+ key={type.key}
295
+ model={model}
296
+ endpoint={endpoint}
297
+ forms={forms}
298
+ type={type}
299
+ record={record}
300
+ initialSource={initialSource}
301
+ searchProducts={lineSearch}
302
+ currency={currency}
303
+ onCancel={() => (canGoBackToTypes ? setStep('type') : onOpenChange(false))}
304
+ onSaved={(rec) => {
305
+ onSaved?.(rec)
306
+ onOpenChange(false)
307
+ }}
308
+ />
309
+ </DialogContent>
310
+ </Dialog>
311
+ )
312
+ }
313
+
235
314
  return (
236
315
  <Dialog open={open} onOpenChange={onOpenChange}>
237
316
  <DialogContent
@@ -311,8 +390,9 @@ export function DocumentFormDialog({
311
390
  }}
312
391
  columns={(lineCfg.columns as LineItemsColumn[] | undefined) ?? ['discount', 'tax']}
313
392
  priceSource={lineCfg.price_source ?? 'sale'}
393
+ discountMode={lineCfg.discount_mode}
314
394
  serverErrors={serverLineErrors}
315
- search={searchProducts}
395
+ search={lineSearch}
316
396
  currency={currency}
317
397
  />
318
398
  )}
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync, readdirSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ // Radix context guard: a component that renders RowActionMenuItem (whose
6
+ // DropdownMenuItem is imported from the ROOT '@asteby/metacore-ui' entry) must
7
+ // import its DropdownMenu shell from the root entry too. Hosts share the root
8
+ // entry as a Module Federation singleton but bundle '/primitives' locally, so
9
+ // mixing entries creates two Menu contexts → "`MenuItem` must be used within
10
+ // `Menu`" at runtime (Pitsline kanban card menu crash, 2026-10-05).
11
+ describe('dropdown menu entry consistency', () => {
12
+ const dir = join(__dirname)
13
+ const files = readdirSync(dir).filter((f) => f.endsWith('.tsx'))
14
+ for (const f of files) {
15
+ const src = readFileSync(join(dir, f), 'utf8')
16
+ if (!src.includes('<RowActionMenuItem')) continue
17
+ it(`${f} imports DropdownMenu from the root ui entry`, () => {
18
+ const primitivesImport = src.match(/import\s*{([^}]*)}\s*from\s*'@asteby\/metacore-ui\/primitives'/)
19
+ const fromPrimitives = primitivesImport ? primitivesImport[1] : ''
20
+ expect(fromPrimitives).not.toMatch(/\bDropdownMenu(Content|Trigger)?\b/)
21
+ })
22
+ }
23
+ })
@@ -78,3 +78,25 @@ export function translateMetadataLabel(
78
78
  const translated = translate(value, { defaultValue: fallback })
79
79
  return translated && translated !== value ? translated : fallback
80
80
  }
81
+
82
+ // A catalog key as addons write them: lowercase dotted segments
83
+ // ("fiscal_mexico.ext.customer.rfc_receptor"). Human labels have spaces,
84
+ // capitals or accents and never match.
85
+ const CATALOG_KEY_RE = /^[a-z0-9_]+(\.[a-z0-9_]+)+$/
86
+
87
+ /**
88
+ * Form-field label as the user should read it. The host usually serves labels
89
+ * already localized; when it could not (an extension field whose addon catalog
90
+ * the server did not resolve), the label arrives as the raw catalog key and the
91
+ * form printed `FISCAL_MEXICO.EXT.CUSTOMER.RFC_RECEPTOR`. Such a key is resolved
92
+ * with the client catalog and, failing that, humanized. Any other label is
93
+ * returned untouched.
94
+ */
95
+ export function localizeFieldLabel(
96
+ label: string | undefined,
97
+ translate?: MetadataTranslator,
98
+ ): string {
99
+ if (!label) return ''
100
+ if (!CATALOG_KEY_RE.test(label)) return label
101
+ return translateMetadataLabel(label, translate)
102
+ }
@@ -243,7 +243,16 @@ export function RowActionMenuItem({
243
243
  * …) and fall back to `state` (purchases, inventory transfers, …). Empty string
244
244
  * is treated as missing so a blank `status` does not hide a populated `state`.
245
245
  */
246
- const rowLifecycleState = (row: any): unknown => {
246
+ const rowLifecycleState = (row: any, stageField?: string): unknown => {
247
+ // A model with a stage machine gates its actions on the stage column, the
248
+ // same field the kernel's checkRequiresState reads (dynamic/action.go).
249
+ // Without this, WorkOrder actions declared with stage keys
250
+ // (requiresState: ['reception', …]) were compared to `status` ('open') and
251
+ // hidden in every row, so an OT could never be advanced from the UI.
252
+ if (stageField) {
253
+ const stage = row?.[stageField]
254
+ if (stage !== undefined && stage !== null && stage !== '') return stage
255
+ }
247
256
  const status = row?.status
248
257
  if (status !== undefined && status !== null && status !== '') return status
249
258
  const state = row?.state
@@ -264,10 +273,10 @@ const rowLifecycleState = (row: any): unknown => {
264
273
  * - action without requiresState (or empty array) → always shown.
265
274
  * - row with neither `status` nor `state` → all actions shown.
266
275
  */
267
- export const isActionAllowedForRowState = (action: any, row: any): boolean => {
276
+ export const isActionAllowedForRowState = (action: any, row: any, stageField?: string): boolean => {
268
277
  const requires: unknown = action?.requiresState ?? action?.requires_state
269
278
  if (!Array.isArray(requires) || requires.length === 0) return true
270
- const status = rowLifecycleState(row)
279
+ const status = rowLifecycleState(row, stageField)
271
280
  if (status === undefined) return true
272
281
  return requires.map(String).includes(String(status))
273
282
  }
@@ -341,8 +350,16 @@ export const isActionConditionMet = (action: any, row: any): boolean => {
341
350
  * (`requiresState`) AND the declarative `condition` must pass. Shared by the
342
351
  * table's action column and the kanban card menu so they hide/show identically.
343
352
  */
344
- export const isRowActionVisible = (action: any, row: any): boolean =>
345
- isActionAllowedForRowState(action, row) && isActionConditionMet(action, row)
353
+ export const isRowActionVisible = (action: any, row: any, stageField?: string): boolean =>
354
+ isActionAllowedForRowState(action, row, stageField) && isActionConditionMet(action, row)
355
+
356
+ /**
357
+ * The column a model's `requiresState` gates read: the served `stage_field`
358
+ * (or, for hosts that predate it, the `group_by` of a model with stages).
359
+ * Undefined → the historical `status` / `state` lookup.
360
+ */
361
+ export const lifecycleStageField = (metadata: any): string | undefined =>
362
+ metadata?.stage_field ?? metadata?.stageField ?? (metadata?.stages?.length ? metadata?.group_by : undefined)
346
363
 
347
364
  const lowerFirst = (value?: string) => {
348
365
  if (!value) return value
@@ -1564,7 +1581,7 @@ export function makeDefaultGetDynamicColumns(
1564
1581
  </DropdownMenuTrigger>
1565
1582
  <DropdownMenuContent align="end">
1566
1583
  {resolvedActions
1567
- .filter((action) => isRowActionVisible(action, row.original))
1584
+ .filter((action) => isRowActionVisible(action, row.original, lifecycleStageField(metadata)))
1568
1585
  .map((action) => (
1569
1586
  <RowActionMenuItem
1570
1587
  key={action.key}