feat(web): lista de documentos con barra de filtros y búsqueda por columna

Cada pantalla de documento del menú abre primero la lista de los últimos
documentos; desde ahí se crea uno (/nuevo) o se abre uno (/:docEntry).

- sbo-list-view: título, botón de alta, barra de filtros, tabla y paginación
  (50 por página, más recientes primero).
- sbo-filter-bar (como el FilterBar del mini-erp): búsqueda por la columna
  activa + filtros declarativos text / select (con valor por defecto) /
  multi (chips + lista de valores) / date (siempre rango [desde – hasta]),
  con Ir y Limpiar. Ir aplica búsqueda y filtros en una sola consulta.
- Búsqueda numérica por igualdad (DocNum eq 314) con aviso si no es número;
  una columna con texto calculado (format) ya no es buscable por defecto.
- Piezas compartidas con sbo-value-help, que se refactoriza sobre ellas:
  createSlList (estado de búsqueda/paginación), sbo-data-table, sbo-pager,
  sbo-search-bar. Insignias de estado (tone) y estilos de botón globales.
- DOCUMENT_SCREENS: columnas y filtros de 17 documentos (ventas, compras,
  inventario) según los campos verificados en el Service Layer real
  (PurchaseRequests no tiene CardCode; StockTransfers no tiene estado).
- Rutas generadas desde el registro: lista, nuevo y :docEntry bajo el
  permiso de la ruta de menú. El formulario es un marcador (paso 9).
- Presupuesto: el aviso del bundle inicial sube a 300 kB; httpResource
  aporta ~18 kB al chunk compartido (278 kB, 78 kB comprimido).
- Verificado en Chrome contra SAP real: 49 órdenes abiertas, 314 en total,
  rango de fechas, chips de monedas, búsqueda por número, navegación y
  botón atrás, tema oscuro; ofertas (4391), transferencias y entradas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 20:01:34 -05:00
1 parent 68caf401a7
commit 598307960e
36 files changed
+2163 -359

No files matched your search

