diff --git a/apps/web/src/app/core/menu/recent-screens.spec.ts b/apps/web/src/app/core/menu/recent-screens.spec.ts new file mode 100644 index 0000000..512cefe --- /dev/null +++ b/apps/web/src/app/core/menu/recent-screens.spec.ts @@ -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([]); + }); +}); diff --git a/apps/web/src/app/core/menu/recent-screens.ts b/apps/web/src/app/core/menu/recent-screens.ts new file mode 100644 index 0000000..9b01c85 --- /dev/null +++ b/apps/web/src/app/core/menu/recent-screens.ts @@ -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(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 []; + } + } +} diff --git a/apps/web/src/app/features/home/home.page.spec.ts b/apps/web/src/app/features/home/home.page.spec.ts new file mode 100644 index 0000000..576f512 --- /dev/null +++ b/apps/web/src/app/features/home/home.page.spec.ts @@ -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; + 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(); + }); +}); diff --git a/apps/web/src/app/features/home/home.page.ts b/apps/web/src/app/features/home/home.page.ts index f94c63e..f3a4f66 100644 --- a/apps/web/src/app/features/home/home.page.ts +++ b/apps/web/src/app/features/home/home.page.ts @@ -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: ` -

Bienvenido, {{ session.user()?.firstName }}

-

{{ session.company() }}

- @for (m of menu.modules(); track m.id) { -
-

{{ m.label }}

- -
+
+
+

Bienvenido, {{ session.user()?.firstName }}

+

{{ session.company() }}

+
+ +
+ + @if (query().trim()) { + + } @else { + @if (recent().length) { +
+

Recientes

+
+ @for (r of recent(); track r.path) { + {{ r.label }} + } +
+
+ } +
+ @for (m of menu.modules(); track m.id) { +
+

{{ m.label }}

+ +
+ } +
} `, 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); + } } diff --git a/apps/web/src/app/layout/main-layout.ts b/apps/web/src/app/layout/main-layout.ts index 5a3a91a..1e50907 100644 --- a/apps/web/src/app/layout/main-layout.ts +++ b/apps/web/src/app/layout/main-layout.ts @@ -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(); }