feat(web): grilla de líneas editable y página de componentes de desarrollo

- sbo-lines-grid: columnas declarativas (texto / número con decimales /
  solo lectura / calculadas), celdas con lista de valores (botón "..." y
  F4) cuyo apply rellena la línea, añadir y quitar línea, y "Elegir
  artículos..." para agregar varias líneas a la vez.
- Teclado: Enter confirma y baja a la misma columna (crea línea en la
  última fila), flechas arriba/abajo cambian de fila. Las celdas se
  confirman al salir de ellas; un número inválido conserva el anterior.
- Documento cerrado: grilla de solo lectura, sin botones ni listas.
- Columnas estándar de línea de SAP (artículo, descripción, cantidad,
  almacén, precio, % dto., impuesto, total) con TaxCode / SalesTaxCodes,
  verificado en el Service Layer real; recalcLine (cantidad x precio x
  (1 - dto)).
- Corrige el foco al crear una línea con Enter: se pedía antes de que la
  fila existiera en el DOM (ahora espera al render).
- /dev/componentes: página de componentes solo para desarrollo
  (canMatch isDevMode), sin entrada en el menú y sin escribir en SAP.
- Verificado en Chrome contra SAP real: artículo elegido de la lista,
  5 x 10,000 con 10 % = 45,000.00, Enter/flechas, varios artículos a la
  vez, quitar línea y subtotal del pie.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 20:17:52 -05:00
1 parent 26f0649da9
commit 0a7bfb850a
11 files changed
+1150

