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:
1 parent
68caf401a7
commit
598307960e
36 files changed
+2163
-359
No files matched your search
@@ -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: '' });
|
||||
});
|
||||
});
|
||||
@@ -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'))",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
@@ -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"> </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"> </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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user