@asteby/metacore-runtime-react 49.3.0 → 49.4.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 +12 -0
- package/dist/business/document-editor-panels.d.ts +2 -1
- package/dist/business/document-editor-panels.d.ts.map +1 -1
- package/dist/business/document-editor-panels.js +6 -6
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +23 -9
- 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 +6 -1
- package/dist/business/line-items-editor.d.ts.map +1 -1
- package/dist/business/line-items-editor.js +109 -83
- package/dist/business/line-product-cell.d.ts +32 -0
- package/dist/business/line-product-cell.d.ts.map +1 -0
- package/dist/business/line-product-cell.js +100 -0
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +20 -12
- package/dist/dynamic-select-field.d.ts +2 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +49 -19
- package/dist/entity-select.d.ts.map +1 -1
- package/dist/entity-select.js +13 -11
- package/dist/field-grid.d.ts +7 -1
- package/dist/field-grid.d.ts.map +1 -1
- package/dist/field-grid.js +4 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/record-picker-actions.d.ts +27 -0
- package/dist/record-picker-actions.d.ts.map +1 -0
- package/dist/record-picker-actions.js +32 -0
- package/package.json +1 -1
- package/src/__tests__/document-editor-inline-selects.test.tsx +202 -0
- package/src/__tests__/document-form-dialog.no-stale-flash.test.tsx +169 -0
- package/src/business/document-editor-panels.tsx +13 -10
- package/src/business/document-editor.tsx +72 -20
- package/src/business/index.ts +1 -0
- package/src/business/line-items-editor.tsx +229 -174
- package/src/business/line-product-cell.tsx +219 -0
- package/src/document-form-dialog.tsx +20 -11
- package/src/dynamic-select-field.tsx +51 -22
- package/src/entity-select.tsx +17 -39
- package/src/field-grid.tsx +15 -1
- package/src/index.ts +2 -0
- package/src/record-picker-actions.tsx +81 -0
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// DocumentLinesGrid — editor único de renglones (UX-2).
|
|
2
2
|
// `LineItemsEditor` es el mismo componente: las props nuevas son opcionales.
|
|
3
3
|
// Controlado: `value`/`onChange` con `LineItem[]`. Guardar = `serializeLineItems`.
|
|
4
|
-
import { useMemo, useState } from 'react'
|
|
4
|
+
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
|
5
5
|
import { useTranslation } from 'react-i18next'
|
|
6
|
-
import { Heading, Minus, Plus,
|
|
6
|
+
import { Heading, Minus, 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
|
+
applyProductToLine,
|
|
12
13
|
lineGridKeyCommand,
|
|
13
14
|
type DocumentLinesMode,
|
|
14
15
|
type PriceSource,
|
|
@@ -22,9 +23,8 @@ import {
|
|
|
22
23
|
type LineItemsPolicy,
|
|
23
24
|
type LineItemsValidation,
|
|
24
25
|
} from './line-items'
|
|
25
|
-
import {
|
|
26
|
+
import { LineProductCell } from './line-product-cell'
|
|
26
27
|
import {
|
|
27
|
-
parseProductQuery,
|
|
28
28
|
type ProductQuery,
|
|
29
29
|
type ProductResult,
|
|
30
30
|
type ProductVariant,
|
|
@@ -68,6 +68,11 @@ export interface DocumentLinesGridProps {
|
|
|
68
68
|
editPermission?: string
|
|
69
69
|
/** Moneda ISO (default: la de la org). */
|
|
70
70
|
currency?: string
|
|
71
|
+
/**
|
|
72
|
+
* Pinta el bloque de totales bajo la tabla. Default true; un contenedor que
|
|
73
|
+
* ya muestra sus propios totales (DocumentEditor) lo apaga.
|
|
74
|
+
*/
|
|
75
|
+
showTotals?: boolean
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
/** @deprecated Usa `DocumentLinesGridProps`. Es el mismo contrato. */
|
|
@@ -94,6 +99,7 @@ export function DocumentLinesGrid({
|
|
|
94
99
|
readOnly = false,
|
|
95
100
|
editPermission,
|
|
96
101
|
currency,
|
|
102
|
+
showTotals = true,
|
|
97
103
|
}: DocumentLinesGridProps) {
|
|
98
104
|
const { t } = useTranslation()
|
|
99
105
|
const can = useCan()
|
|
@@ -136,52 +142,121 @@ export function DocumentLinesGrid({
|
|
|
136
142
|
const cell = (i: number, field: keyof LineItem) => errors[`${i}.${field}`]
|
|
137
143
|
const warn = (i: number, field: string) => validation.warnings[`${i}.${field}`]
|
|
138
144
|
|
|
145
|
+
// Foco tras elegir: Enter en el buscador pasa a «Cant.» del renglón que
|
|
146
|
+
// recibió el producto (nuevo o sumado), como un editor de facturas pro.
|
|
147
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
148
|
+
const [focusQty, setFocusQty] = useState<string | null>(null)
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!focusQty) return
|
|
151
|
+
const el = rootRef.current?.querySelector<HTMLInputElement>(`[data-line-key="${focusQty}"] [data-cell="quantity"]`)
|
|
152
|
+
if (el) {
|
|
153
|
+
el.focus()
|
|
154
|
+
el.select?.()
|
|
155
|
+
}
|
|
156
|
+
setFocusQty(null)
|
|
157
|
+
}, [focusQty, value])
|
|
158
|
+
|
|
159
|
+
const blankLine = () => makeLine(discountMode === 'amount' ? { discount_kind: 'amount' } : {})
|
|
160
|
+
|
|
161
|
+
/** Renglón vacío del final: el producto entra como renglón nuevo (o suma al mismo). */
|
|
139
162
|
const pickProduct = (product: ProductResult, variant?: ProductVariant) => {
|
|
140
|
-
|
|
163
|
+
const next = addProductLine(value, product, variant, { priceSource, warehouseId, mergeSameProduct })
|
|
164
|
+
emit(next)
|
|
165
|
+
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]
|
|
167
|
+
if (target) setFocusQty(target.key)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 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]
|
|
173
|
+
if (!cur) return
|
|
174
|
+
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)))
|
|
176
|
+
setFocusQty(cur.key)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Texto escrito en el renglón vacío sin elegir producto → renglón libre con esa descripción. */
|
|
180
|
+
const addFreeFromDraft = (description: string) => {
|
|
181
|
+
const line = { ...blankLine(), description }
|
|
182
|
+
emit([...value, line])
|
|
183
|
+
setFocusQty(line.key)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Ancho mínimo = columnas fijas + un mínimo legible para «Descripción».
|
|
187
|
+
const minWidth =
|
|
188
|
+
260 +
|
|
189
|
+
(stepper ? 144 : 96) +
|
|
190
|
+
112 +
|
|
191
|
+
112 +
|
|
192
|
+
40 +
|
|
193
|
+
(show('discount') ? (discountMode === 'both' ? 128 : 96) : 0) +
|
|
194
|
+
(show('tax') ? 64 : 0) +
|
|
195
|
+
(show('unit') ? 80 : 0) +
|
|
196
|
+
(show('technician') ? 128 : 0) +
|
|
197
|
+
(show('lot') ? 112 : 0) +
|
|
198
|
+
(show('dot') ? 96 : 0)
|
|
199
|
+
const colCount = 1 + (['unit', 'discount', 'tax', 'technician', 'lot', 'dot'] as LineItemsColumn[]).filter(show).length + 4
|
|
200
|
+
|
|
201
|
+
const descKeys = (e: KeyboardEvent<HTMLInputElement>, i: number, l: LineItem) => {
|
|
202
|
+
const cmd = lineGridKeyCommand(e.key, {
|
|
203
|
+
cellEmpty: !l.description.trim() && !l.product_id,
|
|
204
|
+
mode,
|
|
205
|
+
})
|
|
206
|
+
if (cmd === 'delete') {
|
|
207
|
+
e.preventDefault()
|
|
208
|
+
remove(i)
|
|
209
|
+
} else if (cmd === 'add') {
|
|
210
|
+
e.preventDefault()
|
|
211
|
+
emit([...value, blankLine()])
|
|
212
|
+
}
|
|
141
213
|
}
|
|
142
214
|
|
|
143
215
|
return (
|
|
144
|
-
<div data-slot="line-items-editor" data-component="DocumentLinesGrid" data-mode={mode} className="space-y-2">
|
|
145
|
-
<div className="overflow-x-auto rounded-
|
|
146
|
-
|
|
147
|
-
|
|
216
|
+
<div ref={rootRef} data-slot="line-items-editor" data-component="DocumentLinesGrid" data-mode={mode} className="space-y-2">
|
|
217
|
+
<div className="overflow-x-auto rounded-lg border bg-card/40">
|
|
218
|
+
{/* table-fixed: «Descripción» se queda con el ancho libre y las
|
|
219
|
+
numéricas con el suyo; en pantallas angostas la tabla
|
|
220
|
+
conserva un mínimo y desplaza en horizontal. */}
|
|
221
|
+
<table className="w-full table-fixed border-collapse text-sm" style={{ minWidth }}>
|
|
222
|
+
<thead className="border-b bg-muted/40 text-left text-xs font-medium text-muted-foreground">
|
|
148
223
|
<tr>
|
|
149
|
-
<th className="px-
|
|
150
|
-
{show('unit') && <th className="w-20 px-
|
|
151
|
-
<th className=
|
|
152
|
-
<th className="w-
|
|
224
|
+
<th className="px-3 py-2 font-medium">{t('lineItems.description', { defaultValue: 'Descripción' })}</th>
|
|
225
|
+
{show('unit') && <th className="w-20 px-3 py-2 font-medium">{t('lineItems.unit', { defaultValue: 'Unidad' })}</th>}
|
|
226
|
+
<th className={(stepper ? 'w-36' : 'w-24') + ' px-3 py-2 text-right font-medium'}>{t('lineItems.quantity', { defaultValue: 'Cant.' })}</th>
|
|
227
|
+
<th className="w-28 px-3 py-2 text-right font-medium">
|
|
153
228
|
{priceSource === 'cost'
|
|
154
229
|
? t('lineItems.unitCost', { defaultValue: 'Costo unitario' })
|
|
155
230
|
: t('lineItems.unitPrice', { defaultValue: 'Precio' })}
|
|
156
231
|
</th>
|
|
157
232
|
{show('discount') && (
|
|
158
|
-
<th className=
|
|
233
|
+
<th className={(discountMode === 'both' ? 'w-32' : 'w-24') + ' px-3 py-2 text-right font-medium'}>
|
|
159
234
|
{discountMode === 'amount'
|
|
160
235
|
? t('lineItems.discountAmount', { defaultValue: 'Desc.' })
|
|
161
236
|
: t('lineItems.discount', { defaultValue: 'Desc. %' })}
|
|
162
237
|
</th>
|
|
163
238
|
)}
|
|
164
|
-
{show('tax') && <th className="w-
|
|
165
|
-
{show('technician') && <th className="w-32 px-
|
|
166
|
-
{show('lot') && <th className="w-28 px-
|
|
167
|
-
{show('dot') && <th className="w-24 px-
|
|
168
|
-
<th className="w-
|
|
169
|
-
<th className="w-10"
|
|
239
|
+
{show('tax') && <th className="w-16 px-3 py-2 text-right font-medium">{t('lineItems.tax', { defaultValue: 'IVA %' })}</th>}
|
|
240
|
+
{show('technician') && <th className="w-32 px-3 py-2 font-medium">{t('lineItems.technician', { defaultValue: 'Técnico' })}</th>}
|
|
241
|
+
{show('lot') && <th className="w-28 px-3 py-2 font-medium">{t('lineItems.lot', { defaultValue: 'Lote' })}</th>}
|
|
242
|
+
{show('dot') && <th className="w-24 px-3 py-2 font-medium">DOT</th>}
|
|
243
|
+
<th className="w-28 px-3 py-2 text-right font-medium">{t('lineItems.amount', { defaultValue: 'Importe' })}</th>
|
|
244
|
+
<th className="w-10"><span className="sr-only">{t('common.actions', { defaultValue: 'Acciones' })}</span></th>
|
|
170
245
|
</tr>
|
|
171
246
|
</thead>
|
|
172
247
|
<tbody>
|
|
173
248
|
{value.map((l, i) => {
|
|
174
249
|
if (l.kind !== 'item') {
|
|
175
250
|
return (
|
|
176
|
-
<tr key={l.key} className="border-t bg-muted/30">
|
|
177
|
-
<td colSpan={99} className=
|
|
251
|
+
<tr key={l.key} className="group border-t bg-muted/30" data-line-key={l.key}>
|
|
252
|
+
<td colSpan={99} className={CELL}>
|
|
178
253
|
<div className="flex items-center gap-2">
|
|
179
254
|
<Input
|
|
180
255
|
value={l.description}
|
|
181
256
|
disabled={locked}
|
|
182
257
|
aria-invalid={!!cell(i, 'description')}
|
|
183
258
|
placeholder={l.kind === 'section' ? t('lineItems.sectionPlaceholder', { defaultValue: 'Sección' }) : t('lineItems.notePlaceholder', { defaultValue: 'Nota' })}
|
|
184
|
-
className={l.kind === 'section' ? 'font-semibold' : 'italic'}
|
|
259
|
+
className={'h-8 ' + (l.kind === 'section' ? 'font-semibold' : 'italic')}
|
|
185
260
|
onChange={(e) => patch(i, { description: e.target.value })}
|
|
186
261
|
onKeyDown={(e) => {
|
|
187
262
|
const cmd = lineGridKeyCommand(e.key, { cellEmpty: !l.description.trim(), mode })
|
|
@@ -192,7 +267,7 @@ export function DocumentLinesGrid({
|
|
|
192
267
|
}}
|
|
193
268
|
/>
|
|
194
269
|
{canAdd && (
|
|
195
|
-
<Button type="button" size="icon" variant="ghost" onClick={() => remove(i)} aria-label={t('common.delete', { defaultValue: 'Eliminar' })}>
|
|
270
|
+
<Button type="button" size="icon" variant="ghost" className={DELETE} onClick={() => remove(i)} aria-label={t('common.delete', { defaultValue: 'Eliminar' })}>
|
|
196
271
|
<Trash2 className="size-4" />
|
|
197
272
|
</Button>
|
|
198
273
|
)}
|
|
@@ -207,39 +282,44 @@ export function DocumentLinesGrid({
|
|
|
207
282
|
const kind = shown.discount_kind ?? 'percent'
|
|
208
283
|
const qtyMax = l.max_quantity
|
|
209
284
|
return (
|
|
210
|
-
<tr key={l.key} className="border-t align-top" data-line-key={l.key}>
|
|
211
|
-
<td className=
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
285
|
+
<tr key={l.key} className="group border-t align-top transition-colors hover:bg-muted/30 focus-within:bg-muted/20" data-line-key={l.key}>
|
|
286
|
+
<td className={CELL}>
|
|
287
|
+
{search && canAdd && !l.product_id ? (
|
|
288
|
+
<LineProductCell
|
|
289
|
+
search={search}
|
|
290
|
+
text={l.description}
|
|
291
|
+
onTextChange={(text) => patch(i, { description: text })}
|
|
292
|
+
onPick={(p, v) => fillLine(i, p, v)}
|
|
293
|
+
onKeyDownClosed={(e) => descKeys(e, i, l)}
|
|
294
|
+
warehouseId={warehouseId}
|
|
295
|
+
currency={currency}
|
|
296
|
+
ariaLabel={t('lineItems.description', { defaultValue: 'Descripción' })}
|
|
297
|
+
invalid={!!cell(i, 'description')}
|
|
298
|
+
dataCell="description"
|
|
299
|
+
/>
|
|
300
|
+
) : (
|
|
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
|
+
/>
|
|
311
|
+
)}
|
|
232
312
|
{(l.sku || l.unit) && (
|
|
233
313
|
<p className="mt-0.5 text-xs text-muted-foreground">{[l.sku, !show('unit') ? l.unit : ''].filter(Boolean).join(' · ')}</p>
|
|
234
314
|
)}
|
|
235
315
|
{cell(i, 'description') && <p className="mt-0.5 text-xs text-destructive">{cell(i, 'description')}</p>}
|
|
236
316
|
</td>
|
|
237
317
|
{show('unit') && (
|
|
238
|
-
<td className=
|
|
239
|
-
<Input value={l.unit ?? ''} disabled={locked} onChange={(e) => patch(i, { unit: e.target.value })} />
|
|
318
|
+
<td className={CELL}>
|
|
319
|
+
<Input className={INPUT} value={l.unit ?? ''} disabled={locked} onChange={(e) => patch(i, { unit: e.target.value })} />
|
|
240
320
|
</td>
|
|
241
321
|
)}
|
|
242
|
-
<td className=
|
|
322
|
+
<td className={CELL}>
|
|
243
323
|
{stepper ? (
|
|
244
324
|
<div className="flex items-center justify-end gap-1">
|
|
245
325
|
<Button
|
|
@@ -255,7 +335,8 @@ export function DocumentLinesGrid({
|
|
|
255
335
|
</Button>
|
|
256
336
|
<Input
|
|
257
337
|
inputMode="decimal"
|
|
258
|
-
className="h-8 w-14 text-right"
|
|
338
|
+
className="h-8 w-14 text-right tabular-nums"
|
|
339
|
+
data-cell="quantity"
|
|
259
340
|
value={String(l.quantity)}
|
|
260
341
|
disabled={locked}
|
|
261
342
|
aria-label={t('lineItems.quantity', { defaultValue: 'Cant.' })}
|
|
@@ -285,7 +366,8 @@ export function DocumentLinesGrid({
|
|
|
285
366
|
) : (
|
|
286
367
|
<Input
|
|
287
368
|
inputMode="decimal"
|
|
288
|
-
className=
|
|
369
|
+
className={NUM}
|
|
370
|
+
data-cell="quantity"
|
|
289
371
|
value={String(l.quantity)}
|
|
290
372
|
disabled={locked}
|
|
291
373
|
aria-label={t('lineItems.quantity', { defaultValue: 'Cant.' })}
|
|
@@ -296,10 +378,10 @@ export function DocumentLinesGrid({
|
|
|
296
378
|
{cell(i, 'quantity') && <p className="mt-0.5 text-xs text-destructive">{cell(i, 'quantity')}</p>}
|
|
297
379
|
{!cell(i, 'quantity') && warn(i, 'quantity') && <p className="mt-0.5 text-xs text-muted-foreground">{warn(i, 'quantity')}</p>}
|
|
298
380
|
</td>
|
|
299
|
-
<td className=
|
|
381
|
+
<td className={CELL}>
|
|
300
382
|
<Input
|
|
301
383
|
inputMode="decimal"
|
|
302
|
-
className=
|
|
384
|
+
className={NUM}
|
|
303
385
|
value={String(l.unit_price)}
|
|
304
386
|
disabled={locked}
|
|
305
387
|
aria-label={priceSource === 'cost' ? t('lineItems.unitCost', { defaultValue: 'Costo unitario' }) : t('lineItems.unitPrice', { defaultValue: 'Precio' })}
|
|
@@ -307,7 +389,7 @@ export function DocumentLinesGrid({
|
|
|
307
389
|
onChange={(e) => patch(i, { unit_price: num(e.target.value) })}
|
|
308
390
|
/>
|
|
309
391
|
{l.catalog_price != null && (
|
|
310
|
-
<p className="mt-0.5 text-right text-xs text-muted-foreground">
|
|
392
|
+
<p className="mt-0.5 truncate whitespace-nowrap text-right text-xs text-muted-foreground">
|
|
311
393
|
{t('lineItems.catalogPrice', { defaultValue: 'Catálogo' })} {fmt.money(l.catalog_price)}
|
|
312
394
|
</p>
|
|
313
395
|
)}
|
|
@@ -315,7 +397,7 @@ export function DocumentLinesGrid({
|
|
|
315
397
|
{!cell(i, 'unit_price') && warn(i, 'unit_price') && <p className="mt-0.5 text-xs text-muted-foreground">{warn(i, 'unit_price')}</p>}
|
|
316
398
|
</td>
|
|
317
399
|
{show('discount') && (
|
|
318
|
-
<td className=
|
|
400
|
+
<td className={CELL}>
|
|
319
401
|
<div className="flex items-center gap-1">
|
|
320
402
|
{discountMode === 'both' && (
|
|
321
403
|
<Button
|
|
@@ -331,7 +413,7 @@ export function DocumentLinesGrid({
|
|
|
331
413
|
)}
|
|
332
414
|
<Input
|
|
333
415
|
inputMode="decimal"
|
|
334
|
-
className=
|
|
416
|
+
className={NUM}
|
|
335
417
|
value={String(l.discount)}
|
|
336
418
|
disabled={locked}
|
|
337
419
|
aria-invalid={!!cell(i, 'discount')}
|
|
@@ -342,10 +424,10 @@ export function DocumentLinesGrid({
|
|
|
342
424
|
</td>
|
|
343
425
|
)}
|
|
344
426
|
{show('tax') && (
|
|
345
|
-
<td className=
|
|
427
|
+
<td className={CELL}>
|
|
346
428
|
<Input
|
|
347
429
|
inputMode="decimal"
|
|
348
|
-
className=
|
|
430
|
+
className={NUM}
|
|
349
431
|
value={String(Math.round(l.tax_rate * 10000) / 100)}
|
|
350
432
|
disabled={locked}
|
|
351
433
|
onChange={(e) => patch(i, { tax_rate: num(e.target.value) / 100 })}
|
|
@@ -353,24 +435,26 @@ export function DocumentLinesGrid({
|
|
|
353
435
|
</td>
|
|
354
436
|
)}
|
|
355
437
|
{show('technician') && (
|
|
356
|
-
<td className=
|
|
357
|
-
<Input value={l.technician_id ?? ''} disabled={locked} onChange={(e) => patch(i, { technician_id: e.target.value })} />
|
|
438
|
+
<td className={CELL}>
|
|
439
|
+
<Input className={INPUT} value={l.technician_id ?? ''} disabled={locked} onChange={(e) => patch(i, { technician_id: e.target.value })} />
|
|
358
440
|
</td>
|
|
359
441
|
)}
|
|
360
442
|
{show('lot') && (
|
|
361
|
-
<td className=
|
|
362
|
-
<Input value={l.lot ?? ''} disabled={locked} onChange={(e) => patch(i, { lot: e.target.value })} />
|
|
443
|
+
<td className={CELL}>
|
|
444
|
+
<Input className={INPUT} value={l.lot ?? ''} disabled={locked} onChange={(e) => patch(i, { lot: e.target.value })} />
|
|
363
445
|
</td>
|
|
364
446
|
)}
|
|
365
447
|
{show('dot') && (
|
|
366
|
-
<td className=
|
|
367
|
-
<Input value={l.dot ?? ''} disabled={locked} onChange={(e) => patch(i, { dot: e.target.value })} />
|
|
448
|
+
<td className={CELL}>
|
|
449
|
+
<Input className={INPUT} value={l.dot ?? ''} disabled={locked} onChange={(e) => patch(i, { dot: e.target.value })} />
|
|
368
450
|
</td>
|
|
369
451
|
)}
|
|
370
|
-
<td className="px-
|
|
371
|
-
|
|
452
|
+
<td className="px-3 py-1.5 text-right align-top font-medium tabular-nums" data-slot="line-amount">
|
|
453
|
+
<span className="inline-flex h-8 items-center">{fmt.money(a.net)}</span>
|
|
454
|
+
</td>
|
|
455
|
+
<td className="px-1 py-1.5 align-top">
|
|
372
456
|
{canAdd && (
|
|
373
|
-
<Button type="button" size="icon" variant="ghost" onClick={() => remove(i)} aria-label={t('common.delete', { defaultValue: 'Eliminar' })}>
|
|
457
|
+
<Button type="button" size="icon" variant="ghost" className={DELETE} onClick={() => remove(i)} aria-label={t('common.delete', { defaultValue: 'Eliminar' })}>
|
|
374
458
|
<Trash2 className="size-4" />
|
|
375
459
|
</Button>
|
|
376
460
|
)}
|
|
@@ -386,15 +470,25 @@ export function DocumentLinesGrid({
|
|
|
386
470
|
</tr>
|
|
387
471
|
)}
|
|
388
472
|
{canAdd && search && (
|
|
389
|
-
<
|
|
473
|
+
<DraftRow
|
|
390
474
|
search={search}
|
|
391
475
|
warehouseId={warehouseId}
|
|
392
476
|
currency={currency}
|
|
393
477
|
onPick={pickProduct}
|
|
394
|
-
|
|
478
|
+
onFree={addFreeFromDraft}
|
|
479
|
+
onAddBlank={() => emit([...value, blankLine()])}
|
|
480
|
+
trailingCells={colCount - 1}
|
|
395
481
|
/>
|
|
396
482
|
)}
|
|
397
483
|
</tbody>
|
|
484
|
+
{showTotals && (
|
|
485
|
+
<tfoot className="border-t bg-muted/20 text-sm tabular-nums" data-slot="line-totals">
|
|
486
|
+
<TotalRow span={colCount - 2} label={t('lineItems.subtotal', { defaultValue: 'Subtotal' })} value={fmt.money(totals.subtotal)} />
|
|
487
|
+
{totals.discount > 0 && <TotalRow span={colCount - 2} label={t('lineItems.discountTotal', { defaultValue: 'Descuento' })} value={`-${fmt.money(totals.discount)}`} />}
|
|
488
|
+
{totals.tax > 0 && <TotalRow span={colCount - 2} label={t('lineItems.taxTotal', { defaultValue: 'Impuestos' })} value={fmt.money(totals.tax)} slot="tax-total" />}
|
|
489
|
+
<TotalRow span={colCount - 2} label={t('lineItems.total', { defaultValue: 'Total' })} value={fmt.money(totals.total)} slot="grand-total" strong />
|
|
490
|
+
</tfoot>
|
|
491
|
+
)}
|
|
398
492
|
</table>
|
|
399
493
|
</div>
|
|
400
494
|
|
|
@@ -416,16 +510,16 @@ export function DocumentLinesGrid({
|
|
|
416
510
|
</p>
|
|
417
511
|
)}
|
|
418
512
|
|
|
419
|
-
<div className="flex flex-wrap items-
|
|
513
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
420
514
|
{canAdd ? (
|
|
421
|
-
<div className="flex flex-wrap gap-
|
|
515
|
+
<div className="flex flex-wrap gap-1">
|
|
422
516
|
{onRequestProduct && !search && (
|
|
423
517
|
<Button type="button" size="sm" variant="outline" onClick={onRequestProduct}>
|
|
424
518
|
<Plus className="mr-1 size-4" />
|
|
425
519
|
{t('lineItems.addProduct', { defaultValue: 'Agregar producto' })}
|
|
426
520
|
</Button>
|
|
427
521
|
)}
|
|
428
|
-
<Button type="button" size="sm" variant="
|
|
522
|
+
<Button type="button" size="sm" variant="ghost" onClick={() => emit([...value, blankLine()])}>
|
|
429
523
|
<Plus className="mr-1 size-4" />
|
|
430
524
|
{t('lineItems.addLine', { defaultValue: 'Renglón libre' })}
|
|
431
525
|
</Button>
|
|
@@ -442,15 +536,7 @@ export function DocumentLinesGrid({
|
|
|
442
536
|
</>
|
|
443
537
|
)}
|
|
444
538
|
</div>
|
|
445
|
-
) :
|
|
446
|
-
<span />
|
|
447
|
-
)}
|
|
448
|
-
<dl className="min-w-56 space-y-0.5 text-sm tabular-nums">
|
|
449
|
-
<div className="flex justify-between gap-6"><dt className="text-muted-foreground">{t('lineItems.subtotal', { defaultValue: 'Subtotal' })}</dt><dd>{fmt.money(totals.subtotal)}</dd></div>
|
|
450
|
-
{totals.discount > 0 && <div className="flex justify-between gap-6"><dt className="text-muted-foreground">{t('lineItems.discountTotal', { defaultValue: 'Descuento' })}</dt><dd>-{fmt.money(totals.discount)}</dd></div>}
|
|
451
|
-
{totals.tax > 0 && <div className="flex justify-between gap-6"><dt className="text-muted-foreground">{t('lineItems.taxTotal', { defaultValue: 'Impuestos' })}</dt><dd data-slot="tax-total">{fmt.money(totals.tax)}</dd></div>}
|
|
452
|
-
<div className="flex justify-between gap-6 font-semibold"><dt>{t('lineItems.total', { defaultValue: 'Total' })}</dt><dd data-slot="grand-total">{fmt.money(totals.total)}</dd></div>
|
|
453
|
-
</dl>
|
|
539
|
+
) : null}
|
|
454
540
|
</div>
|
|
455
541
|
</div>
|
|
456
542
|
)
|
|
@@ -463,116 +549,85 @@ function toNum(n: number): number {
|
|
|
463
549
|
return Number.isFinite(n) ? n : 0
|
|
464
550
|
}
|
|
465
551
|
|
|
466
|
-
|
|
552
|
+
/**
|
|
553
|
+
* Renglón vacío del final, siempre listo para buscar: el buscador vive en la
|
|
554
|
+
* celda «Descripción» (no en una barra a todo lo ancho). Elegir agrega el
|
|
555
|
+
* producto; Enter con texto sin coincidencias lo agrega como renglón libre.
|
|
556
|
+
*/
|
|
557
|
+
function DraftRow({
|
|
467
558
|
search,
|
|
468
559
|
warehouseId,
|
|
469
560
|
currency,
|
|
470
561
|
onPick,
|
|
471
|
-
|
|
562
|
+
onFree,
|
|
563
|
+
onAddBlank,
|
|
564
|
+
trailingCells,
|
|
472
565
|
}: {
|
|
566
|
+
trailingCells: number
|
|
473
567
|
search: NonNullable<DocumentLinesGridProps['search']>
|
|
474
568
|
warehouseId?: string
|
|
475
569
|
currency?: string
|
|
476
570
|
onPick: (product: ProductResult, variant?: ProductVariant) => void
|
|
477
|
-
|
|
571
|
+
onFree: (description: string) => void
|
|
572
|
+
onAddBlank: () => void
|
|
478
573
|
}) {
|
|
479
574
|
const { t } = useTranslation()
|
|
480
|
-
const fmt = useFormatter({ currency })
|
|
481
575
|
const [text, setText] = useState('')
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
576
|
+
return (
|
|
577
|
+
<tr className="border-t border-dashed" data-slot="line-draft-row">
|
|
578
|
+
<td className={CELL}>
|
|
579
|
+
<LineProductCell
|
|
580
|
+
search={search}
|
|
581
|
+
text={text}
|
|
582
|
+
onTextChange={setText}
|
|
583
|
+
onPick={(p, v) => {
|
|
584
|
+
setText('')
|
|
585
|
+
onPick(p, v)
|
|
586
|
+
}}
|
|
587
|
+
onKeyDownClosed={(e) => {
|
|
588
|
+
if (e.key !== 'Enter') return
|
|
589
|
+
e.preventDefault()
|
|
590
|
+
const typed = text.trim()
|
|
591
|
+
setText('')
|
|
592
|
+
if (typed) onFree(typed)
|
|
593
|
+
else onAddBlank()
|
|
594
|
+
}}
|
|
595
|
+
warehouseId={warehouseId}
|
|
596
|
+
currency={currency}
|
|
597
|
+
ariaLabel={t('lineItems.searchProduct', { defaultValue: 'Buscar producto' })}
|
|
598
|
+
placeholder={t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
|
|
599
|
+
className="border-dashed bg-transparent shadow-none"
|
|
600
|
+
dataCell="search"
|
|
601
|
+
/>
|
|
602
|
+
</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
|
+
))}
|
|
607
|
+
</tr>
|
|
496
608
|
)
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const choose = (idx: number) => {
|
|
500
|
-
const hit = flat[idx]
|
|
501
|
-
if (!hit) return
|
|
502
|
-
onPick(hit.product, hit.variant)
|
|
503
|
-
setText('')
|
|
504
|
-
setActive(0)
|
|
505
|
-
}
|
|
609
|
+
}
|
|
506
610
|
|
|
611
|
+
/** Fila de totales alineada a la columna «Importe». */
|
|
612
|
+
function TotalRow({ span, label, value, slot, strong }: { span: number; label: string; value: string; slot?: string; strong?: boolean }) {
|
|
507
613
|
return (
|
|
508
|
-
<tr className=
|
|
509
|
-
<td colSpan={
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
<Input
|
|
513
|
-
className="pl-8"
|
|
514
|
-
value={text}
|
|
515
|
-
role="combobox"
|
|
516
|
-
aria-expanded={open}
|
|
517
|
-
aria-label={t('lineItems.searchProduct', { defaultValue: 'Buscar producto' })}
|
|
518
|
-
placeholder={t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
|
|
519
|
-
onChange={(e) => {
|
|
520
|
-
setText(e.target.value)
|
|
521
|
-
setActive(0)
|
|
522
|
-
}}
|
|
523
|
-
onKeyDown={(e) => {
|
|
524
|
-
if (e.key === 'ArrowDown' && open) {
|
|
525
|
-
e.preventDefault()
|
|
526
|
-
setActive((n) => Math.min(flat.length - 1, n + 1))
|
|
527
|
-
return
|
|
528
|
-
}
|
|
529
|
-
if (e.key === 'ArrowUp' && open) {
|
|
530
|
-
e.preventDefault()
|
|
531
|
-
setActive((n) => Math.max(0, n - 1))
|
|
532
|
-
return
|
|
533
|
-
}
|
|
534
|
-
const cmd = lineGridKeyCommand(e.key, { suggestionsOpen: open, cellEmpty: !text.trim(), mode: 'free' })
|
|
535
|
-
if (e.key === 'Enter' && open) {
|
|
536
|
-
e.preventDefault()
|
|
537
|
-
choose(active)
|
|
538
|
-
return
|
|
539
|
-
}
|
|
540
|
-
if (cmd === 'add' && !text.trim()) {
|
|
541
|
-
e.preventDefault()
|
|
542
|
-
onAddFree()
|
|
543
|
-
}
|
|
544
|
-
}}
|
|
545
|
-
/>
|
|
546
|
-
{open && (
|
|
547
|
-
<ul role="listbox" className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md border bg-popover shadow-md">
|
|
548
|
-
{flat.map((hit, idx) => {
|
|
549
|
-
const price = priceOf(hit.product, hit.variant, warehouseId)
|
|
550
|
-
return (
|
|
551
|
-
<li key={`${hit.product.id}:${hit.variant?.id ?? ''}`} role="option" aria-selected={idx === active}>
|
|
552
|
-
<button
|
|
553
|
-
type="button"
|
|
554
|
-
className={'flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left text-sm hover:bg-accent ' + (idx === active ? 'bg-accent' : '')}
|
|
555
|
-
onMouseDown={(e) => e.preventDefault()}
|
|
556
|
-
onClick={() => choose(idx)}
|
|
557
|
-
>
|
|
558
|
-
<span>
|
|
559
|
-
<span className="block font-medium">{hit.variant ? `${hit.product.name} · ${hit.variant.label}` : hit.product.name}</span>
|
|
560
|
-
<span className="block text-xs text-muted-foreground">{hit.variant?.sku ?? hit.product.sku}</span>
|
|
561
|
-
</span>
|
|
562
|
-
{price != null && <span className="tabular-nums">{fmt.money(price)}</span>}
|
|
563
|
-
</button>
|
|
564
|
-
</li>
|
|
565
|
-
)
|
|
566
|
-
})}
|
|
567
|
-
</ul>
|
|
568
|
-
)}
|
|
569
|
-
{loading && <p className="mt-1 text-xs text-muted-foreground">{t('common.searching', { defaultValue: 'Buscando…' })}</p>}
|
|
570
|
-
</div>
|
|
614
|
+
<tr className={strong ? 'font-semibold' : 'text-muted-foreground'}>
|
|
615
|
+
<td colSpan={span} className="px-3 py-1 text-right">{label}</td>
|
|
616
|
+
<td className={'px-3 py-1 text-right ' + (strong ? 'text-base text-foreground' : 'text-foreground')} data-slot={slot}>
|
|
617
|
+
{value}
|
|
571
618
|
</td>
|
|
619
|
+
<td />
|
|
572
620
|
</tr>
|
|
573
621
|
)
|
|
574
622
|
}
|
|
575
623
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
624
|
+
/** Borrar renglón: aparece al pasar o enfocar la fila (siempre visible en móvil). */
|
|
625
|
+
const DELETE =
|
|
626
|
+
'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
|
+
|
|
628
|
+
/** Celda densa: misma altura en todos los renglones. */
|
|
629
|
+
const CELL = 'px-2 py-1.5 align-top'
|
|
630
|
+
/** Inputs de celda: un poco más bajos que los del formulario (densidad de editor). */
|
|
631
|
+
const INPUT = 'h-8'
|
|
632
|
+
/** Numéricos: alineados a la derecha y con cifras tabulares. */
|
|
633
|
+
const NUM = 'h-8 text-right tabular-nums'
|