@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
@@ -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'
@@ -32,11 +32,24 @@ export interface LineItem {
32
32
  cost?: number
33
33
  /** Tope de cantidad (NC: facturado − acreditado; devolución: vendido − devuelto). */
34
34
  max_quantity?: number
35
+ /**
36
+ * Id del renglón del documento origen («crear desde»). Viaja en la columna
37
+ * `line_link_field` de la fuente para que el servidor descuente y valide lo
38
+ * pendiente; un renglón libre no lo lleva.
39
+ */
40
+ source_line_id?: string
35
41
  technician_id?: string
36
42
  lot?: string
37
43
  dot?: string
38
44
  /** Existencia disponible conocida (para la política de sobreventa). */
39
45
  available?: number
46
+ /**
47
+ * Campos de extensión del renglón que aportan OTROS addons (p. ej.
48
+ * fiscal_mexico: `mx_clave_prod_serv`, `mx_clave_unidad`), copiados del
49
+ * producto al elegirlo. Viajan en `fiscal_data` (la bolsa de ModelExtension
50
+ * del host) — el SDK no conoce sus claves.
51
+ */
52
+ extensions?: Record<string, string>
40
53
  }
41
54
 
42
55
  /** Qué hacer si `quantity > available`. */
@@ -196,7 +209,10 @@ export function validateLineItems(lines: LineItem[], policy: LineItemsPolicy = {
196
209
  * Forma que se manda al backend: números reales (nunca `""`), sin claves de UI,
197
210
  * opcionales vacíos omitidos y `position` 1-based conservando el orden.
198
211
  */
199
- export function serializeLineItems(lines: LineItem[]): Array<Record<string, unknown>> {
212
+ export function serializeLineItems(
213
+ lines: LineItem[],
214
+ opts: { /** Columna que recibe `source_line_id` (la `line_link_field` de la fuente). */ sourceLineField?: string } = {},
215
+ ): Array<Record<string, unknown>> {
200
216
  return lines.map((l, i) => {
201
217
  const out: Record<string, unknown> = {
202
218
  position: i + 1,
@@ -214,6 +230,13 @@ export function serializeLineItems(lines: LineItem[]): Array<Record<string, unkn
214
230
  const v = l[k]
215
231
  if (typeof v === 'string' && v.trim()) out[k] = v.trim()
216
232
  }
233
+ if (l.extensions && Object.keys(l.extensions).length > 0) out.fiscal_data = { ...l.extensions }
234
+ if (opts.sourceLineField && l.source_line_id) out[opts.sourceLineField] = l.source_line_id
235
+ // Importes ya calculados: un modelo de renglón sin tasa (solo
236
+ // `tax_amount`/`subtotal`) los guarda tal cual en vez de perder el IVA.
237
+ const a = computeLine(l)
238
+ out.subtotal = a.net
239
+ out.tax_amount = a.tax
217
240
  }
218
241
  return out
219
242
  })
@@ -231,7 +254,7 @@ export function parseLineItems(rows: unknown): LineItem[] {
231
254
  product_id: str(o.product_id),
232
255
  sku: str(o.sku),
233
256
  description: typeof o.description === 'string' ? o.description : '',
234
- quantity: o.quantity == null ? 1 : toAmount(o.quantity),
257
+ quantity: o.quantity == null || o.quantity === '' ? 1 : toAmount(o.quantity),
235
258
  unit_price: toAmount(o.unit_price),
236
259
  discount: toAmount(o.discount),
237
260
  discount_kind: o.discount_kind === 'amount' ? 'amount' : undefined,
@@ -243,6 +266,12 @@ export function parseLineItems(rows: unknown): LineItem[] {
243
266
  technician_id: str(o.technician_id),
244
267
  lot: str(o.lot),
245
268
  dot: str(o.dot),
269
+ extensions:
270
+ o.fiscal_data && typeof o.fiscal_data === 'object'
271
+ ? (Object.fromEntries(
272
+ Object.entries(o.fiscal_data as Record<string, unknown>).filter(([, v]) => typeof v === 'string'),
273
+ ) as Record<string, string>)
274
+ : undefined,
246
275
  })
247
276
  })
248
277
  }
@@ -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
  )}
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'
8
8
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@asteby/metacore-ui/primitives'
9
9
  import { cn } from '@asteby/metacore-ui/lib'
10
10
  import { ActionBar } from './action-bar'
11
+ import { DocumentSecondaryBar } from './secondary-bar'
11
12
  import { DocumentHeader, type HeaderBadge, type HeaderMetric } from './document-header'
12
13
  import { SmartButtons, type SmartButtonItem } from './smart-buttons'
