@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
@@ -23,6 +23,7 @@ import {
23
23
  import { Loader2 } from 'lucide-react'
24
24
  import { toast } from 'sonner'
25
25
  import { useApi } from './api-context'
26
+ import { useOrgTaxRate } from './org-runtime-context'
26
27
  import { DynamicIcon } from './dynamic-icon'
27
28
  import { FieldCell, FieldGrid, FieldLabel } from './field-grid'
28
29
  import { FormErrorBanner } from './business/feedback'
@@ -41,12 +42,13 @@ import { extractFieldErrors, localizeFieldErrorMap } from './server-error'
41
42
  import { validateValues, bagHasErrors } from './validator'
42
43
  import { clearFieldErrorTree } from './field-validation-ui'
43
44
  import { emitRecordMutation } from './record-mutation-events'
44
- import { isLineItemsField, resolveWidget } from './dynamic-form-schema'
45
+ import { isLineItemsField, resolveWidget, scopeValueFromFilterToken } from './dynamic-form-schema'
46
+ import { createCatalogProductSearch, withDefaultTaxRate } from './business/catalog-product-search'
47
+ import { DocumentEditor } from './business/document-editor'
48
+ import { editorLinesConfig, isEditorLayout } from './business/document-editor-model'
45
49
  import type {
46
50
  ActionFieldDef,
47
- DocumentFormLines,
48
51
  DocumentFormsManifest,
49
- DocumentFormType,
50
52
  TableMetadata,
51
53
  } from './types'
52
54
 
@@ -62,10 +64,24 @@ export interface DocumentFormDialogProps {
62
64
  initialType?: string
63
65
  /** Se llama con el registro creado. */
64
66
  onSaved?: (record?: unknown) => void
65
- /** Buscador de productos para el editor de renglones (mismo contrato que ProductPicker). */
67
+ /**
68
+ * Buscador de productos para el editor de renglones (mismo contrato que
69
+ * ProductPicker). Sin él se busca en el catálogo (`productModel`) vía /data.
70
+ */
66
71
  searchProducts?: DocumentLinesGridProps['search']
72
+ /** Modelo de catálogo del buscador por defecto. Default `products.Product`. */
73
+ productModel?: string
74
+ /** Tasa de impuesto (0.16) si el producto no trae la suya. Default: la de la org (OrgRuntimeProvider). */
75
+ defaultTaxRate?: number
67
76
  /** Moneda ISO para el editor de renglones (default: la de la org). */
68
77
  currency?: string
78
+ /**
79
+ * Registro desde el que se abre (acción de fila «Crear NC», «Registrar
80
+ * cobro»…): siembra los campos con `default_from_record`.
81
+ */
82
+ record?: Record<string, any>
83
+ /** Documento origen a precargar en el DocumentEditor (`sources[].key` + id). */
84
+ initialSource?: { key: string; id: string }
69
85
  }
70
86
 
71
87
  /** Manifest efectivo: la prop gana sobre el metadata; sin tipos devuelve undefined. */
@@ -77,10 +93,25 @@ export function resolveDocumentForms(
77
93
  return m && Array.isArray(m.types) && m.types.length > 0 ? m : undefined
78
94
  }
79
95
 
80
- function linesConfig(type: DocumentFormType, forms: DocumentFormsManifest): (DocumentFormLines & { field: string }) | undefined {
81
- if (!type.lines) return undefined
82
- const cfg: DocumentFormLines = type.lines === true ? {} : type.lines
83
- return { ...cfg, field: cfg.field ?? forms.lines_field ?? 'lines' }
96
+ /**
97
+ * Acota el alta guiada a la vista filtrada. Un nav que filtra por la columna
98
+ * discriminadora (`type_field`) lista UN tipo de documento: «Crear» debe abrir
99
+ * ese tipo directo y, si el manifest no trae formulario para él, no ofrecerse
100
+ * (retest r3: «Documentos fiscales → Facturas → Crear» abría el REP, el único
101
+ * tipo declarado). Sin manifest, sin `type_field` o sin filtro sobre él, todo
102
+ * queda igual.
103
+ */
104
+ export function scopeDocumentFormsToFilter(
105
+ forms: DocumentFormsManifest | undefined,
106
+ filter?: Record<string, unknown> | null,
107
+ ): { forms: DocumentFormsManifest | undefined; hideCreate: boolean } {
108
+ const field = forms?.type_field
109
+ if (!forms || !field || !filter || !(field in filter)) return { forms, hideCreate: false }
110
+ const want = scopeValueFromFilterToken(filter[field]).trim().toLowerCase()
111
+ if (want === '') return { forms, hideCreate: false }
112
+ const types = forms.types.filter((x) => String(x.value ?? x.key).trim().toLowerCase() === want)
113
+ if (types.length === 0) return { forms: undefined, hideCreate: true }
114
+ return { forms: types.length === forms.types.length ? forms : { ...forms, types }, hideCreate: false }
84
115
  }
85
116
 
86
117
  type Step = 'type' | 'fields' | 'lines'
@@ -94,10 +125,15 @@ export function DocumentFormDialog({
94
125
  initialType,
95
126
  onSaved,
96
127
  searchProducts,
128
+ productModel,
129
+ defaultTaxRate,
97
130
  currency,
131
+ record,
132
+ initialSource,
98
133
  }: DocumentFormDialogProps) {
99
134
  const { t, i18n } = useTranslation()
100
135
  const api = useApi()
136
+ const orgTaxRate = useOrgTaxRate()
101
137
  const types = forms.types
102
138
  const single = types.length === 1 ? types[0] : undefined
103
139
 
@@ -111,7 +147,19 @@ export function DocumentFormDialog({
111
147
  const [saving, setSaving] = useState(false)
112
148
 
113
149
  const type = useMemo(() => types.find((x) => x.key === typeKey) ?? null, [types, typeKey])
114
- const lineCfg = type ? linesConfig(type, forms) : undefined
150
+ // Sin buscador inyectado, el paso de renglones solo ofrecía «Renglón libre»
151
+ // y la línea entraba sin precio (DynamicCRUDPage no pasa searchProducts).
152
+ // Default: el catálogo de productos de la org. El buscador del host también
153
+ // recibe el IVA de la org para los productos que no traen su tasa.
154
+ const taxRate = defaultTaxRate ?? orgTaxRate
155
+ const lineSearch = useMemo(
156
+ () =>
157
+ searchProducts
158
+ ? withDefaultTaxRate(searchProducts, taxRate)
159
+ : createCatalogProductSearch(api, { model: productModel, defaultTaxRate: taxRate }),
160
+ [searchProducts, api, productModel, taxRate],
161
+ )
162
+ const lineCfg = type ? editorLinesConfig(type, forms) : undefined
115
163
 
116
164
  // Reset every time the dialog (re)opens.
117
165
  useEffect(() => {
@@ -128,10 +176,10 @@ export function DocumentFormDialog({
128
176
  // Seed field defaults for the chosen type.
129
177
  useEffect(() => {
130
178
  if (!type) return
131
- setFormData({ ...buildFieldDefaults(type.fields, undefined), ...(type.defaults ?? {}) })
179
+ setFormData({ ...buildFieldDefaults(type.fields, record), ...(type.defaults ?? {}) })
132
180
  setFieldErrors({})
133
181
  setFormError(undefined)
134
- }, [type])
182
+ }, [type, record])
135
183
 
136
184
  const updateField = (key: string, value: any) => {
137
185
  setFormData((prev) => ({ ...prev, [key]: value }))
@@ -232,6 +280,41 @@ export function DocumentFormDialog({
232
280
  ? t('documentForm.step_data', { defaultValue: 'Datos' })
233
281
  : tl(lineCfg?.title ?? 'Renglones')
234
282
 
283
+ // layout "editor": una sola pantalla por secciones (DocumentEditor). Sin él
284
+ // sigue el wizard de siempre (retrocompatible).
285
+ if (open && type && step !== 'type' && (isEditorLayout(type) || !!initialSource)) {
286
+ return (
287
+ <Dialog open={open} onOpenChange={onOpenChange}>
288
+ <DialogContent
289
+ className="flex max-h-[92dvh] flex-col overflow-hidden"
290
+ style={{ maxHeight: '92dvh', maxWidth: '960px', width: '95vw' }}
291
+ data-slot="document-editor-dialog"
292
+ >
293
+ <DialogHeader className="shrink-0">
294
+ <DialogTitle>{tl(type.label)}</DialogTitle>
295
+ {type.description && <DialogDescription>{tl(type.description)}</DialogDescription>}
296
+ </DialogHeader>
297
+ <DocumentEditor
298
+ key={type.key}
299
+ model={model}
300
+ endpoint={endpoint}
301
+ forms={forms}
302
+ type={type}
303
+ record={record}
304
+ initialSource={initialSource}
305
+ searchProducts={lineSearch}
306
+ currency={currency}
307
+ onCancel={() => (canGoBackToTypes ? setStep('type') : onOpenChange(false))}
308
+ onSaved={(rec) => {
309
+ onSaved?.(rec)
310
+ onOpenChange(false)
311
+ }}
312
+ />
313
+ </DialogContent>
314
+ </Dialog>
315
+ )
316
+ }
317
+
235
318
  return (
236
319
  <Dialog open={open} onOpenChange={onOpenChange}>
237
320
  <DialogContent
@@ -311,8 +394,9 @@ export function DocumentFormDialog({
311
394
  }}
312
395
  columns={(lineCfg.columns as LineItemsColumn[] | undefined) ?? ['discount', 'tax']}
313
396
  priceSource={lineCfg.price_source ?? 'sale'}
397
+ discountMode={lineCfg.discount_mode}
314
398
  serverErrors={serverLineErrors}
315
- search={searchProducts}
399
+ search={lineSearch}
316
400
  currency={currency}
317
401
  />
318
402
  )}
@@ -78,3 +78,25 @@ export function translateMetadataLabel(
78
78
  const translated = translate(value, { defaultValue: fallback })
79
79
  return translated && translated !== value ? translated : fallback
80
80
  }
81
+
82
+ // A catalog key as addons write them: lowercase dotted segments
83
+ // ("fiscal_mexico.ext.customer.rfc_receptor"). Human labels have spaces,
84
+ // capitals or accents and never match.
85
+ const CATALOG_KEY_RE = /^[a-z0-9_]+(\.[a-z0-9_]+)+$/
86
+
87
+ /**
88
+ * Form-field label as the user should read it. The host usually serves labels
89
+ * already localized; when it could not (an extension field whose addon catalog
90
+ * the server did not resolve), the label arrives as the raw catalog key and the
91
+ * form printed `FISCAL_MEXICO.EXT.CUSTOMER.RFC_RECEPTOR`. Such a key is resolved
92
+ * with the client catalog and, failing that, humanized. Any other label is
93
+ * returned untouched.
94
+ */
95
+ export function localizeFieldLabel(
96
+ label: string | undefined,
97
+ translate?: MetadataTranslator,
98
+ ): string {
99
+ if (!label) return ''
100
+ if (!CATALOG_KEY_RE.test(label)) return label
101
+ return translateMetadataLabel(label, translate)
102
+ }
@@ -17,7 +17,7 @@ import { ColumnDef } from '@tanstack/react-table'
17
17
  import { format, type Locale } from 'date-fns'
18
18
  import { isCalendarDayValue, parseCalendarDate } from './calendar-date'
19
19
  import { es, enUS } from 'date-fns/locale'
20
- import { Calendar, Check, Copy, Mail, Minus, MoreHorizontal } from 'lucide-react'
20
+ import { Calendar, Check, Copy, Mail, Minus } from 'lucide-react'
21
21
  import {
22
22
  Avatar,
23
23
  AvatarFallback,
@@ -25,10 +25,6 @@ import {
25
25
  Badge,
26
26
  Button,
27
27
  Checkbox,
28
- DropdownMenu,
29
- DropdownMenuContent,
30
- DropdownMenuItem,
31
- DropdownMenuTrigger,
32
28
  InitialsAvatar,
33
29
  } from '@asteby/metacore-ui'
34
30
  import {
@@ -57,7 +53,7 @@ import { DynamicIcon, isLucideIconName } from './dynamic-icon'
57
53
  import { CollectionCell } from './collection-cell'
58
54
  import { isNilUuid, normalizeNilUuid } from './nil-uuid'
59
55
  import { useOptionsResolver } from './use-options-resolver'
60
- import { RequiresAddonLock, resolveRequiresAddon, useRequiresAddonLabel } from './requires-addon'
56
+ import { RowActionsMenu } from './row-actions-menu'
61
57
  import type { TableMetadata, ColumnDefinition } from './types'
62
58
  import { isColumnVisibleInTable } from './column-visibility'
63
59
  import type {
@@ -208,42 +204,25 @@ const CodeCell: React.FC<{ text: string; maxLength?: number }> = ({ text, maxLen
208
204
  )
209
205
  }
210
206
 
211
- /**
212
- * One entry of the row "…" menu. An action gated by a missing optional addon
213
- * (`requiresAddon`) stays listed with a lock + tooltip; the click still goes to
214
- * `onAction`, whose shared handler (useDynamicRowActions) opens the
215
- * requires-addon dialog instead of executing.
216
- */
217
- export function RowActionMenuItem({
218
- action,
219
- label,
220
- onSelect,
221
- }: {
222
- action: any
223
- label: React.ReactNode
224
- onSelect: (e: React.MouseEvent) => void
225
- }) {
226
- const requirement = resolveRequiresAddon(action)
227
- const requiresAddonLabel = useRequiresAddonLabel()
228
- return (
229
- <DropdownMenuItem
230
- onClick={onSelect}
231
- title={requirement ? requiresAddonLabel(requirement) : undefined}
232
- data-requires-addon={requirement?.key}
233
- >
234
- <DynamicIcon name={action.icon || 'Zap'} className="mr-2 h-4 w-4" />
235
- {label}
236
- {requirement && <RequiresAddonLock requirement={requirement} />}
237
- </DropdownMenuItem>
238
- )
239
- }
207
+ // RowActionMenuItem vive en row-actions-menu.tsx (junto al menú que lo usa);
208
+ // se re-exporta aquí por compatibilidad (dynamic-kanban, hosts).
209
+ export { RowActionMenuItem } from './row-actions-menu'
240
210
 
241
211
  /**
242
212
  * Lifecycle column used by `requiresState`: prefer `status` (workshop, vehicles,
243
213
  * …) and fall back to `state` (purchases, inventory transfers, …). Empty string
244
214
  * is treated as missing so a blank `status` does not hide a populated `state`.
245
215
  */
246
- const rowLifecycleState = (row: any): unknown => {
216
+ const rowLifecycleState = (row: any, stageField?: string): unknown => {
217
+ // A model with a stage machine gates its actions on the stage column, the
218
+ // same field the kernel's checkRequiresState reads (dynamic/action.go).
219
+ // Without this, WorkOrder actions declared with stage keys
220
+ // (requiresState: ['reception', …]) were compared to `status` ('open') and
221
+ // hidden in every row, so an OT could never be advanced from the UI.
222
+ if (stageField) {
223
+ const stage = row?.[stageField]
224
+ if (stage !== undefined && stage !== null && stage !== '') return stage
225
+ }
247
226
  const status = row?.status
248
227
  if (status !== undefined && status !== null && status !== '') return status
249
228
  const state = row?.state
@@ -264,10 +243,10 @@ const rowLifecycleState = (row: any): unknown => {
264
243
  * - action without requiresState (or empty array) → always shown.
265
244
  * - row with neither `status` nor `state` → all actions shown.
266
245
  */
267
- export const isActionAllowedForRowState = (action: any, row: any): boolean => {
246
+ export const isActionAllowedForRowState = (action: any, row: any, stageField?: string): boolean => {
268
247
  const requires: unknown = action?.requiresState ?? action?.requires_state
269
248
  if (!Array.isArray(requires) || requires.length === 0) return true
270
- const status = rowLifecycleState(row)
249
+ const status = rowLifecycleState(row, stageField)
271
250
  if (status === undefined) return true
272
251
  return requires.map(String).includes(String(status))
273
252
  }
@@ -341,8 +320,16 @@ export const isActionConditionMet = (action: any, row: any): boolean => {
341
320
  * (`requiresState`) AND the declarative `condition` must pass. Shared by the
342
321
  * table's action column and the kanban card menu so they hide/show identically.
343
322
  */
344
- export const isRowActionVisible = (action: any, row: any): boolean =>
345
- isActionAllowedForRowState(action, row) && isActionConditionMet(action, row)
323
+ export const isRowActionVisible = (action: any, row: any, stageField?: string): boolean =>
324
+ isActionAllowedForRowState(action, row, stageField) && isActionConditionMet(action, row)
325
+
326
+ /**
327
+ * The column a model's `requiresState` gates read: the served `stage_field`
328
+ * (or, for hosts that predate it, the `group_by` of a model with stages).
329
+ * Undefined → the historical `status` / `state` lookup.
330
+ */
331
+ export const lifecycleStageField = (metadata: any): string | undefined =>
332
+ metadata?.stage_field ?? metadata?.stageField ?? (metadata?.stages?.length ? metadata?.group_by : undefined)
346
333
 
347
334
  const lowerFirst = (value?: string) => {
348
335
  if (!value) return value
@@ -1554,28 +1541,14 @@ export function makeDefaultGetDynamicColumns(
1554
1541
  maxSize: 80,
1555
1542
  meta: {},
1556
1543
  cell: ({ row }) => (
1557
- <div className="flex items-center justify-end">
1558
- <DropdownMenu>
1559
- <DropdownMenuTrigger asChild>
1560
- <Button variant="ghost" className="h-8 w-8 p-0">
1561
- <span className="sr-only">Abrir menú</span>
1562
- <MoreHorizontal className="h-4 w-4" />
1563
- </Button>
1564
- </DropdownMenuTrigger>
1565
- <DropdownMenuContent align="end">
1566
- {resolvedActions
1567
- .filter((action) => isRowActionVisible(action, row.original))
1568
- .map((action) => (
1569
- <RowActionMenuItem
1570
- key={action.key}
1571
- action={action}
1572
- label={translateMetadataLabel(action.label, t)}
1573
- onSelect={() => onAction && onAction(action.key, row.original)}
1574
- />
1575
- ))}
1576
- </DropdownMenuContent>
1577
- </DropdownMenu>
1578
- </div>
1544
+ // Primaria destacada, principales y un «Más…» con las
1545
+ // secundarias (compartir/imprimir/correo/chat + las que
1546
+ // aporten addons instalados). Ver row-actions-menu.tsx.
1547
+ <RowActionsMenu
1548
+ actions={resolvedActions.filter((action) => isRowActionVisible(action, row.original, lifecycleStageField(metadata)))}
1549
+ row={row.original}
1550
+ onAction={onAction}
1551
+ />
1579
1552
  ),
1580
1553
  })
1581
1554
  }
@@ -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,18 @@ 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>
105
+ /**
106
+ * Abre el alta guiada al montar («crear desde»): tipo de documento y origen
107
+ * a precargar (`sources[].key` + id), p. ej. leídos de la URL
108
+ * `?create=<tipo>&from=<fuente>&from_id=<id>`. Solo con document_forms.
109
+ */
110
+ initialCreate?: { type?: string; source?: { key: string; id: string } }
99
111
  }
100
112
 
101
113
  /**
@@ -119,6 +131,8 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
119
131
  classes,
120
132
  onChange,
121
133
  documentForms,
134
+ filter,
135
+ initialCreate,
122
136
  } = props
123
137
 
124
138
  const strings = { ...defaultStrings, ...(i18n ?? {}) }
@@ -130,7 +144,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
130
144
 
131
145
  const [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta ?? null)
132
146
  const [refreshKey, setRefreshKey] = useState(0)
133
- const [openCreate, setOpenCreate] = useState(false)
147
+ const [openCreate, setOpenCreate] = useState(!!initialCreate)
134
148
  const [openExport, setOpenExport] = useState(false)
135
149
  const [openImport, setOpenImport] = useState(false)
136
150
 
@@ -189,11 +203,22 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
189
203
  // (useCan defaults to always-true), in which case create/export/import
190
204
  // require `lowercase(model).create|export|import`.
191
205
  const can = useCan()
192
- const showCreate = primary.showGenericCreate && can(modelCapability(model, 'create'))
206
+ const scoped = scopeDocumentFormsToFilter(resolveDocumentForms(metadata, documentForms), filter)
207
+ const guidedForms = scoped.forms
208
+ const showCreate = primary.showGenericCreate && !scoped.hideCreate && can(modelCapability(model, 'create'))
193
209
  const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import'))
194
210
  const showExport = !effectiveHideExport && can(modelCapability(model, 'export'))
195
211
  const showRefresh = !effectiveHideRefresh
196
- const guidedForms = resolveDocumentForms(metadata, documentForms)
212
+
213
+ // El mismo filtro acota la tabla (forma `eq:<valor>` que espera DynamicTable).
214
+ const filterKey = filter ? JSON.stringify(filter) : ''
215
+ const tableFilters = useMemo(() => {
216
+ if (!filter || Object.keys(filter).length === 0) return undefined
217
+ return Object.fromEntries(
218
+ Object.entries(filter).map(([k, v]) => [k, String(v).includes(':') ? String(v) : `eq:${v}`]),
219
+ )
220
+ // eslint-disable-next-line react-hooks/exhaustive-deps
221
+ }, [filterKey])
197
222
 
198
223
  const handleRefresh = useCallback(() => {
199
224
  setRefreshKey((k) => k + 1)
@@ -278,6 +303,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
278
303
  model={model}
279
304
  endpoint={dataEndpoint}
280
305
  refreshTrigger={refreshKey}
306
+ defaultFilters={tableFilters}
281
307
  />
282
308
  </div>
283
309
  </div>
@@ -289,13 +315,15 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
289
315
  model={model}
290
316
  endpoint={dataEndpoint}
291
317
  forms={guidedForms}
318
+ initialType={initialCreate?.type}
319
+ initialSource={initialCreate?.source}
292
320
  onSaved={handleRefresh}
293
321
  />
294
322
  )}
295
323
 
296
324
  {showCreate && !guidedForms && (
297
325
  <DynamicRecordDialog
298
- open={openCreate}
326
+ open={openCreate && !initialCreate?.source}
299
327
  onOpenChange={setOpenCreate}
300
328
  mode='create'
301
329
  model={model}
@@ -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