No files matched your search

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, newDocumentLine, recalcLine } from './line-columns';
describe('recalcLine', () => {
it('total = cantidad × precio, redondeado a 2 decimales', () => {
expect(recalcLine({ Quantity: 5, Price: 10000 })['LineTotal']).toBe(50000);
expect(recalcLine({ Quantity: 3, Price: 0.1 })['LineTotal']).toBe(0.3);
expect(recalcLine({ Quantity: 1.5, Price: 19.99 })['LineTotal']).toBe(29.99);
});
it('aplica el % de descuento', () => {
expect(recalcLine({ Quantity: 2, Price: 100, DiscountPercent: 10 })['LineTotal']).toBe(180);
expect(recalcLine({ Quantity: 2, Price: 100, DiscountPercent: 100 })['LineTotal']).toBe(0);
});
it('valores vacíos o no numéricos cuentan como 0 y se conserva el resto de la línea', () => {
const line = recalcLine({ ItemCode: 'A', Quantity: null, Price: 'abc' });
expect(line['LineTotal']).toBe(0);
expect(line['ItemCode']).toBe('A');
});
});
describe('columnas estándar de línea', () => {
const keys = DOCUMENT_LINE_COLUMNS.map((c) => c.key);
it('son las de una línea de documento de SAP (con TaxCode)', () => {
expect(keys).toEqual(['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode', 'LineTotal']);
});
it('el total es de solo lectura y artículo y cantidad son obligatorios', () => {
expect(DOCUMENT_LINE_COLUMNS.find((c) => c.key === 'LineTotal')?.readonly).toBe(true);
expect(DOCUMENT_LINE_COLUMNS.filter((c) => c.required).map((c) => c.key)).toEqual(['ItemCode', 'Quantity']);
});
it('artículo, almacén e impuesto tienen lista de valores sobre recursos registrados', () => {
const resources = DOCUMENT_LINE_COLUMNS.filter((c) => c.lookup).map((c) => c.lookup!.config.resource);
expect(resources).toEqual(['Items', 'Warehouses', 'SalesTaxCodes']);
});
it('elegir un artículo rellena código y descripción sin perder el resto', () => {
const apply = DOCUMENT_LINE_COLUMNS[0].lookup!.apply;
expect(apply({ ItemCode: 'I1', ItemName: 'Disco' }, { Quantity: 3 })).toMatchObject({ ItemCode: 'I1', ItemDescription: 'Disco', Quantity: 3 });
});
it('elegir almacén o impuesto solo cambia ese campo', () => {
const wh = DOCUMENT_LINE_COLUMNS.find((c) => c.key === 'WarehouseCode')!.lookup!.apply;
const tax = DOCUMENT_LINE_COLUMNS.find((c) => c.key === 'TaxCode')!.lookup!.apply;
expect(wh({ WarehouseCode: '01', WarehouseName: 'Principal' }, { ItemCode: 'A' })).toEqual({ ItemCode: 'A', WarehouseCode: '01' });
expect(tax({ Code: 'IVA', Rate: 19 }, { ItemCode: 'A' })).toEqual({ ItemCode: 'A', TaxCode: 'IVA' });
});
});
describe('línea nueva y "catálogo completo"', () => {
it('la línea nueva trae cantidad 1 y total 0', () => {
expect(newDocumentLine()).toMatchObject({ Quantity: 1, Price: 0, LineTotal: 0, ItemCode: '' });
});
it('cada artículo elegido se convierte en una línea nueva', () => {
expect(ITEMS_MULTI_ADD.toLine({ ItemCode: 'I9', ItemName: 'Cable' })).toMatchObject({ ItemCode: 'I9', ItemDescription: 'Cable', Quantity: 1 });
});
});
@@ -0,0 +1,101 @@
import type { GridColumn, GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
const round2 = (n: number): number => Math.round((n + Number.EPSILON) * 100) / 100;
const num = (v: unknown): number => {
const n = Number(v ?? 0);
return Number.isFinite(n) ? n : 0;
};
/** Total de la línea antes de impuestos: cantidad × precio × (1 − descuento %). SAP lo recalcula al guardar. */
export function recalcLine(line: GridLine): GridLine {
const total = num(line['Quantity']) * num(line['Price']) * (1 - num(line['DiscountPercent']) / 100);
return { ...line, LineTotal: round2(total) };
}
/** Línea nueva con los valores por defecto de un documento. */
export const newDocumentLine = (): GridLine => ({
ItemCode: '',
ItemDescription: '',
Quantity: 1,
WarehouseCode: '',
Price: 0,
DiscountPercent: 0,
TaxCode: '',
LineTotal: 0,
});
/**
* Columnas estándar de una línea de documento de SAP B1 (artículo, descripción, cantidad, almacén,
* precio, % descuento, código de impuesto y total). El impuesto es `TaxCode` (maestro `SalesTaxCodes`);
* un cliente que use `VatGroup` cambia esa columna en su rama.
*/
export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [
{
key: 'ItemCode',
title: 'Nº de artículo',
width: '140px',
mono: true,
required: true,
lookup: {
config: {
title: 'Artículos',
resource: 'Items',
orderby: 'ItemCode',
columns: [
{ key: 'ItemCode', title: 'Código', width: '150px', mono: true },
{ key: 'ItemName', title: 'Descripción' },
],
},
apply: (item, line) => ({ ...line, ItemCode: item['ItemCode'], ItemDescription: item['ItemName'] ?? line['ItemDescription'] }),
},
},
{ key: 'ItemDescription', title: 'Descripción' },
{ key: 'Quantity', title: 'Cantidad', type: 'number', width: '85px', required: true },
{
key: 'WarehouseCode',
title: 'Almacén',
width: '90px',
mono: true,
lookup: {
config: {
title: 'Almacenes',
resource: 'Warehouses',
orderby: 'WarehouseCode',
columns: [
{ key: 'WarehouseCode', title: 'Código', width: '110px', mono: true },
{ key: 'WarehouseName', title: 'Nombre' },
],
},
apply: (wh, line) => ({ ...line, WarehouseCode: wh['WarehouseCode'] }),
},
},
{ key: 'Price', title: 'Precio', type: 'number', decimals: 2, width: '100px' },
{ key: 'DiscountPercent', title: '% Dto.', type: 'number', decimals: 2, width: '70px' },
{
key: 'TaxCode',
title: 'Impuesto',
width: '90px',
mono: true,
lookup: {
config: {
title: 'Códigos de impuesto',
resource: 'SalesTaxCodes',
orderby: 'Code',
columns: [
{ key: 'Code', title: 'Código', width: '110px', mono: true },
{ key: 'Name', title: 'Nombre' },
{ key: 'Rate', title: 'Tasa %', type: 'number', width: '80px' },
],
},
apply: (tax, line) => ({ ...line, TaxCode: tax['Code'] }),
},
},
{ key: 'LineTotal', title: 'Total', type: 'number', decimals: 2, width: '110px', readonly: true },
];
/** "Catálogo completo": agrega de una vez varios artículos como líneas nuevas. */
export const ITEMS_MULTI_ADD: GridMultiAdd = {
label: 'Elegir artículos…',
lookup: DOCUMENT_LINE_COLUMNS[0].lookup!.config,
toLine: (item) => ({ ...newDocumentLine(), ItemCode: item['ItemCode'], ItemDescription: item['ItemName'] ?? '' }),
};