@asteby/metacore-runtime-react 49.8.0 → 49.9.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 (41) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/business/document-editor.d.ts.map +1 -1
  3. package/dist/business/document-editor.js +5 -1
  4. package/dist/business/index.d.ts +1 -0
  5. package/dist/business/index.d.ts.map +1 -1
  6. package/dist/business/index.js +1 -0
  7. package/dist/business/product-display.d.ts +18 -0
  8. package/dist/business/product-display.d.ts.map +1 -0
  9. package/dist/business/product-display.js +40 -0
  10. package/dist/business/product-options.d.ts +6 -1
  11. package/dist/business/product-options.d.ts.map +1 -1
  12. package/dist/business/product-options.js +10 -1
  13. package/dist/business/product-search.d.ts +8 -0
  14. package/dist/business/product-search.d.ts.map +1 -1
  15. package/dist/dynamic-select-field.d.ts +7 -1
  16. package/dist/dynamic-select-field.d.ts.map +1 -1
  17. package/dist/dynamic-select-field.js +18 -4
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/option-display.d.ts +68 -0
  22. package/dist/option-display.d.ts.map +1 -0
  23. package/dist/option-display.js +135 -0
  24. package/dist/query-batch.d.ts +1 -1
  25. package/dist/query-batch.d.ts.map +1 -1
  26. package/dist/query-batch.js +3 -1
  27. package/dist/use-options-resolver.d.ts +16 -1
  28. package/dist/use-options-resolver.d.ts.map +1 -1
  29. package/dist/use-options-resolver.js +38 -6
  30. package/package.json +1 -1
  31. package/src/__tests__/option-display.test.tsx +241 -0
  32. package/src/business/document-editor.tsx +11 -2
  33. package/src/business/index.ts +1 -0
  34. package/src/business/product-display.ts +62 -0
  35. package/src/business/product-options.tsx +10 -1
  36. package/src/business/product-search.ts +8 -0
  37. package/src/dynamic-select-field.tsx +45 -1
  38. package/src/index.ts +10 -0
  39. package/src/option-display.tsx +255 -0
  40. package/src/query-batch.ts +2 -0
  41. package/src/use-options-resolver.ts +49 -4
@@ -0,0 +1,62 @@
1
+ // withOptionDisplays — completes a product search with the DECLARATIVE option
2
+ // display of the catalog model (manifest v3 `option_display`): after the search
3
+ // answers, ONE `GET /options/<model>?field=id&ids=…` resolves the display of
4
+ // exactly the products found (price, stock contributed by inventory with its
5
+ // tone — in the document's warehouse when `ctx.warehouse_id` is given —,
6
+ // badges) and attaches it to each result. The search keeps owning the business
7
+ // data (price, tax, unit, fiscal keys the line needs); the display only paints
8
+ // the row. A host without the endpoint / model without display → results
9
+ // unchanged (never fails the search).
10
+ import type { OptionDisplayData } from '../option-display'
11
+ import type { ProductResult } from './product-search'
12
+
13
+ interface ApiLike {
14
+ get: (url: string, cfg?: { params?: Record<string, unknown>; signal?: AbortSignal }) => Promise<{ data: any }>
15
+ }
16
+
17
+ export interface OptionDisplaysOptions {
18
+ /** Catalog model the options endpoint resolves (e.g. `products.Product`). */
19
+ model: string
20
+ /** Picker context (`ctx.<key>`), e.g. `{ warehouse_id }`. Empty values are dropped. */
21
+ context?: Record<string, string | null | undefined>
22
+ }
23
+
24
+ /** Max ids per lookup (kernel MaxOptionsIDs). */
25
+ const MAX_IDS = 100
26
+
27
+ export function withOptionDisplays<Q>(
28
+ search: (query: Q, signal: AbortSignal) => Promise<ProductResult[]>,
29
+ api: ApiLike,
30
+ opts: OptionDisplaysOptions,
31
+ ): (query: Q, signal: AbortSignal) => Promise<ProductResult[]> {
32
+ const ctx: Record<string, string> = {}
33
+ for (const [k, v] of Object.entries(opts.context ?? {})) {
34
+ if (v != null && String(v).trim()) ctx[`ctx.${k}`] = String(v).trim()
35
+ }
36
+ return async (query, signal) => {
37
+ const rows = await search(query, signal)
38
+ const ids = [...new Set(rows.filter((r) => !r.display && r.id).map((r) => r.id))].slice(0, MAX_IDS)
39
+ if (ids.length === 0) return rows
40
+ let byId: Map<string, OptionDisplayData>
41
+ try {
42
+ const res = await api.get(`/options/${opts.model}`, { params: { field: 'id', ids: ids.join(','), ...ctx }, signal })
43
+ const data: unknown = res?.data?.data
44
+ byId = new Map()
45
+ if (Array.isArray(data)) {
46
+ for (const o of data) {
47
+ const d = o && typeof o === 'object' ? (o as { display?: unknown }).display : null
48
+ const id = o && typeof o === 'object' ? (o as { id?: unknown; value?: unknown }).id ?? (o as { value?: unknown }).value : null
49
+ if (d && typeof d === 'object' && id != null) byId.set(String(id).toLowerCase(), d as OptionDisplayData)
50
+ }
51
+ }
52
+ } catch {
53
+ if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
54
+ return rows
55
+ }
56
+ if (byId.size === 0) return rows
57
+ return rows.map((r) => {
58
+ const d = byId.get(String(r.id).toLowerCase())
59
+ return d && !r.display ? { ...r, display: d } : r
60
+ })
61
+ }
62
+ }
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'
8
8
  import { Badge } from '@asteby/metacore-ui/primitives'
