From 3d5a81e8065785d16603eddba7f8b1bce7926874 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 13 Oct 2026 19:26:27 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20value-help=20reutilizable=20(lista?= =?UTF-8?q?=20de=20valores)=20con=20selecci=C3=B3n=20=C3=BAnica=20o=20m?= =?UTF-8?q?=C3=BAltiple?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sbo-value-help: búsqueda por columna con contains(), columnas declarativas (texto, número, importe, fecha, format a medida), paginación del servidor con total, baseFilter/orderby, Esc/Enter, errores con reintento. - Selección única (un clic confirma) o múltiple (casillas, seleccionar página, Confirmar (n)). Mejoras sobre el sistema anterior: lo marcado se conserva entre páginas, el filtro base va entre paréntesis (un "or" ya no rompe la precedencia) y las comillas del texto buscado se escapan. - Cambiar la columna elegida no consulta hasta pulsar Buscar. - Peticiones con httpResource: las obsoletas se cancelan solas. - Helpers reutilizables: core/sl/odata (escape, contains, combinar filtros) y core/format (importes y fechas sin desfase de zona horaria). - Verificado en Chrome contra SAP real (253 clientes, 1209 artículos): búsqueda por Nombre, paginación, selección múltiple entre páginas y tema oscuro. Co-Authored-By: Claude Sonnet 5.5 --- apps/web/README.md | 34 ++ apps/web/src/app/core/format.spec.ts | 26 ++ apps/web/src/app/core/format.ts | 24 ++ apps/web/src/app/core/sl/odata.spec.ts | 22 ++ apps/web/src/app/core/sl/odata.ts | 21 ++ .../src/app/shared/value-help/value-help.css | 188 +++++++++++ .../src/app/shared/value-help/value-help.html | 107 ++++++ .../app/shared/value-help/value-help.spec.ts | 311 ++++++++++++++++++ .../src/app/shared/value-help/value-help.ts | 210 ++++++++++++ .../app/shared/value-help/value-help.types.ts | 19 ++ 10 files changed, 962 insertions(+) create mode 100644 apps/web/src/app/core/format.spec.ts create mode 100644 apps/web/src/app/core/format.ts create mode 100644 apps/web/src/app/core/sl/odata.spec.ts create mode 100644 apps/web/src/app/core/sl/odata.ts create mode 100644 apps/web/src/app/shared/value-help/value-help.css create mode 100644 apps/web/src/app/shared/value-help/value-help.html create mode 100644 apps/web/src/app/shared/value-help/value-help.spec.ts create mode 100644 apps/web/src/app/shared/value-help/value-help.ts create mode 100644 apps/web/src/app/shared/value-help/value-help.types.ts diff --git a/apps/web/README.md b/apps/web/README.md index fc202c9..91b3030 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -39,3 +39,37 @@ Tokens: `--app --shell --shell-ink --shell-quiet --win --panel --line --line-sof - `core/menu`: `MenuService` carga `GET /api/user/menu` una vez por sesión; el menú visible sale de `MENU` (`@basic-erp/sap-types`) filtrado por permisos. - `app.routes.ts` genera una ruta por pantalla del menú; las que aún no existen muestran "no disponible" (`ComingSoonPage`). Para construir una pantalla, se reemplaza su `loadComponent`. - Un 401 en cualquier llamada cierra la sesión local y lleva al login recordando la ruta (`?returnUrl=`, solo rutas internas). + +## Componentes compartidos (`src/app/shared`) + +### `sbo-value-help` — lista de valores ("elegir de la lista") + +Búsqueda por columna sobre un recurso del proxy `/api/sl`, selección única o múltiple y paginación del servidor. + +```html + +``` + +```ts +columns: VhColumn[] = [ + { key: 'CardCode', title: 'Código', width: '130px', mono: true }, + { key: 'CardName', title: 'Nombre' }, + { key: 'CurrentAccountBalance', title: 'Saldo', type: 'currency' }, +]; +``` + +- Agregar una columna = una entrada en `columns`; el `$select` se deriva de ahí. +- Columna activa de búsqueda: clic en el encabezado (solo columnas de texto, o `searchable: true`). Se aplica al pulsar **Buscar**/Enter con `contains()`, combinada con `baseFilter` entre paréntesis y con las comillas escapadas. +- `selection="single"`: un clic en la fila confirma y cierra. `selection="multiple"`: casillas, "seleccionar página" y **Confirmar (n)**; lo marcado se conserva al cambiar de página (`[initialSelection]` precarga filas). +- `type`: `text` (defecto), `number`, `currency` (2 decimales), `date` (dd/MM/yyyy); `format: (row) => string` para algo a medida. +- Teclado: Enter en la búsqueda, Enter sobre una fila, Esc para cerrar. +- Cada apertura reinicia búsqueda, página y selección. Las peticiones obsoletas se cancelan solas (`httpResource`). diff --git a/apps/web/src/app/core/format.spec.ts b/apps/web/src/app/core/format.spec.ts new file mode 100644 index 0000000..272bc4d --- /dev/null +++ b/apps/web/src/app/core/format.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { formatDate, formatNumber } from './format'; + +describe('formatNumber', () => { + it('importes con decimales fijos y miles', () => { + expect(formatNumber(1234.5, 2)).toBe('1,234.50'); + expect(formatNumber('8835750', 2)).toBe('8,835,750.00'); + }); + it('cantidades sin ceros sobrantes', () => { + expect(formatNumber(12)).toBe('12'); + expect(formatNumber(0.25)).toBe('0.25'); + }); + it('valores vacíos o no numéricos dan cadena vacía', () => { + for (const v of [null, undefined, '', 'abc', NaN]) expect(formatNumber(v, 2)).toBe(''); + }); +}); + +describe('formatDate', () => { + it('formatea dd/MM/yyyy sin desfase de zona horaria', () => { + expect(formatDate('2026-01-05')).toBe('05/01/2026'); + expect(formatDate('2026-12-31T23:59:59Z')).toBe('31/12/2026'); + }); + it('valores inválidos dan cadena vacía', () => { + for (const v of [null, undefined, '', 'ayer', 20260105]) expect(formatDate(v)).toBe(''); + }); +}); diff --git a/apps/web/src/app/core/format.ts b/apps/web/src/app/core/format.ts new file mode 100644 index 0000000..06818d5 --- /dev/null +++ b/apps/web/src/app/core/format.ts @@ -0,0 +1,24 @@ +const fixed = new Map(); +const flexible = new Intl.NumberFormat('es-PE', { maximumFractionDigits: 3 }); + +function fixedFormat(decimals: number): Intl.NumberFormat { + let f = fixed.get(decimals); + if (!f) { + f = new Intl.NumberFormat('es-PE', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); + fixed.set(decimals, f); + } + return f; +} + +/** `decimals` fijos (importes) o, sin él, hasta 3 decimales sin ceros sobrantes (cantidades). */ +export function formatNumber(value: unknown, decimals?: number): string { + const n = typeof value === 'number' ? value : Number(value); + if (value === null || value === undefined || value === '' || !Number.isFinite(n)) return ''; + return (decimals === undefined ? flexible : fixedFormat(decimals)).format(n); +} + +/** `2026-01-05` o `2026-01-05T00:00:00Z` -> `05/01/2026`, sin pasar por Date (evita desfases de zona horaria). */ +export function formatDate(value: unknown): string { + const m = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null; + return m ? `${m[3]}/${m[2]}/${m[1]}` : ''; +} diff --git a/apps/web/src/app/core/sl/odata.spec.ts b/apps/web/src/app/core/sl/odata.spec.ts new file mode 100644 index 0000000..6308012 --- /dev/null +++ b/apps/web/src/app/core/sl/odata.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { combineFilters, containsFilter, escapeODataString } from './odata'; + +describe('odata', () => { + it('escapa las comillas del texto buscado', () => { + expect(escapeODataString("o'neil")).toBe("o''neil"); + expect(containsFilter('CardName', "x') or ('a'='a")).toBe("contains(CardName,'x'') or (''a''=''a')"); + }); + + it('rechaza nombres de campo que no sean identificadores', () => { + expect(() => containsFilter('CardName,1)--', 'a')).toThrow(); + }); + + it('combineFilters ignora vacíos y no agrega paréntesis a un solo filtro', () => { + expect(combineFilters(undefined, '', ' ')).toBeUndefined(); + expect(combineFilters("CardType eq 'C'")).toBe("CardType eq 'C'"); + }); + + 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'))"); + }); +}); diff --git a/apps/web/src/app/core/sl/odata.ts b/apps/web/src/app/core/sl/odata.ts new file mode 100644 index 0000000..46b023d --- /dev/null +++ b/apps/web/src/app/core/sl/odata.ts @@ -0,0 +1,21 @@ +const IDENTIFIER = /^[A-Za-z0-9_]+$/; + +/** Escapa una cadena para usarla entre comillas simples en un `$filter`. */ +export const escapeODataString = (text: string): string => text.replace(/'/g, "''"); + +function assertIdentifier(field: string): void { + if (!IDENTIFIER.test(field)) throw new Error(`Nombre de campo inválido: ${field}`); +} + +/** `contains(Campo,'texto')`; el backend añade B1S-CaseInsensitive cuando hay filtro. */ +export function containsFilter(field: string, text: string): string { + assertIdentifier(field); + return `contains(${field},'${escapeODataString(text)}')`; +} + +/** Une filtros con `and`; entre paréntesis para que un `or` interno no cambie la precedencia. */ +export function combineFilters(...parts: (string | undefined)[]): string | undefined { + const filters = parts.map((p) => p?.trim()).filter((p): p is string => !!p); + if (filters.length <= 1) return filters[0]; + return filters.map((f) => `(${f})`).join(' and '); +} diff --git a/apps/web/src/app/shared/value-help/value-help.css b/apps/web/src/app/shared/value-help/value-help.css new file mode 100644 index 0000000..b08cf71 --- /dev/null +++ b/apps/web/src/app/shared/value-help/value-help.css @@ -0,0 +1,188 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: var(--overlay); +} +.dlg { + display: flex; + flex-direction: column; + width: min(880px, 100%); + max-height: 85vh; + min-height: 360px; + background: var(--win); + border: 1px solid var(--line); + box-shadow: var(--shadow-pop); +} +header { + display: flex; + align-items: baseline; + gap: 10px; + padding: 8px 12px; + background: var(--panel); + border-bottom: 1px solid var(--line); +} +h2 { + margin: 0; + font-size: 14px; + font-weight: 600; +} +.mode, +.count { + font-size: 12px; + color: var(--quiet); +} +.count { + 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; + 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; +} +.tablewrap { + 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; + align-items: center; + gap: 8px; + padding: 8px 12px; + 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 new file mode 100644 index 0000000..07dd2d5 --- /dev/null +++ b/apps/web/src/app/shared/value-help/value-help.html @@ -0,0 +1,107 @@ +@if (open()) { +
+ +
+} diff --git a/apps/web/src/app/shared/value-help/value-help.spec.ts b/apps/web/src/app/shared/value-help/value-help.spec.ts new file mode 100644 index 0000000..877af00 --- /dev/null +++ b/apps/web/src/app/shared/value-help/value-help.spec.ts @@ -0,0 +1,311 @@ +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 { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ValueHelp } from './value-help'; +import type { VhColumn, VhRow } from './value-help.types'; + +const COLUMNS: VhColumn[] = [ + { key: 'CardCode', title: 'Código' }, + { key: 'CardName', title: 'Nombre' }, + { key: 'Balance', title: 'Saldo', type: 'currency' }, + { key: 'CreateDate', title: 'Creado', type: 'date' }, +]; +const row = (n: number): VhRow => ({ + CardCode: `C${n}`, + CardName: `Cliente ${n}`, + Balance: 1000 * n, + CreateDate: '2026-03-05T00:00:00Z', +}); +const URL = '/api/sl/BusinessPartners'; + +describe('ValueHelp', () => { + let fixture: ComponentFixture; + let ctrl: HttpTestingController; + let el: HTMLElement; + let selected: Mock<(rows: VhRow[]) => void>; + let closed: Mock<() => void>; + + // No se usa whenStable(): con una petición HTTP pendiente (aún sin responder) nunca termina. + const flush = async () => { + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve)); + fixture.detectChanges(); + }; + const request = (): TestRequest => ctrl.expectOne((r) => r.url === URL); + const reply = async (req: TestRequest, rows: VhRow[], count = rows.length) => { + req.flush({ value: rows, 'odata.count': String(count) }); + await flush(); + }; + const open = async (rows: VhRow[] = [row(1), row(2), row(3)], count = rows.length) => { + fixture.componentRef.setInput('open', true); + await flush(); + const req = request(); + await reply(req, rows, count); + return req; + }; + const q = (selector: string) => el.querySelector(selector)!; + const qa = (selector: string) => [...el.querySelectorAll(selector)]; + const bodyRows = () => qa('tbody tr:not(.none)'); + const type = async (value: string) => { + const input = q('input[name=q]'); + input.value = value; + input.dispatchEvent(new Event('input')); + await flush(); + }; + const submitSearch = async () => { + q('form.search').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ValueHelp], + providers: [provideHttpClient(), provideHttpClientTesting()], + }).compileComponents(); + ctrl = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(ValueHelp); + el = fixture.nativeElement; + fixture.componentRef.setInput('open', false); + fixture.componentRef.setInput('title', 'Socios de negocio'); + fixture.componentRef.setInput('resource', 'BusinessPartners'); + fixture.componentRef.setInput('columns', COLUMNS); + selected = vi.fn<(rows: VhRow[]) => void>(); + closed = vi.fn<() => void>(); + fixture.componentInstance.selected.subscribe(selected); + fixture.componentInstance.closed.subscribe(closed); + }); + + it('cerrado no muestra nada ni consulta', async () => { + await flush(); + expect(q('.dlg')).toBeNull(); + ctrl.expectNone(URL); + }); + + it('al abrir consulta la primera página con $select, $top y $skip', async () => { + fixture.componentRef.setInput('orderby', 'CardCode'); + const req = await open(); + expect(req.request.params.get('$select')).toBe('CardCode,CardName,Balance,CreateDate'); + expect(req.request.params.get('$top')).toBe('20'); + expect(req.request.params.get('$skip')).toBe('0'); + expect(req.request.params.get('$orderby')).toBe('CardCode'); + expect(req.request.params.has('$filter')).toBe(false); + expect(bodyRows()).toHaveLength(3); + }); + + it('agrega la clave a $select aunque no sea una columna', async () => { + fixture.componentRef.setInput('columns', [{ key: 'CardName', title: 'Nombre' }]); + fixture.componentRef.setInput('rowKey', 'CardCode'); + fixture.componentRef.setInput('open', true); + await flush(); + expect(request().request.params.get('$select')).toBe('CardName,CardCode'); + }); + + it('formatea importes, fechas y texto', async () => { + await open([row(2)]); + const cells = [...bodyRows()[0].querySelectorAll('td')].map((c) => c.textContent?.trim()); + expect(cells).toEqual(['C2', 'Cliente 2', '2,000.00', '05/03/2026']); + }); + + describe('búsqueda', () => { + it('busca en la primera columna de texto con contains y vuelve a la página 1', async () => { + await open([row(1)], 45); + q('.pager button:last-child').click(); + await flush(); + await reply(request(), [row(21)], 45); + + await type('ACME'); + await submitSearch(); + const req = request(); + expect(req.request.params.get('$filter')).toBe("contains(CardCode,'ACME')"); + expect(req.request.params.get('$skip')).toBe('0'); + await reply(req, [row(1)]); + }); + + it('la columna activa se elige con un clic en el encabezado', async () => { + await open(); + qa('th.pick')[1].click(); + await flush(); + expect(q('input[name=q]').placeholder).toBe('Buscar en Nombre'); + await type('perez'); + await submitSearch(); + const req = request(); + expect(req.request.params.get('$filter')).toBe("contains(CardName,'perez')"); + await reply(req, []); + }); + + it('las columnas numéricas y de fecha no se pueden elegir para buscar', async () => { + await open(); + expect(qa('th.pick').map((t) => t.textContent?.trim())).toEqual(['Código', 'Nombre']); + qa('th')[2].click(); + await flush(); + expect(q('input[name=q]').placeholder).toBe('Buscar en Código'); + }); + + it('escapa las comillas y protege la precedencia del filtro base', async () => { + fixture.componentRef.setInput('baseFilter', "CardType eq 'cCustomer' or CardType eq 'cLid'"); + fixture.componentRef.setInput('open', true); + await flush(); + await reply(request(), []); + await type("o'neil"); + await submitSearch(); + const req = request(); + expect(req.request.params.get('$filter')).toBe( + "(CardType eq 'cCustomer' or CardType eq 'cLid') and (contains(CardCode,'o''neil'))", + ); + await reply(req, []); + }); + + it('sin resultados muestra el aviso', async () => { + await open([]); + expect(q('tr.none').textContent).toContain('Sin resultados'); + }); + }); + + describe('paginación', () => { + it('muestra el rango y avanza/retrocede con skip', async () => { + await open(Array.from({ length: 20 }, (_, i) => row(i + 1)), 45); + expect(q('.pager span').textContent).toBe('1–20 de 45'); + expect(q('.pager button:first-child').disabled).toBe(true); + + q('.pager button:last-child').click(); + await flush(); + const next = request(); + expect(next.request.params.get('$skip')).toBe('20'); + await reply(next, Array.from({ length: 20 }, (_, i) => row(i + 21)), 45); + expect(q('.pager span').textContent).toBe('21–40 de 45'); + + q('.pager button:first-child').click(); + await flush(); + expect(request().request.params.get('$skip')).toBe('0'); + }); + + it('en la última página no hay "Siguiente"', async () => { + await open([row(1), row(2)], 2); + expect(q('.pager button:last-child').disabled).toBe(true); + }); + }); + + describe('selección única', () => { + it('un clic en la fila confirma y cierra', async () => { + await open(); + bodyRows()[1].click(); + expect(selected).toHaveBeenCalledWith([row(2)]); + expect(closed).toHaveBeenCalledTimes(1); + }); + + it('Enter sobre la fila también elige', async () => { + await open(); + bodyRows()[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(selected).toHaveBeenCalledWith([row(1)]); + }); + + it('no muestra casillas ni "Confirmar"', async () => { + await open(); + expect(qa('input[type=checkbox]')).toHaveLength(0); + expect(el.textContent).not.toContain('Confirmar'); + }); + }); + + describe('selección múltiple', () => { + beforeEach(() => fixture.componentRef.setInput('selection', 'multiple')); + + it('marca filas, cuenta y confirma', async () => { + await open(); + bodyRows()[0].click(); + bodyRows()[2].click(); + await flush(); + expect(q('.count').textContent).toContain('Seleccionados: 2'); + const confirm = qa('footer .btn.main')[0] as HTMLButtonElement; + expect(confirm.textContent).toContain('Confirmar (2)'); + confirm.click(); + expect(selected).toHaveBeenCalledWith([row(1), row(3)]); + expect(closed).toHaveBeenCalledTimes(1); + }); + + it('"Confirmar" está deshabilitado sin selección', async () => { + await open(); + expect((qa('footer .btn.main')[0] as HTMLButtonElement).disabled).toBe(true); + }); + + it('conserva lo marcado al cambiar de página (el sistema anterior lo perdía)', async () => { + await open(Array.from({ length: 20 }, (_, i) => row(i + 1)), 40); + bodyRows()[0].click(); // C1 en la página 1 + await flush(); + + q('.pager button:last-child').click(); + await flush(); + await reply(request(), Array.from({ length: 20 }, (_, i) => row(i + 21)), 40); + bodyRows()[0].click(); // C21 en la página 2 + await flush(); + + (qa('footer .btn.main')[0] as HTMLButtonElement).click(); + expect(selected).toHaveBeenCalledWith([row(1), row(21)]); + }); + + it('la casilla del encabezado marca y desmarca la página', async () => { + await open(); + const header = q('thead input[type=checkbox]'); + header.checked = true; + header.dispatchEvent(new Event('change')); + await flush(); + expect(q('.count').textContent).toContain('Seleccionados: 3'); + header.checked = false; + header.dispatchEvent(new Event('change')); + await flush(); + expect(q('.count').textContent).toContain('Seleccionados: 0'); + }); + + it('arranca con la selección inicial y permite desmarcarla', async () => { + fixture.componentRef.setInput('initialSelection', [row(2)]); + await open(); + expect(q('.count').textContent).toContain('Seleccionados: 1'); + expect(bodyRows()[1].classList.contains('sel')).toBe(true); + bodyRows()[1].click(); + await flush(); + expect(q('.count').textContent).toContain('Seleccionados: 0'); + }); + + it('al reabrir se reinicia la búsqueda y la selección', async () => { + await open(); + bodyRows()[0].click(); + await type('algo'); + fixture.componentRef.setInput('open', false); + await flush(); + fixture.componentRef.setInput('open', true); + await flush(); + const req = request(); + expect(req.request.params.has('$filter')).toBe(false); + await reply(req, [row(1)]); + expect(q('.count').textContent).toContain('Seleccionados: 0'); + expect(q('input[name=q]').value).toBe(''); + }); + }); + + it('Escape cierra', async () => { + await open(); + q('.dlg').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(closed).toHaveBeenCalledTimes(1); + }); + + it('el botón Cancelar cierra sin elegir', async () => { + await open(); + (qa('footer .btn').find((b) => b.textContent?.trim() === 'Cancelar') as HTMLButtonElement).click(); + expect(closed).toHaveBeenCalledTimes(1); + expect(selected).not.toHaveBeenCalled(); + }); + + it('muestra el error del backend y permite reintentar', async () => { + fixture.componentRef.setInput('open', true); + await flush(); + request().flush({ message: 'Recurso no disponible: Foo' }, { status: 404, statusText: 'Not Found' }); + await flush(); + expect(q('.error').textContent).toContain('Recurso no disponible: Foo'); + + (q('.error .link') as HTMLButtonElement).click(); + await flush(); + await reply(request(), [row(1)]); + expect(el.querySelector('.error')).toBeNull(); + expect(bodyRows()).toHaveLength(1); + }); +}); diff --git a/apps/web/src/app/shared/value-help/value-help.ts b/apps/web/src/app/shared/value-help/value-help.ts new file mode 100644 index 0000000..875fc02 --- /dev/null +++ b/apps/web/src/app/shared/value-help/value-help.ts @@ -0,0 +1,210 @@ +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'; + +/** + * Lista de valores ("elegir de la lista" de SBO): búsqueda por columna sobre un + * recurso del proxy `/api/sl`, con selección única o múltiple y paginación del servidor. + * + * El padre controla `open` y reacciona a `selected` / `closed`. + */ +@Component({ + selector: 'sbo-value-help', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './value-help.html', + styleUrl: './value-help.css', +}) +export class ValueHelp { + readonly open = input.required(); + readonly title = input.required(); + /** Recurso registrado en el proxy, ej. `BusinessPartners`. */ + readonly resource = input.required(); + readonly columns = input.required(); + readonly selection = input<'single' | 'multiple'>('single'); + /** Filtro fijo (ej. `CardType eq 'cCustomer'`) que se combina con la búsqueda. */ + readonly baseFilter = input(); + readonly orderby = input(); + /** Campo único de cada fila; por defecto, el de la primera columna. */ + readonly rowKey = input(); + readonly pageSize = input(20); + /** Filas ya elegidas al abrir (solo selección múltiple). */ + readonly initialSelection = input([]); + + readonly selected = output(); + readonly closed = output(); + + 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>(new Map()); + + private readonly searchBox = viewChild>('searchBox'); + + 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>(() => { + 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))), + ); + + 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()); + }); + } + + 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 { + if (this.multiple()) { + this.toggle(row); + } else { + this.selected.emit([row]); + this.closed.emit(); + } + } + + protected toggle(row: VhRow): void { + this.chosen.update((current) => { + const next = new Map(current); + const id = this.idOf(row); + if (!next.delete(id)) next.set(id, row); + return next; + }); + } + + 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)); + } + return next; + }); + } + + protected confirm(): void { + this.selected.emit([...this.chosen().values()]); + this.closed.emit(); + } + + protected close(): void { + this.closed.emit(); + } + + private optional(name: string, value: string | undefined): Record { + return value ? { [name]: value } : {}; + } +} diff --git a/apps/web/src/app/shared/value-help/value-help.types.ts b/apps/web/src/app/shared/value-help/value-help.types.ts new file mode 100644 index 0000000..43c1845 --- /dev/null +++ b/apps/web/src/app/shared/value-help/value-help.types.ts @@ -0,0 +1,19 @@ +export type VhRow = Record; + +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';