@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.
- package/CHANGELOG.md +25 -0
- package/dist/business/document-editor-model.d.ts +27 -2
- package/dist/business/document-editor-model.d.ts.map +1 -1
- package/dist/business/document-editor-model.js +53 -3
- package/dist/business/document-editor.d.ts +7 -2
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +121 -22
- package/dist/business/document-lines.d.ts +21 -1
- package/dist/business/document-lines.d.ts.map +1 -1
- package/dist/business/document-lines.js +69 -7
- 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/line-items-editor.d.ts +21 -1
- package/dist/business/line-items-editor.d.ts.map +1 -1
- package/dist/business/line-items-editor.js +74 -15
- package/dist/business/line-items.d.ts +28 -0
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +1 -0
- package/dist/business/line-product-cell.d.ts +6 -0
- package/dist/business/line-product-cell.d.ts.map +1 -1
- package/dist/business/line-product-cell.js +9 -3
- 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 +11 -2
- package/dist/business/product-search.d.ts +8 -0
- package/dist/business/product-search.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +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/field-grid.d.ts +4 -3
- package/dist/field-grid.d.ts.map +1 -1
- package/dist/field-grid.js +1 -1
- 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/record-picker.d.ts +2 -0
- package/dist/record-picker.d.ts.map +1 -1
- package/dist/record-picker.js +11 -4
- 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__/document-editor-inline-selects.test.tsx +11 -4
- package/src/__tests__/line-row-create-edit.test.tsx +312 -0
- package/src/__tests__/option-display.test.tsx +241 -0
- package/src/business/document-editor-model.ts +60 -2
- package/src/business/document-editor.tsx +143 -19
- package/src/business/document-lines.ts +79 -7
- package/src/business/index.ts +1 -0
- package/src/business/line-items-editor.tsx +158 -30
- package/src/business/line-items.ts +31 -0
- package/src/business/line-product-cell.tsx +32 -5
- package/src/business/product-display.ts +62 -0
- package/src/business/product-options.tsx +11 -2
- package/src/business/product-search.ts +8 -0
- package/src/dialogs/dynamic-record.tsx +1 -1
- package/src/dynamic-select-field.tsx +45 -1
- package/src/field-grid.tsx +5 -4
- package/src/index.ts +10 -0
- package/src/option-display.tsx +255 -0
- package/src/query-batch.ts +2 -0
- package/src/record-picker.tsx +16 -3
- 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="
|
|
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.
|
|
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={
|
|
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
|
-
/**
|
|
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="
|
|
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-
|
|
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
|
<>
|
package/src/field-grid.tsx
CHANGED
|
@@ -42,15 +42,16 @@ export function FieldCell({
|
|
|
42
42
|
export function FieldLabel({
|
|
43
43
|
htmlFor,
|
|
44
44
|
required,
|
|
45
|
-
tone = '
|
|
45
|
+
tone = 'sentence',
|
|
46
46
|
children,
|
|
47
47
|
}: {
|
|
48
48
|
htmlFor?: string
|
|
49
49
|
required?: boolean
|
|
50
50
|
/**
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
53
|
-
* uppercase labels
|
|
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)
|
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
|
|