@asteby/metacore-runtime-react 49.7.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 (74) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/business/document-editor-model.d.ts +27 -2
  3. package/dist/business/document-editor-model.d.ts.map +1 -1
  4. package/dist/business/document-editor-model.js +53 -3
  5. package/dist/business/document-editor.d.ts +7 -2
  6. package/dist/business/document-editor.d.ts.map +1 -1
  7. package/dist/business/document-editor.js +121 -22
  8. package/dist/business/document-lines.d.ts +21 -1
  9. package/dist/business/document-lines.d.ts.map +1 -1
  10. package/dist/business/document-lines.js +69 -7
  11. package/dist/business/index.d.ts +1 -0
  12. package/dist/business/index.d.ts.map +1 -1
  13. package/dist/business/index.js +1 -0
  14. package/dist/business/line-items-editor.d.ts +21 -1
  15. package/dist/business/line-items-editor.d.ts.map +1 -1
  16. package/dist/business/line-items-editor.js +74 -15
  17. package/dist/business/line-items.d.ts +28 -0
  18. package/dist/business/line-items.d.ts.map +1 -1
  19. package/dist/business/line-items.js +1 -0
  20. package/dist/business/line-product-cell.d.ts +6 -0
  21. package/dist/business/line-product-cell.d.ts.map +1 -1
  22. package/dist/business/line-product-cell.js +9 -3
  23. package/dist/business/product-display.d.ts +18 -0
  24. package/dist/business/product-display.d.ts.map +1 -0
  25. package/dist/business/product-display.js +40 -0
  26. package/dist/business/product-options.d.ts +6 -1
  27. package/dist/business/product-options.d.ts.map +1 -1
  28. package/dist/business/product-options.js +11 -2
  29. package/dist/business/product-search.d.ts +8 -0
  30. package/dist/business/product-search.d.ts.map +1 -1
  31. package/dist/dialogs/dynamic-record.js +1 -1
  32. package/dist/dynamic-select-field.d.ts +7 -1
  33. package/dist/dynamic-select-field.d.ts.map +1 -1
  34. package/dist/dynamic-select-field.js +18 -4
  35. package/dist/field-grid.d.ts +4 -3
  36. package/dist/field-grid.d.ts.map +1 -1
  37. package/dist/field-grid.js +1 -1
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -0
  41. package/dist/option-display.d.ts +68 -0
  42. package/dist/option-display.d.ts.map +1 -0
  43. package/dist/option-display.js +135 -0
  44. package/dist/query-batch.d.ts +1 -1
  45. package/dist/query-batch.d.ts.map +1 -1
  46. package/dist/query-batch.js +3 -1
  47. package/dist/record-picker.d.ts +2 -0
  48. package/dist/record-picker.d.ts.map +1 -1
  49. package/dist/record-picker.js +11 -4
  50. package/dist/use-options-resolver.d.ts +16 -1
  51. package/dist/use-options-resolver.d.ts.map +1 -1
  52. package/dist/use-options-resolver.js +38 -6
  53. package/package.json +1 -1
  54. package/src/__tests__/document-editor-inline-selects.test.tsx +11 -4
  55. package/src/__tests__/line-row-create-edit.test.tsx +312 -0
  56. package/src/__tests__/option-display.test.tsx +241 -0
  57. package/src/business/document-editor-model.ts +60 -2
  58. package/src/business/document-editor.tsx +143 -19
  59. package/src/business/document-lines.ts +79 -7
  60. package/src/business/index.ts +1 -0
  61. package/src/business/line-items-editor.tsx +158 -30
  62. package/src/business/line-items.ts +31 -0
  63. package/src/business/line-product-cell.tsx +32 -5
  64. package/src/business/product-display.ts +62 -0
  65. package/src/business/product-options.tsx +11 -2
  66. package/src/business/product-search.ts +8 -0
  67. package/src/dialogs/dynamic-record.tsx +1 -1
  68. package/src/dynamic-select-field.tsx +45 -1
  69. package/src/field-grid.tsx +5 -4
  70. package/src/index.ts +10 -0
  71. package/src/option-display.tsx +255 -0
  72. package/src/query-batch.ts +2 -0
  73. package/src/record-picker.tsx +16 -3
  74. package/src/use-options-resolver.ts +49 -4
@@ -3,6 +3,7 @@
3
3
  // guardado de renglones fallaba (PIT-018) por mandar al API cadenas vacías y
