@asteby/metacore-runtime-react 47.1.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 (145) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  3. package/dist/action-modal-dispatcher.js +24 -4
  4. package/dist/business/catalog-product-search.d.ts +25 -0
  5. package/dist/business/catalog-product-search.d.ts.map +1 -0
  6. package/dist/business/catalog-product-search.js +57 -0
  7. package/dist/business/catalog.d.ts.map +1 -1
  8. package/dist/business/catalog.js +2 -0
  9. package/dist/business/document-editor-model.d.ts +107 -0
  10. package/dist/business/document-editor-model.d.ts.map +1 -0
  11. package/dist/business/document-editor-model.js +279 -0
  12. package/dist/business/document-editor-panels.d.ts +64 -0
  13. package/dist/business/document-editor-panels.d.ts.map +1 -0
  14. package/dist/business/document-editor-panels.js +58 -0
  15. package/dist/business/document-editor.d.ts +27 -0
  16. package/dist/business/document-editor.d.ts.map +1 -0
  17. package/dist/business/document-editor.js +445 -0
  18. package/dist/business/document-lines.d.ts.map +1 -1
  19. package/dist/business/document-lines.js +5 -1
  20. package/dist/business/index.d.ts +5 -0
  21. package/dist/business/index.d.ts.map +1 -1
  22. package/dist/business/index.js +5 -0
  23. package/dist/business/line-items.d.ts +7 -0
  24. package/dist/business/line-items.d.ts.map +1 -1
  25. package/dist/business/line-items.js +6 -1
  26. package/dist/business/payment-allocator.d.ts +19 -0
  27. package/dist/business/payment-allocator.d.ts.map +1 -0
  28. package/dist/business/payment-allocator.js +31 -0
  29. package/dist/business/product-search.d.ts +4 -0
  30. package/dist/business/product-search.d.ts.map +1 -1
  31. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  32. package/dist/dialogs/dynamic-record.js +14 -7
  33. package/dist/document-form-dialog.d.ts +31 -2
  34. package/dist/document-form-dialog.d.ts.map +1 -1
  35. package/dist/document-form-dialog.js +43 -11
  36. package/dist/dynamic-columns-helpers.d.ts +9 -0
  37. package/dist/dynamic-columns-helpers.d.ts.map +1 -1
  38. package/dist/dynamic-columns-helpers.js +19 -0
  39. package/dist/dynamic-columns.d.ts +8 -2
  40. package/dist/dynamic-columns.d.ts.map +1 -1
  41. package/dist/dynamic-columns.js +21 -5
  42. package/dist/dynamic-crud-page.d.ts +6 -0
  43. package/dist/dynamic-crud-page.d.ts.map +1 -1
  44. package/dist/dynamic-crud-page.js +14 -5
  45. package/dist/dynamic-form-schema.d.ts.map +1 -1
  46. package/dist/dynamic-form-schema.js +15 -3
  47. package/dist/dynamic-form.d.ts +16 -1
  48. package/dist/dynamic-form.d.ts.map +1 -1
  49. package/dist/dynamic-form.js +78 -20
  50. package/dist/dynamic-kanban.d.ts +9 -3
  51. package/dist/dynamic-kanban.d.ts.map +1 -1
  52. package/dist/dynamic-kanban.js +31 -12
  53. package/dist/dynamic-relation-helpers.d.ts.map +1 -1
  54. package/dist/dynamic-relation-helpers.js +8 -0
  55. package/dist/dynamic-relation.js +1 -1
  56. package/dist/index.d.ts +5 -2
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +13 -2
  59. package/dist/installed-addons-context.d.ts +15 -0
  60. package/dist/installed-addons-context.d.ts.map +1 -0
  61. package/dist/installed-addons-context.js +39 -0
  62. package/dist/org-runtime-context.d.ts +8 -0
  63. package/dist/org-runtime-context.d.ts.map +1 -1
  64. package/dist/org-runtime-context.js +8 -0
  65. package/dist/org-runtime-provider.d.ts +3 -1
  66. package/dist/org-runtime-provider.d.ts.map +1 -1
  67. package/dist/org-runtime-provider.js +3 -3
  68. package/dist/primitives/aging.d.ts +34 -0
  69. package/dist/primitives/aging.d.ts.map +1 -0
  70. package/dist/primitives/aging.js +48 -0
  71. package/dist/primitives/allocation.d.ts +42 -0
  72. package/dist/primitives/allocation.d.ts.map +1 -0
  73. package/dist/primitives/allocation.js +59 -0
  74. package/dist/primitives/approvals.d.ts +71 -0
  75. package/dist/primitives/approvals.d.ts.map +1 -0
  76. package/dist/primitives/approvals.js +48 -0
  77. package/dist/primitives/contributions.d.ts +82 -0
  78. package/dist/primitives/contributions.d.ts.map +1 -0
  79. package/dist/primitives/contributions.js +126 -0
  80. package/dist/primitives/document-kinds.d.ts +25 -0
  81. package/dist/primitives/document-kinds.d.ts.map +1 -0
  82. package/dist/primitives/document-kinds.js +26 -0
  83. package/dist/primitives/index.d.ts +8 -0
  84. package/dist/primitives/index.d.ts.map +1 -0
  85. package/dist/primitives/index.js +23 -0
  86. package/dist/primitives/panels.d.ts +68 -0
  87. package/dist/primitives/panels.d.ts.map +1 -0
  88. package/dist/primitives/panels.js +3 -0
  89. package/dist/primitives/use-contributions.d.ts +15 -0
  90. package/dist/primitives/use-contributions.d.ts.map +1 -0
  91. package/dist/primitives/use-contributions.js +30 -0
  92. package/dist/record-prefill-bar.d.ts +11 -0
  93. package/dist/record-prefill-bar.d.ts.map +1 -0
  94. package/dist/record-prefill-bar.js +33 -0
  95. package/dist/types.d.ts +102 -0
  96. package/dist/types.d.ts.map +1 -1
  97. package/package.json +3 -3
  98. package/src/__tests__/catalog-product-search.test.ts +61 -0
  99. package/src/__tests__/document-editor-model.test.ts +167 -0
  100. package/src/__tests__/document-editor.test.tsx +261 -0
  101. package/src/__tests__/document-form-dialog.test.tsx +118 -3
  102. package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
  103. package/src/__tests__/dynamic-kanban.test.tsx +24 -3
  104. package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
  105. package/src/__tests__/installed-addons-context.test.tsx +50 -0
  106. package/src/__tests__/line-item-totals.test.ts +28 -0
  107. package/src/__tests__/primitives-federated.test.tsx +156 -0
  108. package/src/__tests__/primitives.test.ts +224 -0
  109. package/src/__tests__/record-prefill-bar.test.tsx +50 -0
  110. package/src/action-modal-dispatcher.tsx +47 -4
  111. package/src/business/catalog-product-search.ts +87 -0
  112. package/src/business/catalog.ts +2 -0
  113. package/src/business/document-editor-model.ts +325 -0
  114. package/src/business/document-editor-panels.tsx +164 -0
  115. package/src/business/document-editor.tsx +727 -0
  116. package/src/business/document-lines.ts +5 -1
  117. package/src/business/index.ts +5 -0
  118. package/src/business/line-items.ts +15 -1
  119. package/src/business/payment-allocator.tsx +110 -0
  120. package/src/business/product-search.ts +4 -0
  121. package/src/dialogs/dynamic-record.tsx +29 -6
  122. package/src/document-form-dialog.tsx +92 -12
  123. package/src/dynamic-columns-helpers.ts +22 -0
  124. package/src/dynamic-columns.tsx +23 -6
  125. package/src/dynamic-crud-page.tsx +22 -3
  126. package/src/dynamic-form-schema.ts +15 -2
  127. package/src/dynamic-form.tsx +117 -39
  128. package/src/dynamic-kanban.tsx +34 -7
  129. package/src/dynamic-relation-helpers.ts +9 -0
  130. package/src/dynamic-relation.tsx +7 -2
  131. package/src/index.ts +21 -1
  132. package/src/installed-addons-context.tsx +58 -0
  133. package/src/org-runtime-context.ts +10 -0
  134. package/src/org-runtime-provider.tsx +7 -2
  135. package/src/primitives/aging.ts +76 -0
  136. package/src/primitives/allocation.ts +104 -0
  137. package/src/primitives/approvals.ts +103 -0
  138. package/src/primitives/contributions.ts +208 -0
  139. package/src/primitives/document-kinds.ts +62 -0
  140. package/src/primitives/index.ts +23 -0
  141. package/src/primitives/panels.ts +59 -0
  142. package/src/primitives/use-contributions.ts +42 -0
  143. package/src/record-prefill-bar.tsx +62 -0
  144. package/src/row-action-stage-gate.test.ts +33 -0
  145. package/src/types.ts +101 -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
 
