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

+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+34
View File
@@ -0,0 +1,34 @@
# web
Frontend Angular (standalone, zoneless, señales) del ERP base. Interfaz densa y ligera estilo SAP Business One.
```bash
npm run dev:web # desde la raíz; http://localhost:4200 (proxy /api -> localhost:3000)
npm run build -w apps/web
npm test -w apps/web
```
## Criterios de rendimiento
Es un ERP, no un sitio publicitario: rápido y ligero.
- Sin librerías de UI (nada de Material/PrimeNG): componentes propios, densos (13 px).
- Fuentes del sistema, sin webfonts; sin animaciones.
- Zoneless + `OnPush`; rutas con `loadComponent` para cargar solo lo que se usa.
- Presupuesto del bundle inicial: aviso a 260 kB, error a 400 kB (`angular.json`). Hoy: ~200 kB (56 kB comprimido).
- Listas largas con paginación del servidor (`$top/$skip`) o virtualización.
## Temas
El color sale solo de variables CSS (`src/styles/themes/*.css`). Tema por defecto: **claro**, con la paleta del mockup de Oferta de ventas.
Agregar un tema:
1. `src/styles/themes/<id>.css` con un bloque `:root[data-theme='<id>']` (copiar los tokens de `light.css`).
2. `@import` en `src/styles.css`.
3. Una entrada en `src/app/core/theme/themes.ts`.
Tokens: `--app --shell --shell-ink --shell-quiet --win --panel --line --line-soft --ink --quiet --field --field-ro --field-b --focus --gold --grid-head --row-sel --row-alt --ok --ok-bg --warn --warn-bg --err --err-bg --btn --btn-b --btn-h --overlay --shadow-pop`.
## Tipos compartidos
`@basic-erp/sap-types` (`libs/sap-types`): usuario, respuestas de la API y el evaluador de permisos de menú, los mismos que usa el backend.
+76
View File
@@ -0,0 +1,76 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"web": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "sbo",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "260kB",
"maximumError": "400kB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "web:build:production"
},
"development": {
"buildTarget": "web:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "proxy.conf.json"
}
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "web",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/common": "^22.2.0",
"@angular/compiler": "^22.2.0",
"@angular/core": "^22.2.0",
"@angular/forms": "^22.2.0",
"@angular/platform-browser": "^22.2.0",
"@angular/router": "^22.2.0",
"@basic-erp/sap-types": "*",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.2.2",
"@angular/cli": "^22.2.2",
"@angular/compiler-cli": "^22.2.0",
"jsdom": "^30.0.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vitest": "^5.0.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": false
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+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);
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Basic ERP</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<!-- Aplica el tema guardado antes del primer pintado (evita el parpadeo). -->
<script>
try {
var t = localStorage.getItem('erp_theme');
if (t) document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
</script>
</head>
<body>
<app-root></app-root>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
+3
View File
@@ -0,0 +1,3 @@
@import './styles/themes/light.css';
@import './styles/themes/dark.css';
@import './styles/base.css';
+46
View File
@@ -0,0 +1,46 @@
/*
* Base mínima y rápida: fuentes del sistema (sin webfonts), sin animaciones,
* densidad de ERP (13 px). Todo el color sale de los tokens del tema.
*/
:root {
--font: 'Segoe UI', Tahoma, 'Helvetica Neue', Arial, sans-serif;
--fs: 13px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
height: 100%;
}
body {
margin: 0;
min-height: 100%;
background: var(--app);
color: var(--ink);
font: var(--fs) / 1.35 var(--font);
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
}
}
+39
View File
@@ -0,0 +1,39 @@
:root[data-theme='dark'] {
color-scheme: dark;
--app: #14191e;
--shell: #0d1216;
--shell-ink: #eef2f5;
--shell-quiet: #8d9aa6;
--win: #1d242b;
--panel: #232b33;
--line: #3a4651;
--line-soft: #2e3842;
--ink: #e6eaee;
--quiet: #9aa6b1;
--field: #1a2128;
--field-ro: #252e37;
--field-b: #4b5865;
--focus: #5aa9f0;
--gold: #f2b632;
--grid-head: #2a333c;
--row-sel: #203a55;
--row-alt: #20282f;
--ok: #5fc48a;
--ok-bg: #16301f;
--warn: #f0b357;
--warn-bg: #352812;
--err: #ff7a7a;
--err-bg: #3a1717;
--btn: #2a333c;
--btn-b: #4b5865;
--btn-h: #333e48;
--overlay: rgba(0, 0, 0, 0.55);
--shadow-pop: 0 4px 12px rgba(0, 0, 0, 0.5);
}
+52
View File
@@ -0,0 +1,52 @@
/*
* Tema claro (por defecto). Paleta tomada del mockup "Cotizador · Oferta de ventas".
* Un tema nuevo = un archivo como este con el bloque :root[data-theme='<id>'],
* un @import en styles.css y una entrada en core/theme/themes.ts.
*/
:root,
:root[data-theme='light'] {
color-scheme: light;
/* Superficies */
--app: #e4e8ec; /* fondo de la aplicación */
--shell: #2f4356; /* barra superior */
--shell-ink: #ffffff;
--shell-quiet: #b8c4cf;
--win: #f7f7f7; /* ventana de formulario */
--panel: #ffffff; /* barras, pestañas, tablas */
--line: #c6ced5;
--line-soft: #dde2e7;
/* Texto */
--ink: #32363a;
--quiet: #6a6d70;
/* Campos */
--field: #ffffff;
--field-ro: #eef1f3;
--field-b: #a9b4be;
--focus: #0a6ed1;
--gold: #e9a100; /* marca de "campo con acción" (flecha dorada de SAP) */
/* Tablas */
--grid-head: #e7eaed;
--row-sel: #dcebf9;
--row-alt: #fafbfc;
/* Estados */
--ok: #107e3e;
--ok-bg: #e9f5ee;
--warn: #a85d00;
--warn-bg: #fdf3e2;
--err: #bb0000;
--err-bg: #fbeaea;
/* Botones */
--btn: #f5f6f7;
--btn-b: #a9b4be;
--btn-h: #e8ebee;
/* Otros */
--overlay: rgba(0, 0, 0, 0.35);
--shadow-pop: 0 4px 12px rgba(0, 0, 0, 0.15);
}
+10
View File
@@ -0,0 +1,10 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
}