4
4
  // claves de UI; `serializeLineItems` es la única puerta de salida hacia el backend.
5
5
  import { roundMoney, toAmount } from './format'
6
+ import type { ProductVariant } from './product-search'
6
7
 
7
8
  export type LineItemKind = 'item' | 'section' | 'note'
8
9
 
@@ -50,6 +51,36 @@ export interface LineItem {
50
51
  * del host) — el SDK no conoce sus claves.
51
52
  */
52
53
  extensions?: Record<string, string>
54
+ /**
55
+ * Valores que el catálogo escribió en el renglón la última vez que se
56
+ * aplicó el producto (elegirlo, crearlo o editarlo desde la celda). Un campo
57
+ * cuyo valor actual difiere de su foto es un override del usuario («dirty»)
58
+ * y una edición del producto no lo pisa. No se serializa.
59
+ */
60
+ catalog?: LineCatalogSnapshot
61
+ /**
62
+ * Valores nuevos del catálogo que NO se aplicaron porque el usuario ya
63
+ * había cambiado ese campo a mano: la celda ofrece «aplicar». No se serializa.
64
+ */
65
+ catalog_pending?: Partial<Pick<LineItem, CatalogLineField>>
66
+ }
67
+
68
+ /** Campos del renglón que salen del producto y el usuario puede sobrescribir. */
69
+ export type CatalogLineField = 'description' | 'sku' | 'unit_price' | 'tax_rate' | 'unit'
70
+
71
+ export const CATALOG_LINE_FIELDS: readonly CatalogLineField[] = ['description', 'sku', 'unit_price', 'tax_rate', 'unit']
72
+
73
+ /** Foto de lo que el catálogo escribió en el renglón (por campo) y de qué producto/variante. */
74
+ export interface LineCatalogSnapshot {
75
+ description?: string
76
+ sku?: string
77
+ unit_price?: number
78
+ tax_rate?: number
79
+ unit?: string
80
+ /** Id del producto del catálogo (el registro que se edita), aunque el renglón lleve una variante. */
81
+ product_ref?: string
82
+ /** Variante elegida: al refrescar se vuelve a aplicar la misma. */
83
+ variant?: ProductVariant
53
84
  }
54
85
 
55
86
  /** Qué hacer si `quantity > available`. */
@@ -8,6 +8,10 @@
8
8
  // Teclado: ↑/↓ recorren, Enter elige (el grid pasa el foco a «Cant.»), Esc
9
9
  // cierra, Tab cierra y avanza normal. El texto escrito sin elegir queda como
10
10
  // descripción libre (el renglón libre sigue funcionando igual).
11
+ //
12
+ // Con `onCreate`, la celda ofrece crear el producto como las apps top: «+» unido
13
+ // al buscador y «Crear producto «texto»» al pie de la lista, prellenado con lo
14
+ // buscado. El grid llena el renglón con el producto nuevo al guardarlo.
11
15
  import { forwardRef, useState, type KeyboardEvent } from 'react'
12
16
  import { useTranslation } from 'react-i18next'
13
17
  import { RecordPicker } from '../record-picker'
@@ -47,10 +51,16 @@ export interface LineProductCellProps {
47
51
  className?: string
48
52
  /** Atributos de datos para que el grid ubique la celda (foco por teclado). */
49
53
  dataCell?: string
54
+ /** Alta del producto desde la celda, prellenada con lo buscado. */
55
+ onCreate?: (query: string) => void
56
+ /** Nombre del modelo para «Crear …» (default «producto»). */
57
+ entityLabel?: string
58
+ /** Detalle de qué se puede buscar: tooltip del buscador y estado sin coincidencias. */
59
+ hint?: string
50
60
  }
51
61
 