9
9
  import { useLatestSearch, useRecordSearch } from '../record-picker'
10
10
  import { useFormatter } from './format'
11
+ import { OptionDisplayRow } from '../option-display'
11
12
  import {
12
13
  availableStock,
13
14
  parseProductQuery,
@@ -102,8 +103,16 @@ export function ProductHitTrailing({
102
103
  )
103
104
  }
104
105
 
105
- /** Option body: bold name (· variant), muted meta line, stock/price right. */
106
+ /**
107
+ * Option body: bold name (· variant), muted meta line, stock/price right. A
108
+ * product whose catalog declares an `option_display` (the server resolved it,
109
+ * see `withOptionDisplays`) paints that row instead: avatar, two-line title,
110
+ * subtitle and toned metrics (price + stock contributed by inventory).
111
+ */
106
112
  export function ProductHitRow({ hit, warehouseId, currency, compact }: { hit: ProductHit; warehouseId?: string; currency?: string; compact?: boolean }) {
113
+ if (hit.product.display && !hit.variant) {
114
+ return <OptionDisplayRow display={hit.product.display} label={productHitLabel(hit)} currency={currency} />
115
+ }
107
116
  const meta = productHitMeta(hit)
108
117
  return (
109
118
  <span className="flex w-full min-w-0 items-center justify-between gap-3">
@@ -4,6 +4,7 @@
4
4
  // la entrada para que el backend/addon elija el índice correcto.
5
5
  import type { LineItem } from './line-items'
6
6
  import { makeLine } from './line-items'
7
+ import type { OptionDisplayData } from '../option-display'
7
8
 
8
9
  export interface TireSize {
9
10
  width: number
@@ -84,6 +85,13 @@ export interface ProductResult {
84
85
  unit?: string
85
86
  /** Campos de extensión del producto (fiscal_data.*) que se copian al renglón. */
86
87
  extensions?: Record<string, string>
88
+ /**
89
+ * Presentación declarativa del catálogo (manifest `option_display` del
90
+ * modelo de producto): la fila del buscador la pinta tal cual — precio,
91
+ * existencia aportada por inventory con su tono, badges. Ver
92
+ * `withOptionDisplays`.
93
+ */
94
+ display?: OptionDisplayData | null
87
95
  }
88
96
 
89
97
  /** Existencia disponible: de la variante o del producto, en un almacén o en total. */
@@ -27,10 +27,11 @@ import { useEffect, useRef, useState } from 'react'
27
27
  import { getOptionFilter } from './option-filter'
28
28
  import { useTranslation } from 'react-i18next'
29
29
  import { Badge, Button } from '@asteby/metacore-ui/primitives'
30
- import { ScanLine } from 'lucide-react'
30
+ import { Check, ScanLine } from 'lucide-react'
31
31
  import { BarcodeScanner } from './barcode-scanner'
32
32
  import { RecordPicker } from './record-picker'
33
33
  import { OptionLead } from './record-picker-option'
34
+ import { OptionDisplayRow, OptionDisplayValue, getOptionDisplay, hasOptionDisplays } from './option-display'
34
35
  import { recordLabel, requestRecordCreate, requestRecordEdit, withSearchPrefill } from './record-picker-actions'
35
36
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
36
37
  import { useResolveOptionIds } from './use-option-ids'
@@ -114,6 +115,12 @@ export interface DynamicSelectFieldProps {
114
115
  createLockedFields?: string[]
115
116
  /** Paint trigger with destructive border when validation failed. */
116
117
  invalid?: boolean
118
+ /**
119
+ * Picker context for the declarative option display's contributed
120
+ * metrics (`?ctx.<key>=`), e.g. `{ warehouse_id }` of the document being
121
+ * edited so the product stock is the one of that warehouse.
122
+ */
123
+ optionsContext?: Record<string, string | null | undefined>
117
124
  }
118
125
 
119
126
  /**
@@ -137,6 +144,7 @@ export function DynamicSelectField({
137
144
  createDefaults,
138
145
  createLockedFields,
139
146
  invalid = false,
147
+ optionsContext,
140
148
  }: DynamicSelectFieldProps) {
141
149
  const { t } = useTranslation()
142
150
  const ph = (fallback: string) =>
@@ -186,6 +194,7 @@ export function DynamicSelectField({
186
194
  filterValue: dependsOn ? scope : undefined,
187
195
  optionFilter,
188
196
  keepValue: value,
197
+ context: optionsContext,
189
198
  // Fetch only while open (a held value's label comes from the `?ids=`
190
199
  // lookup below, readonly included). Blocked cascades and static lists
191
200
  // never fetch.
@@ -292,6 +301,10 @@ export function DynamicSelectField({
292
301
 
293
302
  const canMutate = !!fieldRef && !hideCreate && !useStatic && !blockedByDependency
294
303
  const fieldName = field.label ? t(field.label, { defaultValue: field.label }) : fieldRef ?? ''
304
+ // Declarative option display (manifest `option_display`): rich rows with
305
+ // avatar, two-line title, subtitle and toned metrics (price, stock…).
306
+ const richRows = hasOptionDisplays(options)
307
+ const selectedDisplay = getOptionDisplay(selectedOption)
295
308
  const badge = (opt: ResolvedOption) =>
296
309
  opt.description ? (
297
310
  <Badge variant="secondary" className="shrink-0 font-normal tabular-nums">
@@ -320,12 +333,43 @@ export function DynamicSelectField({
320
333
  readOnly={readonly}
321
334
  invalid={invalid}
322
335
  renderLead={(o, where) => <OptionLead option={o} size={where === 'option' ? 24 : 20} />}
336
+ renderItem={
337
+ richRows
338
+ ? (o, st) => {
339
+ const d = getOptionDisplay(o)
340
+ return (
341
+ <>
342
+ {d ? (
343
+ <OptionDisplayRow display={d} label={o.label} active={st.active} selected={st.selected} />
344
+ ) : (
345
+ <span className="flex w-full min-w-0 items-center gap-3">
346
+ <OptionLead option={o} size={32} />
347
+ <span className="flex min-w-0 flex-1 flex-col">
348
+ <span className="line-clamp-2 text-sm font-medium">{o.label}</span>
349
+ {o.description ? <span className="truncate text-xs text-muted-foreground">{o.description}</span> : null}
350
+ </span>
351
+ </span>
352
+ )}
353
+ <Check
354
+ className={'size-4 shrink-0 text-primary ' + (st.selected ? 'opacity-100' : 'opacity-0')}
355
+ aria-hidden
356
+ />
357
+ </>
358
+ )
359
+ }
360
+ : undefined
361
+ }
362
+ isItemDisabled={richRows ? (o) => !!getOptionDisplay(o)?.blocked : undefined}
363
+ minListWidth={richRows ? '26rem' : undefined}
323
364
  getDescription={descriptionAsBadge ? undefined : (o) => o.description}
324
365
  renderTrailing={descriptionAsBadge ? (o) => badge(o) : undefined}
325
366
  renderValue={() => {
326
367
  // Never flash the raw id while the label resolves.
327
368
  if (valueResolving) return resolvingNode
328
369
  if (valueMissing) return missingNode
370
+ if (selectedDisplay && value && !blockedByDependency) {
371
+ return <OptionDisplayValue display={selectedDisplay} label={selectedOption?.label} />
372
+ }
329
373
  if (readonly) {
330
374
  return (
331
375
  <>
package/src/index.ts CHANGED
@@ -502,6 +502,16 @@ export type { RecordPickerActionProps, RecordCreateRequest, RecordEditRequest }
502
502
  export { useRecordPickerDialog } from './record-picker-dialog'
503
503
  export type { UseRecordPickerDialogOptions, UseRecordPickerDialogResult } from './record-picker-dialog'
504
504
  export { OptionLead, OptionThumb } from './record-picker-option'
505
+ export {
506
+ OptionDisplayRow,
507
+ OptionDisplayValue,
508
+ getOptionDisplay,
509
+ hasOptionDisplays,
510
+ formatTrailingValue,
511
+ formatRelativeDate,
512
+ toneVariant,
513
+ } from './option-display'
514
+ export type { OptionDisplayData, OptionTrailingItem, OptionBadgeItem, OptionDisplayTone, OptionDisplayRowProps } from './option-display'
505
515
  export { ViewRecordDialog } from './dialogs/view-record-dialog'
506
516
  export type {
507
517
  ModelKey,
@@ -0,0 +1,255 @@
1
+ // OptionDisplay — paints the declarative option display the kernel resolves for
2
+ // every option of a model with `option_display` (manifest v3): avatar, title on
3
+ // up to two lines, muted subtitle, badges, and right-aligned metrics (price,
4
+ // stock…) with tabular numbers and a toned chip («Agotado» in danger, low
5
+ // stock in warning). The server already evaluated formats' inputs, tones,
6
+ // dim/block — this file only formats and paints, so every picker of the model
7
+ // (dynamic_select, the document editor's product cell, an addon screen) reads
8
+ // the same.
9
+ //
10
+ // It plugs into <RecordPicker> through its existing slots (`renderItem`,
11
+ // `renderValue`, `isItemDisabled`), never by editing the picker itself. An
12
+ // option without `display` keeps rendering exactly as before.
13
+ import { useTranslation } from 'react-i18next'
14
+ import { Badge } from '@asteby/metacore-ui/primitives'
15
+ import { useFormatter, type Formatter } from './business/format'
16
+ import { OptionThumb } from './record-picker-option'
17
+
18
+ export type OptionDisplayTone = 'success' | 'warning' | 'danger' | 'info' | 'neutral'
19
+
20
+ /** One resolved right-aligned metric (`display.trailing[]`). */
21
+ export interface OptionTrailingItem {
22
+ key: string
23
+ label?: string
24
+ value: unknown
25
+ format?: 'money' | 'number' | 'integer' | 'percent' | 'date' | 'relative_date' | 'text' | string
26
+ currency?: string
27
+ tone?: OptionDisplayTone | string
28
+ /** Replaces the formatted value («Agotado»). */
29
+ text?: string
30
+ }
31
+
32
+ export interface OptionBadgeItem {
33
+ text: string
34
+ tone?: OptionDisplayTone | string
35
+ }
36
+
37
+ /** The `display` object of an option (kernel `OptionDisplayValue`). */
38
+ export interface OptionDisplayData {
39
+ title?: string
40
+ subtitle?: string
41
+ image?: string | null
42
+ trailing?: OptionTrailingItem[]
43
+ badges?: OptionBadgeItem[]
44
+ tone?: OptionDisplayTone | string
45
+ /** Attenuated row (still selectable). */
46
+ dimmed?: boolean
47
+ /** Not selectable. */
48
+ blocked?: boolean
49
+ }
50
+
51
+ /** Reads a well-formed display off any option-like value (null when absent). */
52
+ export function getOptionDisplay(option: unknown): OptionDisplayData | null {
53
+ const d = option && typeof option === 'object' ? (option as { display?: unknown }).display : null
54
+ return d && typeof d === 'object' ? (d as OptionDisplayData) : null
55
+ }
56
+
57
+ type BadgeVariant = 'success' | 'warning' | 'danger' | 'info' | 'muted'
58
+ const TONE_VARIANT: Record<string, BadgeVariant> = {
59
+ success: 'success',
60
+ warning: 'warning',
61
+ danger: 'danger',
62
+ info: 'info',
63
+ neutral: 'muted',
64
+ }
65
+ export const toneVariant = (tone?: string): BadgeVariant => TONE_VARIANT[tone ?? ''] ?? 'muted'
66
+
67
+ const asNumber = (v: unknown): number | null => {
68
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null
69
+ if (typeof v === 'string' && v.trim() !== '') {
70
+ const n = Number(v)
71
+ return Number.isFinite(n) ? n : null
72
+ }
73
+ return null
74
+ }
75
+
76
+ const RELATIVE_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
77
+ ['year', 365 * 86400],
78
+ ['month', 30 * 86400],
79
+ ['week', 7 * 86400],
80
+ ['day', 86400],
81
+ ['hour', 3600],
82
+ ['minute', 60],
83
+ ]
84
+
85
+ /** «hace 3 días» / «en 2 semanas» in the formatter's locale. */
86
+ export function formatRelativeDate(input: unknown, locale: string, now: Date = new Date()): string {
87
+ if (input == null || input === '') return ''
88
+ const d = new Date(input as string)
89
+ if (Number.isNaN(d.getTime())) return String(input)
90
+ const secs = (d.getTime() - now.getTime()) / 1000
91
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
92
+ for (const [unit, size] of RELATIVE_UNITS) {
93
+ if (Math.abs(secs) >= size) return rtf.format(Math.round(secs / size), unit)
94
+ }
95
+ return rtf.format(0, 'minute')
96
+ }
97
+
98
+ /** Formats one trailing value per its declared format (org currency for money). */
99
+ export function formatTrailingValue(item: OptionTrailingItem, fmt: Formatter, now?: Date): string {
100
+ const v = item.value
101
+ if (v == null || v === '') return ''
102
+ switch (item.format) {
103
+ case 'money':
104
+ return fmt.money(v, item.currency || undefined)
105
+ case 'number':
106
+ return fmt.quantity(v)
107
+ case 'integer': {
108
+ const n = asNumber(v)
109
+ return n == null ? String(v) : new Intl.NumberFormat(fmt.locale, { maximumFractionDigits: 0 }).format(n)
110
+ }
111
+ case 'percent': {
112
+ const n = asNumber(v)
113
+ if (n == null) return String(v)
114
+ // 0.16 and 16 both mean 16 %.
115
+ const frac = Math.abs(n) <= 1 ? n : n / 100
116
+ return new Intl.NumberFormat(fmt.locale, { style: 'percent', maximumFractionDigits: 1 }).format(frac)
117
+ }
118
+ case 'date':
119
+ return fmt.date(v)
120
+ case 'relative_date':
121
+ return formatRelativeDate(v, fmt.locale, now)
122
+ default:
123
+ return typeof v === 'number' ? fmt.quantity(v) : String(v)
124
+ }
125
+ }
126
+
127
+ /**
128
+ * One metric. A toned metric is a chip carrying its value and label («18
129
+ * disp.», or the tone's text «Agotado»); a plain one is the value in tabular
130
+ * numbers, with its label inline only when `showLabel` (several plain metrics
131
+ * side by side need it to tell «Saldo» from «Vencido»; a lone price does not).
132
+ */
133
+ function TrailingMetric({ item, fmt, showLabel }: { item: OptionTrailingItem; fmt: Formatter; showLabel?: boolean }) {
134
+ const { t } = useTranslation()
135
+ const label = item.label ? t(item.label, { defaultValue: item.label }) : ''
136
+ const value = formatTrailingValue(item, fmt)
137
+ if (item.tone) {
138
+ const text = item.text ? t(item.text, { defaultValue: item.text }) : [value, label].filter(Boolean).join(' ')
139
+ return (
140
+ <Badge
141
+ variant={toneVariant(item.tone)}
142
+ className="font-medium tabular-nums"
143
+ title={label && item.text ? `${label}: ${value}` : undefined}
144
+ data-slot="option-metric-chip"
145
+ data-key={item.key}
146
+ data-tone={item.tone}
147
+ >
148
+ {text}
149
+ </Badge>
150
+ )
151
+ }
152
+ return (
153
+ <span className="flex items-baseline gap-1 whitespace-nowrap" title={label || undefined} data-slot="option-metric" data-key={item.key}>
154
+ {showLabel && label ? <span className="text-[11px] text-muted-foreground">{label}</span> : null}
155
+ <span className="text-sm font-semibold tabular-nums text-foreground" data-slot="option-metric-value">
156
+ {item.text ? t(item.text, { defaultValue: item.text }) : value}
157
+ </span>
158
+ </span>
159
+ )
160
+ }
161
+
162
+ export interface OptionDisplayRowProps {
163
+ display: OptionDisplayData
164
+ /** Fallback title (the option label) when the display has none. */
165
+ label?: string
166
+ /** Keyboard / pointer highlight (for the title color). */
167
+ active?: boolean
168
+ selected?: boolean
169
+ /** Org currency override for money metrics without their own currency. */
170
+ currency?: string
171
+ /** Avatar size in px. Default 32. */
172
+ avatarSize?: number
173
+ }
174
+
175
+ /**
176
+ * The option row: avatar 32px · title (≤2 lines) + badges · muted subtitle ·
177
+ * metrics aligned right. `dimmed` attenuates the content, never the hover.
178
+ */
179
+ export function OptionDisplayRow({ display, label, active, selected, currency, avatarSize = 32 }: OptionDisplayRowProps) {
180
+ const { t } = useTranslation()
181
+ const fmt = useFormatter({ currency })
182
+ const title = display.title || label || ''
183
+ const trailing = display.trailing ?? []
184
+ const badges = display.badges ?? []
185
+ const plainCount = trailing.filter((i) => !i.tone).length
186
+ return (
187
+ <span
188
+ className="flex w-full min-w-0 items-center gap-3 py-0.5"
189
+ data-slot="option-display"
190
+ data-tone={display.tone || undefined}
191
+ data-dimmed={display.dimmed || undefined}
192
+ data-selected={selected || undefined}
193
+ >
194
+ <span className={display.dimmed ? 'opacity-60 grayscale' : undefined}>
195
+ <OptionThumb image={display.image ?? null} name={title} size={avatarSize} />
196
+ </span>
197
+ <span className={'flex min-w-0 flex-1 flex-col gap-0.5' + (display.dimmed ? ' opacity-60' : '')}>
198
+ <span className="flex min-w-0 items-start gap-1.5">
199
+ <span
200
+ className={'line-clamp-2 min-w-0 break-words text-sm font-medium leading-snug' + (active ? ' text-accent-foreground' : '')}
201
+ title={title}
202
+ data-slot="option-display-title"
203
+ >
204
+ {title}
205
+ </span>
206
+ {badges.map((b, i) => (
207
+ <Badge key={i} variant={toneVariant(b.tone)} className="mt-px px-1.5 py-0 text-[10px]" data-slot="option-display-badge">
208
+ {t(b.text, { defaultValue: b.text })}
209
+ </Badge>
210
+ ))}
211
+ </span>
212
+ {display.subtitle ? (
213
+ <span className="truncate text-xs text-muted-foreground" data-slot="option-display-subtitle">
214
+ {display.subtitle}
215
+ </span>
216
+ ) : null}
217
+ </span>
218
+ {trailing.length > 0 ? (
219
+ // Metrics stack right-aligned (price over the stock chip): the
220
+ // column stays narrow so the title keeps its room, and the
221
+ // values line up across rows.
222
+ <span className="ml-auto flex shrink-0 flex-col items-end justify-center gap-1 text-right" data-slot="option-display-trailing">
223
+ {trailing.map((item) => (
224
+ <TrailingMetric key={item.key} item={item} fmt={fmt} showLabel={plainCount > 1} />
225
+ ))}
226
+ </span>
227
+ ) : null}
228
+ </span>
229
+ )
230
+ }
231
+
232
+ /**
233
+ * Compact summary for a picker's trigger: small avatar, title and the most
234
+ * relevant metric (the toned one, else the first).
235
+ */
236
+ export function OptionDisplayValue({ display, label, currency }: { display: OptionDisplayData; label?: string; currency?: string }) {
237
+ const fmt = useFormatter({ currency })
238
+ const title = display.title || label || ''
239
+ const trailing = display.trailing ?? []
240
+ const lead = trailing.find((i) => i.tone) ?? trailing[0]
241
+ const extra = lead && trailing.find((i) => i !== lead && !i.tone)
242
+ return (
243
+ <span className="flex min-w-0 flex-1 items-center gap-2" data-slot="option-display-value">
244
+ <OptionThumb image={display.image ?? null} name={title} size={20} />
245
+ <span className="min-w-0 flex-1 truncate">{title}</span>
246
+ {extra ? (
247
+ <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{formatTrailingValue(extra, fmt)}</span>
248
+ ) : null}
249
+ {lead ? <TrailingMetric item={lead} fmt={fmt} /> : null}
250
+ </span>
251
+ )
252
+ }
253
+
254
+ /** Whether any of the options carries a display (switches a picker to rich rows). */
255
+ export const hasOptionDisplays = (options: readonly unknown[]): boolean => options.some((o) => getOptionDisplay(o) !== null)
@@ -234,12 +234,14 @@ export function optionsBatchToken(
234
234
  query: string,
235
235
  limit: number | undefined,
236
236
  filter: string | undefined,
237
+ context?: Record<string, string>,
237
238
  ): string {
238
239
  const params = new URLSearchParams()
239
240
  params.set('field', field)
240
241
  if (query) params.set('q', query)
241
242
  if (typeof limit === 'number' && limit > 0) params.set('limit', String(limit))
242
243
  if (filter) params.set('filter_value', filter)
244
+ for (const k of Object.keys(context ?? {}).sort()) params.set(`ctx.${k}`, context![k]!)
243
245
  return `o:${model}?${params.toString()}`
244
246
  }
245
247
 
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
14
14
  import { useApi } from './api-context'
15
15
  import { applyOptionFilter, type OptionFilterRule } from './option-filter'
16
16
  import { loadQueryPart, optionsBatchToken, optionsModelFromUrl } from './query-batch'
17
+ import type { OptionDisplayData } from './option-display'
17
18
 
18
19
  export interface ResolvedOption {
19
20
  /** Canonical id (server-side primary key). */
@@ -28,6 +29,12 @@ export interface ResolvedOption {
28
29
  image?: string | null
29
30
  color?: string | null
30
31
  icon?: string | null
32
+ /**
33
+ * Presentación declarativa resuelta por el kernel (manifest v3
34
+ * `option_display`): título, subtítulo, imagen, métricas a la derecha con
35
+ * tono (precio, stock…) y badges. Ausente en hosts/modelos sin display.
36
+ */
37
+ display?: OptionDisplayData | null
31
38
  /**
32
39
  * Campos extra del payload (precio, costo, sku, tasa) que no son decoración.
33
40
  * Solo se llena cuando el option trae números o strings además de id/label.
@@ -98,6 +105,12 @@ export interface UseOptionsResolverArgs {
98
105
  optionFilter?: OptionFilterRule[]
99
106
  /** Current selection: never hidden by `optionFilter`, so its label survives. */
100
107
  keepValue?: unknown
108
+ /**
109
+ * Picker context for the option display's contributed metrics, sent as
110
+ * `?ctx.<key>=<value>` (e.g. `{ warehouse_id }` of the document being
111
+ * edited → stock in that warehouse). Empty values are dropped.
112
+ */
113
+ context?: Record<string, string | null | undefined>
101
114
  }
102
115
 
103
116
  export interface UseOptionsResolverResult {
@@ -157,8 +170,34 @@ export function optionsRequestKey(
157
170
  query: string,
158
171
  limit: number | undefined,
159
172
  filter: string | undefined,
173
+ context?: Record<string, string>,
160
174
  ): string {
161
- return [scope, url, field, query, String(limit ?? ''), filter ?? ''].join('\n')
175
+ const parts = [scope, url, field, query, String(limit ?? ''), filter ?? '']
176
+ const ctx = contextKey(context)
177
+ if (ctx) parts.push(ctx)
178
+ return parts.join('\n')
179
+ }
180
+
181
+ /** Normalized picker context: string values only, empty dropped (null when none). */
182
+ export function normalizeOptionsContext(
183
+ context: Record<string, string | null | undefined> | undefined,
184
+ ): Record<string, string> | undefined {
185
+ if (!context) return undefined
186
+ const out: Record<string, string> = {}
187
+ for (const [k, v] of Object.entries(context)) {
188
+ if (v == null) continue
189
+ const s = String(v).trim()
190
+ if (s) out[k] = s
191
+ }
192
+ return Object.keys(out).length > 0 ? out : undefined
193
+ }
194
+
195
+ function contextKey(context: Record<string, string> | undefined): string {
196
+ if (!context) return ''
197
+ return Object.keys(context)
198
+ .sort()
199
+ .map((k) => `${k}=${context[k]}`)
200
+ .join('&')
162
201
  }
163
202
 
164
203
  /** Org + branch of the host session: part of every options cache key. */
@@ -203,6 +242,8 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
203
242
  optionFilter,
204
243
  keepValue,
205
244
  } = args
245
+ const context = normalizeOptionsContext(args.context)
246
+ const ctxSig = contextKey(context)
206
247
 
207
248
  const api = useApi()
208
249
  const [options, setOptions] = useState<ResolvedOption[]>([])
@@ -251,6 +292,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
251
292
  query ?? '',
252
293
  limit,
253
294
  filterValue,
295
+ context,
254
296
  )
255
297
  if (refreshKey !== seenRefresh.current) {
256
298
  seenRefresh.current = refreshKey
@@ -278,13 +320,14 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
278
320
  if (query) params.q = query
279
321
  if (typeof limit === 'number' && limit > 0) params.limit = limit
280
322
  if (filterValue) params.filter_value = filterValue
323
+ for (const [k, v] of Object.entries(context ?? {})) params[`ctx.${k}`] = v
281
324
  const model = optionsModelFromUrl(url)
282
325
  pending = (async () => {
283
326
  if (model) {
284
327
  try {
285
328
  const part = await loadQueryPart(
286
329
  api,
287
- optionsBatchToken(model, effectiveField, query ?? '', limit, filterValue),
330
+ optionsBatchToken(model, effectiveField, query ?? '', limit, filterValue, context),
288
331
  )
289
332
  if (!part.success) {
290
333
  throw new Error(part.message || 'options resolver: unsuccessful response')
@@ -340,7 +383,8 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
340
383
  return () => {
341
384
  cancelled = true
342
385
  }
343
- }, [api, url, effectiveField, query, limit, enabled, filterValue, refreshKey])
386
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- context is keyed by ctxSig
387
+ }, [api, url, effectiveField, query, limit, enabled, filterValue, refreshKey, ctxSig])
344
388
 
345
389
  // Rules arrive as a fresh array each render; key on their content.
346
390
  const filterKey = optionFilter && optionFilter.length > 0 ? JSON.stringify(optionFilter) : ''
@@ -364,7 +408,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
364
408
  * id/value and label/name fields for legacy parity — we accept either
365
409
  * and surface a stable shape downstream.
366
410
  */
367
- const OPTION_KNOWN_KEYS = new Set(['id', 'value', 'label', 'name', 'description', 'image', 'color', 'icon'])
411
+ const OPTION_KNOWN_KEYS = new Set(['id', 'value', 'label', 'name', 'description', 'image', 'color', 'icon', 'display'])
368
412
 
369
413
  export function projectOption(raw: any): ResolvedOption {
370
414
  const id = raw?.id ?? raw?.value ?? ''
@@ -385,6 +429,7 @@ export function projectOption(raw: any): ResolvedOption {
385
429
  image: raw?.image ?? null,
386
430
  color: raw?.color ?? null,
387
431
  icon: raw?.icon ?? null,
432
+ ...(raw?.display && typeof raw.display === 'object' ? { display: raw.display as OptionDisplayData } : {}),
388
433
  ...(Object.keys(meta).length > 0 ? { meta } : {}),
389
434
  }
390
435
  }