feat: menú Reportes HTML (motor de plantillas Nunjucks para reportes que se imprimen desde el navegador) y menú lateral comprimido al iniciar
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a918d0a109
commit
2d358f0cf1
25 files changed
+725
-23
No files matched your search
@@ -122,6 +122,16 @@ const reportRoutes: Routes = [
|
||||
{ path: ':code', loadComponent: () => import('./features/reports/report-runner.page').then((m) => m.ReportRunnerPage) },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Reportes HTML: plantillas que cada cliente define; misma lista y mismo formulario de parámetros, con `data.source`
|
||||
path: 'reportes-html/ejecutar',
|
||||
canActivate: [permissionGuard],
|
||||
data: { path: '/reportes-html/ejecutar', title: menuLabel('/reportes-html/ejecutar'), source: 'html' },
|
||||
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],
|
||||
@@ -147,7 +157,7 @@ const adminRoutes: Routes = [
|
||||
];
|
||||
|
||||
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
|
||||
const builtPaths = new Set([...CLIENT_PATHS, ...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, JOURNAL_PATH, '/reportes/ejecutar', '/reportes/matricula', '/admin/permisos', '/admin/auditoria']);
|
||||
const builtPaths = new Set([...CLIENT_PATHS, ...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, JOURNAL_PATH, '/reportes/ejecutar', '/reportes-html/ejecutar', '/reportes/matricula', '/admin/permisos', '/admin/auditoria']);
|
||||
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
|
||||
.filter((item) => !builtPaths.has(item.path))
|
||||
.map(
|
||||
|
||||
@@ -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', 'reportes', 'socios', 'admin']);
|
||||
expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'bancos', 'finanzas', 'reportes', 'reportes-html', 'socios', 'admin']);
|
||||
});
|
||||
|
||||
it('si falla marca el error y permite reintentar', async () => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import type { GatewayReport, GatewayValues } from '@basic-erp/sap-types';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { API_URL } from '../../core/api';
|
||||
|
||||
/** Reportes HTML (`/api/html-reports`): plantillas que el servidor rellena con datos de SAP y se imprimen desde el navegador. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HtmlReportsApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
reports(): Promise<GatewayReport[]> {
|
||||
return firstValueFrom(this.http.get<GatewayReport[]>(`${API_URL}/html-reports`));
|
||||
}
|
||||
|
||||
/** El HTML del reporte con los valores escritos. */
|
||||
async render(code: string, values: GatewayValues): Promise<string> {
|
||||
try {
|
||||
return await firstValueFrom(this.http.post(`${API_URL}/html-reports/${encodeURIComponent(code)}/render`, { values }, { responseType: 'text' }));
|
||||
} catch (e) {
|
||||
// la respuesta se pidió como texto: el mensaje de error llega como JSON en texto
|
||||
if (e instanceof HttpErrorResponse && typeof e.error === 'string') {
|
||||
let message: unknown;
|
||||
try {
|
||||
message = (JSON.parse(e.error) as { message?: unknown }).message;
|
||||
} catch {
|
||||
/* no era JSON */
|
||||
}
|
||||
if (typeof message === 'string') throw new HttpErrorResponse({ error: { message }, status: e.status, statusText: e.statusText, url: e.url ?? undefined });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, 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 { HtmlReportsApi } from './html-reports-api';
|
||||
import { ReportRunnerPage } from './report-runner.page';
|
||||
import { ReportsPage } from './reports.page';
|
||||
|
||||
const params: GatewayParam[] = [
|
||||
{ name: 'cantidad', type: 'xsd:decimal', label: 'Cantidad de etiquetas', kind: 'number', required: true },
|
||||
{ name: 'papel', type: 'xsd:string', label: 'Papel', kind: 'select', required: false, defaultValue: 'Carta', options: [{ value: 'Carta', label: 'Carta' }, { value: 'A4', label: 'A4' }] },
|
||||
];
|
||||
const REPORT: GatewayReport = { code: 'etiquetas-bultos', title: 'Etiquetas de bultos', category: 'Ventas', description: 'Etiquetas de una guía', enabled: true, params };
|
||||
|
||||
const tick = async (fixture: ComponentFixture<unknown>) => {
|
||||
fixture.detectChanges();
|
||||
await new Promise((r) => setTimeout(r));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
function setup(html: Partial<Record<keyof HtmlReportsApi, unknown>>, code?: string) {
|
||||
TestBed.resetTestingModule();
|
||||
const gateway = { reports: vi.fn(async () => []), export: vi.fn() };
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: HtmlReportsApi, useValue: html },
|
||||
{ provide: GatewayApi, useValue: gateway },
|
||||
{ provide: ActivatedRoute, useValue: { snapshot: { data: { title: 'Reportes', source: 'html' }, paramMap: convertToParamMap(code ? { code } : {}) } } },
|
||||
],
|
||||
});
|
||||
return gateway;
|
||||
}
|
||||
|
||||
describe('Reportes HTML', () => {
|
||||
beforeEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('la lista sale de los reportes HTML (no del Gateway) y avisa si no hay', async () => {
|
||||
const gateway = setup({ reports: vi.fn(async () => [REPORT]) });
|
||||
const fixture = TestBed.createComponent(ReportsPage);
|
||||
await tick(fixture);
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Etiquetas de bultos');
|
||||
expect(gateway.reports).not.toHaveBeenCalled();
|
||||
|
||||
setup({ reports: vi.fn(async () => []) });
|
||||
const empty = TestBed.createComponent(ReportsPage);
|
||||
await tick(empty);
|
||||
expect((empty.nativeElement as HTMLElement).textContent).toContain('No hay reportes HTML definidos');
|
||||
});
|
||||
|
||||
it('pide los parámetros, genera el HTML y lo muestra en un marco que se imprime', async () => {
|
||||
const render = vi.fn(async () => '<p>Etiqueta 1/6</p>');
|
||||
const gateway = setup({ reports: vi.fn(async () => [REPORT]), render }, 'etiquetas-bultos');
|
||||
const fixture = TestBed.createComponent(ReportRunnerPage);
|
||||
await tick(fixture);
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.textContent).toContain('Cantidad de etiquetas');
|
||||
expect(el.querySelector('button.main')?.textContent).toContain('Generar');
|
||||
expect(el.querySelector('button.main')?.textContent).not.toContain('PDF');
|
||||
|
||||
// falta un obligatorio: no se genera
|
||||
(el.querySelector('button.main') as HTMLButtonElement).click();
|
||||
await tick(fixture);
|
||||
expect(render).not.toHaveBeenCalled();
|
||||
expect(el.querySelector('.status')?.textContent).toContain('Falta indicar: Cantidad de etiquetas');
|
||||
|
||||
// se llena y se genera
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fixture.componentInstance as any).set(params[0], '6');
|
||||
await tick(fixture);
|
||||
(el.querySelector('button.main') as HTMLButtonElement).click();
|
||||
await tick(fixture);
|
||||
expect(render).toHaveBeenCalledWith('etiquetas-bultos', { cantidad: '6', papel: 'Carta' });
|
||||
expect(gateway.export).not.toHaveBeenCalled();
|
||||
const frame = el.querySelector('iframe') as HTMLIFrameElement;
|
||||
expect(frame.getAttribute('srcdoc')).toBe('<p>Etiqueta 1/6</p>');
|
||||
expect(frame.getAttribute('sandbox')).toContain('allow-same-origin');
|
||||
expect(el.textContent).toContain('Márgenes: predeterminados');
|
||||
|
||||
const print = vi.fn();
|
||||
Object.defineProperty(frame, 'contentWindow', { value: { print } });
|
||||
const imprimir = [...el.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Imprimir') as HTMLButtonElement;
|
||||
imprimir.click();
|
||||
expect(print).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('un error del servidor se muestra y no deja un marco vacío', async () => {
|
||||
setup({ reports: vi.fn(async () => [REPORT]), render: vi.fn(async () => { throw new Error('boom'); }) }, 'etiquetas-bultos');
|
||||
const fixture = TestBed.createComponent(ReportRunnerPage);
|
||||
await tick(fixture);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fixture.componentInstance as any).set(params[0], '2');
|
||||
(fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('button.main')!.click();
|
||||
await tick(fixture);
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('iframe')).toBeNull();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.status')?.textContent).toMatch(/error|boom/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HtmlReportsApi', () => {
|
||||
it('el mensaje de error del servidor (JSON en texto) se entrega como mensaje legible', async () => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||
const api = TestBed.inject(HtmlReportsApi);
|
||||
const ctrl = TestBed.inject(HttpTestingController);
|
||||
const pending = api.render('etiquetas-bultos', { cantidad: '0' });
|
||||
ctrl.expectOne('/api/html-reports/etiquetas-bultos/render').flush(JSON.stringify({ message: 'Falta indicar: Entrega' }), { status: 400, statusText: 'Bad Request' });
|
||||
await expect(pending).rejects.toMatchObject({ error: { message: 'Falta indicar: Entrega' }, status: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, resource, signal } from '@angular/core';
|
||||
import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, computed, inject, resource, signal, viewChild } from '@angular/core';
|
||||
import { DomSanitizer, type SafeHtml, 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, blobErrorMessage } from './gateway-api';
|
||||
import { HtmlReportsApi } from './html-reports-api';
|
||||
import { ReportParamField } from './report-param-field';
|
||||
|
||||
type Value = string | readonly string[];
|
||||
@@ -42,8 +43,15 @@ type Value = string | readonly string[];
|
||||
@if (pdfUrl(); as url) {
|
||||
<iframe class="pdf" title="Vista del PDF" [src]="url"></iframe>
|
||||
}
|
||||
@if (htmlDoc(); as doc) {
|
||||
<p class="desc print-hint">Para imprimir bien: <strong>Márgenes: predeterminados · Encabezados y pies de página: desactivados · Escala: 100 %</strong>.</p>
|
||||
<iframe #htmlFrame class="pdf" title="Vista del reporte" sandbox="allow-same-origin allow-modals" [srcdoc]="doc"></iframe>
|
||||
}
|
||||
}
|
||||
<button sboActions class="btn main" type="button" [disabled]="running() || !report()" (click)="run()">{{ running() ? 'Generando…' : action }}</button>
|
||||
@if (htmlDoc()) {
|
||||
<button sboActions class="btn" type="button" (click)="print()">Imprimir</button>
|
||||
}
|
||||
<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>
|
||||
}
|
||||
@@ -57,14 +65,19 @@ type Value = string | readonly string[];
|
||||
`,
|
||||
})
|
||||
export class ReportRunnerPage {
|
||||
private readonly api = inject(GatewayApi);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly api = inject(GatewayApi);
|
||||
private readonly htmlApi = inject(HtmlReportsApi);
|
||||
/** Reportes HTML (se imprimen desde el navegador) o del Gateway (PDF), según la ruta (`data.source`). */
|
||||
private readonly isHtml = this.route.snapshot.data['source'] === 'html';
|
||||
protected readonly action = this.isHtml ? 'Generar' : 'Generar PDF';
|
||||
private readonly htmlFrame = viewChild<ElementRef<HTMLIFrameElement>>('htmlFrame');
|
||||
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() });
|
||||
private readonly reports = resource({ loader: () => (this.isHtml ? this.htmlApi.reports() : 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));
|
||||
@@ -74,6 +87,7 @@ export class ReportRunnerPage {
|
||||
private readonly values = signal<Record<string, Value>>({});
|
||||
protected readonly running = signal(false);
|
||||
protected readonly pdfUrl = signal<SafeResourceUrl | null>(null);
|
||||
protected readonly htmlDoc = signal<SafeHtml | null>(null);
|
||||
protected readonly download = signal<{ url: string; name: string } | null>(null);
|
||||
private readonly message = signal<StatusMessage | null>(null);
|
||||
|
||||
@@ -87,7 +101,7 @@ export class ReportRunnerPage {
|
||||
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».' };
|
||||
return { kind: 'info', text: this.visible().some((p) => p.required) ? `Indique los parámetros obligatorios (*) y pulse «${this.action}».` : `Pulse «${this.action}».` };
|
||||
});
|
||||
|
||||
/** El valor de un parámetro: lo que escribió el usuario o su valor por defecto. */
|
||||
@@ -105,6 +119,11 @@ export class ReportRunnerPage {
|
||||
if (d) URL.revokeObjectURL(d.url);
|
||||
}
|
||||
|
||||
/** Imprime el reporte (solo el marco, no la pantalla). */
|
||||
protected print(): void {
|
||||
this.htmlFrame()?.nativeElement.contentWindow?.print();
|
||||
}
|
||||
|
||||
protected async run(): Promise<void> {
|
||||
const r = this.report();
|
||||
if (!r || this.running()) return;
|
||||
@@ -118,6 +137,12 @@ export class ReportRunnerPage {
|
||||
this.running.set(true);
|
||||
this.message.set({ kind: 'info', text: 'Generando el reporte…' });
|
||||
try {
|
||||
if (this.isHtml) {
|
||||
// el HTML lo arma nuestro servidor a partir de plantillas del proyecto (los valores ya van escapados)
|
||||
this.htmlDoc.set(this.sanitizer.bypassSecurityTrustHtml(await this.htmlApi.render(r.code, values as GatewayValues)));
|
||||
this.message.set({ kind: 'ok', text: 'Reporte generado. Pulse «Imprimir».' });
|
||||
return;
|
||||
}
|
||||
const blob = await this.api.export(r.code, values as GatewayValues);
|
||||
this.revoke();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
import { HtmlReportsApi } from './html-reports-api';
|
||||
|
||||
/** Lista de los reportes matriculados, agrupados por categoría y con búsqueda; elegir uno abre su formulario de parámetros. */
|
||||
@Component({
|
||||
@@ -31,7 +32,7 @@ import { GatewayApi } from './gateway-api';
|
||||
}
|
||||
</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>
|
||||
<p class="none">{{ reports.isLoading() ? 'Cargando reportes…' : query() ? 'Ningún reporte coincide con la búsqueda.' : isHtml ? 'No hay reportes HTML definidos en esta instalación.' : 'No hay reportes matriculados. Un administrador los matricula en «Matrícula de reportes».' }}</p>
|
||||
}
|
||||
</sbo-window>
|
||||
`,
|
||||
@@ -51,9 +52,11 @@ import { GatewayApi } from './gateway-api';
|
||||
`,
|
||||
})
|
||||
export class ReportsPage {
|
||||
private readonly api = inject(GatewayApi);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
/** Reportes del Gateway (Crystal) o reportes HTML, según la ruta (`data.source`). */
|
||||
protected readonly isHtml = this.route.snapshot.data['source'] === 'html';
|
||||
private readonly api = this.isHtml ? inject(HtmlReportsApi) : inject(GatewayApi);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
protected readonly title = this.route.snapshot.data['title'] as string;
|
||||
protected readonly reports = resource({ loader: () => this.api.reports() });
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { Component } from '@angular/core';
|
||||
import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { Router, provideRouter } from '@angular/router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MenuService } from '../core/menu/menu.service';
|
||||
import { MainLayout } from './main-layout';
|
||||
|
||||
describe('MainLayout: menú lateral', () => {
|
||||
@@ -52,3 +54,59 @@ describe('MainLayout: menú lateral', () => {
|
||||
expect(localStorage.getItem('erp_sidebar')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MainLayout: módulos del menú', () => {
|
||||
let fixture: ComponentFixture<MainLayout>;
|
||||
const el = () => fixture.nativeElement as HTMLElement;
|
||||
const mods = () => [...el().querySelectorAll<HTMLButtonElement>('button.mod')];
|
||||
const openOnes = () => mods().filter((m) => m.getAttribute('aria-expanded') === 'true').map((m) => m.textContent?.trim().replace(/^[▾▸]\s*/, ''));
|
||||
|
||||
@Component({ template: '' })
|
||||
class Page {}
|
||||
|
||||
async function open(url?: string) {
|
||||
vi.stubGlobal('matchMedia', () => ({ matches: false }));
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [provideRouter([{ path: 'inventario/lotes', component: Page }, { path: '', component: Page }]), provideHttpClient(), provideHttpClientTesting()],
|
||||
}).compileComponents();
|
||||
// en la aplicación el guardián carga el menú antes de mostrar el layout
|
||||
const loaded = TestBed.inject(MenuService).ensureLoaded();
|
||||
TestBed.inject(HttpTestingController).expectOne('/api/user/menu').flush({ allowedPaths: ['*'] });
|
||||
await loaded;
|
||||
if (url) await TestBed.inject(Router).navigateByUrl(url);
|
||||
fixture = TestBed.createComponent(MainLayout);
|
||||
fixture.detectChanges();
|
||||
TestBed.inject(HttpTestingController).match(() => true).forEach((r) => r.flush({ value: [] }));
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
it('arranca comprimido: ningún módulo desplegado', async () => {
|
||||
await open();
|
||||
expect(mods().length).toBeGreaterThan(3);
|
||||
expect(openOnes()).toEqual([]);
|
||||
expect(el().querySelectorAll('nav.side a').length).toBe(0);
|
||||
});
|
||||
|
||||
it('un clic despliega el módulo y otro lo pliega', async () => {
|
||||
await open();
|
||||
mods()[0].click();
|
||||
fixture.detectChanges();
|
||||
expect(openOnes()).toHaveLength(1);
|
||||
expect(el().querySelectorAll('nav.side a').length).toBeGreaterThan(0);
|
||||
mods()[0].click();
|
||||
fixture.detectChanges();
|
||||
expect(openOnes()).toEqual([]);
|
||||
});
|
||||
|
||||
it('al entrar por una ruta de un módulo, ese módulo (y solo ese) queda desplegado', async () => {
|
||||
await open('/inventario/lotes');
|
||||
expect(openOnes()).toEqual(['Inventario']);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { FieldNames } from '../core/field-names';
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { filter } from 'rxjs';
|
||||
import { CompanySettingsService } from '../core/company/company-settings';
|
||||
import { AccountCodesService } from '../core/sl/account-codes';
|
||||
import { AccountSegmentsService } from '../core/sl/account-segments';
|
||||
@@ -43,10 +45,14 @@ export class MainLayout {
|
||||
private readonly accountCodes = inject(AccountCodesService);
|
||||
|
||||
constructor() {
|
||||
const router = inject(Router);
|
||||
this.reveal(router.url);
|
||||
router.events.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd), takeUntilDestroyed()).subscribe((e) => this.reveal(e.urlAfterRedirects));
|
||||
void this.segments.ensureLoaded();
|
||||
void inject(CompanySettingsService).ensureLoaded();
|
||||
}
|
||||
private readonly collapsed = signal<ReadonlySet<string>>(new Set());
|
||||
/** Módulos desplegados: el menú arranca comprimido; se abre el del lugar donde se está (o se llega por una ruta directa). */
|
||||
private readonly expanded = signal<ReadonlySet<string>>(new Set());
|
||||
|
||||
/** El botón ☰: en pantallas estrechas abre o cierra el menú sobre el contenido; en las anchas lo oculta o lo muestra. */
|
||||
protected toggleSide(): void {
|
||||
@@ -64,17 +70,24 @@ export class MainLayout {
|
||||
}
|
||||
|
||||
protected isOpen(moduleId: string): boolean {
|
||||
return !this.collapsed().has(moduleId);
|
||||
return this.expanded().has(moduleId);
|
||||
}
|
||||
|
||||
protected toggle(moduleId: string): void {
|
||||
this.collapsed.update((s) => {
|
||||
this.expanded.update((s) => {
|
||||
const next = new Set(s);
|
||||
if (!next.delete(moduleId)) next.add(moduleId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/** Despliega el módulo al que pertenece la ruta (para ver dónde se está al entrar por un enlace o al recargar). */
|
||||
private reveal(url: string): void {
|
||||
const path = url.split(/[?#]/)[0];
|
||||
const module = this.menu.modules().find((m) => m.items.some((i) => path === i.path || path.startsWith(`${i.path}/`)));
|
||||
if (module && !this.expanded().has(module.id)) this.expanded.update((s) => new Set(s).add(module.id));
|
||||
}
|
||||
|
||||
protected retryMenu(): void {
|
||||
void this.menu.ensureLoaded();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user