@asteby/metacore-runtime-react 47.0.0 → 48.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  3. package/dist/action-modal-dispatcher.js +24 -4
  4. package/dist/audit-info.d.ts +54 -0
  5. package/dist/audit-info.d.ts.map +1 -0
  6. package/dist/audit-info.js +142 -0
  7. package/dist/business/catalog-product-search.d.ts +25 -0
  8. package/dist/business/catalog-product-search.d.ts.map +1 -0
  9. package/dist/business/catalog-product-search.js +57 -0
  10. package/dist/business/catalog.d.ts.map +1 -1
  11. package/dist/business/catalog.js +2 -0
  12. package/dist/business/document-editor-model.d.ts +107 -0
  13. package/dist/business/document-editor-model.d.ts.map +1 -0
  14. package/dist/business/document-editor-model.js +279 -0
  15. package/dist/business/document-editor-panels.d.ts +64 -0
  16. package/dist/business/document-editor-panels.d.ts.map +1 -0
  17. package/dist/business/document-editor-panels.js +58 -0
  18. package/dist/business/document-editor.d.ts +27 -0
  19. package/dist/business/document-editor.d.ts.map +1 -0
  20. package/dist/business/document-editor.js +445 -0
  21. package/dist/business/document-lines.d.ts.map +1 -1
  22. package/dist/business/document-lines.js +5 -1
  23. package/dist/business/index.d.ts +5 -0
  24. package/dist/business/index.d.ts.map +1 -1
  25. package/dist/business/index.js +5 -0
  26. package/dist/business/line-items.d.ts +7 -0
  27. package/dist/business/line-items.d.ts.map +1 -1
  28. package/dist/business/line-items.js +6 -1
  29. package/dist/business/payment-allocator.d.ts +19 -0
  30. package/dist/business/payment-allocator.d.ts.map +1 -0
  31. package/dist/business/payment-allocator.js +31 -0
  32. package/dist/business/product-search.d.ts +4 -0
  33. package/dist/business/product-search.d.ts.map +1 -1
  34. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  35. package/dist/dialogs/dynamic-record.js +19 -8
  36. package/dist/document-form-dialog.d.ts +31 -2
  37. package/dist/document-form-dialog.d.ts.map +1 -1
  38. package/dist/document-form-dialog.js +43 -11
  39. package/dist/dynamic-columns-helpers.d.ts +9 -0
  40. package/dist/dynamic-columns-helpers.d.ts.map +1 -1
  41. package/dist/dynamic-columns-helpers.js +19 -0
  42. package/dist/dynamic-columns.d.ts +8 -2
  43. package/dist/dynamic-columns.d.ts.map +1 -1
  44. package/dist/dynamic-columns.js +21 -5
  45. package/dist/dynamic-crud-page.d.ts +6 -0
  46. package/dist/dynamic-crud-page.d.ts.map +1 -1
  47. package/dist/dynamic-crud-page.js +14 -5
  48. package/dist/dynamic-form-schema.d.ts.map +1 -1
  49. package/dist/dynamic-form-schema.js +15 -3
  50. package/dist/dynamic-form.d.ts +16 -1
  51. package/dist/dynamic-form.d.ts.map +1 -1
  52. package/dist/dynamic-form.js +81 -20
  53. package/dist/dynamic-kanban.d.ts +9 -3
  54. package/dist/dynamic-kanban.d.ts.map +1 -1
  55. package/dist/dynamic-kanban.js +39 -13
  56. package/dist/dynamic-line-items.d.ts.map +1 -1
  57. package/dist/dynamic-line-items.js +3 -0
  58. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  59. package/dist/dynamic-multi-select-field.js +2 -0
  60. package/dist/dynamic-relation-helpers.d.ts.map +1 -1
  61. package/dist/dynamic-relation-helpers.js +8 -0
  62. package/dist/dynamic-relation.js +1 -1
  63. package/dist/dynamic-select-field.d.ts.map +1 -1
  64. package/dist/dynamic-select-field.js +4 -0
  65. package/dist/index.d.ts +8 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +17 -2
  68. package/dist/installed-addons-context.d.ts +15 -0
  69. package/dist/installed-addons-context.d.ts.map +1 -0
  70. package/dist/installed-addons-context.js +39 -0
  71. package/dist/option-filter.d.ts +31 -0
  72. package/dist/option-filter.d.ts.map +1 -0
  73. package/dist/option-filter.js +49 -0
  74. package/dist/org-runtime-context.d.ts +8 -0
  75. package/dist/org-runtime-context.d.ts.map +1 -1
  76. package/dist/org-runtime-context.js +8 -0
  77. package/dist/org-runtime-provider.d.ts +3 -1
  78. package/dist/org-runtime-provider.d.ts.map +1 -1
  79. package/dist/org-runtime-provider.js +3 -3
  80. package/dist/primitives/aging.d.ts +34 -0
  81. package/dist/primitives/aging.d.ts.map +1 -0
  82. package/dist/primitives/aging.js +48 -0
  83. package/dist/primitives/allocation.d.ts +42 -0
  84. package/dist/primitives/allocation.d.ts.map +1 -0
  85. package/dist/primitives/allocation.js +59 -0
  86. package/dist/primitives/approvals.d.ts +71 -0
  87. package/dist/primitives/approvals.d.ts.map +1 -0
  88. package/dist/primitives/approvals.js +48 -0
  89. package/dist/primitives/contributions.d.ts +82 -0
  90. package/dist/primitives/contributions.d.ts.map +1 -0
  91. package/dist/primitives/contributions.js +126 -0
  92. package/dist/primitives/document-kinds.d.ts +25 -0
  93. package/dist/primitives/document-kinds.d.ts.map +1 -0
  94. package/dist/primitives/document-kinds.js +26 -0
  95. package/dist/primitives/index.d.ts +8 -0
  96. package/dist/primitives/index.d.ts.map +1 -0
  97. package/dist/primitives/index.js +23 -0
  98. package/dist/primitives/panels.d.ts +68 -0
  99. package/dist/primitives/panels.d.ts.map +1 -0
  100. package/dist/primitives/panels.js +3 -0
  101. package/dist/primitives/use-contributions.d.ts +15 -0
  102. package/dist/primitives/use-contributions.d.ts.map +1 -0
  103. package/dist/primitives/use-contributions.js +30 -0
  104. package/dist/record-prefill-bar.d.ts +11 -0
  105. package/dist/record-prefill-bar.d.ts.map +1 -0
  106. package/dist/record-prefill-bar.js +33 -0
  107. package/dist/types.d.ts +142 -0
  108. package/dist/types.d.ts.map +1 -1
  109. package/dist/use-options-resolver.d.ts +8 -0
  110. package/dist/use-options-resolver.d.ts.map +1 -1
  111. package/dist/use-options-resolver.js +8 -2
  112. package/package.json +5 -5
  113. package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
  114. package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
  115. package/src/__tests__/audit-info.test.tsx +103 -0
  116. package/src/__tests__/catalog-product-search.test.ts +61 -0
  117. package/src/__tests__/dependent-options.test.tsx +12 -5
  118. package/src/__tests__/document-editor-model.test.ts +167 -0
  119. package/src/__tests__/document-editor.test.tsx +261 -0
  120. package/src/__tests__/document-form-dialog.test.tsx +118 -3
  121. package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
  122. package/src/__tests__/dynamic-kanban.test.tsx +24 -3
  123. package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
  124. package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
  125. package/src/__tests__/installed-addons-context.test.tsx +50 -0
  126. package/src/__tests__/line-item-totals.test.ts +28 -0
  127. package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
  128. package/src/__tests__/option-filter.test.tsx +115 -0
  129. package/src/__tests__/primitives-federated.test.tsx +156 -0
  130. package/src/__tests__/primitives.test.ts +224 -0
  131. package/src/__tests__/record-prefill-bar.test.tsx +50 -0
  132. package/src/__tests__/structured-view-value.test.tsx +5 -1
  133. package/src/__tests__/wizard-action-modal.test.tsx +1 -1
  134. package/src/action-modal-dispatcher.tsx +47 -4
  135. package/src/audit-info.tsx +219 -0
  136. package/src/business/catalog-product-search.ts +87 -0
  137. package/src/business/catalog.ts +2 -0
  138. package/src/business/document-editor-model.ts +325 -0
  139. package/src/business/document-editor-panels.tsx +164 -0
  140. package/src/business/document-editor.tsx +727 -0
  141. package/src/business/document-lines.ts +5 -1
  142. package/src/business/index.ts +5 -0
  143. package/src/business/line-items.ts +15 -1
  144. package/src/business/payment-allocator.tsx +110 -0
  145. package/src/business/product-search.ts +4 -0
  146. package/src/dialogs/dynamic-record.tsx +42 -6
  147. package/src/document-form-dialog.tsx +92 -12
  148. package/src/dropdown-menu-entry.test.ts +23 -0
  149. package/src/dynamic-columns-helpers.ts +22 -0
  150. package/src/dynamic-columns.tsx +23 -6
  151. package/src/dynamic-crud-page.tsx +22 -3
  152. package/src/dynamic-form-schema.ts +15 -2
  153. package/src/dynamic-form.tsx +120 -39
  154. package/src/dynamic-kanban.tsx +41 -10
  155. package/src/dynamic-line-items.tsx +3 -0
  156. package/src/dynamic-multi-select-field.tsx +2 -0
  157. package/src/dynamic-relation-helpers.ts +9 -0
  158. package/src/dynamic-relation.tsx +7 -2
  159. package/src/dynamic-select-field.tsx +4 -0
  160. package/src/index.ts +34 -1
  161. package/src/installed-addons-context.tsx +58 -0
  162. package/src/option-filter.ts +88 -0
  163. package/src/org-runtime-context.ts +10 -0
  164. package/src/org-runtime-provider.tsx +7 -2
  165. package/src/primitives/aging.ts +76 -0
  166. package/src/primitives/allocation.ts +104 -0
  167. package/src/primitives/approvals.ts +103 -0
  168. package/src/primitives/contributions.ts +208 -0
  169. package/src/primitives/document-kinds.ts +62 -0
  170. package/src/primitives/index.ts +23 -0
  171. package/src/primitives/panels.ts +59 -0
  172. package/src/primitives/use-contributions.ts +42 -0
  173. package/src/record-prefill-bar.tsx +62 -0
  174. package/src/row-action-stage-gate.test.ts +33 -0
  175. package/src/types.ts +142 -0
  176. package/src/use-options-resolver.ts +19 -1
  177. package/vitest.config.ts +1 -0
  178. package/vitest.setup.ts +7 -0
