diff --git a/apps/web/README.md b/apps/web/README.md
index 89e664a..b98e469 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -167,3 +167,28 @@ Marco de las pantallas `nuevo` y `:docEntry`, con la estructura del mockup:
- `sbo-tabs` / `sbo-tab`: los paneles ocultos siguen en el DOM (conservan el estado); `[(selected)]`; flechas ←/→.
- `sbo-menu-button`: lista de acciones que se abre hacia arriba (o `direction="down"`); se cierra con Esc o clic fuera; opciones `disabled`.
- `sbo-badge`: la misma insignia que usan las listas.
+
+### Grilla de líneas (`sbo-lines-grid`)
+
+Tabla editable de las líneas del documento, con columnas declarativas (`GridColumn`):
+
+```html
+
+```
+
+- **Columnas estándar** (`features/documents/line-columns.ts`): Nº de artículo, Descripción, Cantidad, Almacén, Precio, % Dto., Impuesto y Total. El impuesto es `TaxCode` (maestro `SalesTaxCodes`, como en el mini-erp); un cliente que use `VatGroup` cambia esa columna en su rama.
+- Celdas con **lista de valores** (`lookup`): botón "…" o **F4**; al elegir, `apply` copia a la línea los campos de la fila elegida (artículo → código + descripción).
+- Las celdas se confirman al salir de ellas o con **Enter**, que baja a la misma columna de la fila siguiente y crea una línea al llegar a la última. **↑/↓** cambian de fila. Números con formato fuera de foco y valor crudo al editar; un texto que no es número conserva el anterior.
+- `recalc` recalcula la línea tras un cambio (`recalcLine`: cantidad × precio × (1 − % dto.)); SAP recalcula impuestos y totales al guardar.
+- `multiAdd`: "Elegir artículos…" agrega varias líneas de una vez (el "catálogo completo" del mini-erp).
+- `readonly` (documento cerrado o cancelado): sin edición, sin botones de añadir/quitar ni listas.
+
+### Página de componentes (solo desarrollo)
+
+Con `npm run dev:web`, abrir **`/dev/componentes`**: muestra juntos la toolbar, la ventana, los campos, las pestañas, la grilla y las listas de valores sobre datos reales de SAP, sin guardar nada. La ruta solo coincide en modo desarrollo (`canMatch: isDevMode`), no aparece en el menú y se actualiza sola con cada cambio de componentes.
diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts
index 5261762..f947daf 100644
--- a/apps/web/src/app/app.routes.spec.ts
+++ b/apps/web/src/app/app.routes.spec.ts
@@ -31,6 +31,13 @@ describe('rutas', () => {
expect(route.loadComponent).toBeDefined();
});
+ it('la página de componentes está restringida a desarrollo y no está en el menú', () => {
+ const route = children.find((r) => r.path === 'dev/componentes');
+ expect(route?.loadComponent).toBeDefined();
+ expect(route?.canMatch).toHaveLength(1); // solo coincide en desarrollo
+ expect(MENU.flatMap((m) => m.items).some((i) => i.path.startsWith('/dev/'))).toBe(false);
+ });
+
it('las rutas desconocidas vuelven al inicio', () => {
expect(routes.at(-1)).toMatchObject({ path: '**', redirectTo: '' });
});
diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts
index 26ef900..f9f84e8 100644
--- a/apps/web/src/app/app.routes.ts
+++ b/apps/web/src/app/app.routes.ts
@@ -1,3 +1,4 @@
+import { isDevMode } from '@angular/core';
import type { Route, Routes } from '@angular/router';
import { MENU } from '@basic-erp/sap-types';
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
@@ -30,6 +31,18 @@ const documentRoutes: Routes = DOCUMENT_SCREENS.map(
}),
);
+/**
+ * Página de componentes: solo coincide al ejecutar `npm run dev:web` (isDevMode se evalúa al navegar);
+ * en producción cae en la ruta comodín. No aparece en el menú.
+ */
+const devRoutes: Routes = [
+ {
+ path: 'dev/componentes',
+ canMatch: [() => isDevMode()],
+ loadComponent: () => import('./features/dev/showcase.page').then((m) => m.ShowcasePage),
+ },
+];
+
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
const documentPaths = new Set(DOCUMENT_SCREENS.map((s) => s.path));
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
@@ -59,6 +72,7 @@ export const routes: Routes = [
path: 'sin-acceso',
loadComponent: () => import('./features/shared/no-access.page').then((m) => m.NoAccessPage),
},
+ ...devRoutes,
...documentRoutes,
...pendingRoutes,
],
diff --git a/apps/web/src/app/features/dev/showcase.page.ts b/apps/web/src/app/features/dev/showcase.page.ts
new file mode 100644
index 0000000..98f5107
--- /dev/null
+++ b/apps/web/src/app/features/dev/showcase.page.ts
@@ -0,0 +1,147 @@
+import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
+import { formatNumber } from '../../core/format';
+import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, newDocumentLine, recalcLine } from '../documents/line-columns';
+import {
+ DateField,
+ LookupField,
+ SboField,
+ SelectField,
+ TextField,
+ TextInput,
+ TextareaField,
+} from '../../shared/form';
+import type { GridLine } from '../../shared/grid/grid.types';
+import { LinesGrid } from '../../shared/grid/lines-grid';
+import type { VhLookup } from '../../shared/value-help/value-help.types';
+import { Icon, MenuButton, SboWindow, Tab, Tabs, Toolbar, type StatusMessage } from '../../shared/window';
+
+/**
+ * Página de componentes (solo desarrollo, `/dev/componentes`): muestra juntos los componentes
+ * compartidos como se verán en un formulario de documento. No guarda nada en SAP.
+ */
+@Component({
+ selector: 'sbo-showcase-page',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [
+ DateField, LookupField, SboField, SelectField, TextField, TextareaField, TextInput,
+ Icon, MenuButton, SboWindow, Tab, Tabs, Toolbar, LinesGrid,
+ ],
+ template: `
+
Página de componentes (solo desarrollo). Los datos se leen de SAP; nada se guarda .
+
+ Nueva
+ Buscar
+
+
+
+
+
+
+ PDF
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Crear
+ Cancelar
+
+
+
+ `,
+ styles: '.note { margin: 0 0 8px; color: var(--quiet); font-size: 12px; }',
+})
+export class ShowcasePage {
+ protected readonly columns = DOCUMENT_LINE_COLUMNS;
+ protected readonly newLine = newDocumentLine;
+ protected readonly recalc = recalcLine;
+ protected readonly multi = ITEMS_MULTI_ADD;
+
+ protected readonly cardCode = signal('');
+ protected readonly cardName = signal('');
+ protected readonly country = signal('');
+ protected readonly ref = signal('');
+ protected readonly date = signal(new Date().toISOString().slice(0, 10));
+ protected readonly term = signal(-1);
+ protected readonly lines = signal([]);
+ protected readonly status = signal({ kind: 'info', text: 'Elija un cliente para empezar.' });
+
+ protected readonly subtotal = computed(() =>
+ formatNumber(this.lines().reduce((sum, l) => sum + Number(l['LineTotal'] ?? 0), 0), 2),
+ );
+
+ protected readonly terms = [
+ { value: -1, label: 'Contado' },
+ { value: 1, label: 'Crédito 30 días' },
+ ];
+ protected readonly from = [
+ { id: 'last', label: 'Última cotización del cliente' },
+ { id: 'any', label: 'Cotización registrada…' },
+ ];
+ protected readonly to = [{ id: 'order', label: 'Pedido de cliente (etapa 5)', disabled: true }];
+ protected readonly bp: VhLookup = {
+ title: 'Socios de negocio',
+ resource: 'BusinessPartners',
+ baseFilter: "CardType eq 'cCustomer'",
+ orderby: 'CardCode',
+ columns: [
+ { key: 'CardCode', title: 'Código', width: '130px', mono: true },
+ { key: 'CardName', title: 'Nombre' },
+ { key: 'Country', title: 'País', width: '80px' },
+ ],
+ };
+
+ protected onBp(row: Record): void {
+ this.cardName.set(String(row['CardName'] ?? ''));
+ this.country.set(String(row['Country'] ?? ''));
+ this.status.set({ kind: 'info', text: `Cliente ${String(row['CardCode'])} cargado.` });
+ }
+
+ protected demo(): void {
+ this.status.set({ kind: 'ok', text: 'Demostración: no se guardó nada en SAP.' });
+ }
+
+ protected reset(): void {
+ this.lines.set([]);
+ this.status.set({ kind: 'warn', text: 'Se descartaron las líneas de la demostración.' });
+ }
+}
diff --git a/apps/web/src/app/features/documents/line-columns.spec.ts b/apps/web/src/app/features/documents/line-columns.spec.ts
new file mode 100644
index 0000000..589c2f5
--- /dev/null
+++ b/apps/web/src/app/features/documents/line-columns.spec.ts
@@ -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 });
+ });
+});
diff --git a/apps/web/src/app/features/documents/line-columns.ts b/apps/web/src/app/features/documents/line-columns.ts
new file mode 100644
index 0000000..0709341
--- /dev/null
+++ b/apps/web/src/app/features/documents/line-columns.ts
@@ -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'] ?? '' }),
+};
diff --git a/apps/web/src/app/shared/grid/grid.types.ts b/apps/web/src/app/shared/grid/grid.types.ts
new file mode 100644
index 0000000..0b21d7d
--- /dev/null
+++ b/apps/web/src/app/shared/grid/grid.types.ts
@@ -0,0 +1,37 @@
+import type { ListRow } from '../list/list.types';
+import type { VhLookup } from '../value-help/value-help.types';
+
+/** Una línea del documento (los campos de `DocumentLines` del Service Layer). */
+export type GridLine = Record;
+
+/** Lista de valores de una celda: al elegir, `apply` copia a la línea los campos de la fila elegida. */
+export interface GridLookup {
+ config: VhLookup;
+ apply: (picked: ListRow, line: GridLine) => GridLine;
+}
+
+export interface GridColumn {
+ /** Campo de la línea. */
+ key: string;
+ title: string;
+ width?: string;
+ /** `number` se alinea a la derecha, se muestra con formato y se interpreta al salir de la celda. */
+ type?: 'text' | 'number';
+ /** Decimales fijos para `number` (importes: 2); sin él, hasta 3 sin ceros sobrantes. */
+ decimals?: number;
+ /** No editable (siempre, o según la línea). */
+ readonly?: boolean | ((line: GridLine) => boolean);
+ required?: boolean;
+ /** Celda con botón "…" / F4 que abre una lista de valores. */
+ lookup?: GridLookup;
+ /** Texto calculado a mostrar (columnas de solo lectura). */
+ format?: (line: GridLine) => string;
+ mono?: boolean;
+}
+
+/** Varias líneas a la vez desde una lista de selección múltiple (ej. "Catálogo completo"). */
+export interface GridMultiAdd {
+ label: string;
+ lookup: VhLookup;
+ toLine: (picked: ListRow) => GridLine;
+}
diff --git a/apps/web/src/app/shared/grid/lines-grid.css b/apps/web/src/app/shared/grid/lines-grid.css
new file mode 100644
index 0000000..9f0b36b
--- /dev/null
+++ b/apps/web/src/app/shared/grid/lines-grid.css
@@ -0,0 +1,138 @@
+:host {
+ display: block;
+}
+.bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+.count {
+ font-size: 12px;
+ color: var(--quiet);
+}
+.sp {
+ flex: 1;
+}
+.gridwrap {
+ overflow-x: auto;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-panel);
+ background: var(--panel);
+}
+.grid {
+ width: 100%;
+ min-width: 900px;
+ border-collapse: collapse;
+}
+th {
+ padding: 4px 6px;
+ background: var(--grid-head);
+ border-right: 1px solid var(--line);
+ border-bottom: 1px solid var(--line);
+ font-size: 12.5px;
+ font-weight: 600;
+ text-align: left;
+ white-space: nowrap;
+}
+th.num {
+ text-align: right;
+}
+th.req::after {
+ content: ' *';
+ color: var(--err);
+}
+td {
+ height: 27px;
+ padding: 0 2px;
+ border-right: 1px solid var(--line-soft);
+ border-bottom: 1px solid var(--line-soft);
+ white-space: nowrap;
+}
+tbody tr:nth-child(even) td {
+ background: var(--row-alt);
+}
+tbody tr:focus-within td {
+ background: var(--row-sel);
+}
+td.rn,
+th.rn {
+ width: 34px;
+ text-align: center;
+ color: var(--quiet);
+ background: var(--grid-head);
+}
+tbody tr td.rn {
+ background: var(--grid-head);
+}
+td.ro {
+ background: var(--field-ro);
+}
+.cell {
+ width: 100%;
+ height: 22px;
+ padding: 1px 4px;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: var(--radius);
+}
+.cell.num {
+ text-align: right;
+}
+.cell.mono {
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+}
+.cell:hover:not([readonly]) {
+ border-color: var(--field-b);
+}
+.cell:focus {
+ background: var(--field);
+ border-color: var(--focus);
+ outline: none;
+}
+.cell[readonly] {
+ cursor: default;
+}
+.pick {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 22px;
+}
+.pick button {
+ padding: 0;
+ background: var(--btn);
+ border: 1px solid var(--field-b);
+ border-radius: var(--radius);
+ cursor: pointer;
+}
+.pick button:hover {
+ background: var(--btn-h);
+}
+td.del,
+th.del {
+ width: 30px;
+ text-align: center;
+}
+td.del button {
+ padding: 2px 6px;
+ background: none;
+ border: none;
+ color: var(--quiet);
+ font-size: 15px;
+ line-height: 1;
+ cursor: pointer;
+ border-radius: var(--radius);
+}
+td.del button:hover {
+ color: var(--err);
+ background: var(--err-bg);
+}
+tr.none td {
+ padding: 18px;
+ text-align: center;
+ color: var(--quiet);
+ background: transparent;
+}
+.sr {
+ position: absolute;
+ left: -9999px;
+}
diff --git a/apps/web/src/app/shared/grid/lines-grid.html b/apps/web/src/app/shared/grid/lines-grid.html
new file mode 100644
index 0000000..4b757c0
--- /dev/null
+++ b/apps/web/src/app/shared/grid/lines-grid.html
@@ -0,0 +1,113 @@
+
+ {{ lines().length }} {{ lines().length === 1 ? 'línea' : 'líneas' }}
+ @if (!readonly()) {
+
+ @if (multiAdd(); as m) {
+ {{ m.label }}
+ }
+ + {{ addLabel() }}
+ }
+
+
+
+
+@if (lookup(); as cfg) {
+
+}
+@if (multiOpen(); as _) {
+ @if (multiAdd(); as m) {
+
+ }
+}
diff --git a/apps/web/src/app/shared/grid/lines-grid.spec.ts b/apps/web/src/app/shared/grid/lines-grid.spec.ts
new file mode 100644
index 0000000..4a44d0c
--- /dev/null
+++ b/apps/web/src/app/shared/grid/lines-grid.spec.ts
@@ -0,0 +1,302 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { Component, signal } from '@angular/core';
+import { type ComponentFixture, TestBed } from '@angular/core/testing';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, newDocumentLine, recalcLine } from '../../features/documents/line-columns';
+import type { GridLine } from './grid.types';
+import { LinesGrid } from './lines-grid';
+
+@Component({
+ imports: [LinesGrid],
+ template: `
+
+ `,
+})
+class Host {
+ columns = DOCUMENT_LINE_COLUMNS;
+ lines = signal([]);
+ readonly = signal(false);
+ newLine = newDocumentLine;
+ recalc = recalcLine;
+ multi = ITEMS_MULTI_ADD;
+}
+
+const line = (code: string, qty = 1, price = 100): GridLine => recalcLine({ ...newDocumentLine(), ItemCode: code, ItemDescription: `Desc ${code}`, Quantity: qty, Price: price });
+
+describe('LinesGrid', () => {
+ let fixture: ComponentFixture;
+ let ctrl: HttpTestingController;
+ let el: HTMLElement;
+ const q = (s: string) => el.querySelector(s)!;
+ const qa = (s: string) => [...el.querySelectorAll(s)];
+ const cell = (r: number, c: number) => q(`input[data-r="${r}"][data-c="${c}"]`);
+ const lines = () => fixture.componentInstance.lines();
+ const tick = async () => {
+ fixture.detectChanges();
+ await new Promise((r) => setTimeout(r));
+ fixture.detectChanges();
+ };
+ const edit = async (r: number, c: number, text: string) => {
+ const input = cell(r, c);
+ input.dispatchEvent(new Event('focus'));
+ input.value = text;
+ input.dispatchEvent(new Event('change'));
+ input.dispatchEvent(new Event('blur'));
+ await tick();
+ };
+ const key = async (r: number, c: number, k: string) => {
+ cell(r, c).dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
+ await tick();
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [Host],
+ providers: [provideHttpClient(), provideHttpClientTesting()],
+ }).compileComponents();
+ ctrl = TestBed.inject(HttpTestingController);
+ fixture = TestBed.createComponent(Host);
+ el = fixture.nativeElement;
+ await tick();
+ });
+
+ it('sin líneas muestra el aviso y el contador en 0', () => {
+ expect(q('tr.none').textContent).toContain('No hay líneas');
+ expect(q('.count').textContent).toContain('0 líneas');
+ });
+
+ it('muestra los encabezados con * en los obligatorios', () => {
+ expect(qa('thead th').map((t) => t.textContent?.trim())).toEqual(['#', 'Nº de artículo', 'Descripción', 'Cantidad', 'Almacén', 'Precio', '% Dto.', 'Impuesto', 'Total', 'Quitar']);
+ expect(qa('thead th.req').map((t) => t.textContent?.trim())).toEqual(['Nº de artículo', 'Cantidad']);
+ });
+
+ describe('añadir y quitar', () => {
+ it('"+ Añadir línea" crea una línea con sus valores por defecto y enfoca el primer campo editable', async () => {
+ (qa('.bar .btn.main')[0] as HTMLButtonElement).click();
+ await tick();
+ expect(lines()).toHaveLength(1);
+ expect(lines()[0]).toMatchObject({ Quantity: 1, LineTotal: 0 });
+ expect(q('.count').textContent).toContain('1 línea');
+ expect(document.activeElement).toBe(cell(0, 0));
+ });
+
+ it('"×" quita la línea y renumera', async () => {
+ fixture.componentInstance.lines.set([line('A'), line('B'), line('C')]);
+ await tick();
+ (qa('td.del button')[1] as HTMLButtonElement).click();
+ await tick();
+ expect(lines().map((l) => l['ItemCode'])).toEqual(['A', 'C']);
+ expect(qa('td.rn').map((c) => c.textContent?.trim())).toEqual(['1', '2']);
+ });
+ });
+
+ describe('edición de celdas', () => {
+ beforeEach(async () => {
+ fixture.componentInstance.lines.set([line('A', 2, 50)]);
+ await tick();
+ });
+
+ it('muestra números con formato y el total calculado', () => {
+ expect(cell(0, 4).value).toBe('50.00');
+ expect(cell(0, 7).value).toBe('100.00');
+ });
+
+ it('editar la cantidad recalcula el total al salir de la celda', async () => {
+ await edit(0, 2, '3');
+ expect(lines()[0]['Quantity']).toBe(3);
+ expect(lines()[0]['LineTotal']).toBe(150);
+ expect(cell(0, 7).value).toBe('150.00');
+ });
+
+ it('acepta coma decimal y redondea el precio a 2 decimales', async () => {
+ await edit(0, 4, '12,3456');
+ expect(lines()[0]['Price']).toBe(12.35);
+ expect(cell(0, 4).value).toBe('12.35');
+ });
+
+ it('el % de descuento afecta al total', async () => {
+ await edit(0, 5, '10');
+ expect(lines()[0]['LineTotal']).toBe(90);
+ });
+
+ it('un texto que no es número conserva el valor anterior', async () => {
+ await edit(0, 2, 'abc');
+ expect(lines()[0]['Quantity']).toBe(2);
+ expect(cell(0, 2).value).toBe('2');
+ });
+
+ it('vaciar un número lo deja en null', async () => {
+ await edit(0, 4, '');
+ expect(lines()[0]['Price']).toBeNull();
+ expect(lines()[0]['LineTotal']).toBe(0);
+ });
+
+ it('en foco muestra el valor crudo para editarlo', async () => {
+ cell(0, 4).dispatchEvent(new Event('focus'));
+ await tick();
+ expect(cell(0, 4).value).toBe('50');
+ });
+
+ it('editar texto (descripción) no cambia otras celdas', async () => {
+ await edit(0, 1, 'Otra descripción');
+ expect(lines()[0]['ItemDescription']).toBe('Otra descripción');
+ expect(lines()[0]['LineTotal']).toBe(100);
+ });
+
+ it('el total es de solo lectura', () => {
+ expect(cell(0, 7).readOnly).toBe(true);
+ expect(qa('td')[8].classList.contains('ro')).toBe(true);
+ });
+
+ it('sin cambios no se emite una línea nueva', async () => {
+ const before = lines();
+ await edit(0, 2, '2');
+ expect(lines()).toBe(before);
+ });
+ });
+
+ describe('teclado', () => {
+ beforeEach(async () => {
+ fixture.componentInstance.lines.set([line('A'), line('B')]);
+ await tick();
+ });
+
+ it('Enter baja a la misma columna de la fila siguiente', async () => {
+ cell(0, 2).focus();
+ await key(0, 2, 'Enter');
+ expect(document.activeElement).toBe(cell(1, 2));
+ });
+
+ it('Enter en la última fila crea una línea y se queda en esa columna', async () => {
+ await key(1, 2, 'Enter');
+ expect(lines()).toHaveLength(3);
+ expect(document.activeElement).toBe(cell(2, 0));
+ });
+
+ it('Enter confirma el valor escrito antes de moverse', async () => {
+ cell(0, 2).dispatchEvent(new Event('focus'));
+ cell(0, 2).value = '7';
+ await key(0, 2, 'Enter');
+ expect(lines()[0]['Quantity']).toBe(7);
+ });
+
+ it('↑ y ↓ cambian de fila en la misma columna', async () => {
+ cell(0, 1).focus();
+ await key(0, 1, 'ArrowDown');
+ expect(document.activeElement).toBe(cell(1, 1));
+ await key(1, 1, 'ArrowUp');
+ expect(document.activeElement).toBe(cell(0, 1));
+ await key(0, 1, 'ArrowUp'); // ya en la primera: no se mueve
+ expect(document.activeElement).toBe(cell(0, 1));
+ });
+ });
+
+ describe('listas de valores en celdas', () => {
+ beforeEach(async () => {
+ fixture.componentInstance.lines.set([line('A', 2, 50)]);
+ await tick();
+ });
+
+ it('las celdas con lista muestran el botón "…"', () => {
+ expect(qa('.pick button')).toHaveLength(3); // artículo, almacén, impuesto
+ });
+
+ it('elegir un artículo rellena código y descripción y conserva el resto', async () => {
+ (qa('.pick button')[0] as HTMLButtonElement).click();
+ await tick();
+ ctrl
+ .expectOne((r) => r.url === '/api/sl/Items')
+ .flush({ value: [{ ItemCode: 'I00002', ItemName: 'DVD+R Disc 50-Pack' }], 'odata.count': '1' });
+ await tick();
+ (q('.dlg tbody tr') as HTMLElement).click();
+ await tick();
+ expect(lines()[0]).toMatchObject({ ItemCode: 'I00002', ItemDescription: 'DVD+R Disc 50-Pack', Quantity: 2, Price: 50, LineTotal: 100 });
+ expect(el.querySelector('.dlg')).toBeNull();
+ expect(document.activeElement).toBe(cell(0, 0));
+ });
+
+ it('F4 abre la lista de la celda; una celda sin lista no', async () => {
+ await key(0, 3, 'F4'); // almacén
+ ctrl.expectOne((r) => r.url === '/api/sl/Warehouses').flush({ value: [], 'odata.count': '0' });
+ await tick();
+ expect(q('.dlg')).toBeTruthy();
+ q('.dlg').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ await tick();
+ await key(0, 1, 'F4'); // descripción: sin lista
+ ctrl.expectNone((r) => r.url.startsWith('/api/sl/'));
+ });
+
+ it('elegir un impuesto solo cambia el código', async () => {
+ (qa('.pick button')[2] as HTMLButtonElement).click();
+ await tick();
+ ctrl.expectOne((r) => r.url === '/api/sl/SalesTaxCodes').flush({ value: [{ Code: 'IVA', Name: 'IVA', Rate: 19 }], 'odata.count': '1' });
+ await tick();
+ (q('.dlg tbody tr') as HTMLElement).click();
+ await tick();
+ expect(lines()[0]['TaxCode']).toBe('IVA');
+ expect(lines()[0]['ItemCode']).toBe('A');
+ });
+ });
+
+ describe('"Elegir artículos…" (varias líneas a la vez)', () => {
+ it('agrega una línea por artículo marcado, con su total calculado', async () => {
+ fixture.componentInstance.lines.set([line('A')]);
+ await tick();
+ (qa('.bar .btn')[0] as HTMLButtonElement).click();
+ await tick();
+ ctrl
+ .expectOne((r) => r.url === '/api/sl/Items')
+ .flush({
+ value: [
+ { ItemCode: 'X1', ItemName: 'Uno' },
+ { ItemCode: 'X2', ItemName: 'Dos' },
+ ],
+ 'odata.count': '2',
+ });
+ await tick();
+ const rows = qa('.dlg tbody tr');
+ rows[0].click();
+ rows[1].click();
+ await tick();
+ (qa('.dlg footer .btn.main')[0] as HTMLButtonElement).click();
+ await tick();
+ expect(lines().map((l) => l['ItemCode'])).toEqual(['A', 'X1', 'X2']);
+ expect(lines()[1]).toMatchObject({ ItemDescription: 'Uno', Quantity: 1, LineTotal: 0 });
+ expect(el.querySelector('.dlg')).toBeNull();
+ });
+ });
+
+ describe('documento cerrado (solo lectura)', () => {
+ beforeEach(async () => {
+ fixture.componentInstance.lines.set([line('A', 2, 50)]);
+ fixture.componentInstance.readonly.set(true);
+ await tick();
+ });
+
+ it('no hay botones de añadir, quitar ni listas', () => {
+ expect(el.querySelector('.bar .btn')).toBeNull();
+ expect(el.querySelector('td.del')).toBeNull();
+ expect(el.querySelector('.pick')).toBeNull();
+ });
+
+ it('todas las celdas son de solo lectura y no se pueden editar', async () => {
+ expect(qa('tbody input').every((i) => (i as HTMLInputElement).readOnly)).toBe(true);
+ await edit(0, 2, '9');
+ expect(lines()[0]['Quantity']).toBe(2);
+ });
+
+ it('sin líneas dice que el documento no tiene líneas', async () => {
+ fixture.componentInstance.lines.set([]);
+ await tick();
+ expect(q('tr.none').textContent).toContain('no tiene líneas');
+ });
+ });
+});
diff --git a/apps/web/src/app/shared/grid/lines-grid.ts b/apps/web/src/app/shared/grid/lines-grid.ts
new file mode 100644
index 0000000..474d766
--- /dev/null
+++ b/apps/web/src/app/shared/grid/lines-grid.ts
@@ -0,0 +1,205 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ Injector,
+ afterNextRender,
+ computed,
+ inject,
+ input,
+ model,
+ signal,
+} from '@angular/core';
+import { formatNumber, parseDecimal } from '../../core/format';
+import type { ListRow } from '../list/list.types';
+import { ValueHelp } from '../value-help/value-help';
+import type { GridColumn, GridLine, GridMultiAdd } from './grid.types';
+
+interface LookupTarget {
+ row: number;
+ column: GridColumn;
+}
+
+/**
+ * Grilla editable de líneas de un documento. Las celdas se confirman al salir de ellas o con Enter
+ * (que baja a la misma columna de la fila siguiente y crea una línea al llegar a la última).
+ * Flechas ↑/↓ cambian de fila; F4 abre la lista de valores de la celda.
+ */
+@Component({
+ selector: 'sbo-lines-grid',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [ValueHelp],
+ templateUrl: './lines-grid.html',
+ styleUrl: './lines-grid.css',
+})
+export class LinesGrid {
+ readonly columns = input.required();
+ readonly lines = model([]);
+ /** Documento cerrado o cancelado: sin edición ni botones de añadir/quitar. */
+ readonly readonly = input(false);
+ /** Línea nueva con sus valores por defecto. */
+ readonly newLine = input<() => GridLine>(() => ({}));
+ /** Recalcula la línea tras un cambio (ej. total = cantidad × precio). */
+ readonly recalc = input<((line: GridLine, changedKey: string) => GridLine) | undefined>(undefined);
+ readonly multiAdd = input();
+ readonly addLabel = input('Añadir línea');
+
+ private readonly host = inject>(ElementRef);
+ private readonly injector = inject(Injector);
+
+ protected readonly lookupTarget = signal(null);
+ protected readonly multiOpen = signal(false);
+ protected readonly lookup = computed(() => this.lookupTarget()?.column.lookup?.config);
+
+ protected cellText(line: GridLine, column: GridColumn): string {
+ if (column.format) return column.format(line);
+ const value = line[column.key];
+ if (column.type === 'number') return formatNumber(value, column.decimals);
+ return value === null || value === undefined ? '' : String(value);
+ }
+
+ protected isReadonly(line: GridLine, column: GridColumn): boolean {
+ if (this.readonly() || column.format) return true;
+ return typeof column.readonly === 'function' ? column.readonly(line) : !!column.readonly;
+ }
+
+ protected firstEditable(): number {
+ return this.columns().findIndex((c) => !c.format && !c.readonly);
+ }
+
+ // --- edición ---------------------------------------------------------------------------
+
+ protected numberFocus(event: FocusEvent, line: GridLine, column: GridColumn): void {
+ if (this.isReadonly(line, column)) return;
+ const input = event.target as HTMLInputElement;
+ const value = line[column.key];
+ input.value = value === null || value === undefined ? '' : String(value);
+ queueMicrotask(() => input.select());
+ }
+
+ /** Confirma el valor de una celda; si no cambió, no hace nada. */
+ protected commit(row: number, column: GridColumn, input: HTMLInputElement): void {
+ const line = this.lines()[row];
+ if (!line || this.isReadonly(line, column)) return;
+ const text = input.value;
+
+ if (column.type === 'number') {
+ const parsed = text.trim() === '' ? null : parseDecimal(text);
+ if (text.trim() !== '' && parsed === null) {
+ input.value = this.cellText(line, column); // no es un número: se conserva el anterior
+ return;
+ }
+ const d = column.decimals;
+ const value = parsed === null || d === undefined ? parsed : Math.round((parsed + Number.EPSILON) * 10 ** d) / 10 ** d;
+ this.update(row, column.key, value);
+ input.value = formatNumber(value, column.decimals);
+ } else if (text !== String(line[column.key] ?? '')) {
+ this.update(row, column.key, text);
+ }
+ }
+
+ private update(row: number, key: string, value: unknown): void {
+ const current = this.lines()[row];
+ if (!current || current[key] === value) return;
+ this.replace(row, { ...current, [key]: value }, key);
+ }
+
+ private replace(row: number, line: GridLine, key: string): void {
+ const next = this.recalc()?.(line, key) ?? line;
+ this.lines.update((ls) => ls.map((l, i) => (i === row ? next : l)));
+ }
+
+ // --- filas -----------------------------------------------------------------------------
+
+ protected add(): void {
+ if (this.readonly()) return;
+ const row = this.lines().length;
+ this.lines.update((ls) => [...ls, this.recalc()?.(this.newLine()(), '') ?? this.newLine()()]);
+ this.focusCell(row, this.firstEditable());
+ }
+
+ protected remove(row: number): void {
+ if (this.readonly()) return;
+ this.lines.update((ls) => ls.filter((_, i) => i !== row));
+ }
+
+ // --- teclado ---------------------------------------------------------------------------
+
+ protected onKey(event: KeyboardEvent, row: number, col: number): void {
+ const column = this.columns()[col];
+ const input = event.target as HTMLInputElement;
+ switch (event.key) {
+ case 'Enter': {
+ event.preventDefault();
+ this.commit(row, column, input);
+ if (row + 1 >= this.lines().length) this.add();
+ else this.focusCell(row + 1, col);
+ break;
+ }
+ case 'ArrowDown':
+ if (row + 1 < this.lines().length) {
+ event.preventDefault();
+ this.commit(row, column, input);
+ this.focusCell(row + 1, col);
+ }
+ break;
+ case 'ArrowUp':
+ if (row > 0) {
+ event.preventDefault();
+ this.commit(row, column, input);
+ this.focusCell(row - 1, col);
+ }
+ break;
+ case 'F4':
+ if (column.lookup) {
+ event.preventDefault();
+ this.openLookup(row, column);
+ }
+ break;
+ }
+ }
+
+ /** Enfoca una celda; si la fila aún no está dibujada (línea recién creada), espera al render. */
+ protected focusCell(row: number, col: number): void {
+ if (col < 0) return;
+ const focus = (): boolean => {
+ const cell = this.host.nativeElement.querySelector(`input[data-r="${row}"][data-c="${col}"]`);
+ cell?.focus();
+ return !!cell;
+ };
+ if (!focus()) afterNextRender(() => void focus(), { injector: this.injector });
+ }
+
+ // --- listas de valores -----------------------------------------------------------------
+
+ protected openLookup(row: number, column: GridColumn): void {
+ const line = this.lines()[row];
+ if (line && !this.isReadonly(line, column)) this.lookupTarget.set({ row, column });
+ }
+
+ protected lookupPicked(rows: ListRow[]): void {
+ const target = this.lookupTarget();
+ const picked = rows[0];
+ const line = target ? this.lines()[target.row] : undefined;
+ if (!target || !picked || !line || !target.column.lookup) return;
+ this.replace(target.row, target.column.lookup.apply(picked, line), target.column.key);
+ }
+
+ protected lookupClosed(): void {
+ const target = this.lookupTarget();
+ this.lookupTarget.set(null);
+ if (target) this.focusCell(target.row, this.columns().indexOf(target.column));
+ }
+
+ protected multiPicked(rows: ListRow[]): void {
+ const config = this.multiAdd();
+ if (!config || rows.length === 0) return;
+ this.lines.update((ls) => [
+ ...ls,
+ ...rows.map((r) => {
+ const line = config.toLine(r);
+ return this.recalc()?.(line, '') ?? line;
+ }),
+ ]);
+ }
+}