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,99 @@
:host {
display: flex;
flex-direction: column;
min-height: 0;
}
.tablewrap {
flex: 1;
min-height: 0;
overflow: auto;
border: 1px solid var(--line);
background: var(--panel);
}
.tablewrap.busy {
opacity: 0.6;
}
.grid {
width: 100%;
border-collapse: collapse;
}
th {
position: sticky;
top: 0;
padding: 4px 8px;
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.pick {
cursor: pointer;
}
th.pick:hover {
background: var(--btn-h);
}
th.active {
box-shadow: inset 0 -2px 0 var(--focus);
}
td {
padding: 3px 8px;
border-right: 1px solid var(--line-soft);
border-bottom: 1px solid var(--line-soft);
white-space: nowrap;
}
tbody tr {
cursor: pointer;
}
tbody tr:nth-child(even) td {
background: var(--row-alt);
}
tbody tr:hover td,
tbody tr:focus td {
background: var(--row-sel);
}
tbody tr:focus {
outline: 1px solid var(--focus);
outline-offset: -1px;
}
tbody tr.sel td {
background: var(--row-sel);
}
tr.none td {
padding: 24px;
text-align: center;
color: var(--quiet);
cursor: default;
}
.mono {
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.chk {
width: 30px;
text-align: center;
}
.badge {
display: inline-block;
padding: 0 8px;
border: 1px solid var(--line);
border-radius: 10px;
font-size: 11.5px;
color: var(--quiet);
}
.tone-ok {
color: var(--ok);
border-color: var(--ok);
background: var(--ok-bg);
}
.tone-warn {
color: var(--warn);
border-color: var(--warn);
background: var(--warn-bg);
}
.tone-err {
color: var(--err);
border-color: var(--err);
background: var(--err-bg);
}
@@ -0,0 +1,61 @@
<div class="tablewrap" [class.busy]="loading()">
<table class="grid">
<thead>
<tr>
@if (selectable()) {
<th class="chk">
<input
type="checkbox"
aria-label="Seleccionar todas las filas de la página"
[checked]="allChosen()"
(change)="pageToggled.emit($any($event.target).checked)"
/>
</th>
}
@for (c of columns(); track c.key) {
<th
[style.width]="c.width"
[style.text-align]="align(c)"
[class.pick]="searchable(c)"
[class.active]="c.key === activeColumnKey()"
[attr.title]="searchable(c) ? 'Buscar en esta columna' : null"
(click)="columnPicked.emit(c)"
>
{{ c.title }}
</th>
}
</tr>
</thead>
<tbody>
@for (row of rows(); track idOf(row)) {
<tr tabindex="0" [class.sel]="isChosen(row)" (click)="rowActivated.emit(row)" (keydown.enter)="rowActivated.emit(row)">
@if (selectable()) {
<td class="chk">
<input
type="checkbox"
tabindex="-1"
[attr.aria-label]="'Seleccionar ' + idOf(row)"
[checked]="isChosen(row)"
(click)="$event.stopPropagation()"
(change)="rowToggled.emit(row)"
/>
</td>
}
@for (c of columns(); track c.key) {
<td [style.text-align]="align(c)" [class.mono]="c.mono">
@if (tone(row, c); as t) {
<span class="badge" [class]="'badge tone-' + t">{{ cell(row, c) }}</span>
} @else {
{{ cell(row, c) }}
}
</td>
}
</tr>
} @empty {
<tr class="none">
<td [attr.colspan]="colspan()">{{ loading() ? 'Cargando…' : emptyText() }}</td>
</tr>
}
</tbody>
</table>
</div>
@@ -0,0 +1,115 @@
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DataTable } from './data-table';
import type { ListColumn, ListRow } from './list.types';
const COLUMNS: ListColumn[] = [
{ key: 'DocNum', title: 'Número', type: 'number', decimals: 0, searchable: true },
{ key: 'CardName', title: 'Nombre' },
{ key: 'DocTotal', title: 'Total', type: 'currency' },
{ key: 'DocDate', title: 'Fecha', type: 'date' },
{
key: 'DocumentStatus',
title: 'Estado',
format: (r) => (r['DocumentStatus'] === 'bost_Open' ? 'Abierto' : 'Cerrado'),
tone: (r) => (r['DocumentStatus'] === 'bost_Open' ? 'ok' : undefined),
},
];
const rows: ListRow[] = [
{ DocEntry: 1, DocNum: 10, CardName: 'ACME', DocTotal: 1234.5, DocDate: '2026-03-05T00:00:00Z', DocumentStatus: 'bost_Open' },
{ DocEntry: 2, DocNum: 11, CardName: 'Beta', DocTotal: 80, DocDate: '2026-03-06T00:00:00Z', DocumentStatus: 'bost_Close' },
];
describe('DataTable', () => {
let fixture: ComponentFixture<DataTable>;
let el: HTMLElement;
const q = (s: string) => el.querySelector<HTMLElement>(s)!;
const qa = (s: string) => [...el.querySelectorAll<HTMLElement>(s)];
const render = async () => {
fixture.detectChanges();
await fixture.whenStable();
};
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [DataTable] }).compileComponents();
fixture = TestBed.createComponent(DataTable);
el = fixture.nativeElement;
fixture.componentRef.setInput('columns', COLUMNS);
fixture.componentRef.setInput('rows', rows);
fixture.componentRef.setInput('keyField', 'DocEntry');
});
it('formatea cada tipo de celda y alinea los números a la derecha', async () => {
await render();
const cells = [...qa('tbody tr')[0].querySelectorAll('td')].map((c) => c.textContent?.trim());
expect(cells).toEqual(['10', 'ACME', '1,234.50', '05/03/2026', 'Abierto']);
expect((qa('tbody tr')[0].querySelectorAll('td')[2] as HTMLElement).style.textAlign).toBe('right');
});
it('pinta insignia solo donde la columna define un tono', async () => {
await render();
expect(qa('tbody tr')[0].querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Abierto');
expect(qa('tbody tr')[1].querySelector('.badge')).toBeNull();
});
it('solo las columnas buscables se pueden elegir y la activa se resalta', async () => {
fixture.componentRef.setInput('activeColumnKey', 'CardName');
await render();
expect(qa('th.pick').map((t) => t.textContent?.trim())).toEqual(['Número', 'Nombre']);
expect(q('th.active').textContent?.trim()).toBe('Nombre');
const picked = vi.fn();
fixture.componentInstance.columnPicked.subscribe(picked);
qa('th.pick')[0].click();
expect(picked).toHaveBeenCalledWith(COLUMNS[0]);
});
it('emite la fila al hacer clic o pulsar Enter', async () => {
await render();
const activated = vi.fn();
fixture.componentInstance.rowActivated.subscribe(activated);
qa('tbody tr')[1].click();
qa('tbody tr')[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(activated.mock.calls.map((c) => c[0]['DocEntry'])).toEqual([2, 1]);
});
it('sin selectable no hay casillas; con selectable marca y emite', async () => {
await render();
expect(qa('input[type=checkbox]')).toHaveLength(0);
fixture.componentRef.setInput('selectable', true);
fixture.componentRef.setInput('chosen', new Set(['2']));
await render();
expect(qa('tbody input[type=checkbox]').map((c) => (c as HTMLInputElement).checked)).toEqual([false, true]);
expect(qa('tbody tr')[1].classList.contains('sel')).toBe(true);
const toggled = vi.fn();
const page = vi.fn();
fixture.componentInstance.rowToggled.subscribe(toggled);
fixture.componentInstance.pageToggled.subscribe(page);
const box = qa('tbody input[type=checkbox]')[0] as HTMLInputElement;
box.checked = true;
box.dispatchEvent(new Event('change'));
expect(toggled).toHaveBeenCalledTimes(1);
const head = q('thead input[type=checkbox]') as HTMLInputElement;
head.checked = true;
head.dispatchEvent(new Event('change'));
expect(page).toHaveBeenCalledWith(true);
});
it('la casilla del encabezado refleja si toda la página está marcada', async () => {
fixture.componentRef.setInput('selectable', true);
fixture.componentRef.setInput('chosen', new Set(['1', '2']));
await render();
expect((q('thead input[type=checkbox]') as HTMLInputElement).checked).toBe(true);
});
it('sin filas muestra el texto vacío o "Cargando…"', async () => {
fixture.componentRef.setInput('rows', []);
fixture.componentRef.setInput('emptyText', 'Sin documentos');
await render();
expect(q('tr.none').textContent).toContain('Sin documentos');
fixture.componentRef.setInput('loading', true);
await render();
expect(q('tr.none').textContent).toContain('Cargando');
});
});
@@ -0,0 +1,60 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type Tone } from './list.types';
/**
* Tabla densa de solo presentación: encabezados (el de búsqueda activo se subraya), filas
* con celdas formateadas, insignias de estado y casillas opcionales para selección múltiple.
*/
@Component({
selector: 'sbo-data-table',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './data-table.html',
styleUrl: './data-table.css',
})
export class DataTable {
readonly columns = input.required<readonly ListColumn[]>();
readonly rows = input.required<readonly ListRow[]>();
/** Campo único de cada fila. */
readonly keyField = input.required<string>();
/** Columna de búsqueda activa (se resalta; clic en otro encabezado la cambia). */
readonly activeColumnKey = input<string>();
/** Muestra casillas de selección. */
readonly selectable = input(false);
readonly chosen = input<ReadonlySet<string>>(new Set());
readonly loading = input(false);
readonly emptyText = input('Sin resultados');
readonly columnPicked = output<ListColumn>();
readonly rowActivated = output<ListRow>();
readonly rowToggled = output<ListRow>();
readonly pageToggled = output<boolean>();
protected readonly allChosen = computed(
() => this.rows().length > 0 && this.rows().every((r) => this.chosen().has(this.idOf(r))),
);
protected readonly colspan = computed(() => this.columns().length + (this.selectable() ? 1 : 0));
protected idOf(row: ListRow): string {
return String(row[this.keyField()] ?? '');
}
protected isChosen(row: ListRow): boolean {
return this.chosen().has(this.idOf(row));
}
protected cell(row: ListRow, column: ListColumn): string {
return cellText(row, column);
}
protected align(column: ListColumn): string {
return alignOf(column);
}
protected searchable(column: ListColumn): boolean {
return isSearchable(column);
}
protected tone(row: ListRow, column: ListColumn): Tone | undefined {
return column.tone?.(row);
}
}
@@ -0,0 +1,92 @@
.bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 6px 12px;
}
.cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 180px;
}
.cell.grow {
flex: 1;
}
/* El rango de fechas necesita dos campos legibles */
.cell.wide {
flex: 0 1 300px;
min-width: 270px;
}
.lbl {
font-size: 10.5px;
line-height: 1;
color: var(--quiet);
}
.sbo-in,
.sbo-sel {
height: 26px;
}
.hint {
font-size: 11.5px;
color: var(--err);
}
.chips {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 3px;
height: auto;
min-height: 26px;
padding-top: 2px;
padding-bottom: 2px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 0 4px 0 6px;
border: 1px solid var(--focus);
border-radius: 2px;
background: var(--row-sel);
color: var(--focus);
font-size: 11.5px;
}
.chip button {
padding: 0 2px;
border: none;
background: none;
color: inherit;
cursor: pointer;
line-height: 1;
}
.chip button:hover {
color: var(--err);
}
.all {
color: var(--quiet);
}
.open {
margin-left: auto;
padding: 0 4px;
border: none;
background: none;
color: var(--focus);
font-weight: 700;
cursor: pointer;
}
.range {
display: flex;
align-items: center;
gap: 4px;
}
.dash {
color: var(--quiet);
}
.actions {
display: flex;
gap: 6px;
}
.btn {
min-width: 60px;
}
@@ -0,0 +1,106 @@
<form class="bar" (submit)="$event.preventDefault(); search.emit()">
<div class="cell grow">
<span class="lbl">&nbsp;</span>
<input
class="sbo-in"
type="search"
name="q"
autocomplete="off"
[attr.aria-label]="searchPlaceholder()"
[placeholder]="searchPlaceholder()"
[value]="searchText()"
(input)="searchText.set($any($event.target).value)"
/>
@if (searchHint()) {
<span class="hint" role="alert">{{ searchHint() }}</span>
}
</div>
@for (f of filters(); track f.key) {
<div class="cell grow" [class.wide]="f.type === 'date'">
<label class="lbl" [for]="'flt-' + f.key">{{ f.label }}</label>
@switch (f.type) {
@case ('text') {
<input
class="sbo-in"
type="text"
autocomplete="off"
[id]="'flt-' + f.key"
[placeholder]="f.placeholder ?? ''"
[value]="text(f.key)"
(input)="set(f.key, $any($event.target).value)"
/>
}
@case ('select') {
<select class="sbo-sel" [id]="'flt-' + f.key" (change)="set(f.key, $any($event.target).value || undefined)">
<option value="" [selected]="!text(f.key)">Todos</option>
@for (o of f.options; track o.value) {
<option [value]="o.value" [selected]="o.value === text(f.key)">{{ o.label }}</option>
}
</select>
}
@case ('multi') {
<div class="chips sbo-in">
@for (chip of list(f.key); track chip) {
<span class="chip"
>{{ chip }}<button type="button" [attr.aria-label]="'Quitar ' + chip" (click)="removeChip(f.key, chip)">×</button></span
>
} @empty {
<span class="all">Todos</span>
}
<button
class="open"
type="button"
title="Seleccionar"
[attr.aria-label]="'Elegir ' + f.label"
(click)="editingMulti.set(f)"
>
…
</button>
</div>
}
@case ('date') {
<div class="range">
<input
class="sbo-in"
type="date"
[id]="'flt-' + f.key"
[attr.aria-label]="f.label + ' desde'"
[value]="date(f.key, 'from')"
(input)="setDate(f.key, 'from', $any($event.target).value)"
/>
<span class="dash">–</span>
<input
class="sbo-in"
type="date"
[attr.aria-label]="f.label + ' hasta'"
[value]="date(f.key, 'to')"
(input)="setDate(f.key, 'to', $any($event.target).value)"
/>
</div>
}
}
</div>
}
<div class="cell">
<span class="lbl">&nbsp;</span>
<div class="actions">
<button class="btn main" type="submit">Ir</button>
<button class="btn" type="button" (click)="clear.emit()">Limpiar</button>
</div>
</div>
</form>
@if (editingMulti(); as f) {
<sbo-value-help
[open]="true"
[title]="f.label"
[resource]="f.resource"
[columns]="multiColumns()"
selection="multiple"
[initialSelection]="multiInitial()"
(selected)="multiPicked($event)"
(closed)="editingMulti.set(null)"
/>
}
@@ -0,0 +1,158 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ListFilter } from '../../core/sl/list-filters';
import { FilterBar } from './filter-bar';
const FILTERS: ListFilter[] = [
{ type: 'text', key: 'NumAtCard', label: 'Referencia', placeholder: 'OC-123' },
{
type: 'select',
key: 'DocumentStatus',
label: 'Estado',
options: [
{ value: 'bost_Open', label: 'Abierto' },
{ value: 'bost_Close', label: 'Cerrado' },
],
},
{ type: 'multi', key: 'DocCurrency', label: 'Moneda', resource: 'Currencies', valueField: 'Code', labelField: 'Name' },
{ type: 'date', key: 'DocDate', label: 'Fecha contab.' },
];
describe('FilterBar', () => {
let fixture: ComponentFixture<FilterBar>;
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 tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
const type = async (input: HTMLInputElement, value: string) => {
input.value = value;
input.dispatchEvent(new Event('input'));
await tick();
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FilterBar],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(FilterBar);
el = fixture.nativeElement;
fixture.componentRef.setInput('filters', FILTERS);
fixture.componentRef.setInput('searchPlaceholder', 'Buscar en Número');
await tick();
});
it('muestra la búsqueda y cada filtro con su etiqueta', () => {
expect(q<HTMLInputElement>('input[name=q]').placeholder).toBe('Buscar en Número');
expect(qa('.lbl').map((l) => l.textContent?.trim()).filter(Boolean)).toEqual(['Referencia', 'Estado', 'Moneda', 'Fecha contab.']);
});
it('el texto de búsqueda se enlaza con searchText', async () => {
await type(q<HTMLInputElement>('input[name=q]'), 'ACME');
expect(fixture.componentInstance.searchText()).toBe('ACME');
});
it('filtro de texto', async () => {
await type(q<HTMLInputElement>('input[placeholder="OC-123"]'), 'OC-9');
expect(fixture.componentInstance.values()['NumAtCard']).toBe('OC-9');
});
it('select: elige una opción y "Todos" la quita', async () => {
const select = q<HTMLSelectElement>('select');
select.value = 'bost_Close';
select.dispatchEvent(new Event('change'));
expect(fixture.componentInstance.values()['DocumentStatus']).toBe('bost_Close');
select.value = '';
select.dispatchEvent(new Event('change'));
expect(fixture.componentInstance.values()['DocumentStatus']).toBeUndefined();
});
it('el select arranca con la opción del valor recibido', async () => {
fixture.componentRef.setInput('values', { DocumentStatus: 'bost_Open' });
await tick();
expect(q<HTMLSelectElement>('select').value).toBe('bost_Open');
});
it('fecha: siempre un rango [desde – hasta] con dos campos', async () => {
const [from, to] = qa('input[type=date]') as HTMLInputElement[];
await type(from, '2026-09-01');
await type(to, '2026-09-11');
expect(fixture.componentInstance.values()['DocDate']).toEqual({ from: '2026-09-01', to: '2026-09-11' });
await type(from, '');
expect(fixture.componentInstance.values()['DocDate']).toEqual({ from: '', to: '2026-09-11' });
});
describe('multi-selección con chips', () => {
it('sin valores muestra "Todos"; con valores, un chip por valor', async () => {
expect(q('.chips .all').textContent).toContain('Todos');
fixture.componentRef.setInput('values', { DocCurrency: ['CLP', 'USD'] });
await tick();
expect(qa('.chip').map((c) => c.textContent?.replace('×', '').trim())).toEqual(['CLP', 'USD']);
});
it('quitar un chip lo elimina; al quitar el último el filtro desaparece', async () => {
fixture.componentRef.setInput('values', { DocCurrency: ['CLP', 'USD'] });
await tick();
(qa('.chip button')[0] as HTMLButtonElement).click();
await tick();
expect(fixture.componentInstance.values()['DocCurrency']).toEqual(['USD']);
(qa('.chip button')[0] as HTMLButtonElement).click();
await tick();
expect(fixture.componentInstance.values()['DocCurrency']).toBeUndefined();
});
it('"…" abre la lista de valores y confirmar deja los códigos elegidos como chips', async () => {
fixture.componentRef.setInput('values', { DocCurrency: ['EUR'] });
await tick();
q('.chips .open').click();
await tick();
ctrl
.expectOne((r) => r.url === '/api/sl/Currencies')
.flush({
value: [
{ Code: 'CLP', Name: 'Peso' },
{ Code: 'EUR', Name: 'Euro' },
{ Code: 'USD', Name: 'Dólar' },
],
'odata.count': '3',
});
await tick();
expect(q('.dlg')).toBeTruthy();
const rows = qa('.dlg tbody tr');
rows[0].click(); // CLP
rows[2].click(); // USD (EUR ya venía marcado)
await tick();
(qa('.dlg footer .btn.main')[0] as HTMLButtonElement).click();
await tick();
expect(fixture.componentInstance.values()['DocCurrency']).toEqual(['EUR', 'CLP', 'USD']);
expect(el.querySelector('.dlg')).toBeNull();
});
});
it('"Ir" (o Enter) emite search y "Limpiar" emite clear', async () => {
const search = vi.fn();
const clear = vi.fn();
fixture.componentInstance.search.subscribe(search);
fixture.componentInstance.clear.subscribe(clear);
q('form.bar').dispatchEvent(new Event('submit', { cancelable: true }));
expect(search).toHaveBeenCalledTimes(1);
const buttons = qa('.actions button');
expect(buttons.map((b) => b.textContent?.trim())).toEqual(['Ir', 'Limpiar']);
buttons[1].click();
expect(clear).toHaveBeenCalledTimes(1);
});
it('muestra la ayuda de búsqueda inválida', async () => {
fixture.componentRef.setInput('searchHint', 'Escriba un número');
await tick();
expect(q('.hint').textContent).toContain('Escriba un número');
});
});
@@ -0,0 +1,78 @@
import { ChangeDetectionStrategy, Component, computed, input, model, output, signal } from '@angular/core';
import type { FilterValue, FilterValues, ListFilter, MultiFilter } from '../../core/sl/list-filters';
import { ValueHelp } from '../value-help/value-help';
import type { ListColumn, ListRow } from './list.types';
/**
* Barra de búsqueda y filtros de las listas: caja de búsqueda por la columna activa más filtros
* declarativos (texto, select, multi-selección con chips y rango de fechas) con **Ir** y **Limpiar**.
* Solo edita `values` y `searchText`; quien la usa decide cuándo consultar al recibir `search`.
*/
@Component({
selector: 'sbo-filter-bar',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ValueHelp],
templateUrl: './filter-bar.html',
styleUrl: './filter-bar.css',
})
export class FilterBar {
readonly filters = input<readonly ListFilter[]>([]);
readonly searchPlaceholder = input('Buscar');
readonly searchHint = input('');
readonly searchText = model('');
readonly values = model<FilterValues>({});
readonly search = output<void>();
readonly clear = output<void>();
protected readonly editingMulti = signal<MultiFilter | null>(null);
protected readonly multiColumns = computed<ListColumn[]>(() => {
const f = this.editingMulti();
return f
? [
{ key: f.valueField, title: 'Código', width: '110px', mono: true },
{ key: f.labelField, title: 'Nombre' },
]
: [];
});
protected readonly multiInitial = computed<ListRow[]>(() => {
const f = this.editingMulti();
return f ? this.list(f.key).map((v) => ({ [f.valueField]: v })) : [];
});
protected text(key: string): string {
const v = this.values()[key];
return typeof v === 'string' ? v : '';
}
protected list(key: string): string[] {
const v = this.values()[key];
return Array.isArray(v) ? v : [];
}
protected date(key: string, edge: 'from' | 'to'): string {
const v = this.values()[key];
return v && typeof v === 'object' && !Array.isArray(v) ? v[edge] : '';
}
protected set(key: string, value: FilterValue | undefined): void {
this.values.update((current) => ({ ...current, [key]: value }));
}
protected setDate(key: string, edge: 'from' | 'to', value: string): void {
const current = this.values()[key];
const range = current && typeof current === 'object' && !Array.isArray(current) ? current : { from: '', to: '' };
this.set(key, { ...range, [edge]: value });
}
protected removeChip(key: string, chip: string): void {
const rest = this.list(key).filter((v) => v !== chip);
this.set(key, rest.length ? rest : undefined);
}
protected multiPicked(rows: ListRow[]): void {
const f = this.editingMulti();
if (f) this.set(f.key, rows.map((r) => String(r[f.valueField] ?? '')));
}
}
@@ -0,0 +1,43 @@
:host {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
gap: 8px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--line);
}
h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.head p {
margin: 1px 0 0;
font-size: 11.5px;
color: var(--quiet);
}
.error {
padding: 6px 10px;
border: 1px solid var(--err);
background: var(--err-bg);
color: var(--err);
font-size: 12px;
}
.link {
margin-left: 8px;
background: none;
border: none;
color: inherit;
text-decoration: underline;
cursor: pointer;
}
.table {
flex: 1;
}
@@ -0,0 +1,50 @@
<header class="head">
<div>
<h1>{{ title() }}</h1>
@if (description()) {
<p>{{ description() }}</p>
}
</div>
@if (createLabel()) {
<button class="btn main" type="button" (click)="create.emit()">{{ createLabel() }}</button>
}
</header>
<sbo-filter-bar
class="filters"
[filters]="filters()"
[searchPlaceholder]="placeholder()"
[searchHint]="list.searchHint()"
[(searchText)]="list.searchText"
[(values)]="draftValues"
(search)="go()"
(clear)="clearAll()"
/>
@if (list.errorMessage()) {
<div class="error" role="alert">
{{ list.errorMessage() }} <button class="link" type="button" (click)="list.retry()">Reintentar</button>
</div>
}
<sbo-data-table
class="table"
[columns]="columns()"
[rows]="list.rows()"
[keyField]="list.keyField()"
[activeColumnKey]="list.activeColumn()?.key"
[loading]="list.loading()"
[emptyText]="list.errorMessage() ? '' : 'Sin resultados'"
(columnPicked)="list.pickColumn($event)"
(rowActivated)="open.emit($event)"
/>
<sbo-pager
class="pager"
[rangeText]="list.rangeText()"
[hasPrevious]="list.hasPrevious()"
[hasNext]="list.hasNext()"
[busy]="list.loading()"
(previous)="list.previous()"
(next)="list.next()"
/>
@@ -0,0 +1,207 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, type TestRequest, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ListFilter } from '../../core/sl/list-filters';
import type { ListColumn, ListRow } from './list.types';
import { ListView } from './list-view';
const COLUMNS: ListColumn[] = [
{ key: 'DocNum', title: 'Número', type: 'number', decimals: 0, align: 'left', searchable: true },
{ key: 'CardName', title: 'Nombre' },
{ key: 'DocDate', title: 'Fecha', type: 'date' },
];
const FILTERS: ListFilter[] = [
{
type: 'select',
key: 'DocumentStatus',
label: 'Estado',
defaultValue: 'bost_Open',
options: [
{ value: 'bost_Open', label: 'Abierto' },
{ value: 'bost_Close', label: 'Cerrado' },
],
},
{ type: 'date', key: 'DocDate', label: 'Fecha' },
];
const doc = (n: number): ListRow => ({ DocEntry: n, DocNum: n, CardName: `Cliente ${n}`, DocDate: '2026-09-11T00:00:00Z' });
describe('ListView', () => {
let fixture: ComponentFixture<ListView>;
let ctrl: HttpTestingController;
let el: HTMLElement;
const URL = '/api/sl/Orders';
const q = <T extends HTMLElement>(s: string) => el.querySelector<T>(s)!;
const qa = (s: string) => [...el.querySelectorAll<HTMLElement>(s)];
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
const request = (): TestRequest => ctrl.expectOne((r) => r.url === URL);
const reply = async (req: TestRequest, rows: ListRow[], count = rows.length) => {
req.flush({ value: rows, 'odata.count': String(count) });
await tick();
};
const type = async (input: HTMLInputElement, value: string) => {
input.value = value;
input.dispatchEvent(new Event('input'));
await tick();
};
const go = async () => {
q('form.bar').dispatchEvent(new Event('submit', { cancelable: true }));
await tick();
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ListView],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(ListView);
el = fixture.nativeElement;
fixture.componentRef.setInput('title', 'Órdenes de venta');
fixture.componentRef.setInput('resource', 'Orders');
fixture.componentRef.setInput('columns', COLUMNS);
fixture.componentRef.setInput('extraFields', ['Cancelled']);
fixture.componentRef.setInput('filters', FILTERS);
fixture.componentRef.setInput('orderby', 'DocEntry desc');
fixture.componentRef.setInput('rowKey', 'DocEntry');
await tick();
});
it('carga los últimos documentos: 50 por página, más recientes primero, filtros por defecto aplicados', async () => {
const req = request();
expect(req.request.params.get('$top')).toBe('50');
expect(req.request.params.get('$skip')).toBe('0');
expect(req.request.params.get('$orderby')).toBe('DocEntry desc');
expect(req.request.params.get('$select')).toBe('DocNum,CardName,DocDate,Cancelled,DocEntry');
expect(req.request.params.get('$filter')).toBe("DocumentStatus eq 'bost_Open'");
await reply(req, [doc(3), doc(2), doc(1)], 3);
expect(q('h1').textContent).toBe('Órdenes de venta');
expect(qa('tbody tr:not(.none)')).toHaveLength(3);
expect(q('sbo-pager').textContent).toContain('1–3 de 3');
});
it('el botón de alta emite create y se puede ocultar', async () => {
await reply(request(), []);
const create = vi.fn();
fixture.componentInstance.create.subscribe(create);
const btn = q('.head .btn.main');
expect(btn.textContent?.trim()).toBe('Nuevo');
btn.click();
expect(create).toHaveBeenCalledTimes(1);
fixture.componentRef.setInput('createLabel', '');
await tick();
expect(el.querySelector('.head .btn')).toBeNull();
});
it('un clic en la fila emite open con el documento', async () => {
await reply(request(), [doc(7), doc(6)]);
const open = vi.fn();
fixture.componentInstance.open.subscribe(open);
qa('tbody tr')[1].click();
expect(open).toHaveBeenCalledWith(doc(6));
});
it('"Ir" combina filtros y búsqueda en UNA sola consulta', async () => {
await reply(request(), [doc(1)]);
// elegir "Cerrado", un rango de fechas y texto en la columna activa (Número)
const select = q<HTMLSelectElement>('select');
select.value = 'bost_Close';
select.dispatchEvent(new Event('change'));
const [from] = qa('input[type=date]') as HTMLInputElement[];
await type(from, '2026-09-01');
await type(q<HTMLInputElement>('input[name=q]'), '314');
await go();
const req = request(); // expectOne: si hubiera dos peticiones, fallaría
expect(req.request.params.get('$filter')).toBe(
"((DocumentStatus eq 'bost_Close') and (DocDate ge datetime'2026-09-01T00:00:00')) and (DocNum eq 314)",
);
await reply(req, []);
});
it('editar un filtro sin pulsar "Ir" no consulta', async () => {
await reply(request(), [doc(1)]);
const select = q<HTMLSelectElement>('select');
select.value = 'bost_Close';
select.dispatchEvent(new Event('change'));
await tick();
ctrl.expectNone(URL);
});
it('elegir otra columna de búsqueda no consulta hasta pulsar "Ir"', async () => {
await reply(request(), [doc(1)]);
qa('th.pick').find((t) => t.textContent?.includes('Nombre'))?.click();
await tick();
expect(q<HTMLInputElement>('input[name=q]').placeholder).toBe('Buscar en Nombre');
await type(q<HTMLInputElement>('input[name=q]'), 'acme');
ctrl.expectNone(URL);
await go();
const req = request();
expect(req.request.params.get('$filter')).toBe("(DocumentStatus eq 'bost_Open') and (contains(CardName,'acme'))");
await reply(req, []);
});
it('en una columna numérica busca por igualdad y avisa si no es un número', async () => {
await reply(request(), [doc(1)]);
await type(q<HTMLInputElement>('input[name=q]'), 'abc');
await go();
expect(q('.hint').textContent).toContain('Escriba un número');
ctrl.expectNone(URL);
await type(q<HTMLInputElement>('input[name=q]'), '314');
await go();
const req = request();
expect(req.request.params.get('$filter')).toContain('DocNum eq 314');
await reply(req, [doc(314)]);
expect(el.querySelector('.hint')).toBeNull();
});
it('"Limpiar" vacía búsqueda y filtros y vuelve a los valores por defecto', async () => {
await reply(request(), [doc(1)]);
const select = q<HTMLSelectElement>('select');
select.value = '';
select.dispatchEvent(new Event('change'));
await type(q<HTMLInputElement>('input[name=q]'), '5');
await go();
await reply(request(), []);
qa('.actions button')[1].click();
await tick();
const req = request();
expect(req.request.params.get('$filter')).toBe("DocumentStatus eq 'bost_Open'");
expect(q<HTMLSelectElement>('select').value).toBe('bost_Open');
expect(q<HTMLInputElement>('input[name=q]').value).toBe('');
await reply(req, []);
});
it('pagina de 50 en 50 con $skip', async () => {
await reply(request(), Array.from({ length: 50 }, (_, i) => doc(i + 1)), 120);
expect(q('sbo-pager').textContent).toContain('1–50 de 120');
(qa('sbo-pager button')[1] as HTMLButtonElement).click();
await tick();
const req = request();
expect(req.request.params.get('$skip')).toBe('50');
await reply(req, Array.from({ length: 50 }, (_, i) => doc(i + 51)), 120);
expect(q('sbo-pager').textContent).toContain('51–100 de 120');
});
it('muestra el error del backend y permite reintentar', async () => {
request().flush({ message: 'Recurso no disponible: Orders' }, { status: 404, statusText: 'Not Found' });
await tick();
expect(q('.error').textContent).toContain('Recurso no disponible');
(q('.error .link') as HTMLButtonElement).click();
await tick();
await reply(request(), [doc(1)]);
expect(el.querySelector('.error')).toBeNull();
});
it('sin documentos muestra "Sin resultados"', async () => {
await reply(request(), []);
expect(q('tr.none').textContent).toContain('Sin resultados');
});
});
+70
View File
@@ -0,0 +1,70 @@
import { ChangeDetectionStrategy, Component, computed, input, linkedSignal, output } from '@angular/core';
import { combineFilters } from '../../core/sl/odata';
import { buildFilters, initialFilterValues, type FilterValues, type ListFilter } from '../../core/sl/list-filters';
import { createSlList } from '../../core/sl/sl-list';
import { DataTable } from './data-table';
import { FilterBar } from './filter-bar';
import type { ListColumn, ListRow } from './list.types';
import { Pager } from './pager';
/**
* Lista de documentos (o de cualquier recurso) con barra de filtros y búsqueda por columna,
* paginación del servidor y botón "Nuevo". Se abre una fila con un clic.
*/
@Component({
selector: 'sbo-list-view',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DataTable, FilterBar, Pager],
templateUrl: './list-view.html',
styleUrl: './list-view.css',
})
export class ListView {
readonly title = input.required<string>();
readonly description = input<string>();
/** Recurso registrado en el proxy `/api/sl`. */
readonly resource = input.required<string>();
readonly columns = input.required<readonly ListColumn[]>();
/** Campos que hacen falta para pintar (ej. `Cancelled` para el estado) sin tener columna propia. */
readonly extraFields = input<readonly string[]>([]);
readonly filters = input<readonly ListFilter[]>([]);
/** Filtro fijo que siempre se aplica (ej. por tipo de documento). */
readonly baseFilter = input<string>();
readonly orderby = input<string>();
readonly rowKey = input<string>();
readonly pageSize = input(50);
/** Texto del botón de alta; vacío para ocultarlo. */
readonly createLabel = input('Nuevo');
readonly open = output<ListRow>();
readonly create = output<void>();
/** Lo que se está editando en la barra de filtros. */
protected readonly draftValues = linkedSignal<FilterValues>(() => initialFilterValues(this.filters()));
/** Lo que ya se aplicó (solo cambia con "Ir" o "Limpiar"). */
private readonly appliedValues = linkedSignal<FilterValues>(() => initialFilterValues(this.filters()));
private readonly filterString = computed(() => buildFilters(this.filters(), this.appliedValues()));
protected readonly list = createSlList({
resource: () => this.resource(),
columns: () => this.columns(),
extraFields: () => this.extraFields(),
rowKey: () => this.rowKey(),
baseFilter: () => combineFilters(this.baseFilter(), this.filterString()),
orderby: () => this.orderby(),
pageSize: () => this.pageSize(),
});
protected readonly placeholder = computed(() => `Buscar en ${this.list.activeColumn()?.title ?? ''}`);
protected go(): void {
this.appliedValues.set(this.draftValues());
this.list.search();
}
protected clearAll(): void {
const initial = initialFilterValues(this.filters());
this.draftValues.set(initial);
this.appliedValues.set(initial);
this.list.clearSearch();
}
}
@@ -0,0 +1,49 @@
import { formatDate, formatNumber } from '../../core/format';
export type ListRow = Record<string, unknown>;
export type Tone = 'ok' | 'warn' | 'err' | 'muted';
export interface ListColumn {
/** Campo del Service Layer; también se usa en `$select` y, si es buscable, en el filtro. */
key: string;
title: string;
width?: string;
type?: 'text' | 'number' | 'currency' | 'date';
/** Decimales fijos para `number` y `currency` (currency usa 2 por defecto). */
decimals?: number;
align?: 'left' | 'center' | 'right';
mono?: boolean;
/**
* Se puede elegir para buscar. Por defecto, solo las de texto sin `format` (un texto calculado
* no coincide con el valor guardado). En columnas numéricas la búsqueda es por igualdad (`eq`);
* en las de fecha no está soportada.
*/
searchable?: boolean;
/** Texto a mostrar en lugar del valor del campo. */
format?: (row: ListRow) => string;
/** Si devuelve un tono, la celda se muestra como insignia de ese color (ej. estado). */
tone?: (row: ListRow) => Tone | undefined;
}
export const isNumericColumn = (c: ListColumn): boolean => c.type === 'number' || c.type === 'currency';
export const isSearchable = (c: ListColumn): boolean => c.searchable ?? ((c.type ?? 'text') === 'text' && !c.format);
export function cellText(row: ListRow, column: ListColumn): string {
if (column.format) return column.format(row);
const value = row[column.key];
switch (column.type) {
case 'number':
return formatNumber(value, column.decimals);
case 'currency':
return formatNumber(value, column.decimals ?? 2);
case 'date':
return formatDate(value);
default:
return value === null || value === undefined ? '' : String(value);
}
}
export function alignOf(column: ListColumn): 'left' | 'center' | 'right' {
return column.align ?? (isNumericColumn(column) ? 'right' : 'left');
}
+32
View File
@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
/** Paginación del servidor: Anterior / rango de registros / Siguiente. */
@Component({
selector: 'sbo-pager',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="pager">
<button class="btn" type="button" [disabled]="!hasPrevious() || busy()" (click)="previous.emit()">‹ Anterior</button>
<span>{{ rangeText() }}</span>
<button class="btn" type="button" [disabled]="!hasNext() || busy()" (click)="next.emit()">Siguiente ›</button>
</div>
`,
styles: `
.pager {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--quiet);
}
`,
})
export class Pager {
readonly rangeText = input.required<string>();
readonly hasPrevious = input(false);
readonly hasNext = input(false);
readonly busy = input(false);
readonly previous = output<void>();
readonly next = output<void>();
}
@@ -0,0 +1,54 @@
import { ChangeDetectionStrategy, Component, type ElementRef, input, model, output, viewChild } from '@angular/core';
/** Caja de búsqueda con botón; busca con Enter. Para las listas con filtros se usa `sbo-filter-bar`. */
@Component({
selector: 'sbo-search-bar',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form class="search" (submit)="$event.preventDefault(); search.emit()">
<input
#box
class="sbo-in"
type="search"
name="q"
autocomplete="off"
[attr.aria-label]="placeholder()"
[placeholder]="placeholder()"
[value]="text()"
(input)="text.set($any($event.target).value)"
/>
<button class="btn main" type="submit">Buscar</button>
</form>
@if (hint()) {
<p class="hint" role="alert">{{ hint() }}</p>
}
`,
styles: `
.search {
display: flex;
gap: 6px;
}
.sbo-in {
flex: 1;
height: 26px;
padding: 1px 8px;
}
.hint {
margin: 3px 0 0;
font-size: 11.5px;
color: var(--err);
}
`,
})
export class SearchBar {
readonly text = model('');
readonly placeholder = input('Buscar');
readonly hint = input('');
readonly search = output<void>();
private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box');
focus(): void {
this.box().nativeElement.focus();
}
}
@@ -40,46 +40,9 @@ h2 {
margin-left: auto;
}
.search {
display: flex;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--line-soft);
}
.in {
flex: 1;
min-width: 0;
height: 26px;
padding: 1px 8px;
border: 1px solid var(--field-b);
border-radius: 2px;
background: var(--field);
}
.in:focus {
border-color: var(--focus);
outline: none;
box-shadow: 0 0 0 1px var(--focus);
}
.btn {
height: 26px;
min-width: 84px;
padding: 0 14px;
background: var(--btn);
border: 1px solid var(--btn-b);
border-radius: 3px;
cursor: pointer;
}
.btn:hover:not(:disabled) {
background: var(--btn-h);
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
.btn.main {
font-weight: 600;
border-color: var(--focus);
color: var(--focus);
}
.error {
margin: 8px 12px 0;
padding: 6px 10px;
@@ -96,77 +59,9 @@ h2 {
text-decoration: underline;
cursor: pointer;
}
.tablewrap {
.table {
flex: 1;
min-height: 0;
overflow: auto;
margin: 8px 12px;
border: 1px solid var(--line);
background: var(--panel);
}
.tablewrap.busy {
opacity: 0.6;
}
.grid {
width: 100%;
border-collapse: collapse;
}
th {
position: sticky;
top: 0;
padding: 4px 8px;
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.pick {
cursor: pointer;
}
th.pick:hover {
background: var(--btn-h);
}
th.active {
box-shadow: inset 0 -2px 0 var(--focus);
}
td {
padding: 3px 8px;
border-right: 1px solid var(--line-soft);
border-bottom: 1px solid var(--line-soft);
white-space: nowrap;
}
tbody tr {
cursor: pointer;
}
tbody tr:nth-child(even) td {
background: var(--row-alt);
}
tbody tr:hover td,
tbody tr:focus td {
background: var(--row-sel);
}
tbody tr:focus {
outline: 1px solid var(--focus);
outline-offset: -1px;
}
tbody tr.sel td {
background: var(--row-sel);
}
tr.none td {
padding: 24px;
text-align: center;
color: var(--quiet);
cursor: default;
}
.mono {
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.chk {
width: 30px;
text-align: center;
}
footer {
display: flex;
@@ -176,13 +71,6 @@ footer {
border-top: 1px solid var(--line);
background: var(--panel);
}
.pager {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--quiet);
}
.sp {
flex: 1;
}
@@ -9,91 +9,46 @@
}
</header>
<form class="search" (submit)="$event.preventDefault(); search()">
<input
#searchBox
class="in"
type="search"
name="q"
autocomplete="off"
[attr.aria-label]="'Buscar en ' + (activeColumn()?.title ?? '')"
[placeholder]="'Buscar en ' + (activeColumn()?.title ?? '')"
[value]="searchText()"
(input)="searchText.set($any($event.target).value)"
<div class="search">
<sbo-search-bar
[placeholder]="placeholder()"
[hint]="list.searchHint()"
[(text)]="list.searchText"
(search)="list.search()"
/>
<button class="btn main" type="submit">Buscar</button>
</form>
</div>
@if (errorMessage()) {
@if (list.errorMessage()) {
<div class="error" role="alert">
{{ errorMessage() }} <button class="link" type="button" (click)="retry()">Reintentar</button>
{{ list.errorMessage() }} <button class="link" type="button" (click)="list.retry()">Reintentar</button>
</div>
}
<div class="tablewrap" [class.busy]="loading()">
<table class="grid">
<thead>
<tr>
@if (multiple()) {
<th class="chk">
<input
type="checkbox"
aria-label="Seleccionar todas las filas de la página"
[checked]="allPageChosen()"
(change)="togglePage($any($event.target).checked)"
/>
</th>
}
@for (c of columns(); track c.key) {
<th
[style.width]="c.width"
[style.text-align]="align(c)"
[class.pick]="searchable(c)"
[class.active]="c.key === activeColumn()?.key"
[attr.title]="searchable(c) ? 'Buscar en esta columna' : null"
(click)="pickColumn(c)"
>
{{ c.title }}
</th>
}
</tr>
</thead>
<tbody>
@for (row of rows(); track idOf(row)) {
<tr tabindex="0" [class.sel]="isChosen(row)" (click)="rowActivated(row)" (keydown.enter)="rowActivated(row)">
@if (multiple()) {
<td class="chk">
<input
type="checkbox"
tabindex="-1"
[attr.aria-label]="'Seleccionar ' + idOf(row)"
[checked]="isChosen(row)"
(click)="$event.stopPropagation()"
(change)="toggle(row)"
/>
</td>
}
@for (c of columns(); track c.key) {
<td [style.text-align]="align(c)" [class.mono]="c.mono">{{ cell(row, c) }}</td>
}
</tr>
} @empty {
<tr class="none">
<td [attr.colspan]="columns().length + (multiple() ? 1 : 0)">
{{ loading() ? 'Cargando…' : errorMessage() ? '' : 'Sin resultados' }}
</td>
</tr>
}
</tbody>
</table>
</div>
<sbo-data-table
class="table"
[columns]="columns()"
[rows]="list.rows()"
[keyField]="list.keyField()"
[activeColumnKey]="list.activeColumn()?.key"
[selectable]="multiple()"
[chosen]="chosenIds()"
[loading]="list.loading()"
[emptyText]="list.errorMessage() ? '' : 'Sin resultados'"
(columnPicked)="list.pickColumn($event)"
(rowActivated)="rowActivated($event)"
(rowToggled)="toggle($event)"
(pageToggled)="togglePage($event)"
/>
<footer>
<div class="pager">
<button class="btn" type="button" [disabled]="!hasPrevious() || loading()" (click)="previous()">‹ Anterior</button>
<span>{{ rangeText() }}</span>
<button class="btn" type="button" [disabled]="!hasNext() || loading()" (click)="next()">Siguiente ›</button>
</div>
<sbo-pager
[rangeText]="list.rangeText()"
[hasPrevious]="list.hasPrevious()"
[hasNext]="list.hasNext()"
[busy]="list.loading()"
(previous)="list.previous()"
(next)="list.next()"
/>
<span class="sp"></span>
<button class="btn" type="button" (click)="close()">Cancelar</button>
@if (multiple()) {
+33 -139
View File
@@ -1,21 +1,9 @@
import { httpResource } from '@angular/common/http';
import {
ChangeDetectionStrategy,
Component,
type ElementRef,
computed,
effect,
input,
output,
signal,
untracked,
viewChild,
} from '@angular/core';
import { type SlListResponse, slTotal } from '@basic-erp/sap-types';
import { API_URL, apiErrorMessage } from '../../core/api';
import { formatDate, formatNumber } from '../../core/format';
import { combineFilters, containsFilter } from '../../core/sl/odata';
import { type VhColumn, type VhRow, isSearchable } from './value-help.types';
import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal, untracked, viewChild } from '@angular/core';
import { createSlList } from '../../core/sl/sl-list';
import { DataTable } from '../list/data-table';
import { Pager } from '../list/pager';
import { SearchBar } from '../list/search-bar';
import type { ListColumn, ListRow } from '../list/list.types';
/**
* Lista de valores ("elegir de la lista" de SBO): búsqueda por columna sobre un
@@ -26,6 +14,7 @@ import { type VhColumn, type VhRow, isSearchable } from './value-help.types';
@Component({
selector: 'sbo-value-help',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DataTable, Pager, SearchBar],
templateUrl: './value-help.html',
styleUrl: './value-help.css',
})
@@ -34,7 +23,7 @@ export class ValueHelp {
readonly title = input.required<string>();
/** Recurso registrado en el proxy, ej. `BusinessPartners`. */
readonly resource = input.required<string>();
readonly columns = input.required<readonly VhColumn[]>();
readonly columns = input.required<readonly ListColumn[]>();
readonly selection = input<'single' | 'multiple'>('single');
/** Filtro fijo (ej. `CardType eq 'cCustomer'`) que se combina con la búsqueda. */
readonly baseFilter = input<string>();
@@ -43,130 +32,39 @@ export class ValueHelp {
readonly rowKey = input<string>();
readonly pageSize = input(20);
/** Filas ya elegidas al abrir (solo selección múltiple). */
readonly initialSelection = input<readonly VhRow[]>([]);
readonly initialSelection = input<readonly ListRow[]>([]);
readonly selected = output<VhRow[]>();
readonly selected = output<ListRow[]>();
readonly closed = output<void>();
protected readonly searchText = signal('');
/** Búsqueda en vigor; cambiar la columna elegida no consulta hasta pulsar "Buscar". */
private readonly applied = signal<{ column: string; text: string } | null>(null);
protected readonly searchColumn = signal('');
private readonly skip = signal(0);
/** Las marcadas se guardan completas para no perderlas al cambiar de página. */
protected readonly chosen = signal<ReadonlyMap<string, VhRow>>(new Map());
protected readonly list = createSlList({
resource: () => this.resource(),
columns: () => this.columns(),
rowKey: () => this.rowKey(),
baseFilter: () => this.baseFilter(),
orderby: () => this.orderby(),
pageSize: () => this.pageSize(),
active: () => this.open(),
});
private readonly searchBox = viewChild<ElementRef<HTMLInputElement>>('searchBox');
/** Las marcadas se guardan completas para no perderlas al cambiar de página. */
protected readonly chosen = signal<ReadonlyMap<string, ListRow>>(new Map());
protected readonly chosenIds = computed<ReadonlySet<string>>(() => new Set(this.chosen().keys()));
private readonly searchBar = viewChild(SearchBar);
protected readonly multiple = computed(() => this.selection() === 'multiple');
protected readonly keyField = computed(() => this.rowKey() ?? this.columns()[0]?.key ?? '');
protected readonly activeColumn = computed(
() =>
this.columns().find((c) => c.key === this.searchColumn()) ?? this.columns().find((c) => isSearchable(c)),
);
private readonly list = httpResource<SlListResponse<VhRow>>(() => {
if (!this.open()) return undefined;
const fields = new Set(this.columns().map((c) => c.key));
fields.add(this.keyField());
const applied = this.applied();
return {
url: `${API_URL}/sl/${this.resource()}`,
params: {
$select: [...fields].join(','),
$top: this.pageSize(),
$skip: this.skip(),
...this.optional('$orderby', this.orderby()),
...this.optional('$filter', combineFilters(this.baseFilter(), applied ? containsFilter(applied.column, applied.text) : undefined)),
},
};
});
protected readonly rows = computed(() => (this.list.hasValue() ? this.list.value().value : []));
protected readonly total = computed(() => (this.list.hasValue() ? slTotal(this.list.value()) : 0));
protected readonly loading = computed(() => this.list.isLoading());
protected readonly errorMessage = computed(() => (this.list.error() ? apiErrorMessage(this.list.error()) : ''));
protected readonly rangeText = computed(() => {
const total = this.total();
if (total === 0) return '0 registros';
const from = this.skip() + 1;
return `${from}–${Math.min(this.skip() + this.rows().length, total)} de ${total}`;
});
protected readonly hasPrevious = computed(() => this.skip() > 0);
protected readonly hasNext = computed(() => this.skip() + this.pageSize() < this.total());
protected readonly allPageChosen = computed(
() => this.rows().length > 0 && this.rows().every((r) => this.chosen().has(this.idOf(r))),
);
protected readonly placeholder = computed(() => `Buscar en ${this.list.activeColumn()?.title ?? ''}`);
constructor() {
effect(() => {
if (!this.open()) return;
untracked(() => {
this.searchText.set('');
this.applied.set(null);
this.skip.set(0);
this.searchColumn.set(this.columns().find((c) => isSearchable(c))?.key ?? '');
this.chosen.set(new Map(this.initialSelection().map((r) => [this.idOf(r), r])));
});
queueMicrotask(() => this.searchBox()?.nativeElement.focus());
untracked(() => this.chosen.set(new Map(this.initialSelection().map((r) => [this.list.idOf(r), r]))));
queueMicrotask(() => this.searchBar()?.focus());
});
}
protected idOf(row: VhRow): string {
return String(row[this.keyField()] ?? '');
}
protected isChosen(row: VhRow): boolean {
return this.chosen().has(this.idOf(row));
}
protected cell(row: VhRow, column: VhColumn): string {
if (column.format) return column.format(row);
const value = row[column.key];
switch (column.type) {
case 'number':
return formatNumber(value, column.decimals);
case 'currency':
return formatNumber(value, column.decimals ?? 2);
case 'date':
return formatDate(value);
default:
return value === null || value === undefined ? '' : String(value);
}
}
protected align(column: VhColumn): string {
return column.align ?? (column.type === 'number' || column.type === 'currency' ? 'right' : 'left');
}
protected searchable(column: VhColumn): boolean {
return isSearchable(column);
}
protected pickColumn(column: VhColumn): void {
if (isSearchable(column)) this.searchColumn.set(column.key);
}
protected search(): void {
const text = this.searchText().trim();
const column = this.activeColumn();
this.skip.set(0);
this.applied.set(text && column ? { column: column.key, text } : null);
}
protected previous(): void {
this.skip.update((s) => Math.max(0, s - this.pageSize()));
}
protected next(): void {
if (this.hasNext()) this.skip.update((s) => s + this.pageSize());
}
protected retry(): void {
this.list.reload();
}
protected rowActivated(row: VhRow): void {
protected rowActivated(row: ListRow): void {
if (this.multiple()) {
this.toggle(row);
} else {
@@ -175,10 +73,10 @@ export class ValueHelp {
}
}
protected toggle(row: VhRow): void {
protected toggle(row: ListRow): void {
this.chosen.update((current) => {
const next = new Map(current);
const id = this.idOf(row);
const id = this.list.idOf(row);
if (!next.delete(id)) next.set(id, row);
return next;
});
@@ -187,9 +85,9 @@ export class ValueHelp {
protected togglePage(checked: boolean): void {
this.chosen.update((current) => {
const next = new Map(current);
for (const row of this.rows()) {
if (checked) next.set(this.idOf(row), row);
else next.delete(this.idOf(row));
for (const row of this.list.rows()) {
if (checked) next.set(this.list.idOf(row), row);
else next.delete(this.list.idOf(row));
}
return next;
});
@@ -203,8 +101,4 @@ export class ValueHelp {
protected close(): void {
this.closed.emit();
}
private optional(name: string, value: string | undefined): Record<string, string> {
return value ? { [name]: value } : {};
}
}
@@ -1,28 +1,14 @@
export type VhRow = Record<string, unknown>;
import type { ListColumn, ListRow } from '../list/list.types';
export interface VhColumn {
/** Campo del Service Layer; también se usa en `$select` y, si es buscable, en `contains()`. */
key: string;
title: string;
width?: string;
type?: 'text' | 'number' | 'currency' | 'date';
/** Decimales fijos para `number` y `currency` (currency usa 2 por defecto). */
decimals?: number;
align?: 'left' | 'center' | 'right';
mono?: boolean;
/** Se puede elegir para buscar. Por defecto, solo las columnas de texto. */
searchable?: boolean;
/** Texto a mostrar en lugar del valor del campo. */
format?: (row: VhRow) => string;
}
export const isSearchable = (c: VhColumn): boolean => c.searchable ?? (c.type ?? 'text') === 'text';
export type VhRow = ListRow;
export type VhColumn = ListColumn;
export { isSearchable } from '../list/list.types';
/** Configuración de una lista de valores para un campo de búsqueda (`sbo-lookup-field`). */
export interface VhLookup {
title: string;
resource: string;
columns: readonly VhColumn[];
columns: readonly ListColumn[];
baseFilter?: string;
orderby?: string;
/** Campo de la fila elegida que se escribe en el campo; por defecto, la primera columna. */