13
14
  import { StatusBadge } from './status-badge'
@@ -175,6 +176,20 @@ export function DocumentPage(props: DocumentPageProps) {
175
176
  </TabsContent>
176
177
  ))}
177
178
  </Tabs>
179
+
180
+ {/* Compartir / imprimir / correo / PDF-XML y lo que aporten los
181
+ addons instalados: al pie, no compitiendo con la primaria. */}
182
+ <DocumentSecondaryBar
183
+ items={layout.footer.map((a) => ({
184
+ key: a.def.key,
185
+ label: a.def.label,
186
+ icon: a.def.icon,
187
+ blockedReason: a.blockedReason,
188
+ run: () => handleAction(a),
189
+ }))}
190
+ model={spec.model}
191
+ record={record}
192
+ />
178
193
  </div>
179
194
  )
180
195
  }
@@ -1,3 +1,4 @@
1
+ import { classifyActionPriority } from '../action-priority'
1
2
  import type {
2
3
  ActionLayout,
3
4
  DocumentActionDef,
@@ -130,12 +131,15 @@ export function resolveActions(spec: DocumentSpec, ctx: DocumentContext): Resolv
130
131
  const destructive = many(layout.destructive)
131
132
  const secondary: ResolvedAction[] = []
132
133
  const more: ResolvedAction[] = []
134
+ const footer: ResolvedAction[] = []
133
135
  for (const a of [...many(layout.secondary)]) {
134
136
  if (a.def.destructive) destructive.push(a)
137
+ else if (isFooterAction(a.def)) footer.push(a)
135
138
  else secondary.push(a)
136
139
  }
137
140
  for (const a of many(layout.more)) {
138
141
  if (a.def.destructive) destructive.push(a)
142
+ else if (isFooterAction(a.def)) footer.push(a)
139
143
  else more.push(a)
140
144
  }
141
145
  const overflow = secondary.splice(MAX_VISIBLE_SECONDARY)
@@ -146,7 +150,19 @@ export function resolveActions(spec: DocumentSpec, ctx: DocumentContext): Resolv
146
150
  destructive.unshift(primary)
147
151
  primary = undefined
148
152
  }
149
- return { primary, secondary, more, destructive }
153
+ return { primary, secondary, more, destructive, footer }
154
+ }
155
+
156
+ /**
157
+ * ¿Va al pie? `priority: secondary`, o la convención por clave sobre la acción
158
+ * del modelo (`modelAction ?? key`): compartir, imprimir, correo, chat, PDF/XML.
159
+ */
160
+ function isFooterAction(def: DocumentActionDef): boolean {
161
+ if (def.priority) return def.priority === 'secondary'
162
+ return (
163
+ classifyActionPriority({ key: def.key }) === 'secondary' ||
164
+ (!!def.modelAction && classifyActionPriority({ key: def.modelAction }) === 'secondary')
165
+ )
150
166
  }
151
167
 
152
168
  /** Interpola `{{campo}}` con valores del registro (sin evaluar código). */
