feat(web): Reportes Gateway (lista para ejecutar con parámetros y PDF, y matrícula de reportes) con los controles y ventanas de selección del proyecto
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
03ccd97f99
commit
854ee58037
14 files changed
+1178
-3
No files matched your search
@@ -48,7 +48,7 @@ Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de
|
|||||||
- **Proxy `/api/sl/:recurso`**: el frontend arma `$select/$filter/...`; el backend valida y ejecuta solo recursos registrados (`apps/api/src/sl/sl-resources.ts`), con permiso de menú y cabeceras `B1S-*` de lista blanca. Nunca registrar `EmployeesInfo` (tiene `U_WEB_PASS`). `InventoryRequests` no existe: es `InventoryTransferRequests`.
|
- **Proxy `/api/sl/:recurso`**: el frontend arma `$select/$filter/...`; el backend valida y ejecuta solo recursos registrados (`apps/api/src/sl/sl-resources.ts`), con permiso de menú y cabeceras `B1S-*` de lista blanca. Nunca registrar `EmployeesInfo` (tiene `U_WEB_PASS`). `InventoryRequests` no existe: es `InventoryTransferRequests`.
|
||||||
- **Permisos de menú** en SQLite (`SQLITE_PATH`), patrones `*`, `/ventas/*`, `/ventas/ordenes`; `ADMIN_WEB_USERS` garantiza el acceso total; no se puede quitar el último admin.
|
- **Permisos de menú** en SQLite (`SQLITE_PATH`), patrones `*`, `/ventas/*`, `/ventas/ordenes`; `ADMIN_WEB_USERS` garantiza el acceso total; no se puede quitar el último admin.
|
||||||
- **Listas de documentos:** más recientes primero (`DocEntry desc`), 50 por página, **Estado = Abierto por defecto** (como el mini-erp; cambiarlo es un `defaultValue`), filtro de fecha **siempre rango [desde – hasta]**, multi-selección con **chips**, búsqueda numérica por igualdad. **Sin filtro base en las demás listas; las de `Invoices` filtran por subtipo exacto:** cuatro pantallas comparten el recurso y se separan por `DocumentSubType` (enum `BoDocumentSubType`: `bod_None` 0, `bod_InvoiceExempt` 1, `bod_DebitMemo` 2, `bod_Bill` 3, `bod_ExemptBill` 4, `bod_PurchaseDebitMemo` 5, `bod_ExportInvoice` 6…) y por `ReserveInvoice` (`tYES` = de reserva): Facturas (`tNO` y `eq bod_None`), Boletas (`tNO` y `eq bod_Bill`), Facturas de reserva (`tYES` y `eq bod_None`) y Boletas de reserva (`tYES` y `eq bod_Bill`); `baseFilter` para la lista y `fixed` (subtipo, y `ReserveInvoice` en las reservas) al crear. Nunca `ne`: los demás subtipos (factura exenta, nota de débito, boleta exenta, exportación) no se mezclan y tendrán su propia pantalla si se necesitan. `baseFilter` de la pantalla se aplica también a la navegación primero/anterior/siguiente/último. El PDF y los reportes se migran después vía API Gateway (otro servicio, con su propia cola y sesión).
|
- **Listas de documentos:** más recientes primero (`DocEntry desc`), 50 por página, **Estado = Abierto por defecto** (como el mini-erp; cambiarlo es un `defaultValue`), filtro de fecha **siempre rango [desde – hasta]**, multi-selección con **chips**, búsqueda numérica por igualdad. **Sin filtro base en las demás listas; las de `Invoices` filtran por subtipo exacto:** cuatro pantallas comparten el recurso y se separan por `DocumentSubType` (enum `BoDocumentSubType`: `bod_None` 0, `bod_InvoiceExempt` 1, `bod_DebitMemo` 2, `bod_Bill` 3, `bod_ExemptBill` 4, `bod_PurchaseDebitMemo` 5, `bod_ExportInvoice` 6…) y por `ReserveInvoice` (`tYES` = de reserva): Facturas (`tNO` y `eq bod_None`), Boletas (`tNO` y `eq bod_Bill`), Facturas de reserva (`tYES` y `eq bod_None`) y Boletas de reserva (`tYES` y `eq bod_Bill`); `baseFilter` para la lista y `fixed` (subtipo, y `ReserveInvoice` en las reservas) al crear. Nunca `ne`: los demás subtipos (factura exenta, nota de débito, boleta exenta, exportación) no se mezclan y tendrán su propia pantalla si se necesitan. `baseFilter` de la pantalla se aplica también a la navegación primero/anterior/siguiente/último. El PDF y los reportes se migran después vía API Gateway (otro servicio, con su propia cola y sesión).
|
||||||
- Reportes/PDF **no** se generan en la base; el botón PDF queda pendiente hasta migrar el API Gateway.
|
- **Reportes Gateway** (Crystal Reports por el API Gateway de SAP B1, `apps/api/src/gateway/` y `apps/web/.../features/reports/`): menú «Reportes Gateway» con «Reportes» (lista agrupada por categoría y formulario de parámetros con el PDF en pantalla) y «Matrícula de reportes» (solo administradores: de los reportes que el gateway autoriza se eligen los que se usan, con título, categoría y cómo se pide cada parámetro). Servicio aparte del Service Layer: su sesión y su cola propias (`APIGATEWAY_*` en el `.env`; los datos salieron del `.env` del mini-erp y el certificado autofirmado va en `certs/apigateway.pem`). La matrícula vive en SQLite (`gateway_reports`) y arma el cuerpo de `ExportPDFData` (`[{name, type, value:[[…]]}]`: un valor `[[v]]`, varios `[[a],[b]]`, rango `[[desde,hasta]]`); los parámetros se piden con los mismos controles y ventanas de selección de los formularios (texto, número, fecha, lista fija, ventana de selección con un catálogo de listas: socios, artículos, almacenes, documentos por `DocEntry`…). Verificado en la base HANA: los reportes a medida (`DLN20015`, `RCRI0031`) generan PDF con valores discretos y fechas `yyyy-MM-dd`; los estándar de SAP (`RCRI0001`, `RCRI0002`, con títulos y rangos) devolvieron `(---)` con los formatos probados, el formato de rango/varios valores sigue sin confirmar. Los títulos y separadores de un reporte (`…@Title`, `…@separator`) se marcan ocultos. Pendiente: el botón PDF de cada documento (hace falta el código del reporte de cada tipo).
|
||||||
|
|
||||||
## Flujo de las pantallas de documento
|
## Flujo de las pantallas de documento
|
||||||
|
|
||||||
|
|||||||
@@ -32,3 +32,16 @@ SAPB1_SL_MAX_CONCURRENCY=1
|
|||||||
# 3) Solo pruebas locales: SAPB1_SL_VERIFY_SSL=false (rechazado si NODE_ENV=production).
|
# 3) Solo pruebas locales: SAPB1_SL_VERIFY_SSL=false (rechazado si NODE_ENV=production).
|
||||||
SAPB1_SL_CA_FILE=
|
SAPB1_SL_CA_FILE=
|
||||||
SAPB1_SL_VERIFY_SSL=true
|
SAPB1_SL_VERIFY_SSL=true
|
||||||
|
|
||||||
|
# --- SAP Business One API Gateway (reportes Crystal): opcional, sin URL el módulo queda sin configurar ---
|
||||||
|
APIGATEWAY_URL=
|
||||||
|
APIGATEWAY_USER=
|
||||||
|
APIGATEWAY_PASSWORD=
|
||||||
|
APIGATEWAY_COMPANYDB=
|
||||||
|
APIGATEWAY_DBINSTANCE=
|
||||||
|
# Espera máxima (segundos) de cada llamada y reportes que se generan a la vez
|
||||||
|
APIGATEWAY_TIMEOUT=60
|
||||||
|
APIGATEWAY_MAX_CONCURRENCY=2
|
||||||
|
# Certificado del gateway (PEM, relativo a apps/api) si es autofirmado
|
||||||
|
APIGATEWAY_CA_FILE=
|
||||||
|
APIGATEWAY_VERIFY_SSL=true
|
||||||
@@ -88,3 +88,21 @@ Tabla `audit_log` en la misma SQLite. Registra inicios de sesión (correctos, fa
|
|||||||
|
|
||||||
Pendiente de afinar: retención programada, IP real detrás del proxy de desarrollo, exportar a CSV.
|
Pendiente de afinar: retención programada, IP real detrás del proxy de desarrollo, exportar a CSV.
|
||||||
|
|
||||||
|
## Reportes del API Gateway (Crystal Reports)
|
||||||
|
|
||||||
|
Servicio aparte del Service Layer (`APIGATEWAY_*` en el `.env`): su propio login (`/login` con base, usuario, clave e instancia), su propia sesión (se renueva sola ante un 401) y su propia cola (`APIGATEWAY_MAX_CONCURRENCY` reportes a la vez; generar uno pesa). Sin `APIGATEWAY_URL` el módulo responde 503 y el resto de la API no se ve afectado.
|
||||||
|
|
||||||
|
Llama a `rs/v1/LoadAuthorizedCRList` (los reportes autorizados), `rs/v1/LoadCR` (sus parámetros) y `rs/v1/ExportPDFData` (el PDF, en base64; responde 201 y, si falla, un texto de error con 200 o `(---)` si el reporte no produjo nada).
|
||||||
|
|
||||||
|
| Endpoint | Quién | Descripción |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/gateway/authorized` | Administrador | Reportes que el gateway autoriza |
|
||||||
|
| `GET /api/gateway/authorized/:code/parameters` | Administrador | Parámetros de un reporte, como definiciones de matrícula |
|
||||||
|
| `GET /api/gateway/registry` | Administrador | Reportes matriculados |
|
||||||
|
| `PUT /api/gateway/registry/:code` | Administrador | Matricula o actualiza un reporte (título, categoría, habilitado y cómo se pide cada parámetro) |
|
||||||
|
| `DELETE /api/gateway/registry/:code` | Administrador | Quita un reporte de la matrícula |
|
||||||
|
| `GET /api/gateway/reports` | Permiso de menú `/reportes/ejecutar` | Reportes habilitados |
|
||||||
|
| `POST /api/gateway/reports/:code/export` `{values}` | Permiso de menú `/reportes/ejecutar` | Genera el PDF |
|
||||||
|
|
||||||
|
La matrícula (tabla `gateway_reports` de la SQLite) es lo que arma el cuerpo de `ExportPDFData`: el usuario solo manda valores y el servidor los ordena según los parámetros matriculados (nombre y tipo de Crystal), exige los obligatorios y descarta lo que no esté matriculado. La auditoría registra `report_register`, `report_unregister` y `report_export` (solo los nombres de los parámetros, nunca los valores).
|
||||||
|
|
||||||
@@ -110,6 +110,25 @@ const devRoutes: Routes = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Reportes del API Gateway: la lista para ejecutar con su formulario de parámetros y la matrícula (administradores). */
|
||||||
|
const reportRoutes: Routes = [
|
||||||
|
{
|
||||||
|
path: 'reportes/ejecutar',
|
||||||
|
canActivate: [permissionGuard],
|
||||||
|
data: { path: '/reportes/ejecutar', title: menuLabel('/reportes/ejecutar') },
|
||||||
|
children: [
|
||||||
|
{ path: '', loadComponent: () => import('./features/reports/reports.page').then((m) => m.ReportsPage) },
|
||||||
|
{ path: ':code', loadComponent: () => import('./features/reports/report-runner.page').then((m) => m.ReportRunnerPage) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reportes/matricula',
|
||||||
|
canActivate: [permissionGuard],
|
||||||
|
data: { path: '/reportes/matricula', title: menuLabel('/reportes/matricula') },
|
||||||
|
loadComponent: () => import('./features/reports/registry.page').then((m) => m.RegistryPage),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/** Administración: el permiso de menú `/admin/permisos` solo lo da el acceso total (`*`). */
|
/** Administración: el permiso de menú `/admin/permisos` solo lo da el acceso total (`*`). */
|
||||||
const adminRoutes: Routes = [
|
const adminRoutes: Routes = [
|
||||||
{
|
{
|
||||||
@@ -127,7 +146,7 @@ const adminRoutes: Routes = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
|
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
|
||||||
const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, JOURNAL_PATH, '/admin/permisos', '/admin/auditoria']);
|
const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, JOURNAL_PATH, '/reportes/ejecutar', '/reportes/matricula', '/admin/permisos', '/admin/auditoria']);
|
||||||
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
|
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
|
||||||
.filter((item) => !builtPaths.has(item.path))
|
.filter((item) => !builtPaths.has(item.path))
|
||||||
.map(
|
.map(
|
||||||
@@ -160,6 +179,7 @@ export const routes: Routes = [
|
|||||||
...masterRoutes,
|
...masterRoutes,
|
||||||
...paymentRoutes,
|
...paymentRoutes,
|
||||||
...journalRoutes,
|
...journalRoutes,
|
||||||
|
...reportRoutes,
|
||||||
...adminRoutes,
|
...adminRoutes,
|
||||||
...pendingRoutes,
|
...pendingRoutes,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe('MenuService', () => {
|
|||||||
const p = menu.ensureLoaded();
|
const p = menu.ensureLoaded();
|
||||||
ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] });
|
ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] });
|
||||||
await p;
|
await p;
|
||||||
expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'bancos', 'finanzas', 'socios', 'admin']);
|
expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'bancos', 'finanzas', 'reportes', 'socios', 'admin']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('si falla marca el error y permite reintentar', async () => {
|
it('si falla marca el error y permite reintentar', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import type { AuthorizedReport, GatewayParam, GatewayReport, GatewayValues } from '@basic-erp/sap-types';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { API_URL } from '../../core/api';
|
||||||
|
|
||||||
|
/** Reportes del API Gateway a través de `/api/gateway` (matrícula y ejecución). */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class GatewayApi {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
/** Los reportes habilitados, para la lista de ejecución. */
|
||||||
|
reports(): Promise<GatewayReport[]> {
|
||||||
|
return firstValueFrom(this.http.get<GatewayReport[]>(`${API_URL}/gateway/reports`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Genera el PDF de un reporte con los valores escritos. */
|
||||||
|
export(code: string, values: GatewayValues): Promise<Blob> {
|
||||||
|
return firstValueFrom(this.http.post(`${API_URL}/gateway/reports/${encodeURIComponent(code)}/export`, { values }, { responseType: 'blob' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- matrícula (administradores) -----------------------------------------------------------
|
||||||
|
|
||||||
|
registry(): Promise<GatewayReport[]> {
|
||||||
|
return firstValueFrom(this.http.get<GatewayReport[]>(`${API_URL}/gateway/registry`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Los reportes que el API Gateway autoriza (de ellos se eligen los que se matriculan). */
|
||||||
|
authorized(): Promise<AuthorizedReport[]> {
|
||||||
|
return firstValueFrom(this.http.get<AuthorizedReport[]>(`${API_URL}/gateway/authorized`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Los parámetros de un reporte, como definiciones listas para ajustar. */
|
||||||
|
parameters(code: string): Promise<GatewayParam[]> {
|
||||||
|
return firstValueFrom(this.http.get<GatewayParam[]>(`${API_URL}/gateway/authorized/${encodeURIComponent(code)}/parameters`));
|
||||||
|
}
|
||||||
|
|
||||||
|
save(report: GatewayReport): Promise<GatewayReport> {
|
||||||
|
const { code, ...body } = report;
|
||||||
|
return firstValueFrom(this.http.put<GatewayReport>(`${API_URL}/gateway/registry/${encodeURIComponent(code)}`, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(code: string): Promise<void> {
|
||||||
|
return firstValueFrom(this.http.delete<void>(`${API_URL}/gateway/registry/${encodeURIComponent(code)}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Las ventanas de selección que se pueden asignar a un parámetro de reporte (las mismas listas de valores de los
|
||||||
|
* formularios). El valor que recibe el reporte es el del campo `valueKey` de la fila elegida.
|
||||||
|
*/
|
||||||
|
export interface GatewayLookup {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
lookup: VhLookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
const col = (key: string, title: string, width?: string, mono = false) => ({ key, title, ...(width ? { width } : {}), ...(mono ? { mono: true } : {}) });
|
||||||
|
|
||||||
|
const partners = (id: string, label: string, type?: 'cCustomer' | 'cSupplier'): GatewayLookup => ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
lookup: {
|
||||||
|
title: label,
|
||||||
|
resource: 'BusinessPartners',
|
||||||
|
orderby: 'CardCode',
|
||||||
|
valueKey: 'CardCode',
|
||||||
|
...(type ? { baseFilter: `CardType eq '${type}'` } : {}),
|
||||||
|
columns: [col('CardCode', 'Código', '140px', true), col('CardName', 'Nombre')],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Un documento por su `DocEntry` (los reportes de documentos reciben ese número, como `Dockey@`). */
|
||||||
|
const document = (resource: string, label: string): GatewayLookup => ({
|
||||||
|
id: `doc:${resource}`,
|
||||||
|
label: `Documento: ${label}`,
|
||||||
|
lookup: {
|
||||||
|
title: label,
|
||||||
|
resource,
|
||||||
|
orderby: 'DocEntry desc',
|
||||||
|
valueKey: 'DocEntry',
|
||||||
|
columns: [{ ...col('DocNum', 'Número', '110px', true), type: 'number', align: 'left', format: (r) => String(r['DocNum'] ?? ''), searchable: true }, col('CardCode', 'Socio', '120px', true), col('CardName', 'Nombre'), { ...col('DocDate', 'Fecha', '110px'), type: 'date' }, col('DocEntry', 'DocEntry', '100px', true)],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GATEWAY_LOOKUPS: readonly GatewayLookup[] = [
|
||||||
|
partners('socios', 'Socios de negocio'),
|
||||||
|
partners('clientes', 'Clientes', 'cCustomer'),
|
||||||
|
partners('proveedores', 'Proveedores', 'cSupplier'),
|
||||||
|
{
|
||||||
|
id: 'articulos',
|
||||||
|
label: 'Artículos',
|
||||||
|
lookup: { title: 'Artículos', resource: 'Items', orderby: 'ItemCode', valueKey: 'ItemCode', columns: [col('ItemCode', 'Código', '150px', true), col('ItemName', 'Descripción')] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'almacenes',
|
||||||
|
label: 'Almacenes',
|
||||||
|
lookup: { title: 'Almacenes', resource: 'Warehouses', orderby: 'WarehouseCode', valueKey: 'WarehouseCode', columns: [col('WarehouseCode', 'Código', '110px', true), col('WarehouseName', 'Nombre')] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proyectos',
|
||||||
|
label: 'Proyectos',
|
||||||
|
lookup: { title: 'Proyectos', resource: 'Projects', orderby: 'Code', valueKey: 'Code', columns: [col('Code', 'Código', '110px', true), col('Name', 'Nombre')] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'monedas',
|
||||||
|
label: 'Monedas',
|
||||||
|
lookup: { title: 'Monedas', resource: 'Currencies', orderby: 'Code', valueKey: 'Code', columns: [col('Code', 'Código', '90px', true), col('Name', 'Nombre')] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cuentas',
|
||||||
|
label: 'Cuentas contables (código interno)',
|
||||||
|
lookup: { title: 'Plan de cuentas', resource: 'ChartOfAccounts', orderby: 'Code', valueKey: 'Code', columns: [col('Code', 'Código', '170px', true), col('Name', 'Nombre')] },
|
||||||
|
},
|
||||||
|
document('Quotations', 'Ofertas de venta'),
|
||||||
|
document('Orders', 'Órdenes de venta'),
|
||||||
|
document('DeliveryNotes', 'Entregas'),
|
||||||
|
document('Invoices', 'Facturas de venta'),
|
||||||
|
document('CreditNotes', 'Notas de crédito de venta'),
|
||||||
|
document('Returns', 'Devoluciones de venta'),
|
||||||
|
document('PurchaseQuotations', 'Ofertas de compra'),
|
||||||
|
document('PurchaseOrders', 'Órdenes de compra'),
|
||||||
|
document('PurchaseDeliveryNotes', 'Recepciones de compra'),
|
||||||
|
document('PurchaseInvoices', 'Facturas de proveedor'),
|
||||||
|
document('PurchaseCreditNotes', 'Notas de crédito de compra'),
|
||||||
|
document('PurchaseReturns', 'Devoluciones de compra'),
|
||||||
|
document('IncomingPayments', 'Pagos recibidos'),
|
||||||
|
document('VendorPayments', 'Pagos efectuados'),
|
||||||
|
document('InventoryGenEntries', 'Entradas de inventario'),
|
||||||
|
document('InventoryGenExits', 'Salidas de inventario'),
|
||||||
|
document('StockTransfers', 'Transferencias de stock'),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const lookupById = (id: string | undefined): GatewayLookup | undefined => GATEWAY_LOOKUPS.find((l) => l.id === id);
|
||||||
|
|
||||||
|
/** Categorías con que se agrupan los reportes matriculados (las de los módulos del menú). */
|
||||||
|
export const GATEWAY_CATEGORIES: readonly string[] = ['Ventas', 'Compras', 'Inventario', 'Bancos', 'Finanzas', 'Socios de negocio', 'Otros'];
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, inject, resource, signal } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import type { AuthorizedReport, GatewayParam, GatewayParamKind, GatewayReport } from '@basic-erp/sap-types';
|
||||||
|
import { apiErrorMessage } from '../../core/api';
|
||||||
|
import { SelectField, TextField, TextareaField, CheckboxField } from '../../shared/form';
|
||||||
|
import type { DetailColumn } from '../../shared/grid/grid.types';
|
||||||
|
import { RowsTable } from '../../shared/grid/rows-table';
|
||||||
|
import type { ListColumn, ListRow } from '../../shared/list/list.types';
|
||||||
|
import { ValueHelp } from '../../shared/value-help/value-help';
|
||||||
|
import { SboWindow, type StatusMessage } from '../../shared/window';
|
||||||
|
import { GatewayApi } from './gateway-api';
|
||||||
|
import { GATEWAY_CATEGORIES, GATEWAY_LOOKUPS } from './gateway-lookups';
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
code: string;
|
||||||
|
isNew: boolean;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
params: Row[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const YES = 'Sí';
|
||||||
|
const NO = 'No';
|
||||||
|
const KINDS: { value: GatewayParamKind; label: string }[] = [
|
||||||
|
{ value: 'text', label: 'Texto' },
|
||||||
|
{ value: 'number', label: 'Número' },
|
||||||
|
{ value: 'date', label: 'Fecha' },
|
||||||
|
{ value: 'select', label: 'Lista fija' },
|
||||||
|
{ value: 'lookup', label: 'Ventana de selección' },
|
||||||
|
];
|
||||||
|
const yesNo = [{ value: YES, label: YES }, { value: NO, label: NO }];
|
||||||
|
|
||||||
|
/** Una definición de parámetro como fila editable de la tabla. */
|
||||||
|
const toRow = (p: GatewayParam): Row => ({
|
||||||
|
name: p.name,
|
||||||
|
type: p.type,
|
||||||
|
label: p.label,
|
||||||
|
kind: p.kind,
|
||||||
|
required: p.required ? YES : NO,
|
||||||
|
range: p.range ? YES : NO,
|
||||||
|
multiple: p.multiple ? YES : NO,
|
||||||
|
hidden: p.hidden ? YES : NO,
|
||||||
|
lookup: p.lookup ?? '',
|
||||||
|
options: (p.options ?? []).map((o) => (o.value === o.label ? o.value : `${o.value}=${o.label}`)).join('; '),
|
||||||
|
defaultValue: p.defaultValue ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Lo contrario: la fila de la tabla como definición de parámetro (sin los campos que no aplican a su tipo). */
|
||||||
|
export function toParam(r: Row): GatewayParam {
|
||||||
|
const text = (k: string): string => String(r[k] ?? '').trim();
|
||||||
|
const kind = (text('kind') || 'text') as GatewayParamKind;
|
||||||
|
const options = text('options')
|
||||||
|
.split(';')
|
||||||
|
.map((o) => o.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((o) => {
|
||||||
|
const [value, ...label] = o.split('=');
|
||||||
|
return { value: value.trim(), label: (label.join('=') || value).trim() };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
name: text('name'),
|
||||||
|
type: text('type') || 'xsd:string',
|
||||||
|
label: text('label') || text('name'),
|
||||||
|
kind,
|
||||||
|
required: text('required') === YES,
|
||||||
|
...(text('range') === YES ? { range: true } : {}),
|
||||||
|
...(text('multiple') === YES ? { multiple: true } : {}),
|
||||||
|
...(text('hidden') === YES ? { hidden: true } : {}),
|
||||||
|
...(kind === 'lookup' && text('lookup') ? { lookup: text('lookup') } : {}),
|
||||||
|
...(kind === 'select' && options.length ? { options } : {}),
|
||||||
|
...(text('defaultValue') ? { defaultValue: text('defaultValue') } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matrícula de los reportes del API Gateway: de los que el gateway autoriza se eligen los que se van a usar y se
|
||||||
|
* define cómo se pide cada parámetro (texto, número, fecha, lista fija o ventana de selección). Solo administradores.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'sbo-gateway-registry-page',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [CheckboxField, RowsTable, SboWindow, SelectField, TextField, TextareaField, ValueHelp],
|
||||||
|
template: `
|
||||||
|
<sbo-window [title]="title" [status]="status()">
|
||||||
|
@if (draft(); as d) {
|
||||||
|
<div class="sbo-hdr">
|
||||||
|
<div class="sbo-col">
|
||||||
|
<sbo-text-field label="Código" [value]="d.code" readonly />
|
||||||
|
<sbo-text-field label="Título" required [value]="d.title" (valueChange)="patch({ title: $event })" />
|
||||||
|
<sbo-select-field label="Categoría" required [options]="categories" [value]="d.category" (valueChange)="patch({ category: '' + ($event ?? '') })" />
|
||||||
|
</div>
|
||||||
|
<div class="sbo-col">
|
||||||
|
<sbo-textarea-field label="Descripción" [rows]="3" [value]="d.description" (valueChange)="patch({ description: $event })" />
|
||||||
|
<sbo-checkbox-field label="Habilitado (los usuarios lo ven)" [value]="d.enabled" (valueChange)="patch({ enabled: $event })" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2>Parámetros</h2>
|
||||||
|
<p class="hint">«Tipo de control» decide cómo se pide al usuario; con «Ventana de selección» se elige la lista. «Oculto» es para títulos y separadores del reporte: no se piden ni se envían. «Opciones» de una lista fija: <code>valor=etiqueta; valor=etiqueta</code>.</p>
|
||||||
|
<sbo-rows-table [columns]="columns" [rows]="d.params" (rowsChange)="patch({ params: $event })" [removable]="false" emptyText="El reporte no tiene parámetros." />
|
||||||
|
} @else {
|
||||||
|
<div class="bar">
|
||||||
|
<button type="button" class="btn main" (click)="startPick()">Matricular reporte</button>
|
||||||
|
</div>
|
||||||
|
<table class="grid">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Código</th><th>Título</th><th>Categoría</th><th class="num">Parámetros</th><th>Estado</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of rows(); track r.code) {
|
||||||
|
<tr>
|
||||||
|
<td class="code">{{ r.code }}</td>
|
||||||
|
<td>{{ r.title }}</td>
|
||||||
|
<td>{{ r.category }}</td>
|
||||||
|
<td class="num">{{ r.params.length }}</td>
|
||||||
|
<td>{{ r.enabled ? 'Habilitado' : 'Deshabilitado' }}</td>
|
||||||
|
<td class="acts">
|
||||||
|
<button type="button" class="btn" (click)="edit(r)">Editar</button>
|
||||||
|
@if (confirmRemove() === r.code) {
|
||||||
|
<button type="button" class="btn main" (click)="remove(r.code)">Sí, quitar</button>
|
||||||
|
<button type="button" class="btn" (click)="confirmRemove.set(null)">No</button>
|
||||||
|
} @else {
|
||||||
|
<button type="button" class="btn" (click)="confirmRemove.set(r.code)">Quitar</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr class="none"><td colspan="6">{{ registry.isLoading() ? 'Cargando…' : 'Aún no hay reportes matriculados. Pulse «Matricular reporte».' }}</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
@if (draft()) {
|
||||||
|
<button sboActions class="btn main" type="button" [disabled]="saving()" (click)="save()">Guardar</button>
|
||||||
|
}
|
||||||
|
@if (draft()) {
|
||||||
|
<button sboActions class="btn" type="button" (click)="draft.set(null)">Cancelar</button>
|
||||||
|
}
|
||||||
|
</sbo-window>
|
||||||
|
@if (picking()) {
|
||||||
|
<sbo-value-help
|
||||||
|
[open]="true"
|
||||||
|
title="Reportes autorizados en el API Gateway"
|
||||||
|
resource="gateway"
|
||||||
|
[columns]="pickColumns"
|
||||||
|
[localRows]="authorizedRows()"
|
||||||
|
rowKey="code"
|
||||||
|
(selected)="picked($event)"
|
||||||
|
(closed)="picking.set(false)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host { display: block; }
|
||||||
|
h2 { margin: 14px 0 2px; font-size: 13px; font-weight: 600; }
|
||||||
|
.hint { margin: 0 0 6px; color: var(--quiet); font-size: 12px; }
|
||||||
|
.bar { margin-bottom: 8px; }
|
||||||
|
.grid { width: 100%; border-collapse: collapse; }
|
||||||
|
.grid th { padding: 4px 8px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; text-align: left; }
|
||||||
|
.grid td { padding: 3px 8px; border: 1px solid var(--line-soft); }
|
||||||
|
.num { text-align: right; }
|
||||||
|
.code { font-family: var(--mono, monospace); }
|
||||||
|
.acts { display: flex; gap: 6px; justify-content: flex-end; }
|
||||||
|
tr.none td { padding: 14px; text-align: center; color: var(--quiet); }
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class RegistryPage {
|
||||||
|
private readonly api = inject(GatewayApi);
|
||||||
|
protected readonly title = inject(ActivatedRoute).snapshot.data['title'] as string;
|
||||||
|
|
||||||
|
protected readonly categories = GATEWAY_CATEGORIES.map((c) => ({ value: c, label: c }));
|
||||||
|
protected readonly registry = resource({ loader: () => this.api.registry() });
|
||||||
|
protected readonly rows = computed(() => (this.registry.hasValue() ? this.registry.value() : []));
|
||||||
|
|
||||||
|
protected readonly draft = signal<Draft | null>(null);
|
||||||
|
protected readonly saving = signal(false);
|
||||||
|
protected readonly confirmRemove = signal<string | null>(null);
|
||||||
|
private readonly message = signal<StatusMessage | null>(null);
|
||||||
|
|
||||||
|
protected readonly status = computed<StatusMessage>(() => {
|
||||||
|
if (this.registry.error()) return { kind: 'err', text: apiErrorMessage(this.registry.error()) };
|
||||||
|
return this.message() ?? { kind: 'info', text: this.draft() ? 'Ajuste cómo se pide cada parámetro y pulse «Guardar».' : '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Las columnas de la tabla de parámetros. */
|
||||||
|
protected readonly columns: readonly DetailColumn[] = [
|
||||||
|
{ key: 'name', title: 'Parámetro del reporte', type: 'text', readonly: true, width: '230px' },
|
||||||
|
{ key: 'label', title: 'Etiqueta', type: 'text', width: '180px' },
|
||||||
|
{ key: 'kind', title: 'Tipo de control', type: 'select', options: KINDS, width: '150px' },
|
||||||
|
{ key: 'required', title: 'Obligatorio', type: 'select', options: yesNo, width: '95px' },
|
||||||
|
{ key: 'range', title: 'Rango', type: 'select', options: yesNo, width: '80px' },
|
||||||
|
{ key: 'multiple', title: 'Varios', type: 'select', options: yesNo, width: '80px' },
|
||||||
|
{ key: 'lookup', title: 'Ventana de selección', type: 'select', options: GATEWAY_LOOKUPS.map((l) => ({ value: l.id, label: l.label })), width: '220px' },
|
||||||
|
{ key: 'options', title: 'Opciones (lista fija)', type: 'text', width: '200px' },
|
||||||
|
{ key: 'defaultValue', title: 'Valor por defecto', type: 'text', width: '140px' },
|
||||||
|
{ key: 'hidden', title: 'Oculto', type: 'select', options: yesNo, width: '80px' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- elegir un reporte autorizado -------------------------------------------------------------
|
||||||
|
|
||||||
|
protected readonly picking = signal(false);
|
||||||
|
private readonly authorized = signal<AuthorizedReport[]>([]);
|
||||||
|
protected readonly pickColumns: readonly ListColumn[] = [
|
||||||
|
{ key: 'code', title: 'Código', width: '120px', mono: true, searchable: true },
|
||||||
|
{ key: 'name', title: 'Nombre', searchable: true },
|
||||||
|
];
|
||||||
|
/** Los autorizados que todavía no están matriculados. */
|
||||||
|
protected readonly authorizedRows = computed<ListRow[]>(() => {
|
||||||
|
const done = new Set(this.rows().map((r) => r.code));
|
||||||
|
return this.authorized().filter((a) => !done.has(a.code)).map((a) => ({ ...a }));
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async startPick(): Promise<void> {
|
||||||
|
this.message.set({ kind: 'info', text: 'Consultando los reportes autorizados en el API Gateway…' });
|
||||||
|
try {
|
||||||
|
this.authorized.set(await this.api.authorized());
|
||||||
|
this.message.set(null);
|
||||||
|
this.picking.set(true);
|
||||||
|
} catch (e) {
|
||||||
|
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async picked(rows: ListRow[]): Promise<void> {
|
||||||
|
this.picking.set(false);
|
||||||
|
const chosen = rows[0] as unknown as AuthorizedReport | undefined;
|
||||||
|
if (!chosen) return;
|
||||||
|
this.message.set({ kind: 'info', text: `Cargando los parámetros de ${chosen.code}…` });
|
||||||
|
try {
|
||||||
|
const params = await this.api.parameters(chosen.code);
|
||||||
|
this.draft.set({ code: chosen.code, isNew: true, title: chosen.name, category: 'Otros', description: '', enabled: true, params: params.map(toRow) });
|
||||||
|
this.message.set(null);
|
||||||
|
} catch (e) {
|
||||||
|
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- editar y guardar ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
protected edit(r: GatewayReport): void {
|
||||||
|
this.draft.set({ code: r.code, isNew: false, title: r.title, category: r.category, description: r.description ?? '', enabled: r.enabled, params: r.params.map(toRow) });
|
||||||
|
this.message.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected patch(change: Partial<Draft>): void {
|
||||||
|
this.draft.update((d) => (d ? { ...d, ...change } : d));
|
||||||
|
this.message.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async save(): Promise<void> {
|
||||||
|
const d = this.draft();
|
||||||
|
if (!d || this.saving()) return;
|
||||||
|
if (!d.title.trim()) {
|
||||||
|
this.message.set({ kind: 'err', text: 'Indique el título.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lookups = d.params.filter((r) => r['kind'] === 'lookup' && !r['lookup']);
|
||||||
|
if (lookups.length) {
|
||||||
|
this.message.set({ kind: 'err', text: `Elija la ventana de selección de: ${lookups.map((r) => r['label'] || r['name']).join(', ')}.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.saving.set(true);
|
||||||
|
try {
|
||||||
|
await this.api.save({ code: d.code, title: d.title.trim(), category: d.category || 'Otros', ...(d.description.trim() ? { description: d.description.trim() } : {}), enabled: d.enabled, params: d.params.map(toParam) });
|
||||||
|
this.draft.set(null);
|
||||||
|
this.registry.reload();
|
||||||
|
this.message.set({ kind: 'ok', text: `Reporte ${d.code} ${d.isNew ? 'matriculado' : 'actualizado'}.` });
|
||||||
|
} catch (e) {
|
||||||
|
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||||
|
} finally {
|
||||||
|
this.saving.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async remove(code: string): Promise<void> {
|
||||||
|
this.confirmRemove.set(null);
|
||||||
|
try {
|
||||||
|
await this.api.remove(code);
|
||||||
|
this.registry.reload();
|
||||||
|
this.message.set({ kind: 'ok', text: `Reporte ${code} quitado de la matrícula.` });
|
||||||
|
} catch (e) {
|
||||||
|
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import type { GatewayParam } from '@basic-erp/sap-types';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { ReportParamField } from './report-param-field';
|
||||||
|
|
||||||
|
const param = (over: Partial<GatewayParam> = {}): GatewayParam => ({ name: 'P', type: 'xsd:string', label: 'Parámetro', kind: 'text', required: false, ...over });
|
||||||
|
|
||||||
|
describe('ReportParamField', () => {
|
||||||
|
let fixture: ComponentFixture<ReportParamField>;
|
||||||
|
const el = () => fixture.nativeElement as HTMLElement;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const cmp = () => fixture.componentInstance as any;
|
||||||
|
const labels = () => [...el().querySelectorAll('label')].map((l) => l.textContent?.trim());
|
||||||
|
const inputs = () => [...el().querySelectorAll<HTMLInputElement>('input')];
|
||||||
|
const tick = async () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
await new Promise((r) => setTimeout(r));
|
||||||
|
fixture.detectChanges();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function show(p: GatewayParam, value: string | string[] = '') {
|
||||||
|
fixture.componentRef.setInput('param', p);
|
||||||
|
fixture.componentRef.setInput('value', value);
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await TestBed.configureTestingModule({ imports: [ReportParamField], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
|
||||||
|
fixture = TestBed.createComponent(ReportParamField);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un texto, un número y una fecha piden su control con su etiqueta', async () => {
|
||||||
|
await show(param({ label: 'Nombre' }), 'abc');
|
||||||
|
expect(labels()).toContain('Nombre');
|
||||||
|
expect(inputs()[0].value).toBe('abc');
|
||||||
|
await show(param({ kind: 'number', label: 'Cantidad' }), '5');
|
||||||
|
expect(inputs()[0].value).toBe('5');
|
||||||
|
await show(param({ kind: 'date', label: 'Fecha' }), '2026-10-01');
|
||||||
|
expect(el().querySelector<HTMLInputElement>('input[type=date]')!.value).toBe('2026-10-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emite el valor escrito como texto; un número vacío queda vacío', async () => {
|
||||||
|
await show(param({ kind: 'number' }), '');
|
||||||
|
const input = inputs()[0];
|
||||||
|
input.dispatchEvent(new Event('focus'));
|
||||||
|
input.value = '12.5';
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
input.dispatchEvent(new Event('blur'));
|
||||||
|
await tick();
|
||||||
|
expect(fixture.componentInstance.value()).toBe('12.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('una lista fija ofrece sus opciones', async () => {
|
||||||
|
await show(param({ kind: 'select', options: [{ value: '1', label: 'Uno' }, { value: '2', label: 'Dos' }] }), '2');
|
||||||
|
const select = el().querySelector('select') as HTMLSelectElement;
|
||||||
|
expect([...select.options].map((o) => o.textContent?.trim())).toEqual(expect.arrayContaining(['Uno', 'Dos']));
|
||||||
|
expect(select.value).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un rango pide desde y hasta y los emite como lista', async () => {
|
||||||
|
await show(param({ kind: 'date', range: true }), ['2026-01-01', '2026-01-31']);
|
||||||
|
expect(labels().join(' ')).toContain('desde / hasta');
|
||||||
|
const [from, to] = [...el().querySelectorAll<HTMLInputElement>('input[type=date]')];
|
||||||
|
expect([from.value, to.value]).toEqual(['2026-01-01', '2026-01-31']);
|
||||||
|
cmp().setPair(1, '2026-02-28');
|
||||||
|
expect(fixture.componentInstance.value()).toEqual(['2026-01-01', '2026-02-28']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('una ventana de selección de un solo valor es un campo de lista y, con varios, junta lo que se marca en chips', async () => {
|
||||||
|
await show(param({ kind: 'lookup', lookup: 'almacenes', label: 'Almacén' }), '01');
|
||||||
|
expect(el().querySelector('sbo-lookup-field')).not.toBeNull();
|
||||||
|
await show(param({ kind: 'lookup', lookup: 'almacenes', label: 'Almacenes', multiple: true }), ['01']);
|
||||||
|
expect([...el().querySelectorAll('.chip')].map((c) => c.textContent?.replace('×', '').trim())).toEqual(['01']);
|
||||||
|
cmp().picked([{ WarehouseCode: '02' }, { WarehouseCode: '01' }, { WarehouseCode: '03' }]);
|
||||||
|
expect(fixture.componentInstance.value()).toEqual(['01', '02', '03']); // sin repetir
|
||||||
|
cmp().remove('02');
|
||||||
|
expect(fixture.componentInstance.value()).toEqual(['01', '03']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('varios valores de texto se escriben separados por comas', async () => {
|
||||||
|
await show(param({ multiple: true, label: 'Códigos' }), ['A', 'B']);
|
||||||
|
expect(inputs()[0].value).toBe('A, B');
|
||||||
|
expect(cmp().split(' X , Y,, Z ')).toEqual(['X', 'Y', 'Z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('varios valores de una lista fija se marcan con casillas', async () => {
|
||||||
|
await show(param({ kind: 'select', multiple: true, options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] }), ['a']);
|
||||||
|
const boxes = [...el().querySelectorAll<HTMLInputElement>('input[type=checkbox]')];
|
||||||
|
expect(boxes.map((b) => b.checked)).toEqual([true, false]);
|
||||||
|
cmp().toggle('b');
|
||||||
|
expect(fixture.componentInstance.value()).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, input, model, signal } from '@angular/core';
|
||||||
|
import type { GatewayParam } from '@basic-erp/sap-types';
|
||||||
|
import { DateField, DateInput, LookupField, NumberField, NumberInput, SboField, SelectField, TextField, TextInput } from '../../shared/form';
|
||||||
|
import type { ListRow } from '../../shared/list/list.types';
|
||||||
|
import { ValueHelp } from '../../shared/value-help/value-help';
|
||||||
|
import { lookupById } from './gateway-lookups';
|
||||||
|
|
||||||
|
type Value = string | readonly string[];
|
||||||
|
|
||||||
|
const asList = (v: Value): readonly string[] => (typeof v === 'string' ? (v === '' ? [] : [v]) : v);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un parámetro de un reporte, con el control que pide su tipo: texto, número, fecha, lista fija o ventana de
|
||||||
|
* selección. Un parámetro de rango pide desde y hasta, y uno que admite varios valores los junta (marcas en una
|
||||||
|
* ventana de selección, o valores separados por comas). El valor es un texto, o una lista en esos dos casos.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'sbo-report-param-field',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [DateField, DateInput, LookupField, NumberField, NumberInput, SboField, SelectField, TextField, TextInput, ValueHelp],
|
||||||
|
template: `
|
||||||
|
@let p = param();
|
||||||
|
@switch (mode()) {
|
||||||
|
@case ('single') {
|
||||||
|
@switch (p.kind) {
|
||||||
|
@case ('number') {
|
||||||
|
<sbo-number-field [label]="p.label" [required]="p.required" [value]="num(single())" (valueChange)="value.set($event === null ? '' : '' + $event)" />
|
||||||
|
}
|
||||||
|
@case ('date') {
|
||||||
|
<sbo-date-field [label]="p.label" [required]="p.required" [value]="single()" (valueChange)="value.set($event)" />
|
||||||
|
}
|
||||||
|
@case ('select') {
|
||||||
|
<sbo-select-field [label]="p.label" [required]="p.required" [options]="p.options ?? []" placeholder="" [value]="single() || null" (valueChange)="value.set($event === null ? '' : '' + $event)" />
|
||||||
|
}
|
||||||
|
@case ('lookup') {
|
||||||
|
@if (catalog(); as c) {
|
||||||
|
<sbo-lookup-field [label]="p.label" [required]="p.required" [lookup]="c.lookup" resolveOnLeave [value]="single()" (valueChange)="value.set($event)" />
|
||||||
|
} @else {
|
||||||
|
<sbo-text-field [label]="p.label" [required]="p.required" [value]="single()" (valueChange)="value.set($event)" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<sbo-text-field [label]="p.label" [required]="p.required" [placeholder]="p.placeholder ?? ''" [value]="single()" (valueChange)="value.set($event)" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@case ('range') {
|
||||||
|
@if (p.kind === 'lookup' && catalog(); as c) {
|
||||||
|
<sbo-lookup-field [label]="p.label + ' – desde'" [required]="p.required" [lookup]="c.lookup" resolveOnLeave [value]="pair()[0]" (valueChange)="setPair(0, $event)" />
|
||||||
|
<sbo-lookup-field [label]="p.label + ' – hasta'" [lookup]="c.lookup" resolveOnLeave [value]="pair()[1]" (valueChange)="setPair(1, $event)" />
|
||||||
|
} @else {
|
||||||
|
<sbo-field [label]="p.label + ' (desde / hasta)'" [required]="p.required">
|
||||||
|
@switch (p.kind) {
|
||||||
|
@case ('date') {
|
||||||
|
<sbo-date ariaLabel="Desde" [value]="pair()[0]" (valueChange)="setPair(0, $event)" />
|
||||||
|
<sbo-date ariaLabel="Hasta" [value]="pair()[1]" (valueChange)="setPair(1, $event)" />
|
||||||
|
}
|
||||||
|
@case ('number') {
|
||||||
|
<sbo-number ariaLabel="Desde" [value]="num(pair()[0])" (valueChange)="setPair(0, $event === null ? '' : '' + $event)" />
|
||||||
|
<sbo-number ariaLabel="Hasta" [value]="num(pair()[1])" (valueChange)="setPair(1, $event === null ? '' : '' + $event)" />
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<sbo-input ariaLabel="Desde" [value]="pair()[0]" (valueChange)="setPair(0, $event)" />
|
||||||
|
<sbo-input ariaLabel="Hasta" [value]="pair()[1]" (valueChange)="setPair(1, $event)" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</sbo-field>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@case ('multi') {
|
||||||
|
@if (p.kind === 'lookup' && catalog()) {
|
||||||
|
<sbo-field [label]="p.label" [required]="p.required" action>
|
||||||
|
<div class="chips" role="list" [attr.aria-label]="p.label">
|
||||||
|
@for (v of list(); track v) {
|
||||||
|
<span class="chip" role="listitem">{{ v }}<button type="button" [attr.aria-label]="'Quitar ' + v" (click)="remove(v)">×</button></span>
|
||||||
|
} @empty {
|
||||||
|
<span class="none">Ninguno: se usan todos</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="sbo-btn" [attr.aria-label]="'Abrir la lista de ' + p.label" title="Lista de valores" (click)="open.set(true)">…</button>
|
||||||
|
</sbo-field>
|
||||||
|
@if (open()) {
|
||||||
|
<sbo-value-help
|
||||||
|
[open]="true"
|
||||||
|
[title]="catalog()!.lookup.title"
|
||||||
|
[resource]="catalog()!.lookup.resource"
|
||||||
|
[columns]="catalog()!.lookup.columns"
|
||||||
|
[baseFilter]="catalog()!.lookup.baseFilter"
|
||||||
|
[orderby]="catalog()!.lookup.orderby"
|
||||||
|
[rowKey]="catalog()!.lookup.valueKey"
|
||||||
|
[extraFields]="catalog()!.lookup.extraFields ?? []"
|
||||||
|
[initialSelection]="[]"
|
||||||
|
selection="multiple"
|
||||||
|
(selected)="picked($event)"
|
||||||
|
(closed)="open.set(false)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} @else if (p.kind === 'select') {
|
||||||
|
<sbo-field [label]="p.label" [required]="p.required">
|
||||||
|
<div class="checks">
|
||||||
|
@for (o of p.options ?? []; track o.value) {
|
||||||
|
<label><input type="checkbox" [checked]="list().includes(o.value)" (change)="toggle(o.value)" /> {{ o.label }}</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</sbo-field>
|
||||||
|
} @else {
|
||||||
|
<sbo-text-field [label]="p.label + ' (separados por comas)'" [required]="p.required" [value]="list().join(', ')" (valueChange)="value.set(split($event))" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host { display: block; }
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; min-height: 24px; flex: 1; }
|
||||||
|
.chip { display: inline-flex; align-items: center; gap: 4px; padding: 0 4px 0 6px; background: var(--btn); border: 1px solid var(--btn-b); border-radius: var(--radius-pill); font-size: 12px; }
|
||||||
|
.chip button { padding: 0 3px; background: none; border: none; cursor: pointer; color: var(--quiet); }
|
||||||
|
.chip button:hover { color: var(--err); }
|
||||||
|
.none { color: var(--quiet); font-size: 12px; }
|
||||||
|
.checks { display: flex; flex-wrap: wrap; gap: 4px 14px; }
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ReportParamField {
|
||||||
|
readonly param = input.required<GatewayParam>();
|
||||||
|
readonly value = model<Value>('');
|
||||||
|
|
||||||
|
protected readonly open = signal(false);
|
||||||
|
protected readonly mode = computed<'single' | 'range' | 'multi'>(() => (this.param().range ? 'range' : this.param().multiple ? 'multi' : 'single'));
|
||||||
|
protected readonly catalog = computed(() => lookupById(this.param().lookup));
|
||||||
|
protected readonly single = computed(() => asList(this.value())[0] ?? '');
|
||||||
|
protected readonly list = computed(() => asList(this.value()));
|
||||||
|
/** Desde y hasta de un rango. */
|
||||||
|
protected readonly pair = computed<readonly [string, string]>(() => [asList(this.value())[0] ?? '', asList(this.value())[1] ?? '']);
|
||||||
|
|
||||||
|
protected num(v: string): number | null {
|
||||||
|
return v.trim() === '' || Number.isNaN(Number(v)) ? null : Number(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setPair(i: 0 | 1, v: string): void {
|
||||||
|
const next: [string, string] = [...this.pair()];
|
||||||
|
next[i] = v;
|
||||||
|
this.value.set(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected split(text: string): string[] {
|
||||||
|
return text.split(',').map((x) => x.trim()).filter((x) => x !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected remove(v: string): void {
|
||||||
|
this.value.set(this.list().filter((x) => x !== v));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggle(v: string): void {
|
||||||
|
this.value.set(this.list().includes(v) ? this.list().filter((x) => x !== v) : [...this.list(), v]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lo que se marcó en la ventana se suma a lo que ya había (sin repetir). */
|
||||||
|
protected picked(rows: ListRow[]): void {
|
||||||
|
const key = this.catalog()?.lookup.valueKey ?? this.catalog()?.lookup.columns[0]?.key ?? '';
|
||||||
|
const added = rows.map((r) => String(r[key] ?? '')).filter((x) => x !== '');
|
||||||
|
this.value.set([...new Set([...this.list(), ...added])]);
|
||||||
|
this.open.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, resource, signal } from '@angular/core';
|
||||||
|
import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { type GatewayParam, type GatewayReport, type GatewayValues, exportBody } from '@basic-erp/sap-types';
|
||||||
|
import { apiErrorMessage } from '../../core/api';
|
||||||
|
import { SboWindow, type StatusMessage } from '../../shared/window';
|
||||||
|
import { GatewayApi } from './gateway-api';
|
||||||
|
import { ReportParamField } from './report-param-field';
|
||||||
|
|
||||||
|
type Value = string | readonly string[];
|
||||||
|
|
||||||
|
/** El mensaje de un error cuyo cuerpo llegó como archivo (la respuesta esperada era un PDF). */
|
||||||
|
async function blobErrorMessage(err: unknown): Promise<string> {
|
||||||
|
if (err instanceof HttpErrorResponse && err.error instanceof Blob) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(await err.error.text()) as { message?: string };
|
||||||
|
if (body.message) return body.message;
|
||||||
|
} catch {
|
||||||
|
/* se usa el mensaje general */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return apiErrorMessage(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formulario de parámetros de un reporte matriculado y su PDF. Cada parámetro tiene el control que pidió su matrícula
|
||||||
|
* (texto, número, fecha, lista fija o ventana de selección); al generar, el PDF se ve en la misma pantalla.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'sbo-report-runner-page',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [ReportParamField, RouterLink, SboWindow],
|
||||||
|
template: `
|
||||||
|
<sbo-window [title]="report()?.title ?? title" [status]="status()">
|
||||||
|
@if (report(); as r) {
|
||||||
|
@if (r.description) {
|
||||||
|
<p class="desc">{{ r.description }}</p>
|
||||||
|
}
|
||||||
|
@if (visible().length) {
|
||||||
|
<div class="sbo-hdr">
|
||||||
|
<div class="sbo-col">
|
||||||
|
@for (p of left(); track p.name) {
|
||||||
|
<sbo-report-param-field [param]="p" [value]="valueOf(p)" (valueChange)="set(p, $event)" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="sbo-col">
|
||||||
|
@for (p of right(); track p.name) {
|
||||||
|
<sbo-report-param-field [param]="p" [value]="valueOf(p)" (valueChange)="set(p, $event)" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="desc">Este reporte no pide parámetros.</p>
|
||||||
|
}
|
||||||
|
@if (pdfUrl(); as url) {
|
||||||
|
<iframe class="pdf" title="Vista del PDF" [src]="url"></iframe>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<button sboActions class="btn main" type="button" [disabled]="running() || !report()" (click)="run()">{{ running() ? 'Generando…' : 'Generar PDF' }}</button>
|
||||||
|
@if (download(); as d) {
|
||||||
|
<a sboActions class="btn" [href]="d.url" [attr.download]="d.name">Descargar</a>
|
||||||
|
}
|
||||||
|
<a sboActions class="btn" routerLink="..">Volver a la lista</a>
|
||||||
|
</sbo-window>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host { display: block; }
|
||||||
|
.desc { margin: 0 0 8px; color: var(--quiet); }
|
||||||
|
.pdf { display: block; width: 100%; height: 70vh; margin-top: 12px; border: 1px solid var(--line); background: var(--field); }
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ReportRunnerPage {
|
||||||
|
private readonly api = inject(GatewayApi);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
|
protected readonly title = this.route.snapshot.data['title'] as string;
|
||||||
|
protected readonly code = this.route.snapshot.paramMap.get('code') ?? '';
|
||||||
|
|
||||||
|
private readonly reports = resource({ loader: () => this.api.reports() });
|
||||||
|
protected readonly report = computed<GatewayReport | undefined>(() => (this.reports.hasValue() ? this.reports.value().find((r) => r.code === this.code) : undefined));
|
||||||
|
/** Los parámetros que se piden (los títulos y separadores del reporte no). */
|
||||||
|
protected readonly visible = computed(() => (this.report()?.params ?? []).filter((p) => !p.hidden));
|
||||||
|
protected readonly left = computed(() => this.visible().filter((_, i) => i % 2 === 0));
|
||||||
|
protected readonly right = computed(() => this.visible().filter((_, i) => i % 2 === 1));
|
||||||
|
|
||||||
|
private readonly values = signal<Record<string, Value>>({});
|
||||||
|
protected readonly running = signal(false);
|
||||||
|
protected readonly pdfUrl = signal<SafeResourceUrl | null>(null);
|
||||||
|
protected readonly download = signal<{ url: string; name: string } | null>(null);
|
||||||
|
private readonly message = signal<StatusMessage | null>(null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
inject(DestroyRef).onDestroy(() => this.revoke());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly status = computed<StatusMessage>(() => {
|
||||||
|
if (this.reports.isLoading()) return { kind: 'info', text: 'Cargando…' };
|
||||||
|
if (this.reports.error()) return { kind: 'err', text: apiErrorMessage(this.reports.error()) };
|
||||||
|
const message = this.message();
|
||||||
|
if (message) return message;
|
||||||
|
if (!this.report()) return { kind: 'err', text: `El reporte ${this.code} no está matriculado o no está habilitado.` };
|
||||||
|
return { kind: 'info', text: this.visible().some((p) => p.required) ? 'Indique los parámetros obligatorios (*) y pulse «Generar PDF».' : 'Pulse «Generar PDF».' };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** El valor de un parámetro: lo que escribió el usuario o su valor por defecto. */
|
||||||
|
protected valueOf(p: GatewayParam): Value {
|
||||||
|
return this.values()[p.name] ?? p.defaultValue ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected set(p: GatewayParam, v: Value): void {
|
||||||
|
this.values.update((m) => ({ ...m, [p.name]: v }));
|
||||||
|
this.message.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private revoke(): void {
|
||||||
|
const d = this.download();
|
||||||
|
if (d) URL.revokeObjectURL(d.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async run(): Promise<void> {
|
||||||
|
const r = this.report();
|
||||||
|
if (!r || this.running()) return;
|
||||||
|
const values: Record<string, Value> = {};
|
||||||
|
for (const p of r.params) values[p.name] = this.valueOf(p);
|
||||||
|
const { missing } = exportBody(r.params, values as GatewayValues);
|
||||||
|
if (missing.length) {
|
||||||
|
this.message.set({ kind: 'err', text: `Falta indicar: ${missing.join(', ')}.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.running.set(true);
|
||||||
|
this.message.set({ kind: 'info', text: 'Generando el reporte…' });
|
||||||
|
try {
|
||||||
|
const blob = await this.api.export(r.code, values as GatewayValues);
|
||||||
|
this.revoke();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
this.download.set({ url, name: `${r.code}.pdf` });
|
||||||
|
this.pdfUrl.set(this.sanitizer.bypassSecurityTrustResourceUrl(url));
|
||||||
|
this.message.set({ kind: 'ok', text: 'Reporte generado.' });
|
||||||
|
} catch (e) {
|
||||||
|
this.message.set({ kind: 'err', text: await blobErrorMessage(e) });
|
||||||
|
} finally {
|
||||||
|
this.running.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, inject, resource, signal } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import type { GatewayReport } from '@basic-erp/sap-types';
|
||||||
|
import { apiErrorMessage } from '../../core/api';
|
||||||
|
import { SboWindow, type StatusMessage } from '../../shared/window';
|
||||||
|
import { GatewayApi } from './gateway-api';
|
||||||
|
|
||||||
|
/** Lista de los reportes matriculados, agrupados por categoría y con búsqueda; elegir uno abre su formulario de parámetros. */
|
||||||
|
@Component({
|
||||||
|
selector: 'sbo-reports-page',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [SboWindow],
|
||||||
|
template: `
|
||||||
|
<sbo-window [title]="title" [status]="status()">
|
||||||
|
<div class="bar">
|
||||||
|
<input class="search" type="search" placeholder="Buscar reporte por nombre o código" aria-label="Buscar reporte" [value]="query()" (input)="query.set($any($event.target).value)" />
|
||||||
|
</div>
|
||||||
|
@for (g of groups(); track g.category) {
|
||||||
|
<h2>{{ g.category }} <small>{{ g.reports.length }}</small></h2>
|
||||||
|
<ul class="list" role="list">
|
||||||
|
@for (r of g.reports; track r.code) {
|
||||||
|
<li>
|
||||||
|
<button type="button" (click)="open(r)">
|
||||||
|
<span class="name">{{ r.title }}</span>
|
||||||
|
<span class="code">{{ r.code }}</span>
|
||||||
|
@if (r.description) {
|
||||||
|
<span class="desc">{{ r.description }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @empty {
|
||||||
|
<p class="none">{{ reports.isLoading() ? 'Cargando reportes…' : query() ? 'Ningún reporte coincide con la búsqueda.' : 'No hay reportes matriculados. Un administrador los matricula en «Matrícula de reportes».' }}</p>
|
||||||
|
}
|
||||||
|
</sbo-window>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host { display: block; }
|
||||||
|
.bar { margin-bottom: 8px; }
|
||||||
|
.search { width: min(360px, 100%); height: 24px; padding: 1px 6px; background: var(--field); border: 1px solid var(--field-b); border-radius: var(--radius); }
|
||||||
|
h2 { margin: 12px 0 4px; font-size: 13px; font-weight: 600; }
|
||||||
|
h2 small { color: var(--quiet); font-weight: 400; }
|
||||||
|
.list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); }
|
||||||
|
.list li + li { border-top: 1px solid var(--line-soft); }
|
||||||
|
.list button { display: flex; gap: 10px; align-items: baseline; width: 100%; padding: 4px 8px; background: none; border: none; text-align: left; cursor: pointer; }
|
||||||
|
.list button:hover { background: var(--btn-h); }
|
||||||
|
.code { font-family: var(--mono, monospace); color: var(--quiet); font-size: 12px; }
|
||||||
|
.desc { color: var(--quiet); font-size: 12px; }
|
||||||
|
.none { color: var(--quiet); }
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ReportsPage {
|
||||||
|
private readonly api = inject(GatewayApi);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
protected readonly title = this.route.snapshot.data['title'] as string;
|
||||||
|
protected readonly reports = resource({ loader: () => this.api.reports() });
|
||||||
|
protected readonly query = signal('');
|
||||||
|
|
||||||
|
protected readonly groups = computed(() => {
|
||||||
|
const needle = this.query().trim().toLowerCase();
|
||||||
|
const all = (this.reports.hasValue() ? this.reports.value() : []).filter((r) => !needle || r.title.toLowerCase().includes(needle) || r.code.toLowerCase().includes(needle));
|
||||||
|
const by = new Map<string, GatewayReport[]>();
|
||||||
|
for (const r of all) by.set(r.category, [...(by.get(r.category) ?? []), r]);
|
||||||
|
return [...by.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([category, reports]) => ({ category, reports }));
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly status = computed<StatusMessage>(() => {
|
||||||
|
if (this.reports.isLoading()) return { kind: 'info', text: 'Cargando…' };
|
||||||
|
if (this.reports.error()) return { kind: 'err', text: apiErrorMessage(this.reports.error()) };
|
||||||
|
const n = this.reports.hasValue() ? this.reports.value().length : 0;
|
||||||
|
return { kind: 'info', text: n ? 'Elija un reporte para indicar sus parámetros.' : '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
protected open(r: GatewayReport): void {
|
||||||
|
void this.router.navigate([r.code], { relativeTo: this.route });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { HttpErrorResponse, provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import type { GatewayParam, GatewayReport } from '@basic-erp/sap-types';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { GatewayApi } from './gateway-api';
|
||||||
|
import { RegistryPage, toParam } from './registry.page';
|
||||||
|
import { ReportRunnerPage } from './report-runner.page';
|
||||||
|
import { ReportsPage } from './reports.page';
|
||||||
|
|
||||||
|
const params: GatewayParam[] = [
|
||||||
|
{ name: 'Dockey@', type: 'xsd:decimal', label: 'Documento', kind: 'lookup', lookup: 'doc:DeliveryNotes', required: true },
|
||||||
|
{ name: 'CANTIDAD', type: 'xsd:decimal', label: 'Cantidad', kind: 'number', required: false, defaultValue: '1' },
|
||||||
|
{ name: 'Title@Title', type: 'xsd:string', label: 'Título', kind: 'text', required: false, hidden: true },
|
||||||
|
];
|
||||||
|
const REPORTS: GatewayReport[] = [
|
||||||
|
{ code: 'DLN20015', title: 'Impresión de etiquetas', category: 'Inventario', description: 'Etiquetas de una entrega', enabled: true, params },
|
||||||
|
{ code: 'RCRI0031', title: 'Balance de comprobación', category: 'Finanzas', enabled: true, params: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tick = async (fixture: ComponentFixture<unknown>) => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
await new Promise((r) => setTimeout(r));
|
||||||
|
fixture.detectChanges();
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(api: Partial<Record<keyof GatewayApi, unknown>>, route: Record<string, unknown> = {}) {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: GatewayApi, useValue: api },
|
||||||
|
{ provide: ActivatedRoute, useValue: { snapshot: { data: { title: 'Reportes' }, paramMap: convertToParamMap(route) } } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ReportsPage', () => {
|
||||||
|
it('agrupa los reportes por categoría, los busca y abre el elegido', async () => {
|
||||||
|
const navigate = setup({ reports: async () => REPORTS });
|
||||||
|
const fixture = TestBed.createComponent(ReportsPage);
|
||||||
|
await tick(fixture);
|
||||||
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
|
expect([...el.querySelectorAll('h2')].map((h) => h.textContent?.replace(/\d+/, '').trim())).toEqual(['Finanzas', 'Inventario']);
|
||||||
|
expect(el.textContent).toContain('Etiquetas de una entrega');
|
||||||
|
// búsqueda
|
||||||
|
const search = el.querySelector<HTMLInputElement>('input[type=search]')!;
|
||||||
|
search.value = 'etiqueta';
|
||||||
|
search.dispatchEvent(new Event('input'));
|
||||||
|
await tick(fixture);
|
||||||
|
expect([...el.querySelectorAll('.list button .name')].map((n) => n.textContent)).toEqual(['Impresión de etiquetas']);
|
||||||
|
(el.querySelector('.list button') as HTMLElement).click();
|
||||||
|
expect(navigate).toHaveBeenCalledWith(['DLN20015'], expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sin reportes dice dónde se matriculan', async () => {
|
||||||
|
setup({ reports: async () => [] });
|
||||||
|
const fixture = TestBed.createComponent(ReportsPage);
|
||||||
|
await tick(fixture);
|
||||||
|
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Matrícula de reportes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReportRunnerPage', () => {
|
||||||
|
let fixture: ComponentFixture<ReportRunnerPage>;
|
||||||
|
let exportFn: ReturnType<typeof vi.fn>;
|
||||||
|
const el = () => fixture.nativeElement as HTMLElement;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const page = () => fixture.componentInstance as any;
|
||||||
|
const status = () => el().querySelector('.status')!.textContent;
|
||||||
|
const generate = () => (el().querySelector('.actions .btn.main') as HTMLButtonElement).click();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
exportFn = vi.fn(async () => new Blob(['%PDF-1.4'], { type: 'application/pdf' }));
|
||||||
|
setup({ reports: async () => REPORTS, export: exportFn }, { code: 'DLN20015' });
|
||||||
|
URL.createObjectURL = vi.fn(() => 'blob:pdf');
|
||||||
|
URL.revokeObjectURL = vi.fn();
|
||||||
|
fixture = TestBed.createComponent(ReportRunnerPage);
|
||||||
|
await tick(fixture);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pide los parámetros del reporte, sin los títulos, con su valor por defecto', () => {
|
||||||
|
expect(el().textContent).toContain('Impresión de etiquetas');
|
||||||
|
const labels = [...el().querySelectorAll('label')].map((l) => l.textContent?.trim());
|
||||||
|
expect(labels).toEqual(expect.arrayContaining(['Documento', 'Cantidad']));
|
||||||
|
expect(labels).not.toContain('Título');
|
||||||
|
expect(page().valueOf(params[1])).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un obligatorio sin valor no llega a la API', async () => {
|
||||||
|
generate();
|
||||||
|
await tick(fixture);
|
||||||
|
expect(exportFn).not.toHaveBeenCalled();
|
||||||
|
expect(status()).toContain('Falta indicar: Documento');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('genera el PDF con los valores escritos y lo muestra con su enlace de descarga', async () => {
|
||||||
|
page().set(params[0], '137138');
|
||||||
|
await tick(fixture);
|
||||||
|
generate();
|
||||||
|
await tick(fixture);
|
||||||
|
expect(exportFn).toHaveBeenCalledWith('DLN20015', { 'Dockey@': '137138', CANTIDAD: '1', 'Title@Title': '' });
|
||||||
|
expect(status()).toContain('Reporte generado');
|
||||||
|
expect(el().querySelector('iframe')).not.toBeNull();
|
||||||
|
expect(el().querySelector<HTMLAnchorElement>('a[download]')!.getAttribute('download')).toBe('DLN20015.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('muestra el mensaje de error de la API', async () => {
|
||||||
|
exportFn.mockRejectedValueOnce(new HttpErrorResponse({ status: 400, error: new Blob([JSON.stringify({ message: 'El reporte no devolvió un PDF: (---)' })]) }));
|
||||||
|
page().set(params[0], '1');
|
||||||
|
generate();
|
||||||
|
await tick(fixture);
|
||||||
|
expect(status()).toContain('(---)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReportRunnerPage: reporte que no está matriculado', () => {
|
||||||
|
it('lo avisa', async () => {
|
||||||
|
setup({ reports: async () => REPORTS }, { code: 'NOEXISTE' });
|
||||||
|
const fixture = TestBed.createComponent(ReportRunnerPage);
|
||||||
|
await tick(fixture);
|
||||||
|
expect((fixture.nativeElement as HTMLElement).textContent).toContain('NOEXISTE no está matriculado');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RegistryPage', () => {
|
||||||
|
let save: ReturnType<typeof vi.fn>;
|
||||||
|
let fixture: ComponentFixture<RegistryPage>;
|
||||||
|
const el = () => fixture.nativeElement as HTMLElement;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const page = () => fixture.componentInstance as any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
save = vi.fn(async (r: GatewayReport) => r);
|
||||||
|
setup({
|
||||||
|
registry: async () => REPORTS,
|
||||||
|
authorized: async () => [{ code: 'DLN20015', name: 'Etiquetas', root_name: '#1#4', root_guid: '44' }, { code: 'RCRI0099', name: 'Nuevo', root_name: '#1#4', root_guid: '44' }],
|
||||||
|
parameters: async () => [{ name: 'NIVEL', type: 'xsd:decimal', label: 'Nivel', kind: 'number', required: true }],
|
||||||
|
save,
|
||||||
|
remove: vi.fn(async () => undefined),
|
||||||
|
});
|
||||||
|
fixture = TestBed.createComponent(RegistryPage);
|
||||||
|
await tick(fixture);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lista los reportes matriculados con su categoría y la cantidad de parámetros', () => {
|
||||||
|
const rows = [...el().querySelectorAll('tbody tr')].map((r) => r.textContent?.replace(/\s+/g, ' ').trim());
|
||||||
|
expect(rows[0]).toContain('DLN20015');
|
||||||
|
expect(rows[0]).toContain('Inventario');
|
||||||
|
expect(el().textContent).toContain('Balance de comprobación');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('al matricular ofrece solo los autorizados que aún no están, y trae sus parámetros para ajustarlos', async () => {
|
||||||
|
await page().startPick();
|
||||||
|
expect(page().authorizedRows().map((r: { code: string }) => r.code)).toEqual(['RCRI0099']);
|
||||||
|
await page().picked([{ code: 'RCRI0099', name: 'Nuevo reporte' }]);
|
||||||
|
await tick(fixture);
|
||||||
|
expect(page().draft()).toMatchObject({ code: 'RCRI0099', isNew: true, title: 'Nuevo reporte', category: 'Otros', enabled: true });
|
||||||
|
expect(page().draft().params[0]).toMatchObject({ name: 'NIVEL', kind: 'number', required: 'Sí' });
|
||||||
|
expect(el().textContent).toContain('Parámetros');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guarda lo ajustado: etiquetas, tipo de control, ventana de selección y valor por defecto', async () => {
|
||||||
|
page().edit(REPORTS[0]);
|
||||||
|
page().patch({ title: 'Etiquetas de entrega', category: 'Inventario' });
|
||||||
|
page().patch({ params: page().draft().params.map((r: Record<string, unknown>) => (r['name'] === 'CANTIDAD' ? { ...r, label: 'Copias', defaultValue: '2' } : r)) });
|
||||||
|
await page().save();
|
||||||
|
const sent = save.mock.calls[0][0] as GatewayReport;
|
||||||
|
expect(sent).toMatchObject({ code: 'DLN20015', title: 'Etiquetas de entrega', category: 'Inventario', enabled: true });
|
||||||
|
expect(sent.params.find((p) => p.name === 'CANTIDAD')).toMatchObject({ label: 'Copias', defaultValue: '2', kind: 'number' });
|
||||||
|
expect(sent.params.find((p) => p.name === 'Dockey@')).toMatchObject({ kind: 'lookup', lookup: 'doc:DeliveryNotes', required: true });
|
||||||
|
expect(sent.params.find((p) => p.name === 'Title@Title')).toMatchObject({ hidden: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exige la ventana de selección de un parámetro de ese tipo', async () => {
|
||||||
|
page().edit(REPORTS[0]);
|
||||||
|
page().patch({ params: page().draft().params.map((r: Record<string, unknown>) => (r['name'] === 'Dockey@' ? { ...r, lookup: '' } : r)) });
|
||||||
|
await page().save();
|
||||||
|
await tick(fixture);
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
expect(el().querySelector('.status')!.textContent).toContain('Elija la ventana de selección de: Documento');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toParam', () => {
|
||||||
|
it('convierte la fila de la tabla en una definición sin los campos que no aplican', () => {
|
||||||
|
expect(toParam({ name: 'P', type: 'xsd:string', label: ' Estado ', kind: 'select', required: 'No', range: 'No', multiple: 'Sí', hidden: 'No', lookup: 'almacenes', options: 'A=Abierto; C=Cerrado; X', defaultValue: '' })).toEqual({
|
||||||
|
name: 'P',
|
||||||
|
type: 'xsd:string',
|
||||||
|
label: 'Estado',
|
||||||
|
kind: 'select',
|
||||||
|
required: false,
|
||||||
|
multiple: true,
|
||||||
|
options: [{ value: 'A', label: 'Abierto' }, { value: 'C', label: 'Cerrado' }, { value: 'X', label: 'X' }],
|
||||||
|
});
|
||||||
|
expect(toParam({ name: 'Q', kind: 'lookup', lookup: 'articulos', required: 'Sí', options: 'ignorada' })).toMatchObject({ lookup: 'articulos', required: true });
|
||||||
|
expect(toParam({ name: 'Q', kind: 'lookup', lookup: 'articulos' })).not.toHaveProperty('options');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,6 +72,15 @@ export const MENU: readonly MenuModule[] = [
|
|||||||
{ path: '/finanzas/asientos', label: 'Asientos contables' },
|
{ path: '/finanzas/asientos', label: 'Asientos contables' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'reportes',
|
||||||
|
label: 'Reportes Gateway',
|
||||||
|
items: [
|
||||||
|
{ path: '/reportes/ejecutar', label: 'Reportes' },
|
||||||
|
// matricular reportes es de administradores (la API lo exige)
|
||||||
|
{ path: '/reportes/matricula', label: 'Matrícula de reportes' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'socios',
|
id: 'socios',
|
||||||
label: 'Socios de negocio',
|
label: 'Socios de negocio',
|
||||||
|
|||||||
Reference in new issue
Block a user