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:
1 parent
26f0649da9
commit
0a7bfb850a
11 files changed
+1150
No files matched your search
@@ -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-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-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.
|
- `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
|
||||||
|
<sbo-lines-grid
|
||||||
|
[columns]="DOCUMENT_LINE_COLUMNS"
|
||||||
|
[(lines)]="lines"
|
||||||
|
[newLine]="newDocumentLine"
|
||||||
|
[recalc]="recalcLine"
|
||||||
|
[multiAdd]="ITEMS_MULTI_ADD"
|
||||||
|
[readonly]="documentClosed()" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- **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.
|
||||||
@@ -31,6 +31,13 @@ describe('rutas', () => {
|
|||||||
expect(route.loadComponent).toBeDefined();
|
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', () => {
|
it('las rutas desconocidas vuelven al inicio', () => {
|
||||||
expect(routes.at(-1)).toMatchObject({ path: '**', redirectTo: '' });
|
expect(routes.at(-1)).toMatchObject({ path: '**', redirectTo: '' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isDevMode } from '@angular/core';
|
||||||
import type { Route, Routes } from '@angular/router';
|
import type { Route, Routes } from '@angular/router';
|
||||||
import { MENU } from '@basic-erp/sap-types';
|
import { MENU } from '@basic-erp/sap-types';
|
||||||
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
|
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. */
|
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
|
||||||
const documentPaths = new Set(DOCUMENT_SCREENS.map((s) => s.path));
|
const documentPaths = new Set(DOCUMENT_SCREENS.map((s) => s.path));
|
||||||
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
|
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
|
||||||
@@ -59,6 +72,7 @@ export const routes: Routes = [
|
|||||||
path: 'sin-acceso',
|
path: 'sin-acceso',
|
||||||
loadComponent: () => import('./features/shared/no-access.page').then((m) => m.NoAccessPage),
|
loadComponent: () => import('./features/shared/no-access.page').then((m) => m.NoAccessPage),
|
||||||
},
|
},
|
||||||
|
...devRoutes,
|
||||||
...documentRoutes,
|
...documentRoutes,
|
||||||
...pendingRoutes,
|
...pendingRoutes,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 class="note">Página de componentes (solo desarrollo). Los datos se leen de SAP; <b>nada se guarda</b>.</p>
|
||||||
|
<sbo-toolbar aria-label="Acciones del documento">
|
||||||
|
<button class="tb" type="button"><sbo-icon name="new" />Nueva</button>
|
||||||
|
<button class="tb" type="button"><sbo-icon name="find" />Buscar</button>
|
||||||
|
<span class="tb-sep"></span>
|
||||||
|
<button class="tb" type="button" aria-label="Primero"><sbo-icon name="first" /></button>
|
||||||
|
<button class="tb" type="button" aria-label="Anterior"><sbo-icon name="prev" /></button>
|
||||||
|
<button class="tb" type="button" aria-label="Siguiente"><sbo-icon name="next" /></button>
|
||||||
|
<button class="tb" type="button" aria-label="Último"><sbo-icon name="last" /></button>
|
||||||
|
<span class="tb-sep"></span>
|
||||||
|
<button class="tb" type="button"><sbo-icon name="print" />PDF</button>
|
||||||
|
</sbo-toolbar>
|
||||||
|
|
||||||
|
<sbo-window title="Oferta de ventas" [badge]="{ label: 'Nueva', tone: 'info' }" [status]="status()">
|
||||||
|
<div class="sbo-hdr">
|
||||||
|
<div class="sbo-col">
|
||||||
|
<sbo-lookup-field label="Cliente" required linkable [(value)]="cardCode" [lookup]="bp" (picked)="onBp($event)" />
|
||||||
|
<sbo-text-field label="Nombre" readonly [value]="cardName()" />
|
||||||
|
<sbo-field label="Canal / Zona">
|
||||||
|
<sbo-input readonly ariaLabel="Canal" value="—" />
|
||||||
|
<sbo-input readonly ariaLabel="País" [value]="country()" />
|
||||||
|
</sbo-field>
|
||||||
|
<sbo-text-field label="Nº referencia cliente" placeholder="Opcional" [(value)]="ref" />
|
||||||
|
</div>
|
||||||
|
<div class="sbo-col">
|
||||||
|
<sbo-field label="Número">
|
||||||
|
<sbo-input width="72px" readonly ariaLabel="Serie" value="COT" />
|
||||||
|
<sbo-input readonly ariaLabel="Número correlativo" value="000123" />
|
||||||
|
</sbo-field>
|
||||||
|
<sbo-date-field label="Fecha de contabilización" required [(value)]="date" />
|
||||||
|
<sbo-select-field label="Condición de pago" [options]="terms" [(value)]="term" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<sbo-tabs>
|
||||||
|
<sbo-tab label="Contenido">
|
||||||
|
<sbo-lines-grid
|
||||||
|
[columns]="columns"
|
||||||
|
[(lines)]="lines"
|
||||||
|
[newLine]="newLine"
|
||||||
|
[recalc]="recalc"
|
||||||
|
[multiAdd]="multi"
|
||||||
|
/>
|
||||||
|
</sbo-tab>
|
||||||
|
<sbo-tab label="Logística"><sbo-text-field label="Dirección fiscal" readonly value="(vendría del socio de negocio)" /></sbo-tab>
|
||||||
|
<sbo-tab label="Finanzas"><sbo-text-field label="Moneda del documento" readonly value="CLP" /></sbo-tab>
|
||||||
|
</sbo-tabs>
|
||||||
|
|
||||||
|
<div class="sbo-hdr" style="margin-top:14px">
|
||||||
|
<div class="sbo-col"><sbo-textarea-field label="Comentarios" value="Se imprimen en la cotización" /></div>
|
||||||
|
<div class="sbo-col">
|
||||||
|
<sbo-text-field label="Total antes de impuestos" readonly [value]="subtotal()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button sboActions class="btn main" type="button" (click)="demo()">Crear</button>
|
||||||
|
<button sboActions class="btn" type="button" (click)="reset()">Cancelar</button>
|
||||||
|
<sbo-menu-button sboActionsEnd label="Copiar de" [items]="from" />
|
||||||
|
<sbo-menu-button sboActionsEnd label="Copiar a" [items]="to" />
|
||||||
|
</sbo-window>
|
||||||
|
`,
|
||||||
|
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<string | number | null>(-1);
|
||||||
|
protected readonly lines = signal<readonly GridLine[]>([]);
|
||||||
|
protected readonly status = signal<StatusMessage>({ 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<string, unknown>): 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.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'] ?? '' }),
|
||||||
|
};
|
||||||
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<div class="bar">
|
||||||
|
<span class="count">{{ lines().length }} {{ lines().length === 1 ? 'línea' : 'líneas' }}</span>
|
||||||
|
@if (!readonly()) {
|
||||||
|
<span class="sp"></span>
|
||||||
|
@if (multiAdd(); as m) {
|
||||||
|
<button type="button" class="btn" (click)="multiOpen.set(true)">{{ m.label }}</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="btn main" (click)="add()">+ {{ addLabel() }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gridwrap">
|
||||||
|
<table class="grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="rn">#</th>
|
||||||
|
@for (c of columns(); track c.key) {
|
||||||
|
<th [style.width]="c.width" [class.num]="c.type === 'number'" [class.req]="c.required">{{ c.title }}</th>
|
||||||
|
}
|
||||||
|
@if (!readonly()) {
|
||||||
|
<th class="del"><span class="sr">Quitar</span></th>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (line of lines(); track $index; let i = $index) {
|
||||||
|
<tr>
|
||||||
|
<td class="rn">{{ i + 1 }}</td>
|
||||||
|
@for (c of columns(); track c.key; let j = $index) {
|
||||||
|
<td [class.ro]="isReadonly(line, c)">
|
||||||
|
@if (c.lookup && !isReadonly(line, c)) {
|
||||||
|
<div class="pick">
|
||||||
|
<input
|
||||||
|
class="cell"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
[class.mono]="c.mono"
|
||||||
|
[attr.data-r]="i"
|
||||||
|
[attr.data-c]="j"
|
||||||
|
[attr.aria-label]="c.title + ' de la línea ' + (i + 1)"
|
||||||
|
[value]="cellText(line, c)"
|
||||||
|
(change)="commit(i, c, $any($event.target))"
|
||||||
|
(keydown)="onKey($event, i, j)"
|
||||||
|
/>
|
||||||
|
<button type="button" tabindex="-1" title="Lista de valores (F4)" [attr.aria-label]="'Abrir la lista de ' + c.title" (click)="openLookup(i, c)">…</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<input
|
||||||
|
class="cell"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
[class.num]="c.type === 'number'"
|
||||||
|
[class.mono]="c.mono"
|
||||||
|
[readOnly]="isReadonly(line, c)"
|
||||||
|
[attr.data-r]="i"
|
||||||
|
[attr.data-c]="j"
|
||||||
|
[attr.aria-label]="c.title + ' de la línea ' + (i + 1)"
|
||||||
|
[attr.inputmode]="c.type === 'number' ? 'decimal' : null"
|
||||||
|
[attr.title]="cellText(line, c) || null"
|
||||||
|
[value]="cellText(line, c)"
|
||||||
|
(focus)="c.type === 'number' ? numberFocus($event, line, c) : null"
|
||||||
|
(change)="commit(i, c, $any($event.target))"
|
||||||
|
(blur)="c.type === 'number' ? commit(i, c, $any($event.target)) : null"
|
||||||
|
(keydown)="onKey($event, i, j)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
@if (!readonly()) {
|
||||||
|
<td class="del">
|
||||||
|
<button type="button" [attr.aria-label]="'Quitar la línea ' + (i + 1)" title="Quitar línea" (click)="remove(i)">×</button>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr class="none">
|
||||||
|
<td [attr.colspan]="columns().length + (readonly() ? 1 : 2)">
|
||||||
|
{{ readonly() ? 'El documento no tiene líneas.' : 'No hay líneas. Pulse «Añadir línea» para comenzar.' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (lookup(); as cfg) {
|
||||||
|
<sbo-value-help
|
||||||
|
[open]="true"
|
||||||
|
[title]="cfg.title"
|
||||||
|
[resource]="cfg.resource"
|
||||||
|
[columns]="cfg.columns"
|
||||||
|
[baseFilter]="cfg.baseFilter"
|
||||||
|
[orderby]="cfg.orderby"
|
||||||
|
[rowKey]="cfg.valueKey"
|
||||||
|
(selected)="lookupPicked($event)"
|
||||||
|
(closed)="lookupClosed()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (multiOpen(); as _) {
|
||||||
|
@if (multiAdd(); as m) {
|
||||||
|
<sbo-value-help
|
||||||
|
[open]="true"
|
||||||
|
[title]="m.lookup.title"
|
||||||
|
[resource]="m.lookup.resource"
|
||||||
|
[columns]="m.lookup.columns"
|
||||||
|
[baseFilter]="m.lookup.baseFilter"
|
||||||
|
[orderby]="m.lookup.orderby"
|
||||||
|
selection="multiple"
|
||||||
|
(selected)="multiPicked($event)"
|
||||||
|
(closed)="multiOpen.set(false)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: `
|
||||||
|
<sbo-lines-grid
|
||||||
|
[columns]="columns"
|
||||||
|
[(lines)]="lines"
|
||||||
|
[readonly]="readonly()"
|
||||||
|
[newLine]="newLine"
|
||||||
|
[recalc]="recalc"
|
||||||
|
[multiAdd]="multi"
|
||||||
|
/>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
class Host {
|
||||||
|
columns = DOCUMENT_LINE_COLUMNS;
|
||||||
|
lines = signal<readonly GridLine[]>([]);
|
||||||
|
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<Host>;
|
||||||
|
let ctrl: HttpTestingController;
|
||||||
|
let el: HTMLElement;
|
||||||
|
const q = <T extends HTMLElement>(s: string) => el.querySelector<T>(s)!;
|
||||||
|
const qa = (s: string) => [...el.querySelectorAll<HTMLElement>(s)];
|
||||||
|
const cell = (r: number, c: number) => q<HTMLInputElement>(`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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 GridColumn[]>();
|
||||||
|
readonly lines = model<readonly GridLine[]>([]);
|
||||||
|
/** 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<GridMultiAdd>();
|
||||||
|
readonly addLabel = input('Añadir línea');
|
||||||
|
|
||||||
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
private readonly injector = inject(Injector);
|
||||||
|
|
||||||
|
protected readonly lookupTarget = signal<LookupTarget | null>(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<HTMLInputElement>(`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;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user