@@ -13,6 +13,7 @@ export {
13
13
  export { formatFieldValue, type FieldFormatOptions } from './format'
14
14
  export { StatusBadge, STATUS_TONE_STYLE, type StatusBadgeProps } from './status-badge'
15
15
  export { ActionBar, type ActionBarProps } from './action-bar'
16
+ export { DocumentSecondaryBar, type DocumentSecondaryBarProps, type SecondaryBarItem } from './secondary-bar'
16
17
  export { DocumentHeader, type DocumentHeaderProps, type HeaderBadge, type HeaderMetric } from './document-header'
17
18
  export { SmartButtons, type SmartButtonItem, type SmartButtonsProps } from './smart-buttons'
18
19
  export { DocumentTimeline, type DocumentTimelineProps, type TimelineEntry } from './document-timeline'
@@ -0,0 +1,92 @@
1
+ // DocumentSecondaryBar — pie del documento con las acciones secundarias:
2
+ // compartir, imprimir/descargar PDF o XML, enviar por correo, enviar al chat…
3
+ // La cabecera se queda con UNA primaria (y sus principales); esto va abajo,
4
+ // discreto. Junta dos fuentes:
5
+ // - `items`: acciones del propio documento/modelo ya clasificadas como
6
+ // secundarias (resolveActions → `footer`, o splitActionsByPriority);
7
+ // - las aportadas por addons/host con registerRecordAction, sólo si su
8
+ // proveedor está instalado y activo (useRecordActions).
9
+ // Sin nada que mostrar no pinta nada.
10
+ import { useState, type ReactNode } from 'react'
11
+ import { useTranslation } from 'react-i18next'
12
+ import { Loader2 } from 'lucide-react'
13
+ import { Button } from '@asteby/metacore-ui/primitives'
14
+ import { cn } from '@asteby/metacore-ui/lib'
15
+ import { DynamicIcon } from '../dynamic-icon'
16
+ import { useRecordActions } from '../record-actions'
17
+
18
+ export interface SecondaryBarItem {
19
+ key: string
20
+ label: string
21
+ icon?: string
22
+ /** Deshabilitada con su motivo (no desaparece). */
23
+ blockedReason?: string
24
+ run: () => unknown
25
+ }
26
+
27
+ export interface DocumentSecondaryBarProps {
28
+ items?: SecondaryBarItem[]
29
+ /** Modelo + registro: resuelven las acciones aportadas por addons. */
30
+ model?: string
31
+ record?: Record<string, unknown> | null
32
+ /** Controles extra del host (al final). */
33
+ children?: ReactNode
34
+ className?: string
35
+ }
36
+
37
+ export function DocumentSecondaryBar({ items = [], model, record, children, className }: DocumentSecondaryBarProps) {
38
+ const { t } = useTranslation()
39
+ const contributed = useRecordActions(model ?? '', model ? record : null)
40
+ const [busy, setBusy] = useState<string | null>(null)
41
+
42
+ const run = async (key: string, fn?: () => unknown) => {
43
+ if (!fn || busy) return
44
+ setBusy(key)
45
+ try {
46
+ await fn()
47
+ } finally {
48
+ setBusy(null)
49
+ }
50
+ }
51
+
52
+ const all: SecondaryBarItem[] = [
53
+ ...items,
54
+ ...contributed.filter((c) => c.run).map((c) => ({ key: c.key, label: c.label, icon: c.icon, run: c.run! })),
55
+ ]
56
+ const rendered = contributed.filter((c) => c.render && !c.run)
57
+ if (all.length === 0 && rendered.length === 0 && !children) return null
58
+
59
+ return (
60
+ <footer
61
+ className={cn('flex flex-wrap items-center gap-1.5 border-t pt-3', className)}
62
+ data-document-secondary=''
63
+ aria-label={t('datatable.secondary_actions', { defaultValue: 'Compartir y documentos' })}
64
+ >
65
+ {all.map((a) => (
66
+ <Button
67
+ key={a.key}
68
+ type='button'
69
+ size='sm'
70
+ variant='ghost'
71
+ className='text-muted-foreground'
72
+ data-action={a.key}
73
+ disabled={!!a.blockedReason || (!!busy && busy !== a.key)}
74
+ title={a.blockedReason ? t(a.blockedReason, { defaultValue: a.blockedReason }) : undefined}
75
+ onClick={() => void run(a.key, a.run)}
76
+ >
77
+ {busy === a.key ? (
78
+ <Loader2 className='mr-1.5 h-4 w-4 animate-spin' />
79
+ ) : a.icon ? (
80
+ <DynamicIcon name={a.icon} className='mr-1.5 h-4 w-4' />
81
+ ) : null}
82
+ {t(a.label, { defaultValue: a.label })}
83
+ </Button>
84
+ ))}
85
+ {rendered.map((c) => {
86
+ const C = c.render!
87
+ return <C key={c.key} model={model!} record={record!} />
88
+ })}
89
+ {children}
90
+ </footer>
91
+ )
92
+ }
@@ -64,6 +64,12 @@ export interface DocumentActionDef {
64
64
  href?: string
65
65
  openUrl?: string
66
66
  tab?: string
67
+ /**
68
+ * "secondary" (o, sin valor, una clave de compartir/imprimir/correo/chat/
69
+ * PDF/XML por convención) saca la acción de la cabecera y la pone en la
70
+ * barra secundaria del pie — salvo que el layout la haga primaria.
71
+ */
72
+ priority?: 'primary' | 'secondary'
67
73
  /** Estilo crítico: siempre va en la zona destructiva. */
68
74
  destructive?: boolean
69
75
  /** Se muestra deshabilitada con el motivo en lugar de desaparecer. */
@@ -200,6 +206,8 @@ export interface ResolvedActionLayout {
200
206
  secondary: ResolvedAction[]
201
207
  more: ResolvedAction[]
202
208
  destructive: ResolvedAction[]
209
+ /** Secundarias (compartir, imprimir, correo…) → DocumentSecondaryBar, no la cabecera. */
210
+ footer: ResolvedAction[]
203
211
  }
204
212
 
205
213
  export type DocumentSlotRenderer = (ctx: DocumentContext) => ReactNode