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
@@ -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'] ?? '' }),
|
||||
};
|
||||
Reference in new issue
Block a user