feat: botón PDF de los documentos con los reportes del Gateway (formato por defecto y otros formatos, asociados en la matrícula)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 18:42:07 -05:00
1 parent 19c4a9013c
commit 0f43e6cac7
14 files changed
+439 -28

No files matched your search

@@ -6,6 +6,7 @@
<button class="tb" type="button" aria-label="Anterior" (click)="go('prev')"><sbo-icon name="prev" /></button>
<button class="tb" type="button" aria-label="Siguiente" (click)="go('next')"><sbo-icon name="next" /></button>
<button class="tb" type="button" aria-label="Último" (click)="go('last')"><sbo-icon name="last" /></button>
<sbo-document-pdf [screen]="screen.path" [docEntry]="docEntry()" />
</sbo-toolbar>
<sbo-window [title]="windowTitle()" [badge]="badge()" [status]="status()">
@@ -37,6 +37,7 @@ import {
import { SERVICE_DOC_TYPE } from './document-layouts';
import { type ComputedSource, type GroupCell, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasCurrency, hasExpenses, hasFolio, hasPartnerData, hasSeries, headerKeysOf, keysOfFields, requiredHeaderOf } from './document-layouts';
import { type AddressSide, type AddressValues, addressFromExtension, addressFromPartner, composeAddress, toExtension } from './address-fields';
import { DocumentPdf } from '../reports/document-pdf';
import { AddressDialog } from './address-dialog';
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
@@ -75,7 +76,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AddressDialog, Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, TextareaInput, Toolbar, ValueHelp],
imports: [AddressDialog, DocumentPdf, Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, TextareaInput, Toolbar, ValueHelp],
templateUrl: './document-form.page.html',
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
@@ -0,0 +1,88 @@
import { HttpErrorResponse, provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import type { DocumentReport } from '@basic-erp/sap-types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DocumentPdf } from './document-pdf';
import { GatewayApi } from './gateway-api';
const tick = async (fixture: ComponentFixture<unknown>) => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
function setup(formats: DocumentReport[] | Error, docEntry: number | null = 580) {
const api = {
forDocument: vi.fn(async () => {
if (formats instanceof Error) throw formats;
return formats;
}),
exportDocument: vi.fn(async () => new Blob(['%PDF'], { type: 'application/pdf' })),
};
TestBed.resetTestingModule();
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting(), { provide: GatewayApi, useValue: api }] });
const fixture = TestBed.createComponent(DocumentPdf);
fixture.componentRef.setInput('screen', '/ventas/entrega');
fixture.componentRef.setInput('docEntry', docEntry);
return { fixture, api, el: fixture.nativeElement as HTMLElement };
}
const A: DocumentReport = { code: 'DLN20015', title: 'Entrega estándar', isDefault: true };
const B: DocumentReport = { code: 'DLN20016', title: 'Entrega con precios', isDefault: false };
describe('DocumentPdf', () => {
beforeEach(() => {
URL.createObjectURL = vi.fn(() => 'blob:x');
URL.revokeObjectURL = vi.fn();
});
it('sin formatos asociados, sin permiso o sin API Gateway no muestra nada', async () => {
for (const f of [[], new Error('403')] as const) {
const { fixture, el } = setup(f as DocumentReport[] | Error);
await tick(fixture);
expect(el.querySelector('button')).toBeNull();
}
});
it('en un documento nuevo no hay botón', async () => {
const { fixture, el } = setup([A], null);
await tick(fixture);
expect(el.querySelector('button')).toBeNull();
});
it('el botón PDF imprime el formato por defecto del documento abierto', async () => {
const { fixture, api, el } = setup([A, B]);
await tick(fixture);
expect(el.textContent).toContain('Otros formatos');
(el.querySelector('button.tb') as HTMLButtonElement).click();
await tick(fixture);
expect(api.exportDocument).toHaveBeenCalledWith('/ventas/entrega', 'DLN20015', 580);
expect(el.querySelector('iframe')).not.toBeNull();
expect(el.textContent).toContain('Entrega estándar');
});
it('con un solo formato no ofrece «Otros formatos»; los demás salen en el menú', async () => {
const one = setup([A]);
await tick(one.fixture);
expect(one.el.textContent).not.toContain('Otros formatos');
const two = setup([A, B]);
await tick(two.fixture);
(two.el.querySelector('sbo-menu-button button') as HTMLButtonElement).click();
two.fixture.detectChanges();
const items = [...two.el.querySelectorAll('[role=menuitem]')].map((b) => b.textContent?.trim());
expect(items).toEqual(['Entrega con precios']);
(two.el.querySelector('[role=menuitem]') as HTMLButtonElement).click();
await tick(two.fixture);
expect(two.api.exportDocument).toHaveBeenCalledWith('/ventas/entrega', 'DLN20016', 580);
});
it('muestra el error del gateway si no devuelve un PDF', async () => {
const { fixture, api, el } = setup([A]);
api.exportDocument.mockRejectedValueOnce(new HttpErrorResponse({ status: 400, error: new Blob([JSON.stringify({ message: 'Falta indicar: Documento' })]) }));
await tick(fixture);
(el.querySelector('button.tb') as HTMLButtonElement).click();
await tick(fixture);
expect(el.querySelector('[role=alert]')?.textContent).toContain('Falta indicar');
});
});
@@ -0,0 +1,117 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, resource, signal } from '@angular/core';
import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser';
import type { DocumentReport } from '@basic-erp/sap-types';
import { Icon } from '../../shared/window';
import { MenuButton, type MenuItem } from '../../shared/window/menu-button';
import { GatewayApi, blobErrorMessage } from './gateway-api';
/**
* Botón PDF de un documento: imprime con el formato por defecto de su pantalla y, si hay otros formatos asociados
* (matrícula de reportes), ofrece un menú «Otros formatos». El PDF se ve en una ventana. Sin formatos asociados
* (o sin permiso, o sin API Gateway) no se muestra nada.
*/
@Component({
selector: 'sbo-document-pdf',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Icon, MenuButton],
template: `
@if (docEntry() !== null && formats().length) {
<span class="tb-sep"></span>
<button class="tb" type="button" [disabled]="busy()" [attr.title]="'Imprimir: ' + formats()[0].title" (click)="open(formats()[0])"><sbo-icon name="print" />PDF</button>
@if (formats().length > 1) {
<sbo-menu-button label="Otros formatos" [items]="others()" (picked)="pick($event)" />
}
}
@if (view(); as v) {
<div class="overlay">
<div class="dlg" role="dialog" aria-modal="true" [attr.aria-label]="v.title" (keydown.escape)="close()">
<header>
<h2>{{ v.title }}</h2>
<span class="sp"></span>
@if (v.url) {
<a class="btn" [href]="v.raw" [download]="v.name">Descargar</a>
}
<button class="btn" type="button" (click)="close()">Cerrar</button>
</header>
@if (v.error) {
<p class="err" role="alert">{{ v.error }}</p>
} @else if (v.url) {
<iframe title="Vista del PDF" [src]="v.url"></iframe>
} @else {
<p class="wait">Generando el PDF…</p>
}
</div>
</div>
}
`,
styles: `
:host { display: contents; }
.overlay { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; padding: 16px; background: var(--overlay); }
.dlg { display: flex; flex-direction: column; width: min(1000px, 100%); height: 90vh; background: var(--win); border: 1px solid var(--line); border-radius: var(--radius-panel); overflow: hidden; box-shadow: var(--shadow-pop); }
header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--line); }
h2 { margin: 0; font-size: 14px; font-weight: 600; }
.sp { flex: 1; }
iframe { flex: 1; width: 100%; border: 0; }
.wait, .err { margin: 16px; }
.err { color: var(--err, #b00020); }
`,
})
export class DocumentPdf {
private readonly api = inject(GatewayApi);
private readonly sanitizer = inject(DomSanitizer);
/** Ruta de menú de la pantalla del documento (`/ventas/entregas`). */
readonly screen = input.required<string>();
/** `DocEntry` del documento abierto; `null` si es nuevo. */
readonly docEntry = input.required<number | null>();
protected readonly busy = signal(false);
protected readonly view = signal<{ title: string; name: string; url?: SafeResourceUrl; raw?: string; error?: string } | null>(null);
private readonly list = resource({
params: () => this.screen(),
// sin formatos, sin permiso o sin API Gateway: el botón no aparece
loader: ({ params }) => this.api.forDocument(params).catch((): DocumentReport[] => []),
});
protected readonly formats = (): DocumentReport[] => this.list.value() ?? [];
protected readonly others = (): MenuItem[] => this.formats().slice(1).map((f) => ({ id: f.code, label: f.title }));
private raw: string | null = null;
constructor() {
inject(DestroyRef).onDestroy(() => this.revoke());
}
protected pick(code: string): void {
const f = this.formats().find((x) => x.code === code);
if (f) void this.open(f);
}
protected async open(f: DocumentReport): Promise<void> {
const entry = this.docEntry();
if (entry === null || this.busy()) return;
this.busy.set(true);
this.revoke();
this.view.set({ title: f.title, name: `${f.code}-${entry}.pdf` });
try {
const blob = await this.api.exportDocument(this.screen(), f.code, entry);
this.raw = URL.createObjectURL(blob);
this.view.update((v) => (v ? { ...v, raw: this.raw!, url: this.sanitizer.bypassSecurityTrustResourceUrl(this.raw!) } : v));
} catch (e) {
const error = await blobErrorMessage(e);
this.view.update((v) => (v ? { ...v, error } : v));
} finally {
this.busy.set(false);
}
}
protected close(): void {
this.view.set(null);
this.revoke();
}
private revoke(): void {
if (this.raw) URL.revokeObjectURL(this.raw);
this.raw = null;
}
}
@@ -1,8 +1,21 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import type { AuthorizedReport, GatewayParam, GatewayReport, GatewayValues } from '@basic-erp/sap-types';
import type { AuthorizedReport, DocumentReport, GatewayParam, GatewayReport, GatewayValues } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../../core/api';
import { API_URL, apiErrorMessage } from '../../core/api';
/** El mensaje de un error cuyo cuerpo llegó como archivo (la respuesta esperada era un PDF). */
export 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);
}
/** Reportes del API Gateway a través de `/api/gateway` (matrícula y ejecución). */
@Injectable({ providedIn: 'root' })
@@ -19,6 +32,16 @@ export class GatewayApi {
return firstValueFrom(this.http.post(`${API_URL}/gateway/reports/${encodeURIComponent(code)}/export`, { values }, { responseType: 'blob' }));
}
/** Los formatos PDF de una pantalla de documento (el por defecto primero). */
forDocument(screen: string): Promise<DocumentReport[]> {
return firstValueFrom(this.http.get<DocumentReport[]>(`${API_URL}/gateway/documents`, { params: { screen } }));
}
/** El PDF de un documento con el formato indicado. */
exportDocument(screen: string, code: string, docEntry: number): Promise<Blob> {
return firstValueFrom(this.http.post(`${API_URL}/gateway/documents/export`, { screen, code, docEntry }, { responseType: 'blob' }));
}
// --- matrícula (administradores) -----------------------------------------------------------
registry(): Promise<GatewayReport[]> {
@@ -1,6 +1,6 @@
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 { MENU, 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';
@@ -8,6 +8,7 @@ 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 { DOCUMENT_PATHS } from '../documents/document-paths';
import { GatewayApi } from './gateway-api';
import { GATEWAY_CATEGORIES, GATEWAY_LOOKUPS } from './gateway-lookups';
@@ -21,6 +22,9 @@ interface Draft {
description: string;
enabled: boolean;
params: Row[];
documents: string[];
documentParam: string;
defaultFor: string[];
}
const YES = 'Sí';
@@ -101,6 +105,21 @@ export function toParam(r: Row): GatewayParam {
<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." />
<h2>Botón PDF de los documentos</h2>
<p class="hint">Marque los documentos que ofrecen este formato. El que se marca «Por defecto» es el que abre el botón «PDF» de ese documento (uno solo por documento, como en SAP); los demás salen en «Otros formatos». El número del documento (<code>DocEntry</code>) se envía en el parámetro elegido abajo; los demás parámetros toman su valor por defecto.</p>
<sbo-select-field label="Parámetro del documento" [options]="documentParamOptions(d)" [value]="d.documentParam" (valueChange)="patch({ documentParam: '' + ($event ?? '') })" />
<table class="grid docs">
<thead><tr><th>Documento</th><th class="chk">Ofrecer</th><th class="chk">Por defecto</th></tr></thead>
<tbody>
@for (s of docScreens; track s.path) {
<tr>
<td>{{ s.label }}</td>
<td class="chk"><input type="checkbox" [attr.aria-label]="'Ofrecer en ' + s.label" [checked]="d.documents.includes(s.path)" (change)="toggleDoc(s.path, $any($event.target).checked)" /></td>
<td class="chk"><input type="checkbox" [attr.aria-label]="'Por defecto en ' + s.label" [disabled]="!d.documents.includes(s.path)" [checked]="d.defaultFor.includes(s.path)" (change)="toggleDefault(s.path, $any($event.target).checked)" /></td>
</tr>
}
</tbody>
</table>
} @else {
<div class="bar">
<button type="button" class="btn main" (click)="startPick()">Matricular reporte</button>
@@ -162,6 +181,8 @@ export function toParam(r: Row): GatewayParam {
.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; }
.docs { max-width: 520px; margin-top: 6px; }
.chk { width: 90px; text-align: center; }
.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); }
@@ -171,6 +192,25 @@ export class RegistryPage {
private readonly api = inject(GatewayApi);
protected readonly title = inject(ActivatedRoute).snapshot.data['title'] as string;
/** Las pantallas de documento donde se puede ofrecer el botón PDF. */
protected readonly docScreens = DOCUMENT_PATHS.map((path) => ({ path, label: `${MENU.find((m) => m.items.some((i) => i.path === path))?.label ?? ''}: ${MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path}` }));
protected documentParamOptions(d: Draft): { value: string; label: string }[] {
return d.params.filter((r) => r['hidden'] !== YES).map((r) => ({ value: String(r['name']), label: String(r['label'] || r['name']) }));
}
protected toggleDoc(path: string, on: boolean): void {
const d = this.draft();
if (!d) return;
const documents = on ? [...d.documents, path] : d.documents.filter((x) => x !== path);
this.patch({ documents, defaultFor: d.defaultFor.filter((x) => documents.includes(x)) });
}
protected toggleDefault(path: string, on: boolean): void {
const d = this.draft();
if (d) this.patch({ defaultFor: on ? [...d.defaultFor, path] : d.defaultFor.filter((x) => x !== path) });
}
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() : []));
@@ -231,7 +271,7 @@ export class RegistryPage {
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.draft.set({ code: chosen.code, isNew: true, title: chosen.name, category: 'Otros', description: '', enabled: true, params: params.map(toRow), documents: [], documentParam: '', defaultFor: [] });
this.message.set(null);
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
@@ -241,7 +281,7 @@ export class RegistryPage {
// --- 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.draft.set({ code: r.code, isNew: false, title: r.title, category: r.category, description: r.description ?? '', enabled: r.enabled, params: r.params.map(toRow), documents: r.documents ?? [], documentParam: r.documentParam ?? '', defaultFor: r.defaultFor ?? [] });
this.message.set(null);
}
@@ -262,9 +302,13 @@ export class RegistryPage {
this.message.set({ kind: 'err', text: `Elija la ventana de selección de: ${lookups.map((r) => r['label'] || r['name']).join(', ')}.` });
return;
}
if (d.documents.length && !this.documentParamOptions(d).some((o) => o.value === d.documentParam)) {
this.message.set({ kind: 'err', text: 'Elija el parámetro que recibe el número del documento.' });
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) });
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), ...(d.documents.length ? { documents: d.documents, documentParam: d.documentParam, ...(d.defaultFor.length ? { defaultFor: d.defaultFor } : {}) } : {}) });
this.draft.set(null);
this.registry.reload();
this.message.set({ kind: 'ok', text: `Reporte ${d.code} ${d.isNew ? 'matriculado' : 'actualizado'}.` });
@@ -1,28 +1,14 @@
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 { GatewayApi, blobErrorMessage } 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.