52
62
  export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps>(function LineProductCell(
53
- { search, text, onTextChange, onPick, onKeyDownClosed, warehouseId, currency, placeholder, ariaLabel, disabled, invalid, className, dataCell },
63
+ { search, text, onTextChange, onPick, onKeyDownClosed, warehouseId, currency, placeholder, ariaLabel, disabled, invalid, className, dataCell, onCreate, entityLabel, hint },
54
64
  ref,
55
65
  ) {
56
66
  const { t } = useTranslation()
@@ -58,6 +68,8 @@ export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps
58
68
  // capturado no despliega «sin resultados» encima de la tabla.
59
69
  const [typing, setTyping] = useState(false)
60
70
  const { hits, loading, minChars } = useProductSearch(typing ? text : '', search, typing)
71
+ const entity = entityLabel ?? t('lineItems.productEntity', { defaultValue: 'producto' })
72
+ const searchHint = hint ?? t('lineItems.searchHint', { defaultValue: 'Busca por nombre, SKU o código de barras' })
61
73
 
62
74
  return (
63
75
  <RecordPicker<ProductHit>
@@ -66,7 +78,8 @@ export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps
66
78
  freeText
67
79
  anchorSlot="line-product-cell"
68
80
  contentSlot="line-product-results"
69
- minListWidth="22rem"
81
+ minListWidth="26.25rem"
82
+ maxListWidth="35rem"
70
83
  items={hits}
71
84
  loading={loading}
72
85
  minChars={minChars}
@@ -85,10 +98,24 @@ export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps
85
98
  disabled={disabled}
86
99
  invalid={invalid}
87
100
  triggerClassName={className}
88
- triggerProps={{ 'data-cell': dataCell }}
89
- placeholder={placeholder ?? t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
101
+ triggerProps={{ 'data-cell': dataCell, title: searchHint }}
102
+ placeholder={placeholder ?? t('lineItems.searchPlaceholderShort', { defaultValue: 'Buscar producto…' })}
90
103
  loadingText={t('common.searching', { defaultValue: 'Buscando…' })}
91
- emptyText={t('lineItems.noProduct', { defaultValue: 'Sin coincidencias. Se usará como descripción libre.' })}
104
+ emptyText={
105
+ <span className="block space-y-1">
106
+ <span className="block">{t('lineItems.noProduct', { defaultValue: 'Sin coincidencias. Se usará como descripción libre.' })}</span>
107
+ <span className="block text-xs">{searchHint}</span>
108
+ </span>
109
+ }
110
+ onCreate={onCreate}
111
+ entityLabel={entity}
112
+ createLabel={t('lineItems.createProduct', { defaultValue: 'Crear {{entity}}', entity })}
113
+ createFooterLabel={(q) => (
114
+ <>
115
+ {t('lineItems.createProduct', { defaultValue: 'Crear {{entity}}', entity })}
116
+ {q ? <span className="text-muted-foreground"> «{q}»</span> : null}
117
+ </>
118
+ )}
92
119
  />
93
120
  )
94
121
  })
@@ -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,13 +103,21 @@ 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">
110
119
  <span className="min-w-0">
111
- <span className="block truncate font-medium">{productHitLabel(hit)}</span>
120
+ <span className="line-clamp-2 break-words font-medium" title={productHitLabel(hit)}>{productHitLabel(hit)}</span>
112
121
  {meta && <span className="block truncate text-xs text-muted-foreground">{meta}</span>}
113
122
  </span>
114
123
  <span className="flex shrink-0 items-center gap-2">
@@ -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. */
@@ -1334,7 +1334,7 @@ function FieldRow({ field, record, value, mode, onChange, error, locked }: Field
1334
1334
 
1335
1335
  return (
1336
1336
  <div className="flex flex-col gap-1.5" data-aby-field={field.key}>
1337
- <Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
1337
+ <Label className="text-sm font-medium text-foreground/80">
1338
1338
  {localizeFieldLabel(field.label, t)}
1339
1339
  {field.required && mode !== 'view' && !isEditReadonly && (
1340
1340
  <span className="text-destructive ml-0.5">*</span>
@@ -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
  <>
@@ -42,15 +42,16 @@ export function FieldCell({
42
42
  export function FieldLabel({
43
43
  htmlFor,
44
44
  required,
45
- tone = 'caps',
45
+ tone = 'sentence',
46
46
  children,
47
47
  }: {
48
48
  htmlFor?: string
49
49
  required?: boolean
50
50
  /**
51
- * `caps` (default): small uppercase eyebrow of the CRUD modals. `sentence`:
52
- * sentence-case label for dense editors (DocumentEditor) where a column of
53
- * uppercase labels reads as shouting.
51
+ * `sentence` (default): sentence-case label — every form of the runtime
52
+ * (CRUD modal, action modal, DocumentEditor) reads the same and a column of
53
+ * uppercase labels no longer shouts. `caps`: small uppercase eyebrow, for
54
+ * callers that still want it.
54
55
  */
55
56
  tone?: 'caps' | 'sentence'
56
57
  children: ReactNode
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