@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
@@ -29,13 +29,18 @@ import { FormErrorBanner } from './feedback'
29
29
  import { DocumentLinesGrid, type DocumentLinesGridProps, type LineItemsColumn } from './line-items-editor'
30
30
  import { computeTotals, serializeLineItems, taxBreakdown, type LineItem } from './line-items'
31
31
  import { roundMoney, toAmount, useFormatter } from './format'
32
- import { createCatalogProductSearch } from './catalog-product-search'
32
+ import { catalogRecordToProduct, createCatalogProductSearch } from './catalog-product-search'
33
+ import { withOptionDisplays } from './product-display'
34
+ import type { ProductResult } from './product-search'
35
+ import { requestRecordCreate, requestRecordEdit, withSearchPrefill } from '../record-picker-actions'
33
36
  import {
34
37
  allocationAmountField,
35
38
  allocationIssueMessages,
36
39
  allocationPayload,
37
40
  creditStatus,
38
41
  editorLinesConfig,
42
+ fieldDisplayMeta,
43
+ type FieldDisplayMeta,
39
44
  linesFromSource,
40
45
  localIssues,
41
46
  sourceLoadSummary,
@@ -91,8 +96,13 @@ export interface DocumentEditorProps {
91
96
  onSaved?: (record?: unknown) => void
92
97
  onCancel: () => void
93
98
  searchProducts?: DocumentLinesGridProps['search']
94
- /** Modelo de catálogo del buscador por defecto. Default `products.Product`. */
99
+ /**
100
+ * Modelo de catálogo: buscador por defecto y alta/edición del producto
101
+ * desde la celda del renglón. Default `products.Product`.
102
+ */
95
103
  productModel?: string
104
+ /** Columna del modelo de catálogo que recibe lo buscado al crear. Default `name`. */
105
+ productLabelField?: string
96
106
  defaultTaxRate?: number
97
107
  currency?: string
98
108
  /** Hoy (días de atraso del reparto; inyectable en tests). */
@@ -118,6 +128,7 @@ export function DocumentEditor({
118
128
  onCancel,
119
129
  searchProducts,
120
130
  productModel,
131
+ productLabelField = 'name',
121
132
  defaultTaxRate,
122
133
  currency,
123
134
  today,
@@ -145,7 +156,8 @@ export function DocumentEditor({
145
156
  const [linesFromSourceDoc, setLinesFromSourceDoc] = useState(false)
146
157
  const [extFields, setExtFields] = useState<ActionFieldDef[]>([])
147
158
  const [party, setParty] = useState<Record<string, any> | null>(null)
148
- const [partyLabels, setPartyLabels] = useState<Record<string, string>>({})
159
+ const [partyFieldLists, setPartyFieldLists] = useState<unknown[][]>([])
160
+ const [catalogMeta, setCatalogMeta] = useState<Record<string, FieldDisplayMeta> | null>(null)
149
161
  // Etiquetas de los selectores que el editor llena por código (contraparte y
150
162
  // documento origen de «Cargar desde…»): sin ellas el selector cerrado pinta el UUID.
151
163
  const [seeds, setSeeds] = useState<Record<string, SeedLabel>>({})
@@ -172,9 +184,17 @@ export function DocumentEditor({
172
184
  const relCache = useRef(new Map<string, Promise<any[]>>())
173
185
 
174
186
  const taxRate = defaultTaxRate ?? orgTaxRate
187
+ // The header's warehouse (when the document type declares one) scopes the
188
+ // stock the catalog's option_display shows in the product rows.
189
+ const headerWarehouse = typeof header.warehouse_id === 'string' ? header.warehouse_id : ''
175
190
  const search = useMemo(
176
- () => searchProducts ?? createCatalogProductSearch(api, { model: productModel, defaultTaxRate: taxRate }),
177
- [searchProducts, api, productModel, taxRate],
191
+ () =>
192
+ withOptionDisplays(
193
+ searchProducts ?? createCatalogProductSearch(api, { model: productModel, defaultTaxRate: taxRate }),
194
+ api,
195
+ { model: productModel ?? 'products.Product', context: { warehouse_id: headerWarehouse } },
196
+ ),
197
+ [searchProducts, api, productModel, taxRate, headerWarehouse],
178
198
  )
179
199
 
180
200
  const isInstalled = useCallback((k?: string) => !k || !installed || installed.addons.has(k), [installed])
@@ -254,25 +274,93 @@ export function DocumentEditor({
254
274
  }
255
275
  }, [api, type.party, partyId])
256
276
 
257
- // Etiquetas de la tarjeta (RFC, régimen…): las del formulario del modelo de
258
- // la contraparte, incluidas sus extensiones; sin ellas se ve la columna cruda.
277
+ // Etiquetas de la tarjeta (RFC, régimen…) y texto de sus catálogos: la
278
+ // metadata del modelo de la contraparte (formulario, con extensiones, y
279
+ // tabla); sin ellas se veía la columna cruda (`tax_id`).
259
280
  const partyModel = type.party?.model
260
281
  useEffect(() => {
261
282
  if (!partyModel) return
262
283
  let cancelled = false
263
- api.get(`/metadata/modal/${partyModel}`)
264
- .then((res: any) => {
265
- const fields: any[] = res?.data?.data?.fields ?? res?.data?.fields ?? []
266
- const out: Record<string, string> = {}
267
- for (const f of fields) if (typeof f?.key === 'string' && typeof f?.label === 'string' && f.label) out[f.key] = f.label
268
- if (!cancelled) setPartyLabels(out)
269
- })
270
- .catch(() => {})
284
+ void loadModelFieldLists(api, partyModel).then((lists) => {
285
+ if (!cancelled) setPartyFieldLists(lists)
286
+ })
271
287
  return () => {
272
288
  cancelled = true
273
289
  }
274
290
  }, [api, partyModel])
275
291
 
292
+ // Modelo de catálogo del renglón: alta/edición desde la celda y etiquetas
293
+ // de sus catálogos (unidad «unit» → «Pieza»).
294
+ const catalogModel = productModel ?? 'products.Product'
295
+ // Perezoso: solo cuando ya hay un renglón con producto (no compite con la
296
+ // carga inicial del editor).
297
+ const hasCatalogLine = lines.some((l) => l.kind === 'item' && !!l.product_id)
298
+ const wantsCatalogMeta = !!lineCfg && !isAllocation && hasCatalogLine
299
+ useEffect(() => {
300
+ if (!wantsCatalogMeta) return
301
+ let cancelled = false
302
+ void loadModelFieldLists(api, catalogModel).then((lists) => {
303
+ if (!cancelled) setCatalogMeta(fieldDisplayMeta(lists, tl))
304
+ })
305
+ return () => {
306
+ cancelled = true
307
+ }
308
+ }, [api, catalogModel, tl, wantsCatalogMeta])
309
+
310
+ /** Lee el producto guardado con la misma proyección del buscador (catalogRecordToProduct). */
311
+ const loadCatalogProduct = useCallback(
312
+ async (id: string, saved?: Record<string, any>): Promise<ProductResult | null> => {
313
+ try {
314
+ const res: any = await api.get(`/data/${catalogModel}`, { params: { f_id: `eq:${id}`, per_page: 1 } })
315
+ const rows = res?.data?.data
316
+ const row = Array.isArray(rows) ? rows[0] : null
317
+ if (row) return catalogRecordToProduct({ ...(saved ?? {}), ...row }, taxRate)
318
+ } catch {
319
+ // Sin lectura, lo que devolvió el guardado.
320
+ }
321
+ return saved && saved.id != null ? catalogRecordToProduct(saved, taxRate) : null
322
+ },
323
+ [api, catalogModel, taxRate],
324
+ )
325
+ const createProduct = useCallback(
326
+ (query: string) =>
327
+ new Promise<ProductResult | null>((resolve) => {
328
+ requestRecordCreate({
329
+ model: catalogModel,
330
+ defaults: withSearchPrefill(query, productLabelField),
331
+ onCreated: (rec: any) => {
332
+ if (rec?.id == null) return resolve(null)
333
+ void loadCatalogProduct(String(rec.id), rec).then(resolve)
334
+ },
335
+ })
336
+ }),
337
+ [catalogModel, productLabelField, loadCatalogProduct],
338
+ )
339
+ const editProduct = useCallback(
340
+ (line: LineItem) =>
341
+ new Promise<ProductResult | null>((resolve) => {
342
+ const id = line.catalog?.product_ref ?? line.product_id
343
+ if (!id) return resolve(null)
344
+ requestRecordEdit({
345
+ model: catalogModel,
346
+ recordId: id,
347
+ onSaved: (rec: any) => void loadCatalogProduct(id, rec && typeof rec === 'object' ? { id, ...rec } : undefined).then(resolve),
348
+ })
349
+ }),
350
+ [catalogModel, loadCatalogProduct],
351
+ )
352
+ /** Unidad visible: la opción del catálogo; sin opciones declaradas, el valor tal cual. */
353
+ const unitLabel = useCallback(
354
+ (u: string) => {
355
+ if (!catalogMeta) return undefined
356
+ const opts = catalogMeta.unit_of_measure?.options ?? catalogMeta.unit?.options
357
+ if (!opts) return u
358
+ const o = opts.find((x) => String(x.value) === u)
359
+ return typeof o?.label === 'string' && o.label && o.label !== u ? o.label : undefined
360
+ },
361
+ [catalogMeta],
362
+ )
363
+
276
364
  // Cobro: documentos abiertos de la contraparte.
277
365
  const openCfg = lineCfg?.open_documents
278
366
  const allocPartyId = openCfg ? header[openCfg.party_field] ?? partyId : undefined
@@ -595,10 +683,10 @@ export function DocumentEditor({
595
683
  </FieldCell>
596
684
  ) : null
597
685
 
598
- const summaryRows = partySummaryRows(party, type.party?.summary, {
599
- ...Object.fromEntries(Object.entries(partyLabels).map(([k, v]) => [k, tl(v)])),
600
- ...Object.fromEntries(allFields.map((f) => [f.key, tl(f.label)])),
601
- })
686
+ // Etiquetas y catálogos: la metadata de la contraparte y, para las
687
+ // extensiones compartidas (`fiscal_data.uso_cfdi`), los campos del propio documento.
688
+ const summaryMeta = useMemo(() => fieldDisplayMeta([...partyFieldLists, allFields], tl), [partyFieldLists, allFields, tl])
689
+ const summaryRows = partySummaryRows(party, type.party?.summary, {}, 6, summaryMeta)
602
690
  const advancedSummary = groups.advanced
603
691
  .filter(visible)
604
692
  .map((f) => {
@@ -782,6 +870,9 @@ export function DocumentEditor({
782
870
  search={search}
783
871
  currency={currency}
784
872
  showTotals={false}
873
+ onCreateProduct={kind === 'credit' ? undefined : createProduct}
874
+ onEditProduct={editProduct}
875
+ unitLabel={unitLabel}
785
876
  />
786
877
  )}
787
878
  {!fullscreen && totalsPanel}
@@ -954,3 +1045,36 @@ function SourcePicker({
954
1045
  </div>
955
1046
  )
956
1047
  }
1048
+
1049
+ /**
1050
+ * Campos y columnas de un modelo para etiquetar sus valores: formulario
1051
+ * (`/metadata/modal`, con extensiones) y tabla (`/metadata/table`). Si la
1052
+ * clave con módulo (`customers.Customer`) no resuelve en el host, prueba la
1053
+ * corta (`Customer`), como el puente de alta del host. Nunca lanza.
1054
+ */
1055
+ async function loadModelFieldLists(api: { get: (url: string, cfg?: any) => Promise<any> }, model: string): Promise<unknown[][]> {
1056
+ const short = model.includes('.') ? model.slice(model.lastIndexOf('.') + 1) : undefined
1057
+ // `undefined` = la petición falló (el host no conoce esa clave); `[]` = respondió sin campos.
1058
+ const read = async (url: string, key: 'fields' | 'columns'): Promise<unknown[] | undefined> => {
1059
+ try {
1060
+ const res = await api.get(url)
1061
+ const list = res?.data?.data?.[key] ?? res?.data?.[key]
1062
+ return Array.isArray(list) ? list : []
1063
+ } catch {
1064
+ return undefined
1065
+ }
1066
+ }
1067
+ // En serie y solo lo necesario (una lectura en el caso normal): el
1068
+ // formulario; la clave corta solo si la larga falló; la tabla solo si
1069
+ // ningún formulario respondió con campos.
1070
+ const candidates = short ? [model, short] : [model]
1071
+ for (const kind of ['modal', 'table'] as const) {
1072
+ for (const m of candidates) {
1073
+ const list = await read(`/metadata/${kind}/${m}`, kind === 'modal' ? 'fields' : 'columns')
1074
+ if (list === undefined) continue
1075
+ if (list.length > 0) return [list]
1076
+ break
1077
+ }
1078
+ }
1079
+ return []
1080
+ }
@@ -1,7 +1,7 @@
1
1
  // Helpers del editor único de renglones (DocumentLinesGrid / LineItemsEditor).
2
2
  // Puros: el componente solo pinta. `serializeLineItems` sigue siendo la puerta al backend.
3
3
  import { toAmount } from './format'
4
- import { makeLine, type LineItem } from './line-items'
4
+ import { CATALOG_LINE_FIELDS, makeLine, type CatalogLineField, type LineCatalogSnapshot, type LineItem } from './line-items'
5
5
  import { availableStock, type ProductResult, type ProductVariant } from './product-search'
6
6
 
7
7
  export type PriceSource = 'sale' | 'cost' | 'origin'
@@ -33,21 +33,93 @@ export function applyProductToLine(
33
33
  const catalog = source === 'cost' ? (cost ?? list) : list
34
34
  // Nunca cantidad 0: un renglón vacío (o con la celda borrada) entra con 1.
35
35
  const qty = opts.quantity ?? toAmount(line.quantity)
36
+ const values = catalogLineValues(line, product, variant, source)
37
+ const { catalog_pending: _pending, ...rest } = line
36
38
  return {
37
- ...line,
39
+ ...rest,
38
40
  kind: 'item',
39
41
  product_id: variant?.id ?? product.id,
40
- sku: variant?.sku ?? product.sku,
41
- description: variant ? `${product.name} · ${variant.label}` : product.name,
42
+ ...values,
42
43
  quantity: qty !== 0 ? qty : 1,
43
- unit_price: priceFromProduct(product, variant, source),
44
44
  catalog_price: catalog,
45
45
  cost,
46
- tax_rate: product.tax_rate ?? line.tax_rate ?? 0,
47
- unit: product.unit ?? line.unit,
48
46
  extensions: product.extensions ? { ...(line.extensions ?? {}), ...product.extensions } : line.extensions,
49
47
  available: availableStock(src, opts.warehouseId) ?? availableStock(product, opts.warehouseId),
48
+ catalog: { ...values, product_ref: product.id, variant },
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Lo que el producto escribe en los campos editables del renglón. Es EL mapeo
54
+ * producto → renglón: lo usan elegir, crear y editar desde la celda.
55
+ */
56
+ function catalogLineValues(
57
+ line: LineItem,
58
+ product: ProductResult,
59
+ variant: ProductVariant | undefined,
60
+ source: PriceSource,
61
+ ): Pick<LineItem, CatalogLineField> {
62
+ return {
63
+ description: variant ? `${product.name} · ${variant.label}` : product.name,
64
+ sku: variant?.sku ?? product.sku,
65
+ unit_price: priceFromProduct(product, variant, source),
66
+ tax_rate: product.tax_rate ?? line.tax_rate ?? 0,
67
+ unit: product.unit ?? line.unit,
68
+ }
69
+ }
70
+
71
+ const sameValue = (a: unknown, b: unknown): boolean =>
72
+ typeof a === 'number' || typeof b === 'number' ? toAmount(a) === toAmount(b) : (a ?? '') === (b ?? '')
73
+
74
+ /**
75
+ * Campos que el usuario cambió a mano respecto de lo que puso el catálogo
76
+ * («dirty» por campo). Sin foto (renglón libre, o cargado de un documento
77
+ * origen con sus propios precios) todo cuenta como capturado: nada se pisa.
78
+ */
79
+ export function lineOverrides(line: LineItem): Set<CatalogLineField> {
80
+ const snap = line.catalog
81
+ if (!snap) return new Set(CATALOG_LINE_FIELDS)
82
+ return new Set(CATALOG_LINE_FIELDS.filter((f) => !sameValue(line[f], snap[f as keyof LineCatalogSnapshot])))
83
+ }
84
+
85
+ /**
86
+ * El producto del renglón cambió en el catálogo (se editó desde la celda):
87
+ * actualiza SOLO los campos que siguen igual a lo que el catálogo había puesto;
88
+ * lo que el usuario sobrescribió se respeta y el valor nuevo queda en
89
+ * `catalog_pending` para ofrecer «aplicar». Nunca toca la cantidad, el tope de
90
+ * lo pendiente ni el vínculo con el documento origen. Siempre refresca lo que
91
+ * no se captura en el renglón (precio de catálogo, costo, extensiones, existencia).
92
+ */
93
+ export function refreshLineFromProduct(
94
+ line: LineItem,
95
+ product: ProductResult,
96
+ opts: { priceSource?: PriceSource; warehouseId?: string } = {},
97
+ ): LineItem {
98
+ const source = opts.priceSource ?? 'sale'
99
+ const variant = line.catalog?.variant
100
+ const fresh = applyProductToLine(line, product, variant, { ...opts, quantity: toAmount(line.quantity) })
101
+ const overrides = lineOverrides(line)
102
+ const next: LineItem = { ...fresh, product_id: line.product_id ?? fresh.product_id, quantity: line.quantity, max_quantity: line.max_quantity, source_line_id: line.source_line_id }
103
+ const pending: Partial<Pick<LineItem, CatalogLineField>> = {}
104
+ const values = catalogLineValues(line, product, variant, source)
105
+ for (const f of CATALOG_LINE_FIELDS) {
106
+ if (!overrides.has(f)) continue
107
+ // Respeta lo capturado; si el catálogo trae otro valor, se ofrece aplicarlo.
108
+ ;(next as any)[f] = line[f]
109
+ if (!sameValue(values[f], line[f])) (pending as any)[f] = values[f]
50
110
  }
111
+ // La foto avanza a los valores nuevos del catálogo: el override sigue siéndolo.
112
+ next.catalog = { ...values, product_ref: line.catalog?.product_ref ?? product.id, variant }
113
+ if (Object.keys(pending).length > 0) next.catalog_pending = pending
114
+ else delete next.catalog_pending
115
+ return next
116
+ }
117
+
118
+ /** «Aplicar» el aviso del catálogo: los valores pendientes entran al renglón. */
119
+ export function applyCatalogPending(line: LineItem): LineItem {
120
+ if (!line.catalog_pending) return line
121
+ const { catalog_pending: pending, ...rest } = line
122
+ return { ...rest, ...pending }
51
123
  }
52
124
 
53
125
  /**
@@ -22,6 +22,7 @@ export * from './vehicle-picker'
22
22
  export * from './relate-documents'
23
23
  export * from './print-send-dialog'
24
24
  export * from './catalog-product-search'
25
+ export * from './product-display'
25
26
  export * from './document-editor-model'
26
27
  export * from './document-editor-panels'
27
28
  export * from './payment-allocator'
@@ -3,13 +3,15 @@
3
3
  // Controlado: `value`/`onChange` con `LineItem[]`. Guardar = `serializeLineItems`.
4
4
  import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
5
5
  import { useTranslation } from 'react-i18next'
6
- import { Heading, Minus, Plus, StickyNote, Trash2 } from 'lucide-react'
6
+ import { Heading, Minus, Pencil, Plus, StickyNote, Trash2 } from 'lucide-react'
7
7
  import { Button, Input } from '@asteby/metacore-ui'
8
8
  import { useCan } from '../permissions-context'
9
9
  import { useFormatter } from './format'
10
10
  import {
11
11
  addProductLine,
12
+ applyCatalogPending,
12
13
  applyProductToLine,
14
+ refreshLineFromProduct,
13
15
  lineGridKeyCommand,
14
16
  type DocumentLinesMode,
15
17
  type PriceSource,
@@ -73,6 +75,26 @@ export interface DocumentLinesGridProps {
73
75
  * ya muestra sus propios totales (DocumentEditor) lo apaga.
74
76
  */
75
77
  showTotals?: boolean
78
+ /**
79
+ * Alta del producto desde la celda («+» y «Crear producto «texto»»). Resuelve
80
+ * con el producto guardado (o nada si se canceló): el renglón se llena con
81
+ * el mismo mapeo que al elegir uno existente.
82
+ */
83
+ onCreateProduct?: (query: string) => Promise<ProductResult | null | undefined>
84
+ /**
85
+ * Edición del producto del renglón (lápiz en la celda). Resuelve con el
86
+ * producto ya guardado: el renglón se actualiza salvo los campos que el
87
+ * usuario sobrescribió (ver `refreshLineFromProduct`).
88
+ */
89
+ onEditProduct?: (line: LineItem) => Promise<ProductResult | null | undefined>
90
+ /** Nombre del modelo de catálogo para «Crear …» / «Editar …» (default «producto»). */
91
+ productLabel?: string
92
+ /**
93
+ * Etiqueta visible de la unidad (`unit` → «pza»). Si devuelve vacío, la
94
+ * unidad no se muestra bajo el SKU (nunca la clave cruda). Sin esta prop se
95
+ * muestra el valor tal cual.
96
+ */
97
+ unitLabel?: (unit: string) => string | undefined
76
98
  }
77
99
 
78
100
  /** @deprecated Usa `DocumentLinesGridProps`. Es el mismo contrato. */
@@ -100,6 +122,10 @@ export function DocumentLinesGrid({
100
122
  editPermission,
101
123
  currency,
102
124
  showTotals = true,
125
+ onCreateProduct,
126
+ onEditProduct,
127
+ productLabel,
128
+ unitLabel,
103
129
  }: DocumentLinesGridProps) {
104
130
  const { t } = useTranslation()
105
131
  const can = useCan()
@@ -125,6 +151,12 @@ export function DocumentLinesGrid({
125
151
  const canAdd = !locked && !fromSource
126
152
  const [undo, setUndo] = useState<LineItem[] | null>(null)
127
153
 
154
+ // Crear/editar desde la celda resuelven DESPUÉS de un modal: se trabaja
155
+ // sobre los renglones de ese momento (no los del render que abrió el modal).
156
+ const latest = useRef(value)
157
+ latest.current = value
158
+ const entity = productLabel ?? t('lineItems.productEntity', { defaultValue: 'producto' })
159
+
128
160
  const emit = (next: LineItem[]) => {
129
161
  onChange(next)
130
162
  const priced = discountMode === 'amount' ? next.map((l) => (l.kind === 'item' ? { ...l, discount_kind: 'amount' as const } : l)) : next
@@ -132,7 +164,7 @@ export function DocumentLinesGrid({
132
164
  }
133
165
  const patch = (idx: number, p: Partial<LineItem>) => {
134
166
  if (discountMode === 'amount') p = { ...p, discount_kind: 'amount' }
135
- emit(value.map((l, i) => (i === idx ? { ...l, ...p } : l)))
167
+ emit(value.map((l, i) => (i === idx ? withoutAnsweredPending({ ...l, ...p }, p) : l)))
136
168
  }
137
169
  const remove = (idx: number) => {
138
170
  setUndo(value)
@@ -159,23 +191,54 @@ export function DocumentLinesGrid({
159
191
  const blankLine = () => makeLine(discountMode === 'amount' ? { discount_kind: 'amount' } : {})
160
192
 
161
193
  /** Renglón vacío del final: el producto entra como renglón nuevo (o suma al mismo). */
162
- const pickProduct = (product: ProductResult, variant?: ProductVariant) => {
163
- const next = addProductLine(value, product, variant, { priceSource, warehouseId, mergeSameProduct })
194
+ const pickProduct = (product: ProductResult, variant?: ProductVariant, base: LineItem[] = value) => {
195
+ const next = addProductLine(base, product, variant, { priceSource, warehouseId, mergeSameProduct })
164
196
  emit(next)
165
197
  const id = variant?.id ?? product.id
166
- const target = next.find((l) => l.kind === 'item' && l.product_id === id && !value.includes(l)) ?? next[next.length - 1]
198
+ const target = next.find((l) => l.kind === 'item' && l.product_id === id && !base.includes(l)) ?? next[next.length - 1]
167
199
  if (target) setFocusQty(target.key)
168
200
  }
169
201
 
170
202
  /** Renglón libre existente: el producto elegido en su celda llena ESE renglón. */
171
- const fillLine = (idx: number, product: ProductResult, variant?: ProductVariant) => {
172
- const cur = value[idx]
203
+ const fillLine = (idx: number, product: ProductResult, variant?: ProductVariant, base: LineItem[] = value) => {
204
+ const cur = base[idx]
173
205
  if (!cur) return
174
206
  const filled = applyProductToLine(cur, product, variant, { priceSource, warehouseId })
175
- emit(value.map((l, i) => (i === idx ? (discountMode === 'amount' ? { ...filled, discount_kind: 'amount' as const } : filled) : l)))
207
+ emit(base.map((l, i) => (i === idx ? (discountMode === 'amount' ? { ...filled, discount_kind: 'amount' as const } : filled) : l)))
176
208
  setFocusQty(cur.key)
177
209
  }
178
210
 
211
+ /**
212
+ * «Crear producto «texto»» / «+»: el alta del modelo de catálogo; al
213
+ * guardarse, el producto nuevo entra en ESE renglón (o en uno nuevo desde
214
+ * el renglón del final) y lo llena igual que al elegir uno existente.
215
+ */
216
+ const createProduct = onCreateProduct
217
+ ? async (query: string, lineKey?: string) => {
218
+ const product = await onCreateProduct(query)
219
+ if (!product) return
220
+ const base = latest.current
221
+ const idx = lineKey ? base.findIndex((l) => l.key === lineKey) : -1
222
+ if (idx >= 0 && !base[idx].product_id) fillLine(idx, product, undefined, base)
223
+ else pickProduct(product, undefined, base)
224
+ }
225
+ : undefined
226
+
227
+ /**
228
+ * Lápiz: edita el producto del renglón; al guardar, el renglón toma los
229
+ * valores nuevos salvo lo que el usuario sobrescribió (queda el aviso).
230
+ */
231
+ const editProduct = onEditProduct
232
+ ? async (line: LineItem) => {
233
+ const product = await onEditProduct(line)
234
+ if (!product) return
235
+ const base = latest.current
236
+ emit(base.map((l) => (l.key === line.key ? refreshLineFromProduct(l, product, { priceSource, warehouseId }) : l)))
237
+ }
238
+ : undefined
239
+
240
+ const unitText = (u?: string) => (!u ? undefined : unitLabel ? unitLabel(u) || undefined : u)
241
+
179
242
  /** Texto escrito en el renglón vacío sin elegir producto → renglón libre con esa descripción. */
180
243
  const addFreeFromDraft = (description: string) => {
181
244
  const line = { ...blankLine(), description }
@@ -296,21 +359,57 @@ export function DocumentLinesGrid({
296
359
  ariaLabel={t('lineItems.description', { defaultValue: 'Descripción' })}
297
360
  invalid={!!cell(i, 'description')}
298
361
  dataCell="description"
362
+ onCreate={createProduct ? (q) => void createProduct(q, l.key) : undefined}
363
+ entityLabel={entity}
299
364
  />
300
365
  ) : (
301
- <Input
302
- value={l.description}
303
- disabled={locked}
304
- className={INPUT}
305
- data-cell="description"
306
- aria-label={t('lineItems.description', { defaultValue: 'Descripción' })}
307
- aria-invalid={!!cell(i, 'description')}
308
- onChange={(e) => patch(i, { description: e.target.value })}
309
- onKeyDown={(e) => descKeys(e, i, l)}
310
- />
366
+ <div className="relative" data-slot="line-description">
367
+ <Input
368
+ value={l.description}
369
+ disabled={locked}
370
+ className={INPUT + (editProduct && l.product_id && !locked ? ' pr-9' : '')}
371
+ data-cell="description"
372
+ aria-label={t('lineItems.description', { defaultValue: 'Descripción' })}
373
+ aria-invalid={!!cell(i, 'description')}
374
+ onChange={(e) => patch(i, { description: e.target.value })}
375
+ onKeyDown={(e) => descKeys(e, i, l)}
376
+ />
377
+ {editProduct && l.product_id && !locked && (
378
+ <Button
379
+ type="button"
380
+ size="icon"
381
+ variant="ghost"
382
+ className={EDIT}
383
+ data-slot="line-edit-product"
384
+ aria-label={t('lineItems.editProduct', { defaultValue: 'Editar {{entity}}', entity })}
385
+ title={t('lineItems.editProduct', { defaultValue: 'Editar {{entity}}', entity })}
386
+ onClick={() => void editProduct(l)}
387
+ >
388
+ <Pencil className="size-3.5" />
389
+ </Button>
390
+ )}
391
+ </div>
311
392
  )}
312
- {(l.sku || l.unit) && (
313
- <p className="mt-0.5 text-xs text-muted-foreground">{[l.sku, !show('unit') ? l.unit : ''].filter(Boolean).join(' · ')}</p>
393
+ {(l.sku || unitText(l.unit)) && (
394
+ <p className="mt-0.5 text-xs text-muted-foreground">{[l.sku, !show('unit') ? unitText(l.unit) : ''].filter(Boolean).join(' · ')}</p>
395
+ )}
396
+ {l.catalog_pending && !locked && (
397
+ <p className="mt-0.5 text-xs text-muted-foreground" data-slot="catalog-pending" role="status">
398
+ {l.catalog_pending.unit_price != null
399
+ ? t('lineItems.catalogPriceChanged', {
400
+ defaultValue: 'Precio de catálogo cambió a {{price}}',
401
+ price: fmt.money(l.catalog_pending.unit_price),
402
+ })
403
+ : t('lineItems.catalogChanged', { defaultValue: 'El producto cambió en el catálogo' })}
404
+ {' — '}
405
+ <button
406
+ type="button"
407
+ className="font-medium text-foreground underline-offset-2 hover:underline focus-visible:underline focus-visible:outline-none"
408
+ onClick={() => emit(latest.current.map((x) => (x.key === l.key ? applyCatalogPending(x) : x)))}
409
+ >
410
+ {t('lineItems.catalogApply', { defaultValue: 'aplicar' })}
411
+ </button>
412
+ </p>
314
413
  )}
315
414
  {cell(i, 'description') && <p className="mt-0.5 text-xs text-destructive">{cell(i, 'description')}</p>}
316
415
  </td>
@@ -474,10 +573,12 @@ export function DocumentLinesGrid({
474
573
  search={search}
475
574
  warehouseId={warehouseId}
476
575
  currency={currency}
477
- onPick={pickProduct}
576
+ onPick={(p, v) => pickProduct(p, v)}
478
577
  onFree={addFreeFromDraft}
479
578
  onAddBlank={() => emit([...value, blankLine()])}
480
- trailingCells={colCount - 1}
579
+ onCreate={createProduct ? (q) => void createProduct(q) : undefined}
580
+ entityLabel={entity}
581
+ span={colCount - 2}
481
582
  />
482
583
  )}
483
584
  </tbody>
@@ -542,6 +643,15 @@ export function DocumentLinesGrid({
542
643
  )
543
644
  }
544
645
 
646
+ /** Capturar a mano un campo con aviso de catálogo responde el aviso: deja de ofrecer «aplicar» para ese campo. */
647
+ function withoutAnsweredPending(line: LineItem, p: Partial<LineItem>): LineItem {
648
+ if (!line.catalog_pending) return line
649
+ const pending = { ...line.catalog_pending }
650
+ for (const k of Object.keys(p)) delete (pending as Record<string, unknown>)[k]
651
+ const { catalog_pending: _drop, ...rest } = line
652
+ return Object.keys(pending).length > 0 ? { ...rest, catalog_pending: pending } : rest
653
+ }
654
+
545
655
  /** Mismo componente. El nombre anterior sigue exportado. */
546
656
  export const LineItemsEditor = DocumentLinesGrid
547
657
 
@@ -561,9 +671,14 @@ function DraftRow({
561
671
  onPick,
562
672
  onFree,
563
673
  onAddBlank,
564
- trailingCells,
674
+ onCreate,
675
+ entityLabel,
676
+ span,
565
677
  }: {
566
- trailingCells: number
678
+ /** Columnas que ocupa el buscador (todas menos «Importe» y acciones). */
679
+ span: number
680
+ onCreate?: (query: string) => void
681
+ entityLabel?: string
567
682
  search: NonNullable<DocumentLinesGridProps['search']>
568
683
  warehouseId?: string
569
684
  currency?: string
@@ -575,7 +690,10 @@ function DraftRow({
575
690
  const [text, setText] = useState('')
576
691
  return (
577
692
  <tr className="border-t border-dashed" data-slot="line-draft-row">
578
- <td className={CELL}>
693
+ {/* El renglón vacío no tiene cantidad ni precio todavía: el buscador
694
+ usa ese ancho (antes medía solo la columna «Descripción» y el
695
+ texto de ayuda se cortaba). */}
696
+ <td className={CELL} colSpan={span}>
579
697
  <LineProductCell
580
698
  search={search}
581
699
  text={text}
@@ -595,15 +713,21 @@ function DraftRow({
595
713
  warehouseId={warehouseId}
596
714
  currency={currency}
597
715
  ariaLabel={t('lineItems.searchProduct', { defaultValue: 'Buscar producto' })}
598
- placeholder={t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
599
716
  className="border-dashed bg-transparent shadow-none"
600
717
  dataCell="search"
718
+ onCreate={
719
+ onCreate
720
+ ? (q) => {
721
+ setText('')
722
+ onCreate(q)
723
+ }
724
+ : undefined
725
+ }
726
+ entityLabel={entityLabel}
601
727
  />
602
728
  </td>
603
- {/* Celdas vacías con la misma estructura: el buscador mide lo que su columna. */}
604
- {Array.from({ length: trailingCells }, (_, i) => (
605
- <td key={i} aria-hidden />
606
- ))}
729
+ <td aria-hidden />
730
+ <td aria-hidden />
607
731
  </tr>
608
732
  )
609
733
  }
@@ -625,6 +749,10 @@ function TotalRow({ span, label, value, slot, strong }: { span: number; label: s
625
749
  const DELETE =
626
750
  'size-8 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 max-sm:opacity-100'
627
751
 
752
+ /** Lápiz «Editar producto» dentro de la celda: al pasar o enfocar la fila; siempre con teclado y en móvil. */
753
+ const EDIT =
754
+ 'absolute right-0.5 top-1/2 size-7 -translate-y-1/2 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 max-sm:opacity-100'
755
+
628
756
  /** Celda densa: misma altura en todos los renglones. */
629
757
  const CELL = 'px-2 py-1.5 align-top'
630
758
  /** Inputs de celda: un poco más bajos que los del formulario (densidad de editor). */