@asteby/metacore-runtime-react 46.2.3 → 46.4.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 (66) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/business/catalog.d.ts.map +1 -1
  3. package/dist/business/catalog.js +16 -4
  4. package/dist/business/document-lines.d.ts +30 -0
  5. package/dist/business/document-lines.d.ts.map +1 -0
  6. package/dist/business/document-lines.js +65 -0
  7. package/dist/business/index.d.ts +1 -0
  8. package/dist/business/index.d.ts.map +1 -1
  9. package/dist/business/index.js +1 -0
  10. package/dist/business/line-items-editor.d.ts +28 -4
  11. package/dist/business/line-items-editor.d.ts.map +1 -1
  12. package/dist/business/line-items-editor.js +139 -14
  13. package/dist/business/line-items.d.ts +24 -2
  14. package/dist/business/line-items.d.ts.map +1 -1
  15. package/dist/business/line-items.js +50 -5
  16. package/dist/business/product-search.d.ts +4 -0
  17. package/dist/business/product-search.d.ts.map +1 -1
  18. package/dist/business/product-search.js +5 -1
  19. package/dist/dynamic-crud-page.d.ts.map +1 -1
  20. package/dist/dynamic-crud-page.js +14 -6
  21. package/dist/dynamic-form-schema.d.ts +9 -0
  22. package/dist/dynamic-form-schema.d.ts.map +1 -1
  23. package/dist/dynamic-form-schema.js +61 -0
  24. package/dist/dynamic-line-items.d.ts.map +1 -1
  25. package/dist/dynamic-line-items.js +18 -8
  26. package/dist/dynamic-select-field.d.ts +3 -1
  27. package/dist/dynamic-select-field.d.ts.map +1 -1
  28. package/dist/dynamic-select-field.js +2 -1
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -0
  32. package/dist/list-primary-action.d.ts +31 -0
  33. package/dist/list-primary-action.d.ts.map +1 -0
  34. package/dist/list-primary-action.js +35 -0
  35. package/dist/model-action-toolbar.d.ts +7 -1
  36. package/dist/model-action-toolbar.d.ts.map +1 -1
  37. package/dist/model-action-toolbar.js +10 -3
  38. package/dist/model-extension-registry.d.ts +7 -0
  39. package/dist/model-extension-registry.d.ts.map +1 -1
  40. package/dist/types.d.ts +14 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/use-options-resolver.d.ts +5 -5
  43. package/dist/use-options-resolver.d.ts.map +1 -1
  44. package/dist/use-options-resolver.js +11 -0
  45. package/package.json +1 -1
  46. package/src/__tests__/document-lines-grid.test.tsx +79 -0
  47. package/src/__tests__/line-item-formulas.test.ts +18 -1
  48. package/src/__tests__/list-primary-action.test.tsx +157 -0
  49. package/src/__tests__/use-options-resolver.test.ts +5 -0
  50. package/src/business/business.test.ts +43 -0
  51. package/src/business/catalog.ts +16 -4
  52. package/src/business/document-lines.ts +91 -0
  53. package/src/business/index.ts +1 -0
  54. package/src/business/line-items-editor.tsx +365 -46
  55. package/src/business/line-items.ts +65 -7
  56. package/src/business/product-search.ts +9 -1
  57. package/src/dynamic-crud-page.tsx +15 -5
  58. package/src/dynamic-form-schema.ts +60 -0
  59. package/src/dynamic-line-items.tsx +23 -3
  60. package/src/dynamic-select-field.tsx +4 -0
  61. package/src/index.ts +8 -0
  62. package/src/list-primary-action.ts +71 -0
  63. package/src/model-action-toolbar.tsx +18 -3
  64. package/src/model-extension-registry.tsx +7 -0
  65. package/src/types.ts +14 -0
  66. package/src/use-options-resolver.ts +15 -0