@@ -0,0 +1,25 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import type { DocumentScreen } from './document-screens';
/** Formulario del documento: `nuevo` (vacío) o `:docEntry` (ver / editar). Se construye en el paso 9. */
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
template: `
<h1>{{ title }} — {{ docEntry ? 'documento ' + docEntry : 'nuevo' }}</h1>
<p>El formulario aún no está disponible. <a routerLink="..">Volver a la lista</a></p>
`,
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
p { margin: 0; color: var(--quiet); }
a { color: var(--focus); }
`,
})
export class DocumentFormPage {
private readonly route = inject(ActivatedRoute);
protected readonly title = this.route.snapshot.data['title'] as string;
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
protected readonly docEntry = this.route.snapshot.paramMap.get('docEntry');
}
@@ -0,0 +1,61 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DocumentListPage } from './document-list.page';
import { DOCUMENT_SCREENS } from './document-screens';
describe('DocumentListPage', () => {
const screen = DOCUMENT_SCREENS.find((s) => s.resource === 'Orders')!;
let fixture: ComponentFixture<DocumentListPage>;
let ctrl: HttpTestingController;
let navigate: ReturnType<typeof vi.spyOn>;
const route = { snapshot: { data: { screen, title: 'Órdenes de venta' } } };
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DocumentListPage],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: ActivatedRoute, useValue: route },
],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(DocumentListPage);
await tick();
ctrl
.expectOne((r) => r.url === '/api/sl/Orders')
.flush({
value: [{ DocEntry: 41, DocNum: 41, CardCode: 'C1', CardName: 'ACME', DocDate: '2026-09-11T00:00:00Z', DocumentStatus: 'bost_Open', DocTotal: 100, DocCurrency: 'CLP' }],
'odata.count': '1',
});
await tick();
});
it('muestra el título del menú, el botón de alta del documento y los documentos', () => {
const el: HTMLElement = fixture.nativeElement;
expect(el.querySelector('h1')?.textContent).toBe('Órdenes de venta');
expect(el.querySelector('.head .btn.main')?.textContent?.trim()).toBe('Nueva orden');
expect(el.querySelectorAll('tbody tr')).toHaveLength(1);
expect(el.querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Abierto');
});
it('"Nueva orden" va al formulario vacío (ruta "nuevo")', () => {
(fixture.nativeElement.querySelector('.head .btn.main') as HTMLElement).click();
expect(navigate).toHaveBeenCalledWith(['nuevo'], { relativeTo: route });
});
it('un clic en la fila abre ese documento por su DocEntry', () => {
(fixture.nativeElement.querySelector('tbody tr') as HTMLElement).click();
expect(navigate).toHaveBeenCalledWith(['41'], { relativeTo: route });
});
});
@@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ListView } from '../../shared/list/list-view';
import type { ListRow } from '../../shared/list/list.types';
import type { DocumentScreen } from './document-screens';
/** Lista de los últimos documentos de una pantalla del menú; abre uno o crea uno nuevo. */
@Component({
selector: 'sbo-document-list-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ListView],
template: `
<sbo-list-view
[title]="title"
[resource]="screen.resource"
[columns]="screen.columns"
[extraFields]="screen.extraFields ?? []"
[filters]="screen.filters"
[orderby]="screen.orderby"
rowKey="DocEntry"
[createLabel]="screen.createLabel"
(open)="open($event)"
(create)="create()"
/>
`,
styles: ':host { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }',
})
export class DocumentListPage {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
protected readonly title = this.route.snapshot.data['title'] as string;
protected open(row: ListRow): void {
void this.router.navigate([String(row['DocEntry'])], { relativeTo: this.route });
}
protected create(): void {
void this.router.navigate(['nuevo'], { relativeTo: this.route });
}
}
@@ -0,0 +1,82 @@
import { MENU } from '@basic-erp/sap-types';
import { describe, expect, it } from 'vitest';
import { buildFilters, initialFilterValues } from '../../core/sl/list-filters';
import { cellText, isNumericColumn, isSearchable } from '../../shared/list/list.types';
import { DOCUMENT_SCREENS, docNum, documentStatus, documentTotal, menuLabel } from './document-screens';
describe('DOCUMENT_SCREENS', () => {
const menuPaths = new Set(MENU.flatMap((m) => m.items.map((i) => i.path)));
it('cada pantalla cuelga de una ruta del menú, sin repetir rutas ni recursos', () => {
for (const s of DOCUMENT_SCREENS) expect(menuPaths.has(s.path), s.path).toBe(true);
expect(new Set(DOCUMENT_SCREENS.map((s) => s.path)).size).toBe(DOCUMENT_SCREENS.length);
expect(new Set(DOCUMENT_SCREENS.map((s) => s.resource)).size).toBe(DOCUMENT_SCREENS.length);
});
it('todas ordenan por los más recientes y tienen una columna buscable por defecto', () => {
for (const s of DOCUMENT_SCREENS) {
expect(s.orderby, s.resource).toBe('DocEntry desc');
expect(s.columns.some((c) => isSearchable(c)), s.resource).toBe(true);
expect(new Set(s.columns.map((c) => c.key)).size, s.resource).toBe(s.columns.length);
expect(s.createLabel, s.resource).toBeTruthy();
}
});
it('los campos de las columnas son identificadores válidos del Service Layer', () => {
for (const s of DOCUMENT_SCREENS) {
for (const c of s.columns) expect(c.key, `${s.resource}.${c.key}`).toMatch(/^[A-Za-z0-9_]+$/);
}
});
it('ventas y compras muestran socio y total; solicitudes de compra e inventario no', () => {
const by = (r: string) => DOCUMENT_SCREENS.find((s) => s.resource === r)!;
expect(by('Orders').columns.map((c) => c.key)).toContain('CardCode');
expect(by('PurchaseOrders').columns.find((c) => c.key === 'CardCode')?.title).toBe('Proveedor');
expect(by('PurchaseRequests').columns.map((c) => c.key)).not.toContain('CardCode');
expect(by('StockTransfers').columns.map((c) => c.key)).not.toContain('DocumentStatus');
});
it('el estado por defecto es Abierto donde existe estado (como en el mini-erp)', () => {
for (const s of DOCUMENT_SCREENS) {
const hasStatus = s.filters.some((f) => f.key === 'DocumentStatus');
expect(buildFilters(s.filters, initialFilterValues(s.filters)), s.resource).toBe(
hasStatus ? "DocumentStatus eq 'bost_Open'" : undefined,
);
}
});
it('las columnas con texto calculado (estado, total) no son buscables', () => {
const orders = DOCUMENT_SCREENS.find((s) => s.resource === 'Orders')!;
expect(orders.columns.filter((c) => isSearchable(c)).map((c) => c.key)).toEqual(['DocNum', 'CardCode', 'CardName']);
});
});
describe('columna Número', () => {
it('se muestra sin separador de miles pero se busca por igualdad numérica', () => {
expect(cellText({ DocNum: 4391 }, docNum)).toBe('4391');
expect(isNumericColumn(docNum)).toBe(true);
expect(isSearchable(docNum)).toBe(true);
});
});
describe('menuLabel', () => {
it('toma el título del menú', () => expect(menuLabel('/ventas/ordenes')).toBe('Órdenes de venta'));
it('si no existe devuelve la ruta', () => expect(menuLabel('/x/y')).toBe('/x/y'));
});
describe('documentStatus', () => {
it('abierto, cerrado y cancelado', () => {
expect(documentStatus({ DocumentStatus: 'bost_Open', Cancelled: 'tNO' })).toEqual({ label: 'Abierto', tone: 'ok' });
expect(documentStatus({ DocumentStatus: 'bost_Close' })).toEqual({ label: 'Cerrado', tone: 'muted' });
expect(documentStatus({ DocumentStatus: 'bost_Open', Cancelled: 'tYES' })).toEqual({ label: 'Cancelado', tone: 'err' });
expect(documentStatus({})).toBeUndefined();
});
});
describe('documentTotal', () => {
it('moneda local: DocTotal; moneda extranjera: DocTotalFc', () => {
expect(documentTotal({ DocCurrency: 'CLP', DocTotal: 163625, DocTotalFc: 0 })).toBe('CLP 163,625.00');
expect(documentTotal({ DocCurrency: 'USD', DocTotal: 95000, DocTotalFc: 100 })).toBe('USD 100.00');
expect(documentTotal({ DocCurrency: 'CLP' })).toBe('');
});
});
@@ -0,0 +1,159 @@
import { MENU } from '@basic-erp/sap-types';
import { formatNumber } from '../../core/format';
import type { ListFilter } from '../../core/sl/list-filters';
import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types';
/** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */
export interface DocumentScreen {
/** Ruta de menú (`/ventas/ordenes`); de ella cuelgan `nuevo` y `:docEntry`. */
path: string;
/** Recurso del proxy `/api/sl`. */
resource: string;
createLabel: string;
columns: readonly ListColumn[];
/** Campos necesarios para pintar que no tienen columna propia. */
extraFields?: readonly string[];
filters: readonly ListFilter[];
/** Los más recientes primero. */
orderby: string;
}
export const menuLabel = (path: string): string =>
MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path;
export function documentStatus(row: ListRow): { label: string; tone: Tone } | undefined {
if (row['Cancelled'] === 'tYES') return { label: 'Cancelado', tone: 'err' };
switch (row['DocumentStatus']) {
case 'bost_Open':
return { label: 'Abierto', tone: 'ok' };
case 'bost_Close':
return { label: 'Cerrado', tone: 'muted' };
default:
return undefined;
}
}
/** Total en la moneda del documento: SAP guarda en `DocTotalFc` el importe de los documentos en moneda extranjera. */
export function documentTotal(row: ListRow): string {
const fc = Number(row['DocTotalFc'] ?? 0);
const amount = fc !== 0 ? fc : row['DocTotal'];
if (amount === null || amount === undefined) return '';
return `${row['DocCurrency'] ?? ''} ${formatNumber(amount, 2)}`.trim();
}
const statusColumn: ListColumn = {
key: 'DocumentStatus',
title: 'Estado',
width: '100px',
format: (r) => documentStatus(r)?.label ?? '',
tone: (r) => documentStatus(r)?.tone,
};
/** Número de documento: sin separador de miles, y buscable por igualdad (es numérico). */
export const docNum: ListColumn = {
key: 'DocNum',
title: 'Número',
type: 'number',
align: 'left',
width: '110px',
mono: true,
searchable: true,
format: (r) => String(r['DocNum'] ?? ''),
};
const docDate: ListColumn = { key: 'DocDate', title: 'Fecha contab.', type: 'date', align: 'center', width: '115px' };
const statusFilter: ListFilter = {
type: 'select',
key: 'DocumentStatus',
label: 'Estado',
defaultValue: 'bost_Open',
options: [
{ value: 'bost_Open', label: 'Abierto' },
{ value: 'bost_Close', label: 'Cerrado' },
],
};
const dateFilter: ListFilter = { type: 'date', key: 'DocDate', label: 'Fecha contab.' };
const currencyFilter: ListFilter = {
type: 'multi',
key: 'DocCurrency',
label: 'Moneda',
resource: 'Currencies',
valueField: 'Code',
labelField: 'Name',
};
/** Ventas y compras: documentos con socio de negocio y total. */
const marketing = (path: string, resource: string, createLabel: string, partner: string): DocumentScreen => ({
path,
resource,
createLabel,
orderby: 'DocEntry desc',
extraFields: ['Cancelled', 'DocTotalFc', 'DocCurrency'],
columns: [
docNum,
statusColumn,
{ key: 'CardCode', title: partner, width: '130px', mono: true, searchable: true },
{ key: 'CardName', title: 'Nombre' },
docDate,
{ key: 'DocDueDate', title: 'Fecha venc.', type: 'date', align: 'center', width: '115px' },
{ key: 'DocTotal', title: 'Total', align: 'right', width: '140px', format: documentTotal },
],
filters: [statusFilter, currencyFilter, dateFilter],
});
const inventory = (path: string, resource: string, createLabel: string, withStatus: boolean): DocumentScreen => ({
path,
resource,
createLabel,
orderby: 'DocEntry desc',
extraFields: withStatus ? ['Cancelled'] : [],
columns: [
docNum,
...(withStatus ? [statusColumn] : []),
docDate,
...(resource === 'StockTransfers' || resource === 'InventoryTransferRequests'
? [
{ key: 'FromWarehouse', title: 'Almacén origen', width: '130px', mono: true, searchable: true },
{ key: 'ToWarehouse', title: 'Almacén destino', width: '130px', mono: true, searchable: true },
]
: []),
{ key: 'Comments', title: 'Comentarios', searchable: true },
],
filters: withStatus ? [statusFilter, dateFilter] : [dateFilter],
});
export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [
// Ventas
marketing('/ventas/ofertas', 'Quotations', 'Nueva oferta', 'Cliente'),
marketing('/ventas/ordenes', 'Orders', 'Nueva orden', 'Cliente'),
marketing('/ventas/entrega', 'DeliveryNotes', 'Nueva entrega', 'Cliente'),
marketing('/ventas/facturas', 'Invoices', 'Nueva factura', 'Cliente'),
marketing('/ventas/devoluciones', 'Returns', 'Nueva devolución', 'Cliente'),
marketing('/ventas/notas-credito', 'CreditNotes', 'Nueva nota de crédito', 'Cliente'),
// Compras
{
path: '/compras/solicitudes',
resource: 'PurchaseRequests',
createLabel: 'Nueva solicitud',
orderby: 'DocEntry desc',
extraFields: ['Cancelled'],
columns: [
docNum,
statusColumn,
docDate,
{ key: 'RequriedDate', title: 'Fecha requerida', type: 'date', align: 'center', width: '125px' },
{ key: 'RequesterName', title: 'Solicitante', searchable: true },
],
filters: [statusFilter, dateFilter],
},
marketing('/compras/ofertas', 'PurchaseQuotations', 'Nueva oferta', 'Proveedor'),
marketing('/compras/ordenes', 'PurchaseOrders', 'Nueva orden', 'Proveedor'),
marketing('/compras/recepcion', 'PurchaseDeliveryNotes', 'Nueva recepción', 'Proveedor'),
marketing('/compras/facturas', 'PurchaseInvoices', 'Nueva factura', 'Proveedor'),
marketing('/compras/devoluciones', 'PurchaseReturns', 'Nueva devolución', 'Proveedor'),
marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor'),
// Inventario
inventory('/inventario/entradas', 'InventoryGenEntries', 'Nueva entrada', true),
inventory('/inventario/salidas', 'InventoryGenExits', 'Nueva salida', true),
inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false),
inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true),
];