@@ -69,13 +69,14 @@ import type { VisibleWhen } from '../types'
69
69
  import { groupFieldsBySection, type FormLayout } from '../form-layout'
70
70
  import { FieldSection, WizardProgress } from '../form-layout-ui'
71
71
  import { AssistInterview } from '../assist-interview'
72
+ import { RecordPrefillBar } from '../record-prefill-bar'
72
73
  import { FieldCell } from '../field-grid'
73
74
  import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
74
75
  import { normalizeRefFieldsForSubmit } from './normalize-submit'
75
76
  import { validateValues, bagHasErrors, exemptUnchangedRuleIssues } from '../validator'
76
77
  import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
77
78
  import { IconPickerField } from '../icon-picker-field'
78
- import { humanizeToken } from '../dynamic-columns-helpers'
79
+ import { humanizeToken, localizeFieldLabel } from '../dynamic-columns-helpers'
79
80
  import { formatDateCell } from '../dynamic-columns'
80
81
  import {
81
82
  ImageStack,
@@ -667,6 +668,17 @@ export function DynamicRecordDialog({
667
668
  const isCreate = mode === 'create'
668
669
  const isView = mode === 'view'
669
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
+ )
670
682
  const config = MODE_CONFIG[mode]
671
683
 
672
684
  // ── Fetch metadata + record when dialog opens ──────────────────────────
@@ -851,7 +863,7 @@ export function DynamicRecordDialog({
851
863
  // with no matching form field).
852
864
  const labelForKey = (key: string): string => {
853
865
  const f = (modalMeta?.fields ?? []).find(x => x.key === key)
854
- if (f?.label) return f.label
866
+ if (f?.label) return localizeFieldLabel(f.label, t)
855
867
  return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
856
868
  }
857
869
 
@@ -903,7 +915,7 @@ export function DynamicRecordDialog({
903
915
  }
904
916
  if (bagHasErrors(bag)) {
905
917
  const labels: Record<string, string> = {}
906
- for (const f of visible) labels[f.key] = f.label
918
+ for (const f of visible) labels[f.key] = localizeFieldLabel(f.label, t)
907
919
  const next = localizeFieldErrorMap(bag, t, { labels })
908
920
  setFieldErrors(next)
909
921
  const description = Object.entries(next)
@@ -1077,7 +1089,7 @@ export function DynamicRecordDialog({
1077
1089
  const bag = validateValues(stepFields as ActionFieldDef[], formValues)
1078
1090
  if (bagHasErrors(bag)) {
1079
1091
  const labels: Record<string, string> = {}
1080
- for (const f of stepFields) labels[f.key] = f.label
1092
+ for (const f of stepFields) labels[f.key] = localizeFieldLabel(f.label, t)
1081
1093
  setFieldErrors(localizeFieldErrorMap(bag, t, { labels }))
1082
1094
  toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
1083
1095
  return
@@ -1111,6 +1123,16 @@ export function DynamicRecordDialog({
1111
1123
  two-column grid, unchanged. FieldCell gives each
1112
1124
  cell `min-w-0` so a long select/input value can't
1113
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
+ )}
1114
1136
  <form
1115
1137
  id={formId}
1116
1138
  // Stable Aby / guidance contract: unique id={formId}
@@ -1313,11 +1335,12 @@ function FieldRow({ field, record, value, mode, onChange, error, locked }: Field
1313
1335
  // the rich read-only renderer. `locked` forces the same disabled rendering on
1314
1336
  // CREATE for a caller-specified field (see `lockedFields`).
1315
1337
  const isEditReadonly = (mode === 'edit' && !!field.readonly) || !!locked
1338
+ const { t } = useTranslation()
1316
1339
 
1317
1340
  return (
1318
1341
  <div className="flex flex-col gap-1.5" data-aby-field={field.key}>
1319
1342
  <Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
1320
- {field.label}
1343
+ {localizeFieldLabel(field.label, t)}
1321
1344
  {field.required && mode !== 'view' && !isEditReadonly && (
1322
1345
  <span className="text-destructive ml-0.5">*</span>
1323
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
  )}
@@ -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}
@@ -33,7 +33,7 @@ import { useApi } from './api-context'
33
33
  import { useMetadataCache } from './metadata-cache'
34
34
  import { DynamicTable } from './dynamic-table'
35
35
  import { DynamicRecordDialog } from './dialogs/dynamic-record'
36
- import { DocumentFormDialog, resolveDocumentForms } from './document-form-dialog'
36
+ import { DocumentFormDialog, resolveDocumentForms, scopeDocumentFormsToFilter } from './document-form-dialog'
37
37
  import { ExportDialog } from './dialogs/export'
38
38
  import { ImportDialog } from './dialogs/import'
39
39
  import { getModelExtension } from './model-extension-registry'
@@ -96,6 +96,12 @@ export interface DynamicCRUDPageProps {
96
96
  * with neither, the generic record dialog is used as before.
97
97
  */
98
98
  documentForms?: DocumentFormsManifest
99
+ /**
100
+ * Filtro fijo de la vista (el `filter` del nav). Si va sobre el `type_field`
101
+ * del alta guiada, «Crear» abre ese tipo directo o se oculta cuando no hay
102
+ * formulario para él.
103
+ */
104
+ filter?: Record<string, string>
99
105
  }
100
106
 
101
107
  /**
@@ -119,6 +125,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
119
125
  classes,
120
126
  onChange,
121
127
  documentForms,
128
+ filter,
122
129
  } = props
123
130
 
124
131
  const strings = { ...defaultStrings, ...(i18n ?? {}) }
@@ -189,11 +196,22 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
189
196
  // (useCan defaults to always-true), in which case create/export/import
190
197
  // require `lowercase(model).create|export|import`.
191
198
  const can = useCan()
192
- const showCreate = primary.showGenericCreate && can(modelCapability(model, 'create'))
199
+ const scoped = scopeDocumentFormsToFilter(resolveDocumentForms(metadata, documentForms), filter)
200
+ const guidedForms = scoped.forms
201
+ const showCreate = primary.showGenericCreate && !scoped.hideCreate && can(modelCapability(model, 'create'))
193
202
  const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import'))
194
203
  const showExport = !effectiveHideExport && can(modelCapability(model, 'export'))
195
204
  const showRefresh = !effectiveHideRefresh
196
- const guidedForms = resolveDocumentForms(metadata, documentForms)
205
+
206
+ // El mismo filtro acota la tabla (forma `eq:<valor>` que espera DynamicTable).
207
+ const filterKey = filter ? JSON.stringify(filter) : ''
208
+ const tableFilters = useMemo(() => {
209
+ if (!filter || Object.keys(filter).length === 0) return undefined
210
+ return Object.fromEntries(
211
+ Object.entries(filter).map(([k, v]) => [k, String(v).includes(':') ? String(v) : `eq:${v}`]),
212
+ )
213
+ // eslint-disable-next-line react-hooks/exhaustive-deps
214
+ }, [filterKey])
197
215
 
198
216
  const handleRefresh = useCallback(() => {
199
217
  setRefreshKey((k) => k + 1)
@@ -278,6 +296,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
278
296
  model={model}
279
297
  endpoint={dataEndpoint}
280
298
  refreshTrigger={refreshKey}
299
+ defaultFilters={tableFilters}
281
300
  />
282
301
  </div>
283
302
  </div>
@@ -107,12 +107,25 @@ export function computeLineItemTotals(
107
107
  field: ActionFieldDef,
108
108
  rows: any[] | undefined,
109
109
  ): Record<string, number> {
110
- const cols = getItemFields(field).filter((c) => c.total)
110
+ const itemFields = getItemFields(field)
111
+ const cols = itemFields.filter((c) => c.total)
112
+ // A `total` UNIT PRICE column on a grid with a quantity and no line amount
113
+ // column sums the line amounts (qty × price − discount): adding unit prices
114
+ // is meaningless once a quantity exists (2 × 750 footed 750, not 1500).
115
+ const qtyKey = firstKey(itemFields, LINE_QTY_KEYS)
116
+ const lineValued = !!qtyKey && !firstKey(itemFields, LINE_AMOUNT_KEYS)
117
+ const discountKey = firstKey(itemFields, LINE_DISCOUNT_KEYS)
111
118
  const totals: Record<string, number> = {}
112
119
  for (const c of cols) totals[c.key] = 0
113
120
  if (Array.isArray(rows)) {
114
121
  for (const row of rows) {
115
- for (const c of cols) totals[c.key] += toNumber(row?.[c.key])
122
+ for (const c of cols) {
123
+ const v = toNumber(row?.[c.key])
124
+ totals[c.key] +=
125
+ lineValued && LINE_PRICE_KEYS.has(c.key)
126
+ ? toNumber(row?.[qtyKey!]) * v - (discountKey ? toNumber(row?.[discountKey]) : 0)
127
+ : v
128
+ }
116
129
  }
117
130
  }
118
131
  for (const k of Object.keys(totals)) totals[k] = Math.round(totals[k] * 100) / 100