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:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 17:50:40 -05:00
1 parent 03ccd97f99
commit 854ee58037
14 files changed
+1178 -3

No files matched your search

+13
View File
@@ -32,3 +32,16 @@ SAPB1_SL_MAX_CONCURRENCY=1
# 3) Solo pruebas locales: SAPB1_SL_VERIFY_SSL=false (rechazado si NODE_ENV=production).
SAPB1_SL_CA_FILE=
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
+18
View File
@@ -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.
## 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).
+21 -1
View File
@@ -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 (`*`). */
const adminRoutes: Routes = [
{
@@ -127,7 +146,7 @@ const adminRoutes: Routes = [
];
/** 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)
.filter((item) => !builtPaths.has(item.path))
.map(
@@ -160,6 +179,7 @@ export const routes: Routes = [
...masterRoutes,
...paymentRoutes,
...journalRoutes,
...reportRoutes,
...adminRoutes,
...pendingRoutes,
],
@@ -36,7 +36,7 @@ describe('MenuService', () => {
const p = menu.ensureLoaded();
ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] });
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 () => {
@@ -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');
});
});