@asteby/metacore-runtime-react 46.2.2 → 46.3.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 (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/action-modal-dispatcher.js +1 -1
  3. package/dist/business/catalog.d.ts.map +1 -1
  4. package/dist/business/catalog.js +16 -4
  5. package/dist/business/document-lines.d.ts +30 -0
  6. package/dist/business/document-lines.d.ts.map +1 -0
  7. package/dist/business/document-lines.js +65 -0
  8. package/dist/business/index.d.ts +1 -0
  9. package/dist/business/index.d.ts.map +1 -1
  10. package/dist/business/index.js +1 -0
  11. package/dist/business/line-items-editor.d.ts +28 -4
  12. package/dist/business/line-items-editor.d.ts.map +1 -1
  13. package/dist/business/line-items-editor.js +139 -14
  14. package/dist/business/line-items.d.ts +24 -2
  15. package/dist/business/line-items.d.ts.map +1 -1
  16. package/dist/business/line-items.js +50 -5
  17. package/dist/business/product-search.d.ts +4 -0
  18. package/dist/business/product-search.d.ts.map +1 -1
  19. package/dist/business/product-search.js +5 -1
  20. package/dist/dynamic-date-field.d.ts +3 -1
  21. package/dist/dynamic-date-field.d.ts.map +1 -1
  22. package/dist/dynamic-date-field.js +3 -2
  23. package/dist/dynamic-form-schema.d.ts +9 -0
  24. package/dist/dynamic-form-schema.d.ts.map +1 -1
  25. package/dist/dynamic-form-schema.js +61 -0
  26. package/dist/dynamic-line-items.d.ts.map +1 -1
  27. package/dist/dynamic-line-items.js +18 -8
  28. package/dist/dynamic-select-field.d.ts +3 -1
  29. package/dist/dynamic-select-field.d.ts.map +1 -1
  30. package/dist/dynamic-select-field.js +2 -1
  31. package/dist/use-options-resolver.d.ts +5 -5
  32. package/dist/use-options-resolver.d.ts.map +1 -1
  33. package/dist/use-options-resolver.js +11 -0
  34. package/package.json +1 -1
  35. package/src/__tests__/document-lines-grid.test.tsx +79 -0
  36. package/src/__tests__/line-item-formulas.test.ts +18 -1
  37. package/src/__tests__/use-options-resolver.test.ts +5 -0
  38. package/src/action-modal-dispatcher.tsx +1 -1
  39. package/src/business/business.test.ts +43 -0
  40. package/src/business/catalog.ts +16 -4
  41. package/src/business/document-lines.ts +91 -0
  42. package/src/business/index.ts +1 -0
  43. package/src/business/line-items-editor.tsx +365 -46
  44. package/src/business/line-items.ts +65 -7
  45. package/src/business/product-search.ts +9 -1
  46. package/src/dynamic-date-field.tsx +8 -2
  47. package/src/dynamic-form-schema.ts +60 -0
  48. package/src/dynamic-line-items.tsx +23 -3
  49. package/src/dynamic-select-field.tsx +4 -0
  50. 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
  })
@@ -27,6 +27,8 @@ export interface DynamicDateFieldProps {
27
27
  field: ActionFieldDef
28
28
  value: any
29
29
  onChange: (v: any) => void
30
+ /** Server or client validation failed for this field (PIT-046). */
31
+ invalid?: boolean
30
32
  }
31
33
 
32
34
  const toDate = parseCalendarDate
@@ -49,7 +51,7 @@ function label(d: Date | undefined): string {
49
51
  }
50
52
  }
51
53
 
52
- export function DynamicDateField({ field, value, onChange }: DynamicDateFieldProps) {
54
+ export function DynamicDateField({ field, value, onChange, invalid }: DynamicDateFieldProps) {
53
55
  const [open, setOpen] = useState(false)
54
56
  const selected = toDate(value)
55
57
 
@@ -61,7 +63,11 @@ export function DynamicDateField({ field, value, onChange }: DynamicDateFieldPro
61
63
  variant="outline"
62
64
  id={field.key}
63
65
  data-empty={!selected}
64
- className="w-full justify-start text-start font-normal data-[empty=true]:text-muted-foreground"
66
+ aria-invalid={invalid || undefined}
67
+ className={
68
+ 'w-full justify-start text-start font-normal data-[empty=true]:text-muted-foreground' +
69
+ (invalid ? ' border-destructive ring-1 ring-destructive/30' : '')
70
+ }
65
71
  >
66
72
  <CalendarIcon className="mr-2 size-4 shrink-0 opacity-50" />
67
73
  <span className="truncate">
@@ -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
  }
@@ -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
  }