@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
@@ -1,7 +1,7 @@
1
1
  // Minimal standalone DynamicForm. Factored from the dynamic-record-dialog
2
2
  // pattern + ActionFieldDef renderer so callers can reuse the form layout
3
3
  // outside the full record-edit modal.
4
- import { useEffect, useMemo, useState } from 'react'
4
+ import { useEffect, useMemo, useRef, useState } from 'react'
5
5
  import { groupFieldsBySection, type FormLayout } from './form-layout'
6
6
  import { FieldSection, WizardProgress } from './form-layout-ui'
7
7
  import { AssistInterview } from './assist-interview'
@@ -65,8 +65,35 @@ export interface DynamicFormProps {
65
65
  * legacy flat list (unchanged).
66
66
  */
67
67
  formLayout?: FormLayout
68
+ /**
69
+ * Para formularios dentro de un modal con alto acotado: el cuerpo de campos
70
+ * hace scroll propio y la barra de botones (Guardar/Cancelar) queda fija
71
+ * abajo, así nunca queda fuera de pantalla con muchos campos. El contenedor
72
+ * debe ser flex-col con alto máximo (p.ej. `max-h-[90dvh]`).
73
+ */
74
+ scrollableBody?: boolean
68
75
  }
69
76
 
77
+ // Valores iniciales del formulario: el registro (edición) gana sobre el default
78
+ // del campo. Se calcula de forma síncrona en el primer render para que ningún
79
+ // widget (p.ej. un Select de Radix) monte con '' y emita un cambio espurio que
80
+ // pise el valor real del registro.
81
+ function seedValues(fields: ActionFieldDef[], initialValues?: Record<string, any>): Record<string, any> {
82
+ const defaults: Record<string, any> = {}
83
+ for (const f of fields) {
84
+ if (isLineItemsField(f)) {
85
+ defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? []
86
+ continue
87
+ }
88
+ defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? (f.type === 'boolean' ? false : '')
89
+ }
90
+ return defaults
91
+ }
92
+
93
+ const isReadonlyField = (f: ActionFieldDef) =>
94
+ !!(f as { readonly?: boolean; readOnly?: boolean }).readonly ||
95
+ !!(f as { readonly?: boolean; readOnly?: boolean }).readOnly
96
+
70
97
  export function DynamicForm({
71
98
  fields,
72
99
  initialValues,
@@ -76,8 +103,8 @@ export function DynamicForm({
76
103
  cancelLabel = 'Cancelar',
77
104
  disabled = false,
78
105
  formLayout,
106
+ scrollableBody = false,
79
107
  }: DynamicFormProps) {
80
- const [values, setValues] = useState<Record<string, any>>({})
81
108
  const [errors, setErrors] = useState<Record<string, string>>({})
82
109
  const [submitting, setSubmitting] = useState(false)
83
110
 
@@ -87,10 +114,15 @@ export function DynamicForm({
87
114
  // (ColumnDef.Readonly). They are also excluded from defaults/submit so a
88
115
  // create never posts e.g. `status: ""` over the column default.
89
116
  const editableFields = useMemo(
90
- () => fields.filter((f) => !(f as { readonly?: boolean }).readonly),
117
+ () => fields.filter((f) => !isReadonlyField(f)),
91
118
  [fields],
92
119
  )
93
120
 
121
+ const [values, setValues] = useState<Record<string, any>>(() => seedValues(editableFields, initialValues))
122
+ // Campos que el usuario (o un reset dependiente) cambió desde la siembra.
123
+ // En edición, un select que sigue vacío y nadie tocó no viaja en el PATCH.
124
+ const touched = useRef<Set<string>>(new Set())
125
+
94
126
  // Conditional visibility: a field carrying `visible_when` is rendered — and
95
127
  // validated — only while the referenced sibling field's current value
96
128
  // matches the predicate. Driving BOTH the schema and the render off the same
@@ -133,21 +165,22 @@ export function DynamicForm({
133
165
  setStepIndex((i) => Math.min(i, Math.max(groups.length - 1, 0)))
134
166
  }, [groups.length])
135
167
 
168
+ // Re-siembra cuando cambia el registro o los campos DESPUÉS del montaje (el
169
+ // primer render ya quedó sembrado por el inicializador de useState).
170
+ const seededFrom = useRef({ editableFields, initialValues })
136
171
  useEffect(() => {
137
- const defaults: Record<string, any> = {}
138
- for (const f of editableFields) {
139
- if (isLineItemsField(f)) {
140
- defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? []
141
- continue
142
- }
143
- defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? (f.type === 'boolean' ? false : '')
144
- }
145
- setValues(defaults)
172
+ const prev = seededFrom.current
173
+ if (prev.editableFields === editableFields && prev.initialValues === initialValues) return
174
+ seededFrom.current = { editableFields, initialValues }
175
+ touched.current = new Set()
176
+ setValues(seedValues(editableFields, initialValues))
146
177
  setErrors({})
147
178
  }, [editableFields, initialValues])
148
179
 
149
- const update = (k: string, v: any) =>
180
+ const update = (k: string, v: any) => {
181
+ touched.current.add(k)
150
182
  setValues((prev: Record<string, any>) => ({ ...prev, [k]: v }))
183
+ }
151
184
 
152
185
  const handleSubmit = async (e: React.FormEvent) => {
153
186
  e.preventDefault()
@@ -163,8 +196,19 @@ export function DynamicForm({
163
196
  return
164
197
  }
165
198
  setErrors({})
199
+ const data = { ...(result.data as Record<string, any>) }
200
+ // Edición: un select vacío que el usuario no tocó no se manda — el update
201
+ // es parcial, así que omitirlo conserva lo que tenga el registro en vez de
202
+ // pisarlo con ''.
203
+ if (initialValues) {
204
+ for (const f of visibleFields) {
205
+ if (resolveWidget(f) !== 'select') continue
206
+ if (touched.current.has(f.key)) continue
207
+ if (data[f.key] === '' || data[f.key] == null) delete data[f.key]
208
+ }
209
+ }
166
210
  setSubmitting(true)
167
- try { await onSubmit(result.data as Record<string, any>) } finally { setSubmitting(false) }
211
+ try { await onSubmit(data) } finally { setSubmitting(false) }
168
212
  }
169
213
 
170
214
  // Renders one group's fields into the responsive 2-column grid: scalar
@@ -186,6 +230,11 @@ export function DynamicForm({
186
230
  </div>
187
231
  )
188
232
 
233
+ // scrollableBody: el cuerpo hace scroll y el footer de botones queda fijo.
234
+ const formClass = scrollableBody ? 'flex min-h-0 flex-1 flex-col gap-4' : 'grid gap-4'
235
+ const bodyClass = scrollableBody ? '-mx-1 grid min-h-0 flex-1 gap-4 overflow-y-auto px-1' : 'grid gap-4'
236
+ const footerExtra = scrollableBody ? ' shrink-0 border-t' : ''
237
+
189
238
  // ── Steps (wizard) mode ────────────────────────────────────────────────
190
239
  // One step per section; Anterior/Siguiente navigate, submit only on the
191
240
  // last step. Advancing validates just the current step's fields so a later
@@ -212,20 +261,22 @@ export function DynamicForm({
212
261
  const goBack = () => setStepIndex((i) => Math.max(i - 1, 0))
213
262
 
214
263
  return (
215
- <form onSubmit={handleSubmit} className="grid gap-4">
216
- <WizardProgress groups={groups} stepIndex={stepIndex} onStepClick={i => setStepIndex(i)} />
217
- {step.assist ? (
218
- <AssistInterview
219
- assist={step.assist}
220
- values={values}
221
- eyebrow={step.title}
222
- autoStart={step.assist.trigger !== 'button'}
223
- onApply={fields => setValues(prev => ({ ...prev, ...fields }))}
224
- />
225
- ) : (
226
- renderGrid(step.fields)
227
- )}
228
- <div className="flex justify-between gap-2 pt-2">
264
+ <form onSubmit={handleSubmit} className={formClass}>
265
+ <div className={bodyClass}>
266
+ <WizardProgress groups={groups} stepIndex={stepIndex} onStepClick={i => setStepIndex(i)} />
267
+ {step.assist ? (
268
+ <AssistInterview
269
+ assist={step.assist}
270
+ values={values}
271
+ eyebrow={step.title}
272
+ autoStart={step.assist.trigger !== 'button'}
273
+ onApply={fields => setValues(prev => ({ ...prev, ...fields }))}
274
+ />
275
+ ) : (
276
+ renderGrid(step.fields)
277
+ )}
278
+ </div>
279
+ <div className={'flex justify-between gap-2 pt-2' + footerExtra}>
229
280
  {stepIndex > 0 ? (
230
281
  <Button type="button" variant="outline" onClick={goBack} disabled={submitting || disabled}>
231
282
  Anterior
@@ -258,13 +309,15 @@ export function DynamicForm({
258
309
  // stays fully declarative — driven only by field shape. With no layout this
259
310
  // is a single default group rendered without section chrome (unchanged).
260
311
  return (
261
- <form onSubmit={handleSubmit} className="grid gap-4">
262
- {groups.map((group) => (
263
- <FieldSection key={group.key} group={group}>
264
- {renderGrid(group.fields)}
265
- </FieldSection>
266
- ))}
267
- <div className="flex justify-end gap-2 pt-2">
312
+ <form onSubmit={handleSubmit} className={formClass}>
313
+ <div className={bodyClass}>
314
+ {groups.map((group) => (
315
+ <FieldSection key={group.key} group={group}>
316
+ {renderGrid(group.fields)}
317
+ </FieldSection>
318
+ ))}
319
+ </div>
320
+ <div className={'flex justify-end gap-2 pt-2' + footerExtra}>
268
321
  {onCancel && (
269
322
  <Button type="button" variant="outline" onClick={onCancel} disabled={submitting || disabled}>
270
323
  {cancelLabel}
@@ -428,15 +481,17 @@ function FieldRenderer({
428
481
  onChange={onChange}
429
482
  />
430
483
  )
431
- case 'select':
484
+ case 'select': {
485
+ const opts = effectiveOptions ?? field.options
432
486
  return (
433
- <Select value={value || ''} onValueChange={onChange}>
487
+ <Select value={value || ''} onValueChange={guardEmptySelect(value, onChange, (opts ?? []).map((o) => o.value))}>
434
488
  <SelectTrigger className="w-full"><SelectValue placeholder={field.placeholder || 'Seleccionar...'} /></SelectTrigger>
435
489
  <SelectContent>
436
- {(effectiveOptions ?? field.options)?.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
490
+ {opts?.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
437
491
  </SelectContent>
438
492
  </Select>
439
493
  )
494
+ }
440
495
  case 'switch':
441
496
  return <Switch id={field.key} checked={!!value} onCheckedChange={onChange} />
442
497
  case 'number':
@@ -526,6 +581,25 @@ function ScannableInput({
526
581
  )
527
582
  }
528
583
 
584
+ /**
585
+ * Radix Select puede emitir `onValueChange('')` sin interacción del usuario (su
586
+ * <select> nativo oculto colapsa a '' si recibe el valor antes que sus
587
+ * <option>s, p.ej. dentro de un <form> en el primer render). Ese '' pisaría el
588
+ * valor real del registro y viajaría en el PATCH. Se ignora cuando ya hay un
589
+ * valor y '' no es una opción válida — el usuario nunca puede elegir '' así.
590
+ */
591
+ export function guardEmptySelect(
592
+ current: unknown,
593
+ onChange: (v: any) => void,
594
+ optionValues: readonly unknown[],
595
+ ): (v: string) => void {
596
+ return (v: string) => {
597
+ const hasValue = current !== undefined && current !== null && current !== ''
598
+ if (v === '' && hasValue && !optionValues.some((o) => String(o) === '')) return
599
+ onChange(v)
600
+ }
601
+ }
602
+
529
603
  function RefSelect({ field, value, onChange }: FieldRendererProps) {
530
604
  const { options, loading } = useOptionsResolver({
531
605
  modelKey: '', // unused — `ref` drives the URL
@@ -535,7 +609,11 @@ function RefSelect({ field, value, onChange }: FieldRendererProps) {
535
609
  keepValue: value,
536
610
  })
537
611
  return (
538
- <Select value={value || ''} onValueChange={onChange} disabled={loading}>
612
+ <Select
613
+ value={value || ''}
614
+ onValueChange={guardEmptySelect(value, onChange, options.map((o: ResolvedOption) => String(o.id)))}
615
+ disabled={loading}
616
+ >
539
617
  <SelectTrigger className="w-full">
540
618
  <SelectValue placeholder={loading ? 'Cargando…' : (field.placeholder || 'Seleccionar...')} />
541
619
  </SelectTrigger>
@@ -165,6 +165,7 @@ import {
165
165
  aggregateOf,
166
166
  formatAggregateTotal,
167
167
  isRowActionVisible,
168
+ lifecycleStageField,
168
169
  relationKeyFor,
169
170
  RowActionMenuItem,
170
171
  } from './dynamic-columns'
@@ -424,18 +425,39 @@ export function isCardOverdue(card: any, col: ColumnDefinition, now: number = Da
424
425
  * The stage machine (stages, transitions, smart lanes) belongs to the model's
425
426
  * stage column (`stage_field`; older hosts that do not serve it: the served
426
427
  * group_by): grouping by it keeps the machine, grouping by anything else drops
427
- * it and the lanes come from the new column. Returns the SAME object when there
428
- * is nothing to change (no override, the served group_by already, or a column the
429
- * model does not have). Pure — exported for unit tests.
428
+ * it and the lanes come from the new column.
429
+ *
430
+ * The served group_by is the LAST kanban nav entry's, which need not be the
431
+ * stage column: a board opened without `?group_by=` (the list's view toggle)
432
+ * groups by the stage column, and a board grouped by the served column keeps
433
+ * the machine only when that column IS the stage column. Otherwise the stage
434
+ * lanes were painted over another column (by bay: every card under «Sin etapa»
435
+ * and a drop wrote `service_bay_id = 'diagnosis'`, 422). Returns the SAME
436
+ * object when there is nothing to change. Pure — exported for unit tests.
430
437
  */
431
438
  export function withGroupBy(
432
439
  metadata: TableMetadata | null,
433
440
  groupBy: string | undefined,
434
441
  ): TableMetadata | null {
435
- if (!metadata || !groupBy || groupBy === metadata.group_by) return metadata
442
+ if (!metadata) return metadata
443
+ const hasMachine = (metadata.stages?.length ?? 0) > 0
444
+ const stageColumn = metadata.stage_field ?? (hasMachine ? metadata.group_by : undefined)
445
+ if (!groupBy) {
446
+ if (!hasMachine || !stageColumn || stageColumn === metadata.group_by) return metadata
447
+ return { ...metadata, group_by: stageColumn }
448
+ }
449
+ if (groupBy === stageColumn) {
450
+ return groupBy === metadata.group_by ? metadata : { ...metadata, group_by: groupBy }
451
+ }
436
452
  if (!metadata.columns.some((c) => c.key === groupBy)) return metadata
437
- const stageColumn = metadata.stage_field ?? (metadata.stages?.length ? metadata.group_by : undefined)
438
- if (groupBy === stageColumn) return { ...metadata, group_by: groupBy }
453
+ if (
454
+ groupBy === metadata.group_by &&
455
+ !hasMachine &&
456
+ !metadata.transitions &&
457
+ !metadata.smart_lanes
458
+ ) {
459
+ return metadata
460
+ }
439
461
  return {
440
462
  ...metadata,
441
463
  group_by: groupBy,
@@ -2119,6 +2141,7 @@ function DynamicKanbanBoard({
2119
2141
  fieldCols={fieldCols}
2120
2142
  showAllFields={explicitCard}
2121
2143
  actions={rowActions}
2144
+ stageField={lifecycleStageField(metadata)}
2122
2145
  locale={i18n.language}
2123
2146
  timeZone={timeZone}
2124
2147
  currency={currency}
@@ -2151,6 +2174,7 @@ function DynamicKanbanBoard({
2151
2174
  fieldCols={fieldCols}
2152
2175
  showAllFields={explicitCard}
2153
2176
  actions={rowActions}
2177
+ stageField={lifecycleStageField(metadata)}
2154
2178
  locale={i18n.language}
2155
2179
  timeZone={timeZone}
2156
2180
  currency={currency}
@@ -3183,6 +3207,8 @@ interface KanbanCardProps {
3183
3207
  /** The model laid its card out explicitly: show every field with a value. */
3184
3208
  showAllFields?: boolean
3185
3209
  actions: ActionDefinition[]
3210
+ /** Column the actions' requiresState reads (the model's stage_field). */
3211
+ stageField?: string
3186
3212
  locale: string
3187
3213
  timeZone?: string
3188
3214
  currency?: string
@@ -3260,6 +3286,7 @@ function KanbanCard({
3260
3286
  fieldCols,
3261
3287
  showAllFields,
3262
3288
  actions,
3289
+ stageField,
3263
3290
  locale,
3264
3291
  timeZone,
3265
3292
  currency,
@@ -3274,7 +3301,7 @@ function KanbanCard({
3274
3301
  disabled: !draggable,
3275
3302
  })
3276
3303
 
3277
- const visibleActions = actions.filter((a) => isRowActionVisible(a, card))
3304
+ const visibleActions = actions.filter((a) => isRowActionVisible(a, card, stageField))
3278
3305
 
3279
3306
  const menu =
3280
3307
  visibleActions.length > 0 ? (
@@ -175,11 +175,20 @@ export function deriveRelationFormFields(
175
175
  // than a raw uuid text input. Without this the column lost its `ref`
176
176
  // crossing the column→field boundary and degraded to plain text.
177
177
  ref: col.ref,
178
+ // Columnas de solo lectura (escritas por el servidor/sistema): el
179
+ // DynamicForm las oculta y no las manda en el submit. Sin copiarla,
180
+ // el renglón las exponía editables y el PATCH las pisaba.
181
+ ...(isReadonlyColumn(col) ? { readonly: true } : {}),
178
182
  })
179
183
  }
180
184
  return out
181
185
  }
182
186
 
187
+ function isReadonlyColumn(col: ColumnDefinition): boolean {
188
+ const c = col as ColumnDefinition & { readonly?: boolean; readOnly?: boolean; read_only?: boolean }
189
+ return c.readonly === true || c.readOnly === true || c.read_only === true
190
+ }
191
+
183
192
  function columnTypeToFieldType(col: ColumnDefinition): string {
184
193
  switch (col.type) {
185
194
  case 'number': return 'number'
@@ -636,11 +636,16 @@ function OneToManyRelation({
636
636
  )}
637
637
 
638
638
  <Dialog open={formOpen} onOpenChange={(open: boolean) => { setFormOpen(open); if (!open) setEditingRow(null) }}>
639
- <DialogContent>
640
- <DialogHeader>
639
+ {/* Alto acotado al viewport: el cuerpo del form hace scroll y el
640
+ footer (Guardar) queda fijo, para que un renglón con muchos
641
+ campos no deje el botón fuera de pantalla. */}
642
+ <DialogContent className="flex max-h-[90dvh] flex-col overflow-hidden">
643
+ <DialogHeader className="shrink-0">
641
644
  <DialogTitle>{editingRow ? labels.editLabel : labels.addLabel}</DialogTitle>
642
645
  </DialogHeader>
643
646
  <DynamicForm
647
+ key={editingRow ? `edit-${editingRow.id ?? ''}` : 'new'}
648
+ scrollableBody
644
649
  fields={formFields}
645
650
  initialValues={editingRow || undefined}
646
651
  onSubmit={handleSubmit}
@@ -40,6 +40,16 @@ import { DynamicRecordDialog } from './dialogs/dynamic-record'
40
40
  import { RequiresAddonDialog, resolveRequiresAddon } from './requires-addon'
41
41
  import type { TableMetadata, ActionMetadata, RequiresAddon } from './types'
42
42
 
43
+ /**
44
+ * Destino de una acción link. La query (`?create=…&from_id=…`) va como `search`:
45
+ * metida en `to` el router la tomaba como parte del path y la ruta no casaba.
46
+ */
47
+ export function linkNavigation(url: string): { to: string; search?: Record<string, string> } {
48
+ const i = url.indexOf('?')
49
+ if (i < 0) return { to: url }
50
+ return { to: url.slice(0, i), search: Object.fromEntries(new URLSearchParams(url.slice(i + 1))) }
51
+ }
52
+
43
53
  export interface UseDynamicRowActionsParams {
44
54
  /** Model key as registered on the backend (e.g. "issue"). */
45
55
  model: string
@@ -128,8 +138,8 @@ export function useDynamicRowActions({
128
138
  }
129
139
  const linkDef = metadata?.actions?.find((a) => a.key === action && a.type === 'link')
130
140
  if (linkDef?.linkUrl) {
131
- const url = linkDef.linkUrl.replace(/\{(\w+)\}/g, (_: string, field: string) => String(row[field] ?? ''))
132
- navigate({ to: url })
141
+ const url = linkDef.linkUrl.replace(/\{(\w+)\}/g, (_: string, field: string) => encodeURIComponent(String(row[field] ?? '')))
142
+ navigate(linkNavigation(url) as any)
133
143
  return
134
144
  }
135
145
  const actionDef = metadata?.actions?.find((a) => a.key === action)
@@ -80,6 +80,7 @@ import {
80
80
  resolveVirtualizeThreshold,
81
81
  } from './table-virtualization'
82
82
  import { OptionsContext } from './options-context'
83
+ import { RowActionsModelContext } from './row-actions-menu'
83
84
  import type { TableMetadata, ApiResponse, ColumnDefinition } from './types'
84
85
  import { getSearchableColumnKeys } from './column-visibility'
85
86
  import { visibleRelationInclude } from './list-include'
@@ -1455,6 +1456,7 @@ export function DynamicTable({
1455
1456
  }
1456
1457
 
1457
1458
  return (
1459
+ <RowActionsModelContext.Provider value={model}>
1458
1460
  <OptionsContext.Provider value={{ optionsMap }}>
1459
1461
  <div className='flex flex-col h-full min-h-0 w-full'>
1460
1462
  <div className='pb-4 shrink-0'>
@@ -1899,6 +1901,7 @@ export function DynamicTable({
1899
1901
  </Button>
1900
1902
  </DataTableBulkActions>
1901
1903
  </OptionsContext.Provider>
1904
+ </RowActionsModelContext.Provider>
1902
1905
  )
1903
1906
  }
1904
1907
 
package/src/index.ts CHANGED
@@ -206,6 +206,7 @@ export {
206
206
  export {
207
207
  DocumentFormDialog,
208
208
  resolveDocumentForms,
209
+ scopeDocumentFormsToFilter,
209
210
  type DocumentFormDialogProps,
210
211
  } from './document-form-dialog'
211
212
  export {
@@ -359,6 +360,10 @@ export {
359
360
  } from './sidebar-layout-manager'
360
361
  export * from './org-runtime-context'
361
362
  export * from './business'
363
+ // Primitivos de negocio (PaymentAllocator, AgingTable, ApprovalInbox,
364
+ // contribuciones/modales federados, tipos de documento y contratos de paneles).
365
+ // Ver primitives/index.ts para el mapa completo.
366
+ export * from './primitives'
362
367
  export * from './org-runtime-provider'
363
368
  export * from './navigation-builder'
364
369
  export * from './i18n-provider'
@@ -422,7 +427,7 @@ export {
422
427
  resolveRelationLabel,
423
428
  type DynamicColumnsHelpers,
424
429
  } from './dynamic-columns'
425
- export { humanizeToken, translateMetadataLabel } from './dynamic-columns-helpers'
430
+ export { humanizeToken, localizeFieldLabel, translateMetadataLabel } from './dynamic-columns-helpers'
426
431
  export type { MetadataTranslator } from './dynamic-columns-helpers'
427
432
  export {
428
433
  UrlChip,
@@ -674,3 +679,43 @@ export * from './document'
674
679
  // (`option_filter` / `optionFilter` on the field).
675
680
  export { getOptionFilter, applyOptionFilter, optionPassesRule } from './option-filter'
676
681
  export type { OptionFilter, OptionFilterRule } from './option-filter'
682
+
683
+ // Addons instalados / capacidades provistas — primitivo único de «¿qué hay
684
+ // instalado?» para hosts y remotes federados. Sin provider los hooks devuelven
685
+ // `undefined` (desconocido): el consumidor NUNCA debe asumir ausente.
686
+ export {
687
+ InstalledAddonsProvider,
688
+ useAddonInstalled,
689
+ useCapabilityProvided,
690
+ useInstalledAddons,
691
+ type InstalledAddonsValue,
692
+ } from './installed-addons-context'
693
+
694
+ // Ayudantes de captura (registerRecordPrefill de @asteby/metacore-sdk) arriba
695
+ // del formulario genérico de un modelo; DynamicRecordDialog ya lo monta.
696
+ export { RecordPrefillBar, type RecordPrefillBarProps } from './record-prefill-bar'
697
+
698
+ // Acciones primarias / secundarias de un registro: una sola primaria
699
+ // destacada; compartir/imprimir/correo/chat al «Más…» de la fila y al pie del
700
+ // documento. Las aportadas por addons (registerRecordAction del SDK) sólo con
701
+ // su proveedor instalado y activo.
702
+ export {
703
+ RowActionsMenu,
704
+ RowActionMenuItem,
705
+ RowActionsModelContext,
706
+ type RowActionsMenuProps,
707
+ } from './row-actions-menu'
708
+ export {
709
+ classifyActionPriority,
710
+ isActionProviderActive,
711
+ isRecordActionProviderActive,
712
+ resolveRecordActions,
713
+ splitActionsByPriority,
714
+ useRecordActions,
715
+ type ActionPriority,
716
+ type PrioritizableAction,
717
+ type RecordActionContext,
718
+ type RecordActionContribution,
719
+ type ResolvedRecordAction,
720
+ type SplitActions,
721
+ } from './record-actions'
@@ -0,0 +1,58 @@
1
+ // InstalledAddonsProvider / useAddonInstalled / useCapabilityProvided —
2
+ // primitivo ÚNICO de "¿qué hay instalado?" para hosts y addons.
3
+ //
4
+ // Hoy cada addon lo resuelve a su manera: POS lee `installedAddons` del host
5
+ // (lib/host.ts addonKnownMissing), sondea `/options/<Model>` (use-model-available:
6
+ // 403/404 = ausente) o hardcodea claves (caja-policy useAnyFiscalAddonInstalled);
7
+ // `useInstalledAddons` vive sólo en @asteby/metacore-marketplace. Esto lo sube a
8
+ // runtime-react con dos niveles:
9
+ // - addon key (useAddonInstalled('fiscal_mexico'))
10
+ // - capability (useCapabilityProvided('fiscal.invoice_issuer')) — contrato
11
+ // independiente del país/vertical: fiscal_mexico, fiscal_colombia… lo proveen
12
+ // vía manifest `provides_capabilities`; caja pregunta por la capacidad,
13
+ // no por la clave del addon.
14
+ // Fuente: el host monta el provider con lo que ya trae su bootstrap
15
+ // (installed addons + provides_capabilities del manifest). Sin provider:
16
+ // `undefined` = desconocido (el consumidor decide; NUNCA asume ausente).
17
+ import { createContext, useContext, useMemo, type ReactNode } from 'react'
18
+
19
+ export interface InstalledAddonsValue {
20
+ addons: ReadonlySet<string>
21
+ capabilities: ReadonlySet<string>
22
+ }
23
+
24
+ const Ctx = createContext<InstalledAddonsValue | null>(null)
25
+
26
+ export function InstalledAddonsProvider({
27
+ addons,
28
+ capabilities = [],
29
+ children,
30
+ }: {
31
+ addons: readonly string[]
32
+ capabilities?: readonly string[]
33
+ children: ReactNode
34
+ }) {
35
+ const aKey = [...addons].sort().join(',')
36
+ const cKey = [...capabilities].sort().join(',')
37
+ const value = useMemo<InstalledAddonsValue>(
38
+ () => ({ addons: new Set(addons), capabilities: new Set(capabilities) }),
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps
40
+ [aKey, cKey],
41
+ )
42
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>
43
+ }
44
+
45
+ /** true/false si el host lo sabe; undefined si no hay provider. */
46
+ export function useAddonInstalled(key: string): boolean | undefined {
47
+ const v = useContext(Ctx)
48
+ return v ? v.addons.has(key) : undefined
49
+ }
50
+
51
+ export function useCapabilityProvided(capability: string): boolean | undefined {
52
+ const v = useContext(Ctx)
53
+ return v ? v.capabilities.has(capability) : undefined
54
+ }
55
+
56
+ export function useInstalledAddons(): InstalledAddonsValue | null {
57
+ return useContext(Ctx)
58
+ }
@@ -26,3 +26,13 @@ export const CurrencyContext = createContext<string | undefined>(undefined)
26
26
 
27
27
  /** Reads the nearest org currency (undefined outside a provider). */
28
28
  export const useCurrency = () => useContext(CurrencyContext)
29
+
30
+ /**
31
+ * Org default tax rate as a fraction (e.g. `0.16`), from the org's fiscal
32
+ * config. Document line editors use it when the picked product carries no rate
33
+ * of its own. `undefined` outside a provider → lines start at 0.
34
+ */
35
+ export const TaxRateContext = createContext<number | undefined>(undefined)
36
+
37
+ /** Reads the nearest org default tax rate (undefined outside a provider). */
38
+ export const useOrgTaxRate = () => useContext(TaxRateContext)
@@ -12,13 +12,15 @@ import {
12
12
  identityImageUrl,
13
13
  type GetImageUrl,
14
14
  } from './image-url-context'
15
- import { CurrencyContext, TimeZoneContext } from './org-runtime-context'
15
+ import { CurrencyContext, TaxRateContext, TimeZoneContext } from './org-runtime-context'
16
16
 
17
17
  export interface OrgRuntimeProviderProps {
18
18
  /** Org IANA timezone (e.g. `America/Mexico_City`). */
19
19
  timeZone?: string
20
20
  /** Org ISO-4217 currency (e.g. `MXN`). */
21
21
  currency?: string
22
+ /** Org default tax rate as a fraction (e.g. `0.16`) for document lines. */
23
+ taxRate?: number
22
24
  /** Resolver turning a stored path into a fetchable URL. Defaults to identity. */
23
25
  getImageUrl?: GetImageUrl
24
26
  children: ReactNode
@@ -27,6 +29,7 @@ export interface OrgRuntimeProviderProps {
27
29
  export function OrgRuntimeProvider({
28
30
  timeZone,
29
31
  currency,
32
+ taxRate,
30
33
  getImageUrl = identityImageUrl,
31
34
  children,
32
35
  }: OrgRuntimeProviderProps) {
@@ -34,7 +37,9 @@ export function OrgRuntimeProvider({
34
37
  <ImageUrlContext.Provider value={getImageUrl}>
35
38
  <TimeZoneContext.Provider value={timeZone}>
36
39
  <CurrencyContext.Provider value={currency}>
37
- {children}
40
+ <TaxRateContext.Provider value={taxRate}>
41
+ {children}
42
+ </TaxRateContext.Provider>
38
43
  </CurrencyContext.Provider>
39
44
  </TimeZoneContext.Provider>
40
45
  </ImageUrlContext.Provider>