feat(web): app Angular, librería compartida sap-types y sistema de temas

- apps/web: Angular 22 standalone, zoneless, OnPush, vitest; proxy /api
  hacia el backend; presupuesto de bundle (aviso 260 kB, error 400 kB).
- libs/sap-types: tipos de auth/permisos/Service Layer y el evaluador de
  patrones de menú, compartidos por api y web (se compila en postinstall).
  El backend deja de tener su propia copia del evaluador.
- Temas por variables CSS: claro por defecto con la paleta del mockup de
  Oferta de ventas, oscuro incluido; ThemeService + selector, con el tema
  guardado aplicado antes del primer pintado. Agregar un tema = 1 css +
  1 import + 1 entrada.
- Criterios de interfaz ligera en CLAUDE.md y README.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 19:11:05 -05:00
1 parent c0990ca83e
commit f1156929c7
40 files changed
+6231 -5

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)],
};
+21
View File
@@ -0,0 +1,21 @@
.shell {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
background: var(--shell);
color: var(--shell-ink);
}
.brand {
font-weight: 600;
font-size: 14px;
letter-spacing: 0.2px;
}
.brand small {
font-weight: 400;
color: var(--shell-quiet);
margin-left: 8px;
}
sbo-theme-switcher {
margin-left: auto;
}
+5
View File
@@ -0,0 +1,5 @@
<header class="shell">
<span class="brand">Basic ERP <small>SAP Business One</small></span>
<sbo-theme-switcher />
</header>
<router-outlet />
+3
View File
@@ -0,0 +1,3 @@
import { Routes } from '@angular/router';
export const routes: Routes = [];
+15
View File
@@ -0,0 +1,15 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { describe, expect, it } from 'vitest';
import { App } from './app';
describe('App', () => {
it('muestra la barra superior con el selector de tema', async () => {
await TestBed.configureTestingModule({ imports: [App], providers: [provideRouter([])] }).compileComponents();
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('.brand')?.textContent).toContain('Basic ERP');
expect(el.querySelector('sbo-theme-switcher select')).toBeTruthy();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ThemeSwitcher } from './core/theme/theme-switcher';
@Component({
selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, ThemeSwitcher],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}
@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ThemeService } from './theme.service';
@Component({
selector: 'sbo-theme-switcher',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<label class="theme">
<span>Tema</span>
<select [value]="theme.current()" (change)="theme.set($any($event.target).value)" aria-label="Tema de la interfaz">
@for (t of theme.themes; track t.id) {
<option [value]="t.id">{{ t.name }}</option>
}
</select>
</label>
`,
styles: `
.theme {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--shell-quiet);
}
select {
background: transparent;
color: var(--shell-ink);
border: 1px solid var(--shell-quiet);
border-radius: 3px;
padding: 1px 4px;
}
option {
color: var(--ink);
background: var(--panel);
}
`,
})
export class ThemeSwitcher {
protected readonly theme = inject(ThemeService);
}
@@ -0,0 +1,41 @@
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { ThemeService } from './theme.service';
import { THEME_STORAGE_KEY } from './themes';
const make = () => {
TestBed.resetTestingModule();
return TestBed.inject(ThemeService);
};
const attr = () => document.documentElement.getAttribute('data-theme');
describe('ThemeService', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute('data-theme');
});
it('por defecto usa el tema claro', () => {
const service = make();
expect(service.current()).toBe('light');
expect(attr()).toBe('light');
});
it('cambia el tema, lo aplica y lo recuerda', () => {
const service = make();
service.set('dark');
expect(service.current()).toBe('dark');
expect(attr()).toBe('dark');
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark');
expect(make().current()).toBe('dark'); // "recarga"
});
it('ignora temas desconocidos, también si venían guardados', () => {
localStorage.setItem(THEME_STORAGE_KEY, 'neon');
const service = make();
expect(service.current()).toBe('light');
service.set('neon');
expect(service.current()).toBe('light');
expect(attr()).toBe('light');
});
});
@@ -0,0 +1,40 @@
import { DOCUMENT } from '@angular/common';
import { Injectable, inject, signal } from '@angular/core';
import { DEFAULT_THEME, THEMES, THEME_STORAGE_KEY, type ThemeId, isThemeId } from './themes';
/** Tema activo: se aplica como `data-theme` en <html> y se recuerda por navegador. */
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly doc = inject(DOCUMENT);
readonly themes = THEMES;
readonly current = signal<ThemeId>(this.read());
constructor() {
this.apply(this.current());
}
set(id: string): void {
if (!isThemeId(id)) return;
this.current.set(id);
this.apply(id);
try {
localStorage.setItem(THEME_STORAGE_KEY, id);
} catch {
// almacenamiento bloqueado: el tema vale solo para esta sesión
}
}
private read(): ThemeId {
try {
const saved = localStorage.getItem(THEME_STORAGE_KEY);
return isThemeId(saved) ? saved : DEFAULT_THEME;
} catch {
return DEFAULT_THEME;
}
}
private apply(id: ThemeId): void {
this.doc.documentElement.setAttribute('data-theme', id);
}
}
+12
View File
@@ -0,0 +1,12 @@
/** Temas disponibles. Para agregar uno: CSS en styles/themes/, @import en styles.css y una entrada aquí. */
export const THEMES = [
{ id: 'light', name: 'Claro' },
{ id: 'dark', name: 'Oscuro' },
] as const;
export type ThemeId = (typeof THEMES)[number]['id'];
export const DEFAULT_THEME: ThemeId = 'light';
export const THEME_STORAGE_KEY = 'erp_theme';
export const isThemeId = (value: unknown): value is ThemeId => THEMES.some((t) => t.id === value);