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

+4
View File
@@ -28,3 +28,7 @@ Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de
2. Cada cliente tiene su branch (`cliente/<nombre-cliente>`); ahí viven sus personalizaciones y fixes.
3. Los cambios de una branch de cliente no se mezclan con `main` ni con otro cliente, salvo backport genérico decidido explícitamente.
4. Antes de crear branches, mergear a `main`, o hacer merge/backport entre clientes, confirmar con el usuario. No hacer `git push` sin confirmación.
## Flujo de las pantallas de documento
Cada menú abre primero una **lista de los últimos documentos** (con barra de filtros y búsqueda por columna, 50 por página); desde ahí se crea uno nuevo (`/…/nuevo`, formulario vacío) o se abre uno existente (`/…/:docEntry`, ver/editar). Las pantallas se definen en `apps/web/src/app/features/documents/document-screens.ts`.
+24
View File
@@ -109,3 +109,27 @@ Una **fila** (`sbo-field`) es: etiqueta + uno o varios controles en la misma lí
Detalles: `sbo-number` muestra `1,234.50` fuera de foco y acepta `12,5` o `1,234.50` al escribir (`decimals` redondea); `sbo-date` usa texto ISO `yyyy-MM-dd`; `sbo-select` devuelve el valor con su tipo original.
Estilos globales en `styles/forms.css` (`.sbo-in`, `.sbo-sel`, `.sbo-ta`, `.sbo-btn`, `.sbo-group`, `.sbo-hdr`, `.sbo-col`). El ancho de etiqueta se ajusta con `--label-w` (165 px por defecto).
### Pantallas de documento: lista, nuevo y ver/editar
Cada pantalla de documento del menú abre primero la **lista de los últimos documentos** (más recientes primero, 50 por página); desde ahí se crea uno nuevo o se abre uno existente:
| Ruta | Muestra |
|---|---|
| `/ventas/ordenes` | Lista (`sbo-list-view`) |
| `/ventas/ordenes/nuevo` | Formulario vacío |
| `/ventas/ordenes/:docEntry` | Formulario con datos (ver / editar) |
Las tres rutas las gobierna el permiso de menú de la ruta padre. Se generan solas desde `features/documents/document-screens.ts` (`DOCUMENT_SCREENS`): por documento define recurso, columnas, campos extra, filtros y el texto del botón de alta. Agregar un documento = una entrada ahí (más su ruta en `MENU` y su recurso en el proxy del backend).
### `sbo-list-view` y `sbo-filter-bar`
Lista con **barra de filtros** y **búsqueda por columna**, igual que el mini-erp:
- **Búsqueda:** caja que busca en la columna activa (clic en el encabezado; solo columnas buscables). Texto → `contains`; columna numérica → igualdad (`DocNum eq 314`, avisa si no es un número).
- **Filtros declarativos** (`ListFilter`): `text`, `select` (con `defaultValue`, ej. Estado = Abierto), `multi` (chips + lista de valores) y `date` (siempre un rango **[desde – hasta]**, ambos inclusive y opcionales).
- **Ir** aplica búsqueda y filtros en **una sola** consulta; **Limpiar** vuelve a los valores por defecto. Editar un filtro no consulta hasta pulsar Ir.
- Una columna con `format` (texto calculado) no es buscable salvo `searchable: true`; `tone` pinta una insignia (estado).
- Entradas: `title`, `resource`, `columns`, `filters`, `extraFields`, `baseFilter`, `orderby`, `pageSize` (50), `createLabel`; salidas `(open)` y `(create)`.
El estado de búsqueda/paginación (`core/sl/sl-list.ts`) y la tabla (`sbo-data-table`) son compartidos con `sbo-value-help`.
+1 -1
View File
@@ -33,7 +33,7 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "260kB",
"maximumWarning": "300kB",
"maximumError": "400kB"
},
{
+37
View File
@@ -0,0 +1,37 @@
import { MENU } from '@basic-erp/sap-types';
import { describe, expect, it } from 'vitest';
import { routes } from './app.routes';
import { permissionGuard } from './core/auth/guards';
import { DOCUMENT_SCREENS } from './features/documents/document-screens';
const shell = routes.find((r) => r.path === '')!;
const children = shell.children!;
describe('rutas', () => {
it('cada pantalla del menú tiene su ruta protegida por permiso', () => {
for (const item of MENU.flatMap((m) => m.items)) {
const route = children.find((r) => r.path === item.path.slice(1));
expect(route, item.path).toBeDefined();
expect(route?.canActivate, item.path).toContain(permissionGuard);
expect(route?.data?.['path'], item.path).toBe(item.path);
}
});
it('cada documento ofrece lista, "nuevo" y "ver/editar" bajo la ruta del menú', () => {
for (const screen of DOCUMENT_SCREENS) {
const route = children.find((r) => r.path === screen.path.slice(1))!;
expect(route.children?.map((c) => c.path), screen.path).toEqual(['', 'nuevo', ':docEntry']);
expect(route.data?.['screen']).toBe(screen);
}
});
it('las pantallas que no son documentos (maestros) siguen como "no disponible"', () => {
const route = children.find((r) => r.path === 'socios/clientes')!;
expect(route.children).toBeUndefined();
expect(route.loadComponent).toBeDefined();
});
it('las rutas desconocidas vuelven al inicio', () => {
expect(routes.at(-1)).toMatchObject({ path: '**', redirectTo: '' });
});
});
+43 -9
View File
@@ -1,14 +1,47 @@
import type { Routes } from '@angular/router';
import type { Route, Routes } from '@angular/router';
import { MENU } from '@basic-erp/sap-types';
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
import { DOCUMENT_SCREENS, menuLabel } from './features/documents/document-screens';
/** Una ruta por pantalla del menú; las que aún no existen muestran "no disponible". */
const screens: Routes = MENU.flatMap((m) => m.items).map((item) => ({
path: item.path.slice(1),
canActivate: [permissionGuard],
data: { path: item.path, title: item.label },
loadComponent: () => import('./features/shared/coming-soon.page').then((m) => m.ComingSoonPage),
}));
/**
* Pantallas de documento: la ruta del menú muestra la lista de los últimos documentos;
* `nuevo` abre el formulario vacío y `:docEntry` el documento para ver o editar.
* El permiso de menú gobierna las tres (el guard del padre; `data` se hereda en los hijos).
*/
const documentRoutes: Routes = DOCUMENT_SCREENS.map(
(screen): Route => ({
path: screen.path.slice(1),
canActivate: [permissionGuard],
data: { path: screen.path, title: menuLabel(screen.path), screen },
children: [
{
path: '',
loadComponent: () => import('./features/documents/document-list.page').then((m) => m.DocumentListPage),
},
{
path: 'nuevo',
loadComponent: () => import('./features/documents/document-form.page').then((m) => m.DocumentFormPage),
},
{
path: ':docEntry',
loadComponent: () => import('./features/documents/document-form.page').then((m) => m.DocumentFormPage),
},
],
}),
);
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
const documentPaths = new Set(DOCUMENT_SCREENS.map((s) => s.path));
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
.filter((item) => !documentPaths.has(item.path))
.map(
(item): Route => ({
path: item.path.slice(1),
canActivate: [permissionGuard],
data: { path: item.path, title: item.label },
loadComponent: () => import('./features/shared/coming-soon.page').then((m) => m.ComingSoonPage),
}),
);
export const routes: Routes = [
{
@@ -26,7 +59,8 @@ export const routes: Routes = [
path: 'sin-acceso',
loadComponent: () => import('./features/shared/no-access.page').then((m) => m.NoAccessPage),
},
...screens,
...documentRoutes,
...pendingRoutes,
],
},
{ path: '**', redirectTo: '' },
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { type ListFilter, buildFilters, initialFilterValues, nextDay } from './list-filters';
const filters: ListFilter[] = [
{ type: 'text', key: 'NumAtCard', label: 'Ref.' },
{
type: 'select',
key: 'DocumentStatus',
label: 'Estado',
defaultValue: 'bost_Open',
options: [
{ value: 'bost_Open', label: 'Abierto' },
{ value: 'bost_Close', label: 'Cerrado' },
],
},
{ type: 'select', key: 'GroupNum', label: 'Pago', valueType: 'number', options: [] },
{ type: 'multi', key: 'DocCurrency', label: 'Moneda', resource: 'Currencies', valueField: 'Code', labelField: 'Name' },
{ type: 'date', key: 'DocDate', label: 'Fecha' },
];
describe('nextDay', () => {
it('pasa de mes y de año sin desfase de zona horaria', () => {
expect(nextDay('2026-01-31')).toBe('2026-02-01');
expect(nextDay('2026-12-31')).toBe('2027-01-01');
expect(nextDay('2028-02-28')).toBe('2028-02-29');
});
});
describe('initialFilterValues', () => {
it('solo trae los valores por defecto de los select', () => {
expect(initialFilterValues(filters)).toEqual({ DocumentStatus: 'bost_Open' });
});
});
describe('buildFilters', () => {
it('sin valores no hay filtro', () => {
expect(buildFilters(filters, {})).toBeUndefined();
});
it('texto con contains y comillas escapadas', () => {
expect(buildFilters(filters, { NumAtCard: " o'neil " })).toBe("contains(NumAtCard,'o''neil')");
});
it('select de texto y de número', () => {
expect(buildFilters(filters, { DocumentStatus: 'bost_Close' })).toBe("DocumentStatus eq 'bost_Close'");
expect(buildFilters(filters, { GroupNum: '-1' })).toBe('GroupNum eq -1');
expect(buildFilters(filters, { GroupNum: 'abc' })).toBeUndefined();
});
it('multi: un valor sin paréntesis, varios unidos con or', () => {
expect(buildFilters(filters, { DocCurrency: ['CLP'] })).toBe("DocCurrency eq 'CLP'");
expect(buildFilters(filters, { DocCurrency: ['CLP', 'USD'] })).toBe("(DocCurrency eq 'CLP' or DocCurrency eq 'USD')");
expect(buildFilters(filters, { DocCurrency: [] })).toBeUndefined();
});
it('fecha: siempre es un rango [desde – hasta] con extremos inclusivos y opcionales', () => {
expect(buildFilters(filters, { DocDate: { from: '2026-09-01', to: '2026-09-11' } })).toBe(
"(DocDate ge datetime'2026-09-01T00:00:00') and (DocDate lt datetime'2026-09-12T00:00:00')",
);
// solo "desde": desde esa fecha en adelante
expect(buildFilters(filters, { DocDate: { from: '2026-09-11', to: '' } })).toBe("DocDate ge datetime'2026-09-11T00:00:00'");
// solo "hasta": hasta esa fecha, incluida
expect(buildFilters(filters, { DocDate: { from: '', to: '2026-09-11' } })).toBe("DocDate lt datetime'2026-09-12T00:00:00'");
// fechas inválidas se ignoran (no se cuela texto en el filtro)
expect(buildFilters(filters, { DocDate: { from: "x' or 1 eq 1", to: '' } })).toBeUndefined();
expect(buildFilters(filters, { DocDate: { from: '', to: '' } })).toBeUndefined();
});
it('varios filtros se combinan con and y entre paréntesis', () => {
expect(buildFilters(filters, { DocumentStatus: 'bost_Open', DocCurrency: ['CLP', 'USD'] })).toBe(
"(DocumentStatus eq 'bost_Open') and ((DocCurrency eq 'CLP' or DocCurrency eq 'USD'))",
);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { combineFilters, containsFilter, escapeODataString } from './odata';
interface FilterBase {
/** Campo del Service Layer sobre el que filtra. */
key: string;
label: string;
}
export interface TextFilter extends FilterBase {
type: 'text';
placeholder?: string;
}
export interface SelectFilter extends FilterBase {
type: 'select';
options: readonly { value: string; label: string }[];
/** `number`: compara sin comillas (`Campo eq 3`). */
valueType?: 'string' | 'number';
/** Valor con el que arranca (y al pulsar "Limpiar"), ej. `bost_Open`. */
defaultValue?: string;
}
/** Varios valores elegidos de una lista (chips); se unen con `or`. */
export interface MultiFilter extends FilterBase {
type: 'multi';
/** Recurso cuyo listado se muestra al elegir. */
resource: string;
valueField: string;
labelField: string;
valueType?: 'string' | 'number';
}
/** Rango de fechas [desde – hasta], ambos inclusive; cualquiera de los dos extremos puede quedar libre. */
export interface DateFilter extends FilterBase {
type: 'date';
}
export type ListFilter = TextFilter | SelectFilter | MultiFilter | DateFilter;
export interface DateRange {
from: string;
to: string;
}
export type FilterValue = string | string[] | DateRange;
export type FilterValues = Readonly<Record<string, FilterValue | undefined>>;
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
/** `2026-01-31` -> `2026-02-01`, sin pasar por la zona horaria local. */
export function nextDay(iso: string): string {
const [y, m, d] = iso.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10);
}
export function initialFilterValues(filters: readonly ListFilter[]): FilterValues {
const values: Record<string, FilterValue> = {};
for (const f of filters) if (f.type === 'select' && f.defaultValue) values[f.key] = f.defaultValue;
return values;
}
function equals(key: string, value: string, type: 'string' | 'number' | undefined): string | undefined {
if (type === 'number') return Number.isFinite(Number(value)) && value.trim() !== '' ? `${key} eq ${Number(value)}` : undefined;
return `${key} eq '${escapeODataString(value)}'`;
}
/** Convierte los valores de la barra de filtros en un `$filter` OData (o `undefined` si no hay ninguno). */
export function buildFilters(filters: readonly ListFilter[], values: FilterValues): string | undefined {
const parts: (string | undefined)[] = [];
for (const f of filters) {
const value = values[f.key];
if (f.type === 'text') {
if (typeof value === 'string' && value.trim()) parts.push(containsFilter(f.key, value.trim()));
} else if (f.type === 'select') {
if (typeof value === 'string' && value) parts.push(equals(f.key, value, f.valueType));
} else if (f.type === 'multi') {
if (Array.isArray(value) && value.length) {
const ors = value.map((v) => equals(f.key, v, f.valueType)).filter((p): p is string => !!p);
if (ors.length) parts.push(ors.length === 1 ? ors[0] : `(${ors.join(' or ')})`);
}
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
const from = ISO_DATE.test(value.from) ? value.from : '';
const to = ISO_DATE.test(value.to) ? value.to : '';
if (from) parts.push(`${f.key} ge datetime'${from}T00:00:00'`);
if (to) parts.push(`${f.key} lt datetime'${nextDay(to)}T00:00:00'`);
}
}
return combineFilters(...parts);
}
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { combineFilters, containsFilter, escapeODataString } from './odata';
import { combineFilters, containsFilter, escapeODataString, numberFilter } from './odata';
describe('odata', () => {
it('escapa las comillas del texto buscado', () => {
@@ -19,4 +19,11 @@ describe('odata', () => {
it('combineFilters protege la precedencia de un "or"', () => {
expect(combineFilters("A eq 1 or B eq 2", "contains(N,'x')")).toBe("(A eq 1 or B eq 2) and (contains(N,'x'))");
});
it('numberFilter arma "campo eq n" y rechaza campos o valores inválidos', () => {
expect(numberFilter('DocNum', 123)).toBe('DocNum eq 123');
expect(numberFilter('Balance', -4.5)).toBe('Balance eq -4.5');
expect(() => numberFilter('DocNum or 1', 1)).toThrow();
expect(() => numberFilter('DocNum', Number.NaN)).toThrow();
});
});
+7
View File
@@ -19,3 +19,10 @@ export function combineFilters(...parts: (string | undefined)[]): string | undef
if (filters.length <= 1) return filters[0];
return filters.map((f) => `(${f})`).join(' and ');
}
/** `Campo eq 123`: para columnas numéricas, donde `contains()` no aplica. */
export function numberFilter(field: string, value: number): string {
assertIdentifier(field);
if (!Number.isFinite(value)) throw new Error('Valor numérico inválido');
return `${field} eq ${value}`;
}
+138
View File
@@ -0,0 +1,138 @@
import { httpResource } from '@angular/common/http';
import { computed, effect, signal, untracked } from '@angular/core';
import { type SlListResponse, slTotal } from '@basic-erp/sap-types';
import { type ListColumn, type ListRow, isNumericColumn, isSearchable } from '../../shared/list/list.types';
import { API_URL, apiErrorMessage } from '../api';
import { parseDecimal } from '../format';
import { combineFilters, containsFilter, numberFilter } from './odata';
export interface SlListOptions {
resource: () => string;
columns: () => readonly ListColumn[];
/** Campos extra para `$select` (ej. `Cancelled`, necesario para pintar el estado). */
extraFields?: () => readonly string[];
/** Campo único de cada fila; por defecto, el de la primera columna. */
rowKey?: () => string | undefined;
baseFilter?: () => string | undefined;
orderby?: () => string | undefined;
pageSize: () => number;
/** Solo consulta mientras esté activo (ej. el modal abierto). */
active?: () => boolean;
}
interface Applied {
column: ListColumn;
text: string;
}
/**
* Estado de un listado del Service Layer con búsqueda por columna y paginación del
* servidor. Comparten este estado el value-help y las listas de documentos.
* Debe crearse en un contexto de inyección (usa `httpResource`).
*/
export function createSlList(opts: SlListOptions) {
const searchText = signal('');
const searchColumn = signal('');
const searchHint = signal('');
const skip = signal(0);
/** Búsqueda en vigor; cambiar la columna elegida no consulta hasta llamar a `search()`. */
const applied = signal<Applied | null>(null);
const keyField = computed(() => opts.rowKey?.() ?? opts.columns()[0]?.key ?? '');
const activeColumn = computed(
() => opts.columns().find((c) => c.key === searchColumn()) ?? opts.columns().find((c) => isSearchable(c)),
);
const searchFilter = (a: Applied | null): string | undefined => {
if (!a) return undefined;
if (isNumericColumn(a.column)) return numberFilter(a.column.key, parseDecimal(a.text) ?? Number.NaN);
return containsFilter(a.column.key, a.text);
};
const list = httpResource<SlListResponse<ListRow>>(() => {
if (opts.active && !opts.active()) return undefined;
const fields = new Set(opts.columns().map((c) => c.key));
for (const f of opts.extraFields?.() ?? []) fields.add(f);
fields.add(keyField());
const orderby = opts.orderby?.();
const filter = combineFilters(opts.baseFilter?.(), searchFilter(applied()));
return {
url: `${API_URL}/sl/${opts.resource()}`,
params: {
$select: [...fields].join(','),
$top: opts.pageSize(),
$skip: skip(),
...(orderby ? { $orderby: orderby } : {}),
...(filter ? { $filter: filter } : {}),
},
};
});
const rows = computed(() => (list.hasValue() ? list.value().value : []));
const total = computed(() => (list.hasValue() ? slTotal(list.value()) : 0));
const rangeText = computed(() => {
if (total() === 0) return '0 registros';
return `${skip() + 1}–${Math.min(skip() + rows().length, total())} de ${total()}`;
});
const reset = () => {
searchText.set('');
searchHint.set('');
applied.set(null);
skip.set(0);
searchColumn.set(opts.columns().find((c) => isSearchable(c))?.key ?? '');
};
// Sin `active` no hace falta reiniciar al crear: las columnas (inputs) aún no tienen valor y
// `activeColumn` ya cae en la primera columna buscable.
if (opts.active) {
effect(() => {
if (opts.active?.()) untracked(reset);
});
}
return {
searchText,
searchHint,
activeColumn,
keyField,
rows,
total,
rangeText,
loading: computed(() => list.isLoading()),
errorMessage: computed(() => (list.error() ? apiErrorMessage(list.error()) : '')),
hasPrevious: computed(() => skip() > 0),
hasNext: computed(() => skip() + opts.pageSize() < total()),
idOf: (row: ListRow): string => String(row[keyField()] ?? ''),
pickColumn(column: ListColumn): void {
if (isSearchable(column)) searchColumn.set(column.key);
},
search(): void {
const text = searchText().trim();
const column = activeColumn();
searchHint.set('');
if (text && column && isNumericColumn(column) && parseDecimal(text) === null) {
searchHint.set('Escriba un número');
return;
}
skip.set(0);
applied.set(text && column ? { column, text } : null);
},
previous: () => skip.update((s) => Math.max(0, s - opts.pageSize())),
next(): void {
if (skip() + opts.pageSize() < total()) skip.update((s) => s + opts.pageSize());
},
/** Borra la búsqueda por columna y vuelve a la primera página (conserva la columna elegida). */
clearSearch(): void {
searchText.set('');
searchHint.set('');
applied.set(null);
skip.set(0);
},
retry: () => list.reload(),
reset,
};
}
export type SlList = ReturnType<typeof createSlList>;
@@ -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),
];
+2
View File
@@ -107,6 +107,8 @@ li a.active {
color: var(--err);
}
.content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
overflow: auto;
@@ -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. */
+23
View File
@@ -84,3 +84,26 @@
gap: 4px;
}
}
/* Botones de acción */
.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);
}