From 0a7bfb850ab4408dda7efbf706998f04bee34fb6 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 13 Oct 2026 20:17:52 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20grilla=20de=20l=C3=ADneas=20editab?= =?UTF-8?q?le=20y=20p=C3=A1gina=20de=20componentes=20de=20desarrollo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/web/README.md | 25 ++ apps/web/src/app/app.routes.spec.ts | 7 + apps/web/src/app/app.routes.ts | 14 + .../web/src/app/features/dev/showcase.page.ts | 147 +++++++++ .../features/documents/line-columns.spec.ts | 61 ++++ .../app/features/documents/line-columns.ts | 101 ++++++ apps/web/src/app/shared/grid/grid.types.ts | 37 +++ apps/web/src/app/shared/grid/lines-grid.css | 138 ++++++++ apps/web/src/app/shared/grid/lines-grid.html | 113 +++++++ .../src/app/shared/grid/lines-grid.spec.ts | 302 ++++++++++++++++++ apps/web/src/app/shared/grid/lines-grid.ts | 205 ++++++++++++ 11 files changed, 1150 insertions(+) create mode 100644 apps/web/src/app/features/dev/showcase.page.ts create mode 100644 apps/web/src/app/features/documents/line-columns.spec.ts create mode 100644 apps/web/src/app/features/documents/line-columns.ts create mode 100644 apps/web/src/app/shared/grid/grid.types.ts create mode 100644 apps/web/src/app/shared/grid/lines-grid.css create mode 100644 apps/web/src/app/shared/grid/lines-grid.html create mode 100644 apps/web/src/app/shared/grid/lines-grid.spec.ts create mode 100644 apps/web/src/app/shared/grid/lines-grid.ts 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.

+ + + + + + + + + + + + + +
+
+ + + + + + + +
+
+ + + + + + +
+
+ + + + + + + + + +
+
+
+ +
+
+ + + + + +
+ `, + 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) { + + } + + } +
+ +
+ + + + + @for (c of columns(); track c.key) { + + } + @if (!readonly()) { + + } + + + + @for (line of lines(); track $index; let i = $index) { + + + @for (c of columns(); track c.key; let j = $index) { + + } + @if (!readonly()) { + + } + + } @empty { + + + + } + +
#{{ c.title }}Quitar
{{ i + 1 }} + @if (c.lookup && !isReadonly(line, c)) { +
+ + +
+ } @else { + + } +
+ +
+ {{ readonly() ? 'El documento no tiene líneas.' : 'No hay líneas. Pulse «Añadir línea» para comenzar.' }} +
+
+ +@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; + }), + ]); + } +}