feat(web): bienvenida con buscador de pantallas, recientes y una tarjeta por módulo

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-20 17:07:52 -05:00
1 parent a472731277
commit f5e15a8f79
5 files changed
+314 -13

No files matched your search

@@ -0,0 +1,62 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { beforeEach, describe, expect, it } from 'vitest';
import { MenuService } from './menu.service';
import { RecentScreens } from './recent-screens';
@Component({ template: '' })
class Page {}
const ROUTES = ['ventas/ordenes', 'ventas/entrega', 'inventario/lotes', 'compras/ordenes', 'compras/facturas', 'bancos/pagos-recibidos', 'finanzas/asientos'].map((path) => ({ path, component: Page }));
describe('RecentScreens', () => {
async function setup(allowed: string[] = ['*']) {
TestBed.resetTestingModule();
TestBed.configureTestingModule({ providers: [provideRouter(ROUTES), provideHttpClient(), provideHttpClientTesting()] });
const loaded = TestBed.inject(MenuService).ensureLoaded();
TestBed.inject(HttpTestingController).expectOne('/api/user/menu').flush({ allowedPaths: allowed });
await loaded;
return { recent: TestBed.inject(RecentScreens), router: TestBed.inject(Router) };
}
beforeEach(() => localStorage.clear());
it('anota las pantallas del menú que se abren, la más reciente primero, y las recuerda', async () => {
const { recent, router } = await setup();
await router.navigateByUrl('/ventas/ordenes');
await router.navigateByUrl('/inventario/lotes');
expect(recent.paths()).toEqual(['/inventario/lotes', '/ventas/ordenes']);
expect(JSON.parse(localStorage.getItem('erp_recent')!)).toEqual(['/inventario/lotes', '/ventas/ordenes']);
// otra sesión del navegador las recupera
const again = (await setup()).recent;
expect(again.paths()).toEqual(['/inventario/lotes', '/ventas/ordenes']);
});
it('una pantalla abierta de nuevo sube al primer lugar sin repetirse, y el detalle cuenta como su pantalla', async () => {
const { recent, router } = await setup();
await router.navigateByUrl('/ventas/ordenes');
await router.navigateByUrl('/inventario/lotes');
await router.navigateByUrl('/ventas/ordenes');
expect(recent.paths()).toEqual(['/ventas/ordenes', '/inventario/lotes']);
recent.record('/inventario/lotes/75437?x=1');
expect(recent.paths()).toEqual(['/inventario/lotes', '/ventas/ordenes']);
});
it('guarda solo las 5 últimas e ignora lo que no es del menú', async () => {
const { recent, router } = await setup();
for (const p of ['/ventas/ordenes', '/ventas/entrega', '/inventario/lotes', '/compras/ordenes', '/compras/facturas', '/bancos/pagos-recibidos']) await router.navigateByUrl(p);
expect(recent.paths()).toHaveLength(5);
expect(recent.paths()[0]).toBe('/bancos/pagos-recibidos');
expect(recent.paths()).not.toContain('/ventas/ordenes');
recent.record('/no-esta-en-el-menu');
expect(recent.paths()).toHaveLength(5);
});
it('un valor dañado del navegador no rompe nada', async () => {
localStorage.setItem('erp_recent', '{no es json');
expect((await setup()).recent.paths()).toEqual([]);
});
});
@@ -0,0 +1,46 @@
import { Injectable, inject, signal } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
import { MenuService } from './menu.service';
const KEY = 'erp_recent';
const MAX = 5;
/**
* Las últimas pantallas del menú que se abrieron (hasta 5, la más reciente primero), recordadas en el navegador. Se llenan
* solas al navegar; la bienvenida las ofrece como atajo. Solo cuentan las pantallas del menú del usuario.
*/
@Injectable({ providedIn: 'root' })
export class RecentScreens {
private readonly menu = inject(MenuService);
readonly paths = signal<readonly string[]>(this.read());
constructor() {
inject(Router)
.events.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
.subscribe((e) => this.record(e.urlAfterRedirects));
}
/** Anota la pantalla del menú a la que pertenece la ruta (`/inventario/lotes/75437` cuenta como `/inventario/lotes`). */
record(url: string): void {
const path = url.split(/[?#]/)[0];
const item = this.menu.modules().flatMap((m) => m.items).find((i) => path === i.path || path.startsWith(`${i.path}/`));
if (!item || this.paths()[0] === item.path) return;
const next = [item.path, ...this.paths().filter((p) => p !== item.path)].slice(0, MAX);
this.paths.set(next);
try {
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
/* sin almacenamiento: vale para esta sesión */
}
}
private read(): string[] {
try {
const v: unknown = JSON.parse(localStorage.getItem(KEY) ?? '[]');
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string').slice(0, MAX) : [];
} catch {
return [];
}
}
}
@@ -0,0 +1,97 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SessionStore } from '../../core/auth/session.store';
import { MenuService } from '../../core/menu/menu.service';
import { HomePage } from './home.page';
describe('HomePage', () => {
let fixture: ComponentFixture<HomePage>;
const el = () => fixture.nativeElement as HTMLElement;
const tick = async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
};
async function open(allowed: string[], recent: string[] = []) {
TestBed.resetTestingModule();
localStorage.clear();
if (recent.length) localStorage.setItem('erp_recent', JSON.stringify(recent));
TestBed.configureTestingModule({ providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()] });
const loaded = TestBed.inject(MenuService).ensureLoaded();
TestBed.inject(HttpTestingController).expectOne('/api/user/menu').flush({ allowedPaths: allowed });
await loaded;
fixture = TestBed.createComponent(HomePage);
await tick();
}
const type = async (text: string) => {
const input = el().querySelector('input.find') as HTMLInputElement;
input.value = text;
input.dispatchEvent(new Event('input'));
await tick();
return input;
};
const cards = () => [...el().querySelectorAll('.card')].map((c) => [c.querySelector('h2')?.textContent?.trim(), [...c.querySelectorAll('a')].map((a) => a.textContent?.trim())] as const);
beforeEach(() => TestBed.resetTestingModule());
it('muestra una tarjeta por módulo con sus pantallas en lista, solo las permitidas', async () => {
await open(['/ventas/ordenes', '/ventas/entrega', '/inventario/lotes']);
expect(cards()).toEqual([
['Ventas', ['Órdenes de venta', 'Entregas']],
['Inventario', ['Lotes']],
]);
expect(el().querySelector('.card a')?.getAttribute('href')).toBe('/ventas/ordenes');
});
it('el saludo lleva el nombre y la empresa', async () => {
await open(['*']);
TestBed.inject(SessionStore).set({ token: 't', user: { webUser: 'u', empId: 1, firstName: 'Jose', lastName: 'H' }, company: 'SBO_X', sessionTimeoutMinutes: 30 } as never);
await tick();
expect(el().querySelector('h1')?.textContent).toContain('Bienvenido, Jose');
expect(el().querySelector('.sub')?.textContent).toContain('SBO_X');
});
it('el buscador filtra las pantallas de todos los módulos, sin tildes ni mayúsculas, y dice a qué módulo pertenecen', async () => {
await open(['*']);
await type('ORDENES');
const found = [...el().querySelectorAll('.found a')].map((a) => [a.querySelector('strong')?.textContent, a.querySelector('span')?.textContent]);
expect(found).toEqual([['Órdenes de venta', 'Ventas'], ['Órdenes de compra', 'Compras']]);
expect(el().querySelector('.cards')).toBeNull();
// varias palabras: todas deben estar (en la pantalla o en su módulo)
await type('compra orden');
expect([...el().querySelectorAll('.found a strong')].map((s) => s.textContent)).toEqual(['Órdenes de compra']);
});
it('sin coincidencias lo dice, y vaciar el buscador (o Esc) devuelve las tarjetas', async () => {
await open(['*']);
const input = await type('zzzz');
expect(el().querySelector('.found .none')?.textContent).toContain('Ninguna pantalla coincide con «zzzz»');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await tick();
expect(el().querySelector('.cards')).not.toBeNull();
expect((el().querySelector('input.find') as HTMLInputElement).value).toBe('');
});
it('Enter abre la primera coincidencia', async () => {
await open(['*']);
const navigate = vi.spyOn(TestBed.inject(Router), 'navigateByUrl').mockResolvedValue(true);
const input = await type('lotes');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(navigate).toHaveBeenCalledWith('/inventario/lotes');
});
it('ofrece las pantallas recientes que el usuario todavía puede abrir', async () => {
await open(['/ventas/ordenes', '/inventario/lotes'], ['/inventario/lotes', '/compras/ordenes', '/ventas/ordenes']);
expect([...el().querySelectorAll('.recent a')].map((a) => a.textContent?.trim())).toEqual(['Lotes', 'Órdenes de venta']);
});
it('sin recientes no muestra esa fila', async () => {
await open(['*']);
expect(el().querySelector('.recent')).toBeNull();
});
});
+106 -13
View File
@@ -1,36 +1,129 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { SessionStore } from '../../core/auth/session.store';
import { MenuService } from '../../core/menu/menu.service';
import { RecentScreens } from '../../core/menu/recent-screens';
/** Sin tildes ni mayúsculas, para que «ordenes» encuentre «Órdenes de venta». */
const plain = (s: string): string => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
/**
* Bienvenida: un buscador de pantallas, las últimas que se abrieron y una tarjeta por módulo del menú con sus pantallas en
* lista. Solo lo que el usuario puede abrir.
*/
@Component({
selector: 'sbo-home-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
template: `
<header class="top">
<div>
<h1>Bienvenido, {{ session.user()?.firstName }}</h1>
<p class="sub">{{ session.company() }}</p>
@for (m of menu.modules(); track m.id) {
<section>
<h2>{{ m.label }}</h2>
<div class="links">
@for (i of m.items; track i.path) {
<a [routerLink]="i.path">{{ i.label }}</a>
</div>
<input
class="find"
type="search"
placeholder="Buscar pantalla…"
aria-label="Buscar pantalla"
autocomplete="off"
[value]="query()"
(input)="query.set($any($event.target).value)"
(keydown.enter)="openFirst()"
(keydown.escape)="query.set('')"
/>
</header>
@if (query().trim()) {
<ul class="found" role="list" aria-label="Pantallas encontradas">
@for (r of found(); track r.path) {
<li><a [routerLink]="r.path"><strong>{{ r.label }}</strong> <span>{{ r.module }}</span></a></li>
} @empty {
<li class="none">Ninguna pantalla coincide con «{{ query().trim() }}».</li>
}
</ul>
} @else {
@if (recent().length) {
<section class="recent" aria-label="Recientes">
<h2>Recientes</h2>
<div class="chips">
@for (r of recent(); track r.path) {
<a [routerLink]="r.path" [title]="r.module">{{ r.label }}</a>
}
</div>
</section>
}
<div class="cards">
@for (m of menu.modules(); track m.id) {
<section class="card">
<h2>{{ m.label }}</h2>
<ul role="list">
@for (i of m.items; track i.path) {
<li><a [routerLink]="i.path">{{ i.label }}</a></li>
}
</ul>
</section>
}
</div>
}
`,
styles: `
:host { display: block; }
.top { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 8px 16px; margin-bottom: 14px; }
h1 { margin: 0; font-size: 18px; font-weight: 600; }
.sub { margin: 2px 0 16px; color: var(--quiet); }
h2 { margin: 12px 0 6px; font-size: 13px; color: var(--quiet); text-transform: uppercase; letter-spacing: .4px; }
.links { display: flex; flex-wrap: wrap; gap: 6px; }
a { padding: 4px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-lg); color: var(--ink); text-decoration: none; }
a:hover { background: var(--row-sel); border-color: var(--focus); }
.sub { margin: 2px 0 0; color: var(--quiet); }
.find { width: min(320px, 100%); height: 26px; padding: 1px 8px; background: var(--field); border: 1px solid var(--field-b); border-radius: var(--radius); }
.find:focus { border-color: var(--focus); outline: none; }
h2 { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .4px; text-transform: uppercase; color: var(--quiet); }
.recent { margin-bottom: 14px; }
.recent h2 { margin-bottom: 4px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chips a { padding: 3px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-pill, 12px); color: var(--ink); text-decoration: none; }
.chips a:hover { background: var(--row-sel); border-color: var(--focus); }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; align-items: start; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-panel); overflow: hidden; }
.card h2 { padding: 6px 12px; background: var(--grid-head); border-bottom: 1px solid var(--line); color: var(--ink); }
.card ul, .found { list-style: none; margin: 0; padding: 0; }
.card li a { display: block; padding: 4px 12px; color: var(--ink); text-decoration: none; }
.card li a:hover { background: var(--row-sel); }
.card li + li { border-top: 1px solid var(--line-soft); }
.found { max-width: 560px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-panel); }
.found li + li { border-top: 1px solid var(--line-soft); }
.found a { display: flex; justify-content: space-between; gap: 12px; padding: 5px 12px; color: var(--ink); text-decoration: none; }
.found a:hover { background: var(--row-sel); }
.found span { color: var(--quiet); }
.found .none { padding: 10px 12px; color: var(--quiet); }
`,
})
export class HomePage {
protected readonly session = inject(SessionStore);
protected readonly menu = inject(MenuService);
private readonly router = inject(Router);
private readonly recents = inject(RecentScreens);
protected readonly query = signal('');
/** Todas las pantallas que el usuario puede abrir, con su módulo. */
private readonly all = computed(() => this.menu.modules().flatMap((m) => m.items.map((i) => ({ path: i.path, label: i.label, module: m.label }))));
/** Las pantallas que coinciden con lo escrito (en el nombre de la pantalla o del módulo), sin tildes ni mayúsculas. */
protected readonly found = computed(() => {
const words = plain(this.query()).split(/\s+/).filter(Boolean);
return this.all().filter((r) => words.every((w) => plain(`${r.label} ${r.module}`).includes(w)));
});
/** Las últimas pantallas abiertas que el usuario todavía puede ver. */
protected readonly recent = computed(() => {
const byPath = new Map(this.all().map((r) => [r.path, r]));
return this.recents.paths().flatMap((p) => byPath.get(p) ?? []);
});
/** Enter abre la primera coincidencia. */
protected openFirst(): void {
const first = this.found()[0];
if (first) void this.router.navigateByUrl(first.path);
}
}
+3
View File
@@ -9,6 +9,7 @@ import { AccountSegmentsService } from '../core/sl/account-segments';
import { AuthService } from '../core/auth/auth.service';
import { SessionStore } from '../core/auth/session.store';
import { MenuService } from '../core/menu/menu.service';
import { RecentScreens } from '../core/menu/recent-screens';
import { ThemeSwitcher } from '../core/theme/theme-switcher';
const SIDEBAR_KEY = 'erp_sidebar';
@@ -48,6 +49,8 @@ export class MainLayout {
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));
// empieza a anotar las pantallas que se abren (las ofrece la bienvenida)
inject(RecentScreens);
void this.segments.ensureLoaded();
void inject(CompanySettingsService).ensureLoaded();
}