// DocumentLinesGrid — editor único de renglones (UX-2). // `LineItemsEditor` es el mismo componente: las props nuevas son opcionales. // Controlado: `value`/`onChange` con `LineItem[]`. Guardar = `serializeLineItems`. import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import { useTranslation } from 'react-i18next' import { Heading, Minus, Pencil, Plus, StickyNote, Trash2 } from 'lucide-react' import { Button, Input } from '@asteby/metacore-ui' import { useCan } from '../permissions-context' import { useFormatter } from './format' import { addProductLine, applyCatalogPending, applyProductToLine, refreshLineFromProduct, lineGridKeyCommand, type DocumentLinesMode, type PriceSource, } from './document-lines' import { computeLine, computeTotals, makeLine, validateLineItems, type LineItem, type LineItemsPolicy, type LineItemsValidation, } from './line-items' import { LineProductCell } from './line-product-cell' import { type ProductQuery, type ProductResult, type ProductVariant, } from './product-search' export type LineItemsColumn = 'discount' | 'tax' | 'technician' | 'lot' | 'dot' | 'unit' export interface DocumentLinesGridProps { value: LineItem[] /** Se emite con cada cambio; las líneas siempre traen `key`. */ onChange: (lines: LineItem[]) => void /** Columnas opcionales visibles. Default: `['discount']`. */ columns?: LineItemsColumn[] policy?: LineItemsPolicy /** Errores externos del servidor `"<índice>."` (de `mapApiError().fields`). */ serverErrors?: Record /** Se emite tras cada cambio con el resultado de validar (para bloquear «Guardar»). */ onValidate?: (v: LineItemsValidation) => void /** Slot legado: abre el ProductPicker. Con `search`, el buscador va en la fila. */ onRequestProduct?: () => void /** * Búsqueda en la fila (mismo contrato que ProductPicker). Al elegir, el renglón * se llena aquí; un segundo clic del mismo producto suma cantidad. */ search?: (query: Exclude, signal: AbortSignal) => Promise /** `sale` usa el precio de lista; `cost` el costo de catálogo (compras). Default `sale`. */ priceSource?: PriceSource /** `from_source` oculta alta/borrado y permite cantidad 0 hasta `max_quantity`. */ mode?: DocumentLinesMode /** Cómo se captura el descuento. Default `percent` (el comportamiento anterior). */ discountMode?: 'percent' | 'amount' | 'both' /** − / cantidad / +. Default: activo cuando hay `search`. */ quantityStepper?: boolean /** Un segundo clic incrementa la cantidad del mismo producto. Default true. */ mergeSameProduct?: boolean warehouseId?: string /** Permite secciones y notas (Odoo-style). Default true. */ allowSections?: boolean readOnly?: boolean /** Permiso requerido para editar (default: sin gate). Sin él → solo lectura. */ editPermission?: string /** Moneda ISO (default: la de la org). */ currency?: string /** * Pinta el bloque de totales bajo la tabla. Default true; un contenedor que * ya muestra sus propios totales (DocumentEditor) lo apaga. */ showTotals?: boolean /** * Alta del producto desde la celda («+» y «Crear producto «texto»»). Resuelve * con el producto guardado (o nada si se canceló): el renglón se llena con * el mismo mapeo que al elegir uno existente. */ onCreateProduct?: (query: string) => Promise /** * Edición del producto del renglón (lápiz en la celda). Resuelve con el * producto ya guardado: el renglón se actualiza salvo los campos que el * usuario sobrescribió (ver `refreshLineFromProduct`). */ onEditProduct?: (line: LineItem) => Promise /** Nombre del modelo de catálogo para «Crear …» / «Editar …» (default «producto»). */ productLabel?: string /** * Etiqueta visible de la unidad (`unit` → «pza»). Si devuelve vacío, la * unidad no se muestra bajo el SKU (nunca la clave cruda). Sin esta prop se * muestra el valor tal cual. */ unitLabel?: (unit: string) => string | undefined } /** @deprecated Usa `DocumentLinesGridProps`. Es el mismo contrato. */ export type LineItemsEditorProps = DocumentLinesGridProps const num = (s: string): number => (s.trim() === '' ? 0 : Number(s.replace(',', '.')) || 0) export function DocumentLinesGrid({ value, onChange, columns = ['discount'], policy, serverErrors, onValidate, onRequestProduct, search, priceSource = 'sale', mode = 'free', discountMode = 'percent', quantityStepper, mergeSameProduct = true, warehouseId, allowSections = true, readOnly = false, editPermission, currency, showTotals = true, onCreateProduct, onEditProduct, productLabel, unitLabel, }: DocumentLinesGridProps) { const { t } = useTranslation() const can = useCan() const fmt = useFormatter({ currency }) const locked = readOnly || (editPermission ? !can(editPermission) : false) const fromSource = mode === 'from_source' const effectivePolicy = useMemo( () => (fromSource ? { ...policy, allowZeroQuantity: policy?.allowZeroQuantity ?? true } : (policy ?? {})), [policy, fromSource], ) const view = useMemo( () => discountMode === 'amount' ? value.map((l) => (l.kind === 'item' ? { ...l, discount_kind: 'amount' as const } : l)) : value, [value, discountMode], ) const validation = useMemo(() => validateLineItems(view, effectivePolicy), [view, effectivePolicy]) const totals = useMemo(() => computeTotals(view), [view]) const errors = { ...validation.errors, ...serverErrors } const show = (c: LineItemsColumn) => columns.includes(c) const stepper = quantityStepper ?? !!search const canAdd = !locked && !fromSource const [undo, setUndo] = useState(null) // Crear/editar desde la celda resuelven DESPUÉS de un modal: se trabaja // sobre los renglones de ese momento (no los del render que abrió el modal). const latest = useRef(value) latest.current = value const entity = productLabel ?? t('lineItems.productEntity', { defaultValue: 'producto' }) const emit = (next: LineItem[]) => { onChange(next) const priced = discountMode === 'amount' ? next.map((l) => (l.kind === 'item' ? { ...l, discount_kind: 'amount' as const } : l)) : next onValidate?.(validateLineItems(priced, effectivePolicy)) } const patch = (idx: number, p: Partial) => { if (discountMode === 'amount') p = { ...p, discount_kind: 'amount' } emit(value.map((l, i) => (i === idx ? withoutAnsweredPending({ ...l, ...p }, p) : l))) } const remove = (idx: number) => { setUndo(value) emit(value.filter((_, i) => i !== idx)) } const cell = (i: number, field: keyof LineItem) => errors[`${i}.${field}`] const warn = (i: number, field: string) => validation.warnings[`${i}.${field}`] // Foco tras elegir: Enter en el buscador pasa a «Cant.» del renglón que // recibió el producto (nuevo o sumado), como un editor de facturas pro. const rootRef = useRef(null) const [focusQty, setFocusQty] = useState(null) useEffect(() => { if (!focusQty) return const el = rootRef.current?.querySelector(`[data-line-key="${focusQty}"] [data-cell="quantity"]`) if (el) { el.focus() el.select?.() } setFocusQty(null) }, [focusQty, value]) const blankLine = () => makeLine(discountMode === 'amount' ? { discount_kind: 'amount' } : {}) /** Renglón vacío del final: el producto entra como renglón nuevo (o suma al mismo). */ const pickProduct = (product: ProductResult, variant?: ProductVariant, base: LineItem[] = value) => { const next = addProductLine(base, product, variant, { priceSource, warehouseId, mergeSameProduct }) emit(next) const id = variant?.id ?? product.id const target = next.find((l) => l.kind === 'item' && l.product_id === id && !base.includes(l)) ?? next[next.length - 1] if (target) setFocusQty(target.key) } /** Renglón libre existente: el producto elegido en su celda llena ESE renglón. */ const fillLine = (idx: number, product: ProductResult, variant?: ProductVariant, base: LineItem[] = value) => { const cur = base[idx] if (!cur) return const filled = applyProductToLine(cur, product, variant, { priceSource, warehouseId }) emit(base.map((l, i) => (i === idx ? (discountMode === 'amount' ? { ...filled, discount_kind: 'amount' as const } : filled) : l))) setFocusQty(cur.key) } /** * «Crear producto «texto»» / «+»: el alta del modelo de catálogo; al * guardarse, el producto nuevo entra en ESE renglón (o en uno nuevo desde * el renglón del final) y lo llena igual que al elegir uno existente. */ const createProduct = onCreateProduct ? async (query: string, lineKey?: string) => { const product = await onCreateProduct(query) if (!product) return const base = latest.current const idx = lineKey ? base.findIndex((l) => l.key === lineKey) : -1 if (idx >= 0 && !base[idx].product_id) fillLine(idx, product, undefined, base) else pickProduct(product, undefined, base) } : undefined /** * Lápiz: edita el producto del renglón; al guardar, el renglón toma los * valores nuevos salvo lo que el usuario sobrescribió (queda el aviso). */ const editProduct = onEditProduct ? async (line: LineItem) => { const product = await onEditProduct(line) if (!product) return const base = latest.current emit(base.map((l) => (l.key === line.key ? refreshLineFromProduct(l, product, { priceSource, warehouseId }) : l))) } : undefined const unitText = (u?: string) => (!u ? undefined : unitLabel ? unitLabel(u) || undefined : u) /** Texto escrito en el renglón vacío sin elegir producto → renglón libre con esa descripción. */ const addFreeFromDraft = (description: string) => { const line = { ...blankLine(), description } emit([...value, line]) setFocusQty(line.key) } // Ancho mínimo = columnas fijas + un mínimo legible para «Descripción». const minWidth = 260 + (stepper ? 144 : 96) + 112 + 112 + 40 + (show('discount') ? (discountMode === 'both' ? 128 : 96) : 0) + (show('tax') ? 64 : 0) + (show('unit') ? 80 : 0) + (show('technician') ? 128 : 0) + (show('lot') ? 112 : 0) + (show('dot') ? 96 : 0) const colCount = 1 + (['unit', 'discount', 'tax', 'technician', 'lot', 'dot'] as LineItemsColumn[]).filter(show).length + 4 const descKeys = (e: KeyboardEvent, i: number, l: LineItem) => { const cmd = lineGridKeyCommand(e.key, { cellEmpty: !l.description.trim() && !l.product_id, mode, }) if (cmd === 'delete') { e.preventDefault() remove(i) } else if (cmd === 'add') { e.preventDefault() emit([...value, blankLine()]) } } return (
{/* table-fixed: «Descripción» se queda con el ancho libre y las numéricas con el suyo; en pantallas angostas la tabla conserva un mínimo y desplaza en horizontal. */} {show('unit') && } {show('discount') && ( )} {show('tax') && } {show('technician') && } {show('lot') && } {show('dot') && } {value.map((l, i) => { if (l.kind !== 'item') { return ( ) } const shown = view[i] ?? l const a = computeLine(shown) const kind = shown.discount_kind ?? 'percent' const qtyMax = l.max_quantity return ( {show('unit') && ( )} {show('discount') && ( )} {show('tax') && ( )} {show('technician') && ( )} {show('lot') && ( )} {show('dot') && ( )} ) })} {value.length === 0 && !search && ( )} {canAdd && search && ( pickProduct(p, v)} onFree={addFreeFromDraft} onAddBlank={() => emit([...value, blankLine()])} onCreate={createProduct ? (q) => void createProduct(q) : undefined} entityLabel={entity} span={colCount - 2} /> )} {showTotals && ( {totals.discount > 0 && } {totals.tax > 0 && } )}
{t('lineItems.description', { defaultValue: 'Descripción' })}{t('lineItems.unit', { defaultValue: 'Unidad' })}{t('lineItems.quantity', { defaultValue: 'Cant.' })} {priceSource === 'cost' ? t('lineItems.unitCost', { defaultValue: 'Costo unitario' }) : t('lineItems.unitPrice', { defaultValue: 'Precio' })} {discountMode === 'amount' ? t('lineItems.discountAmount', { defaultValue: 'Desc.' }) : t('lineItems.discount', { defaultValue: 'Desc. %' })} {t('lineItems.tax', { defaultValue: 'IVA %' })}{t('lineItems.technician', { defaultValue: 'Técnico' })}{t('lineItems.lot', { defaultValue: 'Lote' })}DOT{t('lineItems.amount', { defaultValue: 'Importe' })} {t('common.actions', { defaultValue: 'Acciones' })}
patch(i, { description: e.target.value })} onKeyDown={(e) => { const cmd = lineGridKeyCommand(e.key, { cellEmpty: !l.description.trim(), mode }) if (cmd === 'delete') { e.preventDefault() remove(i) } }} /> {canAdd && ( )}
{cell(i, 'description') &&

{cell(i, 'description')}

}
{search && canAdd && !l.product_id ? ( patch(i, { description: text })} onPick={(p, v) => fillLine(i, p, v)} onKeyDownClosed={(e) => descKeys(e, i, l)} warehouseId={warehouseId} currency={currency} ariaLabel={t('lineItems.description', { defaultValue: 'Descripción' })} invalid={!!cell(i, 'description')} dataCell="description" onCreate={createProduct ? (q) => void createProduct(q, l.key) : undefined} entityLabel={entity} /> ) : (
patch(i, { description: e.target.value })} onKeyDown={(e) => descKeys(e, i, l)} /> {editProduct && l.product_id && !locked && ( )}
)} {(l.sku || unitText(l.unit)) && (

{[l.sku, !show('unit') ? unitText(l.unit) : ''].filter(Boolean).join(' · ')}

)} {l.catalog_pending && !locked && (

{l.catalog_pending.unit_price != null ? t('lineItems.catalogPriceChanged', { defaultValue: 'Precio de catálogo cambió a {{price}}', price: fmt.money(l.catalog_pending.unit_price), }) : t('lineItems.catalogChanged', { defaultValue: 'El producto cambió en el catálogo' })} {' — '}

)} {cell(i, 'description') &&

{cell(i, 'description')}

}
patch(i, { unit: e.target.value })} /> {stepper ? (
{ let q = num(e.target.value) if (qtyMax != null && q > qtyMax) q = qtyMax if (q < 0 && !effectivePolicy.allowNegative) q = 0 patch(i, { quantity: q }) }} />
) : ( patch(i, { quantity: num(e.target.value) })} /> )} {cell(i, 'quantity') &&

{cell(i, 'quantity')}

} {!cell(i, 'quantity') && warn(i, 'quantity') &&

{warn(i, 'quantity')}

}
patch(i, { unit_price: num(e.target.value) })} /> {l.catalog_price != null && (

{t('lineItems.catalogPrice', { defaultValue: 'Catálogo' })} {fmt.money(l.catalog_price)}

)} {cell(i, 'unit_price') &&

{cell(i, 'unit_price')}

} {!cell(i, 'unit_price') && warn(i, 'unit_price') &&

{warn(i, 'unit_price')}

}
{discountMode === 'both' && ( )} patch(i, { discount: num(e.target.value), discount_kind: kind })} />
{cell(i, 'discount') &&

{cell(i, 'discount')}

}
patch(i, { tax_rate: num(e.target.value) / 100 })} /> patch(i, { technician_id: e.target.value })} /> patch(i, { lot: e.target.value })} /> patch(i, { dot: e.target.value })} /> {fmt.money(a.net)} {canAdd && ( )}
{t('lineItems.empty', { defaultValue: 'Aún no hay renglones. Agrega un producto o un renglón libre.' })}
{validation.form && value.length > 0 &&

{validation.form}

} {undo && canAdd && (

{t('lineItems.removed', { defaultValue: 'Renglón eliminado.' })}{' '}

)}
{canAdd ? (
{onRequestProduct && !search && ( )} {allowSections && ( <> )}
) : null}
) } /** Capturar a mano un campo con aviso de catálogo responde el aviso: deja de ofrecer «aplicar» para ese campo. */ function withoutAnsweredPending(line: LineItem, p: Partial): LineItem { if (!line.catalog_pending) return line const pending = { ...line.catalog_pending } for (const k of Object.keys(p)) delete (pending as Record)[k] const { catalog_pending: _drop, ...rest } = line return Object.keys(pending).length > 0 ? { ...rest, catalog_pending: pending } : rest } /** Mismo componente. El nombre anterior sigue exportado. */ export const LineItemsEditor = DocumentLinesGrid function toNum(n: number): number { return Number.isFinite(n) ? n : 0 } /** * Renglón vacío del final, siempre listo para buscar: el buscador vive en la * celda «Descripción» (no en una barra a todo lo ancho). Elegir agrega el * producto; Enter con texto sin coincidencias lo agrega como renglón libre. */ function DraftRow({ search, warehouseId, currency, onPick, onFree, onAddBlank, onCreate, entityLabel, span, }: { /** Columnas antes de «Importe» y acciones; el buscador ocupa solo la primera. */ span: number onCreate?: (query: string) => void entityLabel?: string search: NonNullable warehouseId?: string currency?: string onPick: (product: ProductResult, variant?: ProductVariant) => void onFree: (description: string) => void onAddBlank: () => void }) { const { t } = useTranslation() const [text, setText] = useState('') return ( {/* El buscador vive SOLO en la celda «Descripción», como cualquier renglón: no invade Cant./Precio/Desc. (esas celdas quedan vacías hasta elegir producto). */} { setText('') onPick(p, v) }} onKeyDownClosed={(e) => { if (e.key !== 'Enter') return e.preventDefault() const typed = text.trim() setText('') if (typed) onFree(typed) else onAddBlank() }} warehouseId={warehouseId} currency={currency} ariaLabel={t('lineItems.searchProduct', { defaultValue: 'Buscar producto' })} className="border-dashed bg-transparent shadow-none" dataCell="search" onCreate={ onCreate ? (q) => { setText('') onCreate(q) } : undefined } entityLabel={entityLabel} /> {Array.from({ length: Math.max(0, span - 1) }, (_, i) => ( ))} ) } /** Fila de totales alineada a la columna «Importe». */ function TotalRow({ span, label, value, slot, strong }: { span: number; label: string; value: string; slot?: string; strong?: boolean }) { return ( {label} {value} ) } /** Borrar renglón: aparece al pasar o enfocar la fila (siempre visible en móvil). */ const DELETE = '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' /** Lápiz «Editar producto» dentro de la celda: al pasar o enfocar la fila; siempre con teclado y en móvil. */ const EDIT = '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' /** Celda densa: misma altura en todos los renglones. */ const CELL = 'px-2 py-1.5 align-top' /** Inputs de celda: un poco más bajos que los del formulario (densidad de editor). */ const INPUT = 'h-8' /** Numéricos: alineados a la derecha y con cifras tabulares. */ const NUM = 'h-8 text-right tabular-nums'