@@ -16,10 +16,22 @@ export interface LineItem {
16
16
  description: string
17
17
  quantity: number
18
18
  unit_price: number
19
- /** Descuento en porcentaje 0-100. */
19
+ /**
20
+ * Descuento. Porcentaje 0-100 salvo `discount_kind: 'amount'`, donde es importe.
21
+ */
20
22
  discount: number
23
+ /** `percent` (default, se omite al serializar) o `amount`. */
24
+ discount_kind?: 'percent' | 'amount'
21
25
  /** Tasa de impuesto como fracción (0.16). Viene de la config fiscal de la org. */
22
26
  tax_rate: number
27
+ /** Unidad de medida (pza, juego…). Solo se serializa si viene informada. */
28
+ unit?: string
29
+ /** Precio o costo de catálogo, para mostrarlo junto al precio editado. */
30
+ catalog_price?: number
31
+ /** Costo conocido. Aviso si el precio de venta queda por debajo. No se serializa. */
32
+ cost?: number
33
+ /** Tope de cantidad (NC: facturado − acreditado; devolución: vendido − devuelto). */
34
+ max_quantity?: number
23
35
  technician_id?: string
24
36
  lot?: string
25
37
  dot?: string
@@ -37,6 +49,8 @@ export interface LineItemsPolicy {
37
49
  allowNegative?: boolean
38
50
  /** Exige al menos un renglón `item`. Default true. */
39
51
  requireItems?: boolean
52
+ /** Permite cantidad 0 (renglón de origen que no se acredita/devuelve). Default false. */
53
+ allowZeroQuantity?: boolean
40
54
  }
41
55
 
42
56
  let seq = 0
@@ -67,10 +81,16 @@ export interface LineAmounts {
67
81
  total: number
68
82
  }
69
83
 
70
- export function computeLine(l: Pick<LineItem, 'kind' | 'quantity' | 'unit_price' | 'discount' | 'tax_rate'>): LineAmounts {
84
+ export function computeLine(
85
+ l: Pick<LineItem, 'kind' | 'quantity' | 'unit_price' | 'discount' | 'tax_rate'> & { discount_kind?: LineItem['discount_kind'] },
86
+ ): LineAmounts {
71
87
  if (l.kind !== 'item') return { gross: 0, discount: 0, net: 0, tax: 0, total: 0 }
72
88
  const gross = roundMoney(toAmount(l.quantity) * toAmount(l.unit_price))
73
- const discount = roundMoney(gross * (toAmount(l.discount) / 100))
89
+ const raw = toAmount(l.discount)
90
+ const discount =
91
+ l.discount_kind === 'amount'
92
+ ? roundMoney(Math.min(Math.max(raw, 0), Math.max(gross, 0)))
93
+ : roundMoney(gross * (raw / 100))
74
94
  const net = roundMoney(gross - discount)
75
95
  const tax = roundMoney(net * toAmount(l.tax_rate))
76
96
  return { gross, discount, net, tax, total: roundMoney(net + tax) }
@@ -102,6 +122,25 @@ export function computeTotals(lines: LineItem[]): LineItemsTotals {
102
122
  return t
103
123
  }
104
124
 
125
+ /** Impuesto agrupado por tasa, sumando el impuesto ya redondeado de cada renglón. */
126
+ export function taxBreakdown(lines: LineItem[]): Array<{ rate: number; base: number; tax: number }> {
127
+ const map = new Map<number, { base: number; tax: number }>()
128
+ for (const l of lines) {
129
+ if (l.kind !== 'item') continue
130
+ const a = computeLine(l)
131
+ const rate = toAmount(l.tax_rate)
132
+ const cur = map.get(rate) ?? { base: 0, tax: 0 }
133
+ cur.base += a.net
134
+ cur.tax += a.tax
135
+ map.set(rate, cur)
136
+ }
137
+ return [...map.entries()].map(([rate, v]) => ({
138
+ rate,
139
+ base: roundMoney(v.base),
140
+ tax: roundMoney(v.tax),
141
+ }))
142
+ }
143
+
105
144
  export interface LineItemsValidation {
106
145
  /** Errores por celda `"<índice>.<campo>"` (mismo formato que DynamicLineItems). */
107
146
  errors: Record<string, string>
@@ -113,7 +152,7 @@ export interface LineItemsValidation {
113
152
  }
114
153
 
115
154
  export function validateLineItems(lines: LineItem[], policy: LineItemsPolicy = {}): LineItemsValidation {
116
- const { stockPolicy = 'warn', allowNegative = false, requireItems = true } = policy
155
+ const { stockPolicy = 'warn', allowNegative = false, requireItems = true, allowZeroQuantity = false } = policy
117
156
  const errors: Record<string, string> = {}
118
157
  const warnings: Record<string, string> = {}
119
158
  let items = 0
@@ -127,10 +166,22 @@ export function validateLineItems(lines: LineItem[], policy: LineItemsPolicy = {
127
166
  const price = toAmount(l.unit_price)
128
167
  const disc = toAmount(l.discount)
129
168
  if (!l.description.trim() && !l.product_id) errors[`${i}.description`] = 'Elige un producto o escribe una descripción'
130
- if (qty === 0) errors[`${i}.quantity`] = 'La cantidad debe ser distinta de cero'
169
+ if (qty === 0 && !allowZeroQuantity) errors[`${i}.quantity`] = 'La cantidad debe ser distinta de cero'
131
170
  else if (qty < 0 && !allowNegative) errors[`${i}.quantity`] = 'La cantidad no puede ser negativa'
171
+ if (l.max_quantity != null && qty > toAmount(l.max_quantity)) {
172
+ errors[`${i}.quantity`] = `El máximo es ${l.max_quantity}`
173
+ }
132
174
  if (price < 0 && !allowNegative) errors[`${i}.unit_price`] = 'El precio no puede ser negativo'
133
- if (disc < 0 || disc > 100) errors[`${i}.discount`] = 'El descuento debe estar entre 0 y 100%'
175
+ if (l.cost != null && price < toAmount(l.cost)) {
176
+ warnings[`${i}.unit_price`] = 'El precio está por debajo del costo'
177
+ }
178
+ if (l.discount_kind === 'amount') {
179
+ const gross = roundMoney(qty * price)
180
+ if (disc < 0) errors[`${i}.discount`] = 'El descuento no puede ser negativo'
181
+ else if (disc > gross) errors[`${i}.discount`] = 'El descuento no puede superar el importe'
182
+ } else if (disc < 0 || disc > 100) {
183
+ errors[`${i}.discount`] = 'El descuento debe estar entre 0 y 100%'
184
+ }
134
185
  if (l.available != null && qty > l.available && stockPolicy !== 'allow') {
135
186
  const msg = `Solo hay ${l.available} disponibles`
136
187
  if (stockPolicy === 'block') errors[`${i}.quantity`] ??= msg
@@ -157,7 +208,9 @@ export function serializeLineItems(lines: LineItem[]): Array<Record<string, unkn
157
208
  out.unit_price = toAmount(l.unit_price)
158
209
  out.discount = toAmount(l.discount)
159
210
  out.tax_rate = toAmount(l.tax_rate)
160
- for (const k of ['product_id', 'sku', 'technician_id', 'lot', 'dot'] as const) {
211
+ if (l.discount_kind === 'amount') out.discount_kind = 'amount'
212
+ if (l.catalog_price != null) out.catalog_price = toAmount(l.catalog_price)
213
+ for (const k of ['product_id', 'sku', 'technician_id', 'lot', 'dot', 'unit'] as const) {
161
214
  const v = l[k]
162
215
  if (typeof v === 'string' && v.trim()) out[k] = v.trim()
163
216
  }
@@ -181,7 +234,12 @@ export function parseLineItems(rows: unknown): LineItem[] {
181
234
  quantity: o.quantity == null ? 1 : toAmount(o.quantity),
182
235
  unit_price: toAmount(o.unit_price),
183
236
  discount: toAmount(o.discount),
237
+ discount_kind: o.discount_kind === 'amount' ? 'amount' : undefined,
184
238
  tax_rate: toAmount(o.tax_rate),
239
+ unit: str(o.unit),
240
+ catalog_price: o.catalog_price == null || o.catalog_price === '' ? undefined : toAmount(o.catalog_price),
241
+ cost: o.cost == null || o.cost === '' ? undefined : toAmount(o.cost),
242
+ max_quantity: o.max_quantity == null || o.max_quantity === '' ? undefined : toAmount(o.max_quantity),
185
243
  technician_id: str(o.technician_id),
186
244
  lot: str(o.lot),
187
245
  dot: str(o.dot),
@@ -58,6 +58,8 @@ export interface ProductVariant {
58
58
  sku?: string
59
59
  barcode?: string
60
60
  price?: number
61
+ /** Costo de catálogo (compras). Opcional; el precio de lista sigue en `price`. */
62
+ cost?: number
61
63
  stock?: StockByWarehouse[]
62
64
  }
63
65
 
@@ -70,6 +72,8 @@ export interface ProductResult {
70
72
  supplier_sku?: string
71
73
  kind?: 'product' | 'tire' | 'service' | 'bundle'
72
74
  price?: number
75
+ /** Costo de catálogo (compras). */
76
+ cost?: number
73
77
  tax_rate?: number
74
78
  tire?: TireSize
75
79
  variants?: ProductVariant[]
@@ -96,12 +100,16 @@ export function productToLine(
96
100
  opts: { warehouseId?: string; quantity?: number } = {},
97
101
  ): LineItem {
98
102
  const src = variant ?? p
103
+ const list = variant?.price ?? p.price
104
+ const cost = variant?.cost ?? p.cost
99
105
  return makeLine({
100
106
  product_id: variant?.id ?? p.id,
101
107
  sku: variant?.sku ?? p.sku,
102
108
  description: variant ? `${p.name} · ${variant.label}` : p.name,
103
109
  quantity: opts.quantity ?? 1,
104
- unit_price: variant?.price ?? p.price ?? 0,
110
+ unit_price: list ?? 0,
111
+ catalog_price: list,
112
+ cost,
105
113
  tax_rate: p.tax_rate ?? 0,
106
114
  available: availableStock(src, opts.warehouseId) ?? availableStock(p, opts.warehouseId),
107
115
  })
@@ -37,6 +37,7 @@ import { ExportDialog } from './dialogs/export'
37
37
  import { ImportDialog } from './dialogs/import'
38
38
  import { getModelExtension } from './model-extension-registry'
39
39
  import { ModelActionToolbar } from './model-action-toolbar'
40
+ import { resolveListPrimaryAction } from './list-primary-action'
40
41
  import { useCan, modelCapability } from './permissions-context'
41
42
  import type { TableMetadata } from './types'
42
43
 
@@ -159,10 +160,17 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
159
160
  }, [title])
160
161
 
161
162
  const enableCRUD = metadata?.enableCRUDActions ?? false
162
- // A "create"-placement action ships a custom create experience — it
163
- // replaces the generic create button (the ModelActionToolbar renders it).
164
- const hasCreateAction = metadata?.actions?.some((a) => a.placement === 'create') ?? false
165
- const effectiveHideCreate = hideCreate || ext?.hideCreate || hasCreateAction
163
+ // Una sola primaria: placement create, replaces_create, create_mode o la
164
+ // extensión del modelo ocultan el Crear genérico. El shell de ops debe
165
+ // usar el mismo resolveListPrimaryAction.
166
+ const primary = resolveListPrimaryAction({
167
+ enableCRUD,
168
+ canCreate: metadata?.canCreate,
169
+ hideCreate: hideCreate || ext?.hideCreate,
170
+ createMode: ext?.createMode ?? metadata?.create_mode,
171
+ primaryActionKey: ext?.primaryActionKey,
172
+ actions: metadata?.actions,
173
+ })
166
174
  const effectiveHideExport = hideExport || ext?.hideExport
167
175
  const effectiveHideImport = hideImport || ext?.hideImport
168
176
  // Refresh defaults to hidden in the page header — <DynamicTable> ships
@@ -174,7 +182,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
174
182
  // (useCan defaults to always-true), in which case create/export/import
175
183
  // require `lowercase(model).create|export|import`.
176
184
  const can = useCan()
177
- const showCreate = enableCRUD && !effectiveHideCreate && can(modelCapability(model, 'create'))
185
+ const showCreate = primary.showGenericCreate && can(modelCapability(model, 'create'))
178
186
  const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import'))
179
187
  const showExport = !effectiveHideExport && can(modelCapability(model, 'export'))
180
188
  const showRefresh = !effectiveHideRefresh
@@ -239,11 +247,13 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
239
247
  model={model}
240
248
  endpoint={dataEndpoint}
241
249
  actions={metadata?.actions}
250
+ primaryActionKey={ext?.primaryActionKey}
242
251
  onChange={handleRefresh}
243
252
  />
244
253
  {showCreate && (
245
254
  <button
246
255
  type='button'
256
+ data-primary='true'
247
257
  onClick={() => setOpenCreate(true)}
248
258
  className='inline-flex items-center gap-2 h-9 px-3 rounded-md bg-primary text-primary-foreground hover:opacity-90 text-sm font-medium'
249
259
  >
@@ -177,6 +177,66 @@ export function applyLineItemRowFormulas(
177
177
  return out
178
178
  }
179
179
 
180
+ const AUTOFILL_PRICE_KEYS = new Set(['unit_price', 'price', 'sale_price'])
181
+ const AUTOFILL_COST_KEYS = new Set(['cost', 'unit_cost', 'catalog_cost', 'catalog_price'])
182
+
183
+ function metaNumber(meta: Record<string, unknown>, keys: string[]): number | undefined {
184
+ for (const k of keys) {
185
+ const v = meta[k]
186
+ if (typeof v === 'number' && Number.isFinite(v)) return v
187
+ if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v)
188
+ }
189
+ return undefined
190
+ }
191
+
192
+ /**
193
+ * Al elegir un producto en una grilla declarativa, copia precio/costo/SKU/tasa
194
+ * que vengan en `option.meta` a las columnas hermanas y recalcula el importe.
195
+ * No-op si el meta no trae datos útiles (el journal entry no se toca).
196
+ */
197
+ export function autofillLineFromOption(
198
+ itemFields: ActionFieldDef[],
199
+ row: Record<string, any>,
200
+ option: { label?: string; meta?: Record<string, unknown> },
201
+ ): Record<string, any> {
202
+ const meta = option.meta
203
+ if (!meta || Object.keys(meta).length === 0) return row
204
+ const next: Record<string, any> = { ...row }
205
+ let changed = false
206
+ for (const f of itemFields) {
207
+ if (AUTOFILL_PRICE_KEYS.has(f.key)) {
208
+ const v = metaNumber(meta, ['unit_price', 'price', 'sale_price'])
209
+ if (v != null) {
210
+ next[f.key] = v
211
+ changed = true
212
+ }
213
+ } else if (AUTOFILL_COST_KEYS.has(f.key)) {
214
+ const v = metaNumber(meta, ['cost', 'unit_cost', 'catalog_price', 'catalog_cost'])
215
+ if (v != null) {
216
+ next[f.key] = v
217
+ changed = true
218
+ }
219
+ } else if (f.key === 'tax_rate' || f.key === 'tax') {
220
+ const v = metaNumber(meta, ['tax_rate', 'tax'])
221
+ if (v != null) {
222
+ next[f.key] = v
223
+ changed = true
224
+ }
225
+ } else if (f.key === 'sku' || f.key === 'code') {
226
+ const v = meta.sku ?? meta.code
227
+ if (typeof v === 'string' && v.trim()) {
228
+ next[f.key] = v.trim()
229
+ changed = true
230
+ }
231
+ } else if ((f.key === 'description' || f.key === 'name') && !next[f.key] && option.label) {
232
+ next[f.key] = option.label
233
+ changed = true
234
+ }
235
+ }
236
+ if (!changed) return row
237
+ return applyLineItemRowFormulas(itemFields, next)
238
+ }
239
+
180
240
  export interface BalanceState {
181
241
  debit: number
182
242
  credit: number
@@ -26,6 +26,7 @@ import {
26
26
  getItemFields,
27
27
  computeLineItemTotals,
28
28
  applyLineItemRowFormulas,
29
+ autofillLineFromOption,
29
30
  evaluateBalance,
30
31
  toNumber,
31
32
  getDependsOn,
@@ -139,6 +140,16 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
139
140
  updateCell(idx, key, cellValue)
140
141
  }
141
142
 
143
+ const handlePick = (idx: number, key: string, opt: ResolvedOption) => {
144
+ onChange(
145
+ rows.map((r, i) =>
146
+ i === idx
147
+ ? autofillLineFromOption(itemFields, { ...r, [key]: String(opt.id) }, opt)
148
+ : r,
149
+ ),
150
+ )
151
+ }
152
+
142
153
  const totalCols = itemFields.filter((c) => c.total)
143
154
 
144
155
  return (
@@ -189,6 +200,7 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
189
200
  field={col}
190
201
  value={row?.[col.key]}
191
202
  onChange={(v: any) => handleCell(idx, col.key, v)}
203
+ onPick={(opt) => handlePick(idx, col.key, opt)}
192
204
  disabled={disabled}
193
205
  formValues={formValues}
194
206
  rowValues={row}
@@ -260,6 +272,7 @@ export function DynamicLineItems({ field, value, onChange, disabled = false, for
260
272
  field={col}
261
273
  value={row?.[col.key]}
262
274
  onChange={(v: any) => handleCell(idx, col.key, v)}
275
+ onPick={(opt) => handlePick(idx, col.key, opt)}
263
276
  disabled={disabled}
264
277
  formValues={formValues}
265
278
  rowValues={row}
@@ -377,6 +390,7 @@ interface CellRendererProps {
377
390
  field: ActionFieldDef
378
391
  value: any
379
392
  onChange: (v: any) => void
393
+ onPick?: (opt: ResolvedOption) => void
380
394
  disabled?: boolean
381
395
  /** Header form values — for resolving a cell's `dependsOn` to a header field. */
382
396
  formValues?: Record<string, any>
@@ -390,7 +404,7 @@ interface CellRendererProps {
390
404
  // without the per-field Label (the column header is the label) and sized for a
391
405
  // table cell. Nested line-items inside a row are not supported (a row column is
392
406
  // a scalar widget).
393
- function CellRenderer({ field, value, onChange, disabled, formValues, rowValues, invalid }: CellRendererProps) {
407
+ function CellRenderer({ field, value, onChange, onPick, disabled, formValues, rowValues, invalid }: CellRendererProps) {
394
408
  const widget = resolveWidget(field)
395
409
  const invalidCls = invalid
396
410
  ? 'border-destructive ring-1 ring-destructive/30 focus-visible:ring-destructive'
@@ -434,6 +448,7 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues,
434
448
  field={field}
435
449
  value={value}
436
450
  onChange={onChange}
451
+ onPick={onPick}
437
452
  dependsValue={dependsValue}
438
453
  readonly={ro}
439
454
  invalid={invalid}
@@ -446,6 +461,7 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues,
446
461
  field={field}
447
462
  value={value}
448
463
  onChange={onChange}
464
+ onPick={onPick}
449
465
  disabled={disabled}
450
466
  formValues={formValues}
451
467
  rowValues={rowValues}
@@ -541,7 +557,7 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues,
541
557
  }
542
558
  }
543
559
 
544
- function RefCell({ field, value, onChange, disabled, formValues, rowValues }: CellRendererProps) {
560
+ function RefCell({ field, value, onChange, onPick, disabled, formValues, rowValues }: CellRendererProps) {
545
561
  // Cascade: resolve the value of the field this cell `dependsOn` from the
546
562
  // row (sibling) first, then the header form. While empty, the picker is
547
563
  // disabled with a hint instead of listing the whole (unscoped) table.
@@ -581,7 +597,11 @@ function RefCell({ field, value, onChange, disabled, formValues, rowValues }: Ce
581
597
  return (
582
598
  <Select
583
599
  value={value || ''}
584
- onValueChange={onChange}
600
+ onValueChange={(v) => {
601
+ onChange(v)
602
+ const opt = options.find((o) => String(o.id) === String(v))
603
+ if (opt) onPick?.(opt)
604
+ }}
585
605
  disabled={disabled || loading || blockedByDependency}
586
606
  >
587
607
  <SelectTrigger className="w-full" data-depends-blocked={blockedByDependency ? '' : undefined}>
@@ -149,6 +149,8 @@ export interface DynamicSelectFieldProps {
149
149
  field: ActionFieldDef
150
150
  value: any
151
151
  onChange: (v: any) => void
152
+ /** Opción completa recién elegida (incluye `meta` para autollenar columnas hermanas). */
153
+ onPick?: (opt: ResolvedOption) => void
152
154
  /**
153
155
  * Pre-resolved option for the CURRENT value (label + image/color/icon) the
154
156
  * caller already has — e.g. the relation sibling the table served. Lets the
@@ -212,6 +214,7 @@ export function DynamicSelectField({
212
214
  field,
213
215
  value,
214
216
  onChange,
217
+ onPick,
215
218
  seedOption,
216
219
  dependsValue,
217
220
  dependsHint,
@@ -327,6 +330,7 @@ export function DynamicSelectField({
327
330
  const handlePick = (opt: ResolvedOption) => {
328
331
  setPicked(opt)
329
332
  onChange(String(opt.id))
333
+ onPick?.(opt)
330
334
  setOpen(false)
331
335
  setSearch('')
332
336
  }
package/src/index.ts CHANGED
@@ -188,6 +188,14 @@ export {
188
188
  type ModelActionToolbarProps,
189
189
  type ActionPlacement,
190
190
  } from './model-action-toolbar'
191
+ export {
192
+ resolveListPrimaryAction,
193
+ actionReplacesCreate,
194
+ type ListPrimaryActionInput,
195
+ type ListPrimaryActionResult,
196
+ type ListActionRef,
197
+ type CreateMode,
198
+ } from './list-primary-action'
191
199
  export * from './addon-loader'
192
200
  export {
193
201
  PURGE_ADDON_MESSAGE,
@@ -0,0 +1,71 @@
1
+ // Una sola acción primaria de alta por pantalla (UX-1).
2
+ // Puro: DynamicCRUDPage y ModelActionToolbar lo usan, y el shell de ops
3
+ // (`/m/$model`) debe llamarlo también para no pintar un segundo «Crear».
4
+
5
+ export type CreateMode = 'generic' | 'hidden' | 'action'
6
+
7
+ export interface ListActionRef {
8
+ key: string
9
+ placement?: string
10
+ /** El kernel sirve snake_case. CamelCase se acepta por si el host ya lo normalizó. */
11
+ replaces_create?: boolean
12
+ replacesCreate?: boolean
13
+ }
14
+
15
+ export interface ListPrimaryActionInput {
16
+ /** `metadata.enableCRUDActions`. Sin él no hay Crear genérico. */
17
+ enableCRUD?: boolean
18
+ /** `metadata.canCreate`. `false` oculta el genérico; `undefined` no cambia nada. */
19
+ canCreate?: boolean
20
+ hideCreate?: boolean
21
+ createMode?: CreateMode
22
+ /** Si apunta a una acción de toolbar, esa es la primaria y oculta el genérico. */
23
+ primaryActionKey?: string
24
+ actions?: ListActionRef[]
25
+ }
26
+
27
+ export interface ListPrimaryActionResult {
28
+ showGenericCreate: boolean
29
+ primaryAction?: ListActionRef
30
+ /** Acciones de toolbar que no son la primaria (siguen visibles, en secundario). */
31
+ secondaryActions: ListActionRef[]
32
+ /** Más de una acción reclama ser la primaria. Solo una se usa. */
33
+ conflict: boolean
34
+ }
35
+
36
+ /** `placement: create`, o el flag declarativo para una acción que hoy es `table`. */
37
+ export function actionReplacesCreate(action: ListActionRef): boolean {
38
+ return action.placement === 'create' || action.replaces_create === true || action.replacesCreate === true
39
+ }
40
+
41
+ function isToolbar(action: ListActionRef): boolean {
42
+ const placement = action.placement ?? 'row'
43
+ return placement === 'table' || placement === 'create' || actionReplacesCreate(action)
44
+ }
45
+
46
+ export function resolveListPrimaryAction(input: ListPrimaryActionInput): ListPrimaryActionResult {
47
+ const actions = input.actions ?? []
48
+ const claimants = actions.filter(actionReplacesCreate)
49
+
50
+ let primary: ListActionRef | undefined
51
+ if (input.primaryActionKey) {
52
+ const keyed = actions.find((a) => a.key === input.primaryActionKey)
53
+ if (keyed && isToolbar(keyed)) primary = keyed
54
+ }
55
+ if (!primary && claimants.length > 0) primary = claimants[0]
56
+
57
+ const hideGeneric =
58
+ input.hideCreate === true ||
59
+ input.canCreate === false ||
60
+ input.createMode === 'hidden' ||
61
+ input.createMode === 'action' ||
62
+ input.enableCRUD !== true ||
63
+ primary != null
64
+
65
+ return {
66
+ showGenericCreate: !hideGeneric,
67
+ primaryAction: primary,
68
+ secondaryActions: actions.filter((a) => isToolbar(a) && a.key !== primary?.key),
69
+ conflict: claimants.length > 1,
70
+ }
71
+ }
@@ -32,6 +32,7 @@ import {
32
32
  useRequiresAddonLabel,
33
33
  } from './requires-addon'
34
34
  import type { ActionDefinition, ActionMetadata, RequiresAddon, TableMetadata } from './types'
35
+ import { resolveListPrimaryAction } from './list-primary-action'
35
36
 
36
37
  export type ActionPlacement = 'row' | 'table' | 'create'
37
38
 
@@ -58,6 +59,12 @@ export interface ModelActionToolbarProps {
58
59
  * lazy load without blocking first paint of the list page.
59
60
  */
60
61
  onActionIntent?: (action: ActionDefinition) => void
62
+ /**
63
+ * Acción que queda como único botón primario (`data-primary`). El resto de
64
+ * las que reclaman el alta bajan a `outline`. Lo resuelve igual
65
+ * `resolveListPrimaryAction`.
66
+ */
67
+ primaryActionKey?: string
61
68
  }
62
69
 
63
70
  const DEFAULT_PLACEMENTS: ActionPlacement[] = ['table', 'create']
@@ -139,6 +146,7 @@ export function ModelActionToolbar({
139
146
  onChange,
140
147
  className,
141
148
  onActionIntent,
149
+ primaryActionKey,
142
150
  }: ModelActionToolbarProps) {
143
151
  const { t } = useTranslation()
144
152
  const all = useModelActions(model, placements, actions)
@@ -153,6 +161,11 @@ export function ModelActionToolbar({
153
161
  const [missingAddon, setMissingAddon] = useState<RequiresAddon | null>(null)
154
162
  const requiresAddonLabel = useRequiresAddonLabel()
155
163
  const dataEndpoint = endpoint ?? `/data/${model}/me`
164
+ const primary = resolveListPrimaryAction({
165
+ enableCRUD: true,
166
+ primaryActionKey,
167
+ actions: surfaced,
168
+ })
156
169
 
157
170
  if (surfaced.length === 0) return null
158
171
 
@@ -160,16 +173,18 @@ export function ModelActionToolbar({
160
173
  <>
161
174
  <div className={className ?? 'flex items-center gap-2'}>
162
175
  {surfaced.map((a) => {
163
- const isCreate = (a.placement ?? 'row') === 'create'
176
+ const isPrimary = primary.primaryAction?.key === a.key
177
+ const isCreate = isPrimary || (a.placement ?? 'row') === 'create'
164
178
  // Locked while its optional addon is missing: still shown,
165
179
  // but a click explains + offers install instead of running.
166
180
  const requirement = resolveRequiresAddon(a)
167
181
  return (
168
182
  <Button
169
183
  key={a.key}
170
- variant={isCreate ? 'default' : 'outline'}
184
+ variant={isPrimary ? 'default' : 'outline'}
185
+ data-primary={isPrimary ? 'true' : undefined}
171
186
  data-aby-target={
172
- isCreate
187
+ isPrimary
173
188
  ? `model.${model}.create`
174
189
  : `model.${model}.action.${a.key}`
175
190
  }
@@ -24,6 +24,13 @@ export interface ModelExtension {
24
24
  headerExtras?: React.ComponentType<ModelExtensionProps>
25
25
  toolbarExtras?: React.ComponentType<ModelExtensionProps>
26
26
  hideCreate?: boolean
27
+ /**
28
+ * `hidden` quita el Crear genérico. `action` también, porque la acción del
29
+ * módulo (ver `primaryActionKey`) es la alta. `generic` lo deja.
30
+ */
31
+ createMode?: 'generic' | 'hidden' | 'action'
32
+ /** Acción de toolbar que queda como único botón primario y oculta el Crear genérico. */
33
+ primaryActionKey?: string
27
34
  hideExport?: boolean
28
35
  hideImport?: boolean
29
36
  hideRefresh?: boolean
package/src/types.ts CHANGED
@@ -36,6 +36,13 @@ export interface TableMetadata {
36
36
  canExport?: boolean
37
37
  canImport?: boolean
38
38
  canCreate?: boolean
39
+ /**
40
+ * Cómo se pinta el alta genérica. `generic` (o ausente) es el botón Crear.
41
+ * `hidden` lo quita. `action` lo quita porque el módulo trae la suya
42
+ * (`placement: create` o `replaces_create`). Lo sirve el kernel; un host
43
+ * también puede fijarlo con `ModelExtension.createMode` sin cambio de kernel.
44
+ */
45
+ create_mode?: 'generic' | 'hidden' | 'action'
39
46
  /**
40
47
  * The model's spreadsheet-import declaration, served by the kernel: the
41
48
  * columns of the generated template and the headers accepted when parsing
@@ -702,6 +709,13 @@ export interface ActionDefinition {
702
709
  * "create" — toolbar button that replaces the generic create button.
703
710
  */
704
711
  placement?: 'row' | 'table' | 'create'
712
+ /**
713
+ * Esta acción de toolbar sustituye al Crear genérico aunque su `placement`
714
+ * sea `table`. El kernel tiene que reenviar el campo; si lo recorta, el
715
+ * host usa `ModelExtension.primaryActionKey` o `createMode`.
716
+ */
717
+ replaces_create?: boolean
718
+ replacesCreate?: boolean
705
719
  /**
706
720
  * Optional addon this action depends on, stamped by the host only while it
707
721
  * is NOT installed (served as `requires_addon`; read through
@@ -27,6 +27,11 @@ export interface ResolvedOption {
27
27
  image?: string | null
28
28
  color?: string | null
29
29
  icon?: string | null
30
+ /**
31
+ * Campos extra del payload (precio, costo, sku, tasa) que no son decoración.
32
+ * Solo se llena cuando el option trae números o strings además de id/label.
33
+ */
34
+ meta?: Record<string, unknown>
30
35
  }
31
36
 
32
37
  export interface OptionsMeta {
@@ -331,9 +336,18 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
331
336
  * id/value and label/name fields for legacy parity — we accept either
332
337
  * and surface a stable shape downstream.
333
338
  */
339
+ const OPTION_KNOWN_KEYS = new Set(['id', 'value', 'label', 'name', 'description', 'image', 'color', 'icon'])
340
+
334
341
  export function projectOption(raw: any): ResolvedOption {
335
342
  const id = raw?.id ?? raw?.value ?? ''
336
343
  const label = String(raw?.label ?? raw?.name ?? id ?? '')
344
+ const meta: Record<string, unknown> = {}
345
+ if (raw && typeof raw === 'object') {
346
+ for (const [k, v] of Object.entries(raw)) {
347
+ if (OPTION_KNOWN_KEYS.has(k)) continue
348
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') meta[k] = v
349
+ }
350
+ }
337
351
  return {
338
352
  id,
339
353
  value: raw?.value ?? id,
@@ -343,5 +357,6 @@ export function projectOption(raw: any): ResolvedOption {
343
357
  image: raw?.image ?? null,
344
358
  color: raw?.color ?? null,
345
359
  icon: raw?.icon ?? null,
360
+ ...(Object.keys(meta).length > 0 ? { meta } : {}),
346
361
  }
347
362
  }