@@ -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
@@ -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'
@@ -30,6 +30,7 @@ import {
30
30
  } from './dynamic-form-schema'
31
31
  import { ScanLine } from 'lucide-react'
32
32
  import { BarcodeScanner } from './barcode-scanner'
33
+ import { getOptionFilter } from './option-filter'
33
34
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
34
35
  import { DynamicLineItems } from './dynamic-line-items'
35
36
  import { DynamicSelectField } from './dynamic-select-field'
@@ -64,8 +65,35 @@ export interface DynamicFormProps {
64
65
  * legacy flat list (unchanged).
65
66
  */
66
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
67
75
  }
68
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
+
69
97
  export function DynamicForm({
70
98
  fields,
71
99
  initialValues,
@@ -75,8 +103,8 @@ export function DynamicForm({
75
103
  cancelLabel = 'Cancelar',
76
104
  disabled = false,
77
105
  formLayout,
106
+ scrollableBody = false,
78
107
  }: DynamicFormProps) {
79
- const [values, setValues] = useState<Record<string, any>>({})
80
108
  const [errors, setErrors] = useState<Record<string, string>>({})
81
109
  const [submitting, setSubmitting] = useState(false)
82
110
 
@@ -86,10 +114,15 @@ export function DynamicForm({
86
114
  // (ColumnDef.Readonly). They are also excluded from defaults/submit so a
87
115
  // create never posts e.g. `status: ""` over the column default.
88
116
  const editableFields = useMemo(
89
- () => fields.filter((f) => !(f as { readonly?: boolean }).readonly),
117
+ () => fields.filter((f) => !isReadonlyField(f)),
90
118
  [fields],
91
119
  )
92
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
+
93
126
  // Conditional visibility: a field carrying `visible_when` is rendered — and
94
127
  // validated — only while the referenced sibling field's current value
95
128
  // matches the predicate. Driving BOTH the schema and the render off the same
@@ -132,21 +165,22 @@ export function DynamicForm({
132
165
  setStepIndex((i) => Math.min(i, Math.max(groups.length - 1, 0)))
133
166
  }, [groups.length])
134
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 })
135
171
  useEffect(() => {
136
- const defaults: Record<string, any> = {}
137
- for (const f of editableFields) {
138
- if (isLineItemsField(f)) {
139
- defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? []
140
- continue
141
- }
142
- defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? (f.type === 'boolean' ? false : '')
143
- }
144
- 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))
145
177
  setErrors({})
146
178
  }, [editableFields, initialValues])
147
179
 
148
- const update = (k: string, v: any) =>
180
+ const update = (k: string, v: any) => {
181
+ touched.current.add(k)
149
182
  setValues((prev: Record<string, any>) => ({ ...prev, [k]: v }))
183
+ }
150
184
 
151
185
  const handleSubmit = async (e: React.FormEvent) => {
152
186
  e.preventDefault()
@@ -162,8 +196,19 @@ export function DynamicForm({
162
196
  return
163
197
  }
164
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
+ }
165
210
  setSubmitting(true)
166
- try { await onSubmit(result.data as Record<string, any>) } finally { setSubmitting(false) }
211
+ try { await onSubmit(data) } finally { setSubmitting(false) }
167
212
  }
168
213
 
169
214
  // Renders one group's fields into the responsive 2-column grid: scalar
@@ -185,6 +230,11 @@ export function DynamicForm({
185
230
  </div>
186
231
  )
187
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
+
188
238
  // ── Steps (wizard) mode ────────────────────────────────────────────────
189
239
  // One step per section; Anterior/Siguiente navigate, submit only on the
190
240
  // last step. Advancing validates just the current step's fields so a later
@@ -211,20 +261,22 @@ export function DynamicForm({
211
261
  const goBack = () => setStepIndex((i) => Math.max(i - 1, 0))
212
262
 
213
263
  return (
214
- <form onSubmit={handleSubmit} className="grid gap-4">
215
- <WizardProgress groups={groups} stepIndex={stepIndex} onStepClick={i => setStepIndex(i)} />
216
- {step.assist ? (
217
- <AssistInterview
218
- assist={step.assist}
219
- values={values}
220
- eyebrow={step.title}
221
- autoStart={step.assist.trigger !== 'button'}
222
- onApply={fields => setValues(prev => ({ ...prev, ...fields }))}
223
- />
224
- ) : (
225
- renderGrid(step.fields)
226
- )}
227
- <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}>
228
280
  {stepIndex > 0 ? (
229
281
  <Button type="button" variant="outline" onClick={goBack} disabled={submitting || disabled}>
230
282
  Anterior
@@ -257,13 +309,15 @@ export function DynamicForm({
257
309
  // stays fully declarative — driven only by field shape. With no layout this
258
310
  // is a single default group rendered without section chrome (unchanged).
259
311
  return (
260
- <form onSubmit={handleSubmit} className="grid gap-4">
261
- {groups.map((group) => (
262
- <FieldSection key={group.key} group={group}>
263
- {renderGrid(group.fields)}
264
- </FieldSection>
265
- ))}
266
- <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}>
267
321
  {onCancel && (
268
322
  <Button type="button" variant="outline" onClick={onCancel} disabled={submitting || disabled}>
269
323
  {cancelLabel}
@@ -427,15 +481,17 @@ function FieldRenderer({
427
481
  onChange={onChange}
428
482
  />
429
483
  )
430
- case 'select':
484
+ case 'select': {
485
+ const opts = effectiveOptions ?? field.options
431
486
  return (
432
- <Select value={value || ''} onValueChange={onChange}>
487
+ <Select value={value || ''} onValueChange={guardEmptySelect(value, onChange, (opts ?? []).map((o) => o.value))}>
433
488
  <SelectTrigger className="w-full"><SelectValue placeholder={field.placeholder || 'Seleccionar...'} /></SelectTrigger>
434
489
  <SelectContent>
435
- {(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>)}
436
491
  </SelectContent>
437
492
  </Select>
438
493
  )
494
+ }
439
495
  case 'switch':
440
496
  return <Switch id={field.key} checked={!!value} onCheckedChange={onChange} />
441
497
  case 'number':
@@ -525,14 +581,39 @@ function ScannableInput({
525
581
  )
526
582
  }
527
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
+
528
603
  function RefSelect({ field, value, onChange }: FieldRendererProps) {
529
604
  const { options, loading } = useOptionsResolver({
530
605
  modelKey: '', // unused — `ref` drives the URL
531
606
  fieldKey: 'id',
532
607
  ref: field.ref,
608
+ optionFilter: getOptionFilter(field),
609
+ keepValue: value,
533
610
  })
534
611
  return (
535
- <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
+ >
536
617
  <SelectTrigger className="w-full">
537
618
  <SelectValue placeholder={loading ? 'Cargando…' : (field.placeholder || 'Seleccionar...')} />
538
619
  </SelectTrigger>
@@ -94,9 +94,6 @@ import {
94
94
  Button,
95
95
  Card,
96
96
  CardContent,
97
- DropdownMenu,
98
- DropdownMenuContent,
99
- DropdownMenuTrigger,
100
97
  Input,
101
98
  Popover,
102
99
  PopoverContent,
@@ -113,6 +110,13 @@ import {
113
110
  SheetTrigger,
114
111
  Skeleton,
115
112
  } from '@asteby/metacore-ui/primitives'
113
+ // The card "…" menu renders RowActionMenuItem (dynamic-columns), whose
114
+ // DropdownMenuItem comes from the ROOT '@asteby/metacore-ui' entry. The menu
115
+ // shell MUST come from that same entry: hosts share the root entry as a Module
116
+ // Federation singleton while '/primitives' is bundled locally, so mixing them
117
+ // yields two Radix Menu contexts and the click crashes the app with
118
+ // "`MenuItem` must be used within `Menu`" (Pitsline taller kanban, 2026-10-05).
119
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@asteby/metacore-ui'
116
120
  import { ColumnFilterControl, FilterValueCombobox, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
117
121
  import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
118
122
  import { useApi } from './api-context'
@@ -161,6 +165,7 @@ import {
161
165
  aggregateOf,
162
166
  formatAggregateTotal,
163
167
  isRowActionVisible,
168
+ lifecycleStageField,
164
169
  relationKeyFor,
165
170
  RowActionMenuItem,
166
171
  } from './dynamic-columns'
@@ -420,18 +425,39 @@ export function isCardOverdue(card: any, col: ColumnDefinition, now: number = Da
420
425
  * The stage machine (stages, transitions, smart lanes) belongs to the model's
421
426
  * stage column (`stage_field`; older hosts that do not serve it: the served
422
427
  * group_by): grouping by it keeps the machine, grouping by anything else drops
423
- * it and the lanes come from the new column. Returns the SAME object when there
424
- * is nothing to change (no override, the served group_by already, or a column the
425
- * 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.
426
437
  */
427
438
  export function withGroupBy(
428
439
  metadata: TableMetadata | null,
429
440
  groupBy: string | undefined,
430
441
  ): TableMetadata | null {
431
- 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
+ }
432
452
  if (!metadata.columns.some((c) => c.key === groupBy)) return metadata
433
- const stageColumn = metadata.stage_field ?? (metadata.stages?.length ? metadata.group_by : undefined)
434
- 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
+ }
435
461
  return {
436
462
  ...metadata,
437
463
  group_by: groupBy,
@@ -2115,6 +2141,7 @@ function DynamicKanbanBoard({
2115
2141
  fieldCols={fieldCols}
2116
2142
  showAllFields={explicitCard}
2117
2143
  actions={rowActions}
2144
+ stageField={lifecycleStageField(metadata)}
2118
2145
  locale={i18n.language}
2119
2146
  timeZone={timeZone}
2120
2147
  currency={currency}
@@ -2147,6 +2174,7 @@ function DynamicKanbanBoard({
2147
2174
  fieldCols={fieldCols}
2148
2175
  showAllFields={explicitCard}
2149
2176
  actions={rowActions}
2177
+ stageField={lifecycleStageField(metadata)}
2150
2178
  locale={i18n.language}
2151
2179
  timeZone={timeZone}
2152
2180
  currency={currency}
@@ -3179,6 +3207,8 @@ interface KanbanCardProps {
3179
3207
  /** The model laid its card out explicitly: show every field with a value. */
3180
3208
  showAllFields?: boolean
3181
3209
  actions: ActionDefinition[]
3210
+ /** Column the actions' requiresState reads (the model's stage_field). */
3211
+ stageField?: string
3182
3212
  locale: string
3183
3213
  timeZone?: string
3184
3214
  currency?: string
@@ -3256,6 +3286,7 @@ function KanbanCard({
3256
3286
  fieldCols,
3257
3287
  showAllFields,
3258
3288
  actions,
3289
+ stageField,
3259
3290
  locale,
3260
3291
  timeZone,
3261
3292
  currency,
@@ -3270,7 +3301,7 @@ function KanbanCard({
3270
3301
  disabled: !draggable,
3271
3302
  })
3272
3303
 
3273
- const visibleActions = actions.filter((a) => isRowActionVisible(a, card))
3304
+ const visibleActions = actions.filter((a) => isRowActionVisible(a, card, stageField))
3274
3305
 
3275
3306
  const menu =
3276
3307
  visibleActions.length > 0 ? (
@@ -36,6 +36,7 @@ import {
36
36
  applyOptionWhen,
37
37
  } from './dynamic-form-schema'
38
38
  import { DynamicSelectField, DEFAULT_DEPENDS_HINT } from './dynamic-select-field'
39
+ import { getOptionFilter } from './option-filter'
39
40
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
40
41
 
41
42
  export interface DynamicLineItemsProps {
@@ -575,6 +576,8 @@ function RefCell({ field, value, onChange, onPick, disabled, formValues, rowValu
575
576
  ref: optSource.ref,
576
577
  endpoint: optSource.endpoint,
577
578
  filterValue: dependsOn ? scope : undefined,
579
+ optionFilter: getOptionFilter(field),
580
+ keepValue: value,
578
581
  enabled: !blockedByDependency,
579
582
  })
580
583
 
@@ -19,6 +19,7 @@
19
19
  // value instead.
20
20
  import { useMemo } from 'react'
21
21
  import { MultiSelect } from '@asteby/metacore-ui/primitives'
22
+ import { getOptionFilter } from './option-filter'
22
23
  import { useOptionsResolver } from './use-options-resolver'
23
24
  import { getFieldRef } from './dynamic-form-schema'
24
25
  import type { ActionFieldDef } from './types'
@@ -38,6 +39,7 @@ export function DynamicMultiSelectField({ field, value, onChange }: DynamicMulti
38
39
  ref,
39
40
  endpoint: !ref && field.searchEndpoint ? field.searchEndpoint : undefined,
40
41
  limit: 200,
42
+ optionFilter: getOptionFilter(field),
41
43
  })
42
44
 
43
45
  const selected = useMemo(() => (Array.isArray(value) ? value.map(String) : []), [value])
@@ -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}
@@ -22,6 +22,7 @@
22
22
  // value). A dedicated `?ids=` lookup is a follow-up; create flows — the common
23
23
  // case — start empty and never hit this.
24
24
  import { useEffect, useRef, useState } from 'react'
25
+ import { getOptionFilter } from './option-filter'
25
26
  import { useTranslation } from 'react-i18next'
26
27
  import {
27
28
  Badge,
@@ -266,6 +267,7 @@ export function DynamicSelectField({
266
267
  const blockedByDependency = !!dependsOn && scope === ''
267
268
 
268
269
  const useStatic = Array.isArray(staticOptions)
270
+ const optionFilter = getOptionFilter(field)
269
271
 
270
272
  const { options: fetchedOptions, loading: fetchLoading } = useOptionsResolver({
271
273
  modelKey: '',
@@ -280,6 +282,8 @@ export function DynamicSelectField({
280
282
  // Cascade scope forwarded as filter_value (only when this field
281
283
  // declares a dependency). Re-fetches when the parent value changes.
282
284
  filterValue: dependsOn ? scope : undefined,
285
+ optionFilter,
286
+ keepValue: value,
283
287
  // Don't fetch until the popover opens (and keep fetching as the query
284
288
  // changes while open). A picker blocked by an unset dependency never
285
289
  // fetches. A readonly cell fetches eagerly so its value's label resolves
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,
@@ -584,6 +589,14 @@ export {
584
589
  type ActivityEvent,
585
590
  type ActivityDiffProps,
586
591
  } from './activity-diff'
592
+ export {
593
+ AuditInfo,
594
+ readAuditMeta,
595
+ resolveAuditActor,
596
+ SYSTEM_ACTOR_ID,
597
+ type AuditInfoProps,
598
+ type AuditActor,
599
+ } from './audit-info'
587
600
  export {
588
601
  RecordHistory,
589
602
  type RecordHistoryProps,
@@ -661,3 +674,23 @@ export {
661
674
 
662
675
  // Página de documento (benchmark §5/§6): cabecera, badges, acciones por estado, botones inteligentes.
663
676
  export * from './document'
677
+
678
+ // Option filter — hide options of a relation / dynamic picker from the manifest
679
+ // (`option_filter` / `optionFilter` on the field).
680
+ export { getOptionFilter, applyOptionFilter, optionPassesRule } from './option-filter'
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'