From 598307960e763c92a5e1ad79b647c3ef68368081 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 13 Oct 2026 20:01:34 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20lista=20de=20documentos=20con=20ba?= =?UTF-8?q?rra=20de=20filtros=20y=20b=C3=BAsqueda=20por=20columna?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 4 + apps/web/README.md | 24 ++ apps/web/angular.json | 2 +- apps/web/src/app/app.routes.spec.ts | 37 ++++ apps/web/src/app/app.routes.ts | 52 ++++- apps/web/src/app/core/sl/list-filters.spec.ts | 74 +++++++ apps/web/src/app/core/sl/list-filters.ts | 88 ++++++++ apps/web/src/app/core/sl/odata.spec.ts | 9 +- apps/web/src/app/core/sl/odata.ts | 7 + apps/web/src/app/core/sl/sl-list.ts | 138 ++++++++++++ .../features/documents/document-form.page.ts | 25 +++ .../documents/document-list.page.spec.ts | 61 ++++++ .../features/documents/document-list.page.ts | 42 ++++ .../documents/document-screens.spec.ts | 82 +++++++ .../features/documents/document-screens.ts | 159 ++++++++++++++ apps/web/src/app/layout/main-layout.css | 2 + apps/web/src/app/shared/list/data-table.css | 99 +++++++++ apps/web/src/app/shared/list/data-table.html | 61 ++++++ .../src/app/shared/list/data-table.spec.ts | 115 ++++++++++ apps/web/src/app/shared/list/data-table.ts | 60 +++++ apps/web/src/app/shared/list/filter-bar.css | 92 ++++++++ apps/web/src/app/shared/list/filter-bar.html | 106 +++++++++ .../src/app/shared/list/filter-bar.spec.ts | 158 +++++++++++++ apps/web/src/app/shared/list/filter-bar.ts | 78 +++++++ apps/web/src/app/shared/list/list-view.css | 43 ++++ apps/web/src/app/shared/list/list-view.html | 50 +++++ .../web/src/app/shared/list/list-view.spec.ts | 207 ++++++++++++++++++ apps/web/src/app/shared/list/list-view.ts | 70 ++++++ apps/web/src/app/shared/list/list.types.ts | 49 +++++ apps/web/src/app/shared/list/pager.ts | 32 +++ apps/web/src/app/shared/list/search-bar.ts | 54 +++++ .../src/app/shared/value-help/value-help.css | 114 +--------- .../src/app/shared/value-help/value-help.html | 109 +++------ .../src/app/shared/value-help/value-help.ts | 172 +++------------ .../app/shared/value-help/value-help.types.ts | 24 +- apps/web/src/styles/forms.css | 23 ++ 36 files changed, 2163 insertions(+), 359 deletions(-) create mode 100644 apps/web/src/app/app.routes.spec.ts create mode 100644 apps/web/src/app/core/sl/list-filters.spec.ts create mode 100644 apps/web/src/app/core/sl/list-filters.ts create mode 100644 apps/web/src/app/core/sl/sl-list.ts create mode 100644 apps/web/src/app/features/documents/document-form.page.ts create mode 100644 apps/web/src/app/features/documents/document-list.page.spec.ts create mode 100644 apps/web/src/app/features/documents/document-list.page.ts create mode 100644 apps/web/src/app/features/documents/document-screens.spec.ts create mode 100644 apps/web/src/app/features/documents/document-screens.ts create mode 100644 apps/web/src/app/shared/list/data-table.css create mode 100644 apps/web/src/app/shared/list/data-table.html create mode 100644 apps/web/src/app/shared/list/data-table.spec.ts create mode 100644 apps/web/src/app/shared/list/data-table.ts create mode 100644 apps/web/src/app/shared/list/filter-bar.css create mode 100644 apps/web/src/app/shared/list/filter-bar.html create mode 100644 apps/web/src/app/shared/list/filter-bar.spec.ts create mode 100644 apps/web/src/app/shared/list/filter-bar.ts create mode 100644 apps/web/src/app/shared/list/list-view.css create mode 100644 apps/web/src/app/shared/list/list-view.html create mode 100644 apps/web/src/app/shared/list/list-view.spec.ts create mode 100644 apps/web/src/app/shared/list/list-view.ts create mode 100644 apps/web/src/app/shared/list/list.types.ts create mode 100644 apps/web/src/app/shared/list/pager.ts create mode 100644 apps/web/src/app/shared/list/search-bar.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7d3631e..fb00c58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,3 +28,7 @@ Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de 2. Cada cliente tiene su branch (`cliente/`); ahí viven sus personalizaciones y fixes. 3. Los cambios de una branch de cliente no se mezclan con `main` ni con otro cliente, salvo backport genérico decidido explícitamente. 4. Antes de crear branches, mergear a `main`, o hacer merge/backport entre clientes, confirmar con el usuario. No hacer `git push` sin confirmación. + +## Flujo de las pantallas de documento + +Cada menú abre primero una **lista de los últimos documentos** (con barra de filtros y búsqueda por columna, 50 por página); desde ahí se crea uno nuevo (`/…/nuevo`, formulario vacío) o se abre uno existente (`/…/:docEntry`, ver/editar). Las pantallas se definen en `apps/web/src/app/features/documents/document-screens.ts`. diff --git a/apps/web/README.md b/apps/web/README.md index abc508e..5bce5c5 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -109,3 +109,27 @@ Una **fila** (`sbo-field`) es: etiqueta + uno o varios controles en la misma lí Detalles: `sbo-number` muestra `1,234.50` fuera de foco y acepta `12,5` o `1,234.50` al escribir (`decimals` redondea); `sbo-date` usa texto ISO `yyyy-MM-dd`; `sbo-select` devuelve el valor con su tipo original. Estilos globales en `styles/forms.css` (`.sbo-in`, `.sbo-sel`, `.sbo-ta`, `.sbo-btn`, `.sbo-group`, `.sbo-hdr`, `.sbo-col`). El ancho de etiqueta se ajusta con `--label-w` (165 px por defecto). + +### Pantallas de documento: lista, nuevo y ver/editar + +Cada pantalla de documento del menú abre primero la **lista de los últimos documentos** (más recientes primero, 50 por página); desde ahí se crea uno nuevo o se abre uno existente: + +| Ruta | Muestra | +|---|---| +| `/ventas/ordenes` | Lista (`sbo-list-view`) | +| `/ventas/ordenes/nuevo` | Formulario vacío | +| `/ventas/ordenes/:docEntry` | Formulario con datos (ver / editar) | + +Las tres rutas las gobierna el permiso de menú de la ruta padre. Se generan solas desde `features/documents/document-screens.ts` (`DOCUMENT_SCREENS`): por documento define recurso, columnas, campos extra, filtros y el texto del botón de alta. Agregar un documento = una entrada ahí (más su ruta en `MENU` y su recurso en el proxy del backend). + +### `sbo-list-view` y `sbo-filter-bar` + +Lista con **barra de filtros** y **búsqueda por columna**, igual que el mini-erp: + +- **Búsqueda:** caja que busca en la columna activa (clic en el encabezado; solo columnas buscables). Texto → `contains`; columna numérica → igualdad (`DocNum eq 314`, avisa si no es un número). +- **Filtros declarativos** (`ListFilter`): `text`, `select` (con `defaultValue`, ej. Estado = Abierto), `multi` (chips + lista de valores) y `date` (siempre un rango **[desde – hasta]**, ambos inclusive y opcionales). +- **Ir** aplica búsqueda y filtros en **una sola** consulta; **Limpiar** vuelve a los valores por defecto. Editar un filtro no consulta hasta pulsar Ir. +- Una columna con `format` (texto calculado) no es buscable salvo `searchable: true`; `tone` pinta una insignia (estado). +- Entradas: `title`, `resource`, `columns`, `filters`, `extraFields`, `baseFilter`, `orderby`, `pageSize` (50), `createLabel`; salidas `(open)` y `(create)`. + +El estado de búsqueda/paginación (`core/sl/sl-list.ts`) y la tabla (`sbo-data-table`) son compartidos con `sbo-value-help`. diff --git a/apps/web/angular.json b/apps/web/angular.json index 10b5515..3d3ec09 100644 --- a/apps/web/angular.json +++ b/apps/web/angular.json @@ -33,7 +33,7 @@ "budgets": [ { "type": "initial", - "maximumWarning": "260kB", + "maximumWarning": "300kB", "maximumError": "400kB" }, { diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts new file mode 100644 index 0000000..5261762 --- /dev/null +++ b/apps/web/src/app/app.routes.spec.ts @@ -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: '' }); + }); +}); diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 4a93b1a..26ef900 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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: '' }, diff --git a/apps/web/src/app/core/sl/list-filters.spec.ts b/apps/web/src/app/core/sl/list-filters.spec.ts new file mode 100644 index 0000000..abd96d4 --- /dev/null +++ b/apps/web/src/app/core/sl/list-filters.spec.ts @@ -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'))", + ); + }); +}); diff --git a/apps/web/src/app/core/sl/list-filters.ts b/apps/web/src/app/core/sl/list-filters.ts new file mode 100644 index 0000000..7d8f5cc --- /dev/null +++ b/apps/web/src/app/core/sl/list-filters.ts @@ -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>; + +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 = {}; + 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); +} diff --git a/apps/web/src/app/core/sl/odata.spec.ts b/apps/web/src/app/core/sl/odata.spec.ts index 6308012..f227cf2 100644 --- a/apps/web/src/app/core/sl/odata.spec.ts +++ b/apps/web/src/app/core/sl/odata.spec.ts @@ -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(); + }); }); diff --git a/apps/web/src/app/core/sl/odata.ts b/apps/web/src/app/core/sl/odata.ts index 46b023d..6fd8005 100644 --- a/apps/web/src/app/core/sl/odata.ts +++ b/apps/web/src/app/core/sl/odata.ts @@ -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}`; +} diff --git a/apps/web/src/app/core/sl/sl-list.ts b/apps/web/src/app/core/sl/sl-list.ts new file mode 100644 index 0000000..3f62b0e --- /dev/null +++ b/apps/web/src/app/core/sl/sl-list.ts @@ -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(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>(() => { + 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; diff --git a/apps/web/src/app/features/documents/document-form.page.ts b/apps/web/src/app/features/documents/document-form.page.ts new file mode 100644 index 0000000..be30e35 --- /dev/null +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -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: ` +

{{ title }} — {{ docEntry ? 'documento ' + docEntry : 'nuevo' }}

+

El formulario aún no está disponible. Volver a la lista

+ `, + 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'); +} diff --git a/apps/web/src/app/features/documents/document-list.page.spec.ts b/apps/web/src/app/features/documents/document-list.page.spec.ts new file mode 100644 index 0000000..5d81a69 --- /dev/null +++ b/apps/web/src/app/features/documents/document-list.page.spec.ts @@ -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; + let ctrl: HttpTestingController; + let navigate: ReturnType; + 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 }); + }); +}); diff --git a/apps/web/src/app/features/documents/document-list.page.ts b/apps/web/src/app/features/documents/document-list.page.ts new file mode 100644 index 0000000..56963cf --- /dev/null +++ b/apps/web/src/app/features/documents/document-list.page.ts @@ -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: ` + + `, + 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 }); + } +} diff --git a/apps/web/src/app/features/documents/document-screens.spec.ts b/apps/web/src/app/features/documents/document-screens.spec.ts new file mode 100644 index 0000000..314554f --- /dev/null +++ b/apps/web/src/app/features/documents/document-screens.spec.ts @@ -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(''); + }); +}); diff --git a/apps/web/src/app/features/documents/document-screens.ts b/apps/web/src/app/features/documents/document-screens.ts new file mode 100644 index 0000000..6e70d7a --- /dev/null +++ b/apps/web/src/app/features/documents/document-screens.ts @@ -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), +]; diff --git a/apps/web/src/app/layout/main-layout.css b/apps/web/src/app/layout/main-layout.css index c61fec3..91a39cc 100644 --- a/apps/web/src/app/layout/main-layout.css +++ b/apps/web/src/app/layout/main-layout.css @@ -107,6 +107,8 @@ li a.active { color: var(--err); } .content { + display: flex; + flex-direction: column; flex: 1; min-width: 0; overflow: auto; diff --git a/apps/web/src/app/shared/list/data-table.css b/apps/web/src/app/shared/list/data-table.css new file mode 100644 index 0000000..d3aa513 --- /dev/null +++ b/apps/web/src/app/shared/list/data-table.css @@ -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); +} diff --git a/apps/web/src/app/shared/list/data-table.html b/apps/web/src/app/shared/list/data-table.html new file mode 100644 index 0000000..73f254c --- /dev/null +++ b/apps/web/src/app/shared/list/data-table.html @@ -0,0 +1,61 @@ +
+ + + + @if (selectable()) { + + } + @for (c of columns(); track c.key) { + + } + + + + @for (row of rows(); track idOf(row)) { + + @if (selectable()) { + + } + @for (c of columns(); track c.key) { + + } + + } @empty { + + + + } + +
+ + + {{ c.title }} +
+ + + @if (tone(row, c); as t) { + {{ cell(row, c) }} + } @else { + {{ cell(row, c) }} + } +
{{ loading() ? 'Cargando…' : emptyText() }}
+
diff --git a/apps/web/src/app/shared/list/data-table.spec.ts b/apps/web/src/app/shared/list/data-table.spec.ts new file mode 100644 index 0000000..8837707 --- /dev/null +++ b/apps/web/src/app/shared/list/data-table.spec.ts @@ -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; + let el: HTMLElement; + const q = (s: string) => el.querySelector(s)!; + const qa = (s: string) => [...el.querySelectorAll(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'); + }); +}); diff --git a/apps/web/src/app/shared/list/data-table.ts b/apps/web/src/app/shared/list/data-table.ts new file mode 100644 index 0000000..473d02d --- /dev/null +++ b/apps/web/src/app/shared/list/data-table.ts @@ -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 rows = input.required(); + /** Campo único de cada fila. */ + readonly keyField = input.required(); + /** Columna de búsqueda activa (se resalta; clic en otro encabezado la cambia). */ + readonly activeColumnKey = input(); + /** Muestra casillas de selección. */ + readonly selectable = input(false); + readonly chosen = input>(new Set()); + readonly loading = input(false); + readonly emptyText = input('Sin resultados'); + + readonly columnPicked = output(); + readonly rowActivated = output(); + readonly rowToggled = output(); + readonly pageToggled = output(); + + 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); + } +} diff --git a/apps/web/src/app/shared/list/filter-bar.css b/apps/web/src/app/shared/list/filter-bar.css new file mode 100644 index 0000000..5e6b4c0 --- /dev/null +++ b/apps/web/src/app/shared/list/filter-bar.css @@ -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; +} diff --git a/apps/web/src/app/shared/list/filter-bar.html b/apps/web/src/app/shared/list/filter-bar.html new file mode 100644 index 0000000..18019ad --- /dev/null +++ b/apps/web/src/app/shared/list/filter-bar.html @@ -0,0 +1,106 @@ +
+
+   + + @if (searchHint()) { + {{ searchHint() }} + } +
+ + @for (f of filters(); track f.key) { +
+ + @switch (f.type) { + @case ('text') { + + } + @case ('select') { + + } + @case ('multi') { +
+ @for (chip of list(f.key); track chip) { + {{ chip }} + } @empty { + Todos + } + +
+ } + @case ('date') { +
+ + – + +
+ } + } +
+ } + +
+   +
+ + +
+
+
+ +@if (editingMulti(); as f) { + +} diff --git a/apps/web/src/app/shared/list/filter-bar.spec.ts b/apps/web/src/app/shared/list/filter-bar.spec.ts new file mode 100644 index 0000000..d3d31bb --- /dev/null +++ b/apps/web/src/app/shared/list/filter-bar.spec.ts @@ -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; + let ctrl: HttpTestingController; + let el: HTMLElement; + const q = (s: string) => el.querySelector(s)!; + const qa = (s: string) => [...el.querySelectorAll(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('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('input[name=q]'), 'ACME'); + expect(fixture.componentInstance.searchText()).toBe('ACME'); + }); + + it('filtro de texto', async () => { + await type(q('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('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('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'); + }); +}); diff --git a/apps/web/src/app/shared/list/filter-bar.ts b/apps/web/src/app/shared/list/filter-bar.ts new file mode 100644 index 0000000..6503935 --- /dev/null +++ b/apps/web/src/app/shared/list/filter-bar.ts @@ -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 searchPlaceholder = input('Buscar'); + readonly searchHint = input(''); + + readonly searchText = model(''); + readonly values = model({}); + + readonly search = output(); + readonly clear = output(); + + protected readonly editingMulti = signal(null); + protected readonly multiColumns = computed(() => { + 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(() => { + 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] ?? ''))); + } +} diff --git a/apps/web/src/app/shared/list/list-view.css b/apps/web/src/app/shared/list/list-view.css new file mode 100644 index 0000000..d0f7cf4 --- /dev/null +++ b/apps/web/src/app/shared/list/list-view.css @@ -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; +} diff --git a/apps/web/src/app/shared/list/list-view.html b/apps/web/src/app/shared/list/list-view.html new file mode 100644 index 0000000..69080c2 --- /dev/null +++ b/apps/web/src/app/shared/list/list-view.html @@ -0,0 +1,50 @@ +
+
+

{{ title() }}

+ @if (description()) { +

{{ description() }}

+ } +
+ @if (createLabel()) { + + } +
+ + + +@if (list.errorMessage()) { + +} + + + + diff --git a/apps/web/src/app/shared/list/list-view.spec.ts b/apps/web/src/app/shared/list/list-view.spec.ts new file mode 100644 index 0000000..9416e23 --- /dev/null +++ b/apps/web/src/app/shared/list/list-view.spec.ts @@ -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; + let ctrl: HttpTestingController; + let el: HTMLElement; + const URL = '/api/sl/Orders'; + const q = (s: string) => el.querySelector(s)!; + const qa = (s: string) => [...el.querySelectorAll(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('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('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('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('input[name=q]').placeholder).toBe('Buscar en Nombre'); + await type(q('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('input[name=q]'), 'abc'); + await go(); + expect(q('.hint').textContent).toContain('Escriba un número'); + ctrl.expectNone(URL); + + await type(q('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('select'); + select.value = ''; + select.dispatchEvent(new Event('change')); + await type(q('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('select').value).toBe('bost_Open'); + expect(q('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'); + }); +}); diff --git a/apps/web/src/app/shared/list/list-view.ts b/apps/web/src/app/shared/list/list-view.ts new file mode 100644 index 0000000..3882077 --- /dev/null +++ b/apps/web/src/app/shared/list/list-view.ts @@ -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(); + readonly description = input(); + /** Recurso registrado en el proxy `/api/sl`. */ + readonly resource = input.required(); + readonly columns = input.required(); + /** Campos que hacen falta para pintar (ej. `Cancelled` para el estado) sin tener columna propia. */ + readonly extraFields = input([]); + readonly filters = input([]); + /** Filtro fijo que siempre se aplica (ej. por tipo de documento). */ + readonly baseFilter = input(); + readonly orderby = input(); + readonly rowKey = input(); + readonly pageSize = input(50); + /** Texto del botón de alta; vacío para ocultarlo. */ + readonly createLabel = input('Nuevo'); + + readonly open = output(); + readonly create = output(); + + /** Lo que se está editando en la barra de filtros. */ + protected readonly draftValues = linkedSignal(() => initialFilterValues(this.filters())); + /** Lo que ya se aplicó (solo cambia con "Ir" o "Limpiar"). */ + private readonly appliedValues = linkedSignal(() => 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(); + } +} diff --git a/apps/web/src/app/shared/list/list.types.ts b/apps/web/src/app/shared/list/list.types.ts new file mode 100644 index 0000000..4419a54 --- /dev/null +++ b/apps/web/src/app/shared/list/list.types.ts @@ -0,0 +1,49 @@ +import { formatDate, formatNumber } from '../../core/format'; + +export type ListRow = Record; +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'); +} diff --git a/apps/web/src/app/shared/list/pager.ts b/apps/web/src/app/shared/list/pager.ts new file mode 100644 index 0000000..b575989 --- /dev/null +++ b/apps/web/src/app/shared/list/pager.ts @@ -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: ` +
+ + {{ rangeText() }} + +
+ `, + styles: ` + .pager { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--quiet); + } + `, +}) +export class Pager { + readonly rangeText = input.required(); + readonly hasPrevious = input(false); + readonly hasNext = input(false); + readonly busy = input(false); + + readonly previous = output(); + readonly next = output(); +} diff --git a/apps/web/src/app/shared/list/search-bar.ts b/apps/web/src/app/shared/list/search-bar.ts new file mode 100644 index 0000000..0b640a8 --- /dev/null +++ b/apps/web/src/app/shared/list/search-bar.ts @@ -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: ` + + @if (hint()) { + + } + `, + 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(); + + private readonly box = viewChild.required>('box'); + + focus(): void { + this.box().nativeElement.focus(); + } +} diff --git a/apps/web/src/app/shared/value-help/value-help.css b/apps/web/src/app/shared/value-help/value-help.css index b08cf71..66f7656 100644 --- a/apps/web/src/app/shared/value-help/value-help.css +++ b/apps/web/src/app/shared/value-help/value-help.css @@ -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; } diff --git a/apps/web/src/app/shared/value-help/value-help.html b/apps/web/src/app/shared/value-help/value-help.html index 07dd2d5..b6c35bb 100644 --- a/apps/web/src/app/shared/value-help/value-help.html +++ b/apps/web/src/app/shared/value-help/value-help.html @@ -9,91 +9,46 @@ } -