@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.
- package/CHANGELOG.md +19 -0
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +5 -1
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/product-display.d.ts +18 -0
- package/dist/business/product-display.d.ts.map +1 -0
- package/dist/business/product-display.js +40 -0
- package/dist/business/product-options.d.ts +6 -1
- package/dist/business/product-options.d.ts.map +1 -1
- package/dist/business/product-options.js +10 -1
- package/dist/business/product-search.d.ts +8 -0
- package/dist/business/product-search.d.ts.map +1 -1
- package/dist/dynamic-select-field.d.ts +7 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +18 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/option-display.d.ts +68 -0
- package/dist/option-display.d.ts.map +1 -0
- package/dist/option-display.js +135 -0
- package/dist/query-batch.d.ts +1 -1
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +3 -1
- package/dist/use-options-resolver.d.ts +16 -1
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +38 -6
- package/package.json +1 -1
- package/src/__tests__/option-display.test.tsx +241 -0
- package/src/business/document-editor.tsx +11 -2
- package/src/business/index.ts +1 -0
- package/src/business/product-display.ts +62 -0
- package/src/business/product-options.tsx +10 -1
- package/src/business/product-search.ts +8 -0
- package/src/dynamic-select-field.tsx +45 -1
- package/src/index.ts +10 -0
- package/src/option-display.tsx +255 -0
- package/src/query-batch.ts +2 -0
- 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
|
-
/**
|
|
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)
|
package/src/query-batch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|