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

+26
View File
@@ -0,0 +1,26 @@
{
"name": "@basic-erp/sap-types",
"version": "0.1.0",
"private": true,
"description": "Tipos y utilidades compartidas entre apps/api y apps/web",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": ["dist"],
"sideEffects": false,
"scripts": {
"build": "tsc -p tsconfig.json",
"watch": "tsc -p tsconfig.json --watch",
"test": "vitest run"
},
"devDependencies": {
"typescript": "~6.0.2",
"vitest": "^4.1.2"
}
}
+25
View File
@@ -0,0 +1,25 @@
/** Empleado SAP autenticado en la web (campos estándar de EmployeesInfo). */
export interface AuthUser {
webUser: string;
empId: number;
firstName: string;
lastName: string;
}
export interface LoginRequest {
username: string;
password: string;
}
/** Respuesta de `POST /api/auth/login`. */
export interface LoginResponse {
token: string;
user: AuthUser;
company: string;
}
/** Respuesta de `GET /api/auth/me`. */
export interface MeResponse {
user: AuthUser;
company: string;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './auth.js';
export * from './permissions.js';
export * from './sl.js';
+27
View File
@@ -0,0 +1,27 @@
import { isPathAllowed, matchesPattern } from './permissions.js';
describe('matchesPattern', () => {
it('"*" permite todo', () => expect(matchesPattern('*', '/lo/que/sea')).toBe(true));
it('"/ventas/*" permite la ruta y sus hijas, no prefijos parecidos', () => {
expect(matchesPattern('/ventas/*', '/ventas')).toBe(true);
expect(matchesPattern('/ventas/*', '/ventas/ordenes')).toBe(true);
expect(matchesPattern('/ventas/*', '/ventas/ordenes/mantenimiento')).toBe(true);
expect(matchesPattern('/ventas/*', '/ventas-x')).toBe(false);
expect(matchesPattern('/ventas/*', '/compras/ordenes')).toBe(false);
});
it('ruta exacta solo coincide consigo misma', () => {
expect(matchesPattern('/ventas/ordenes', '/ventas/ordenes')).toBe(true);
expect(matchesPattern('/ventas/ordenes', '/ventas/ordenes/x')).toBe(false);
});
});
describe('isPathAllowed', () => {
it('null = cualquier sesión, incluso sin permisos', () => expect(isPathAllowed([], null)).toBe(true));
it('sin patrones no hay acceso', () => expect(isPathAllowed([], '/ventas/ordenes')).toBe(false));
it('basta con una de varias rutas requeridas', () => {
expect(isPathAllowed(['/socios/proveedores'], ['/socios/clientes', '/socios/proveedores'])).toBe(true);
expect(isPathAllowed(['/socios/otro'], ['/socios/clientes', '/socios/proveedores'])).toBe(false);
});
});
+27
View File
@@ -0,0 +1,27 @@
/** Respuesta de `GET /api/user/menu`. */
export interface UserMenuResponse {
allowedPaths: string[];
}
/** Permisos de un usuario web (administración). */
export interface UserPermissions {
webUser: string;
paths: string[];
}
/** Patrón de permiso: `*`, `/ventas/*` (la ruta y todo lo que cuelga) o una ruta exacta. */
export function matchesPattern(pattern: string, path: string): boolean {
if (pattern === '*') return true;
if (pattern.endsWith('/*')) {
const base = pattern.slice(0, -2);
return path === base || path.startsWith(`${base}/`);
}
return pattern === path;
}
/** `required`: una ruta o varias (basta con una). `null` = cualquier sesión. */
export function isPathAllowed(patterns: readonly string[], required: string | readonly string[] | null): boolean {
if (required === null) return true;
const paths = typeof required === 'string' ? [required] : required;
return paths.some((path) => patterns.some((pattern) => matchesPattern(pattern, path)));
}
+11
View File
@@ -0,0 +1,11 @@
import { slTotal } from './sl.js';
describe('slTotal', () => {
it('lee odata.count como texto o número', () => {
expect(slTotal({ value: [], 'odata.count': '1209' })).toBe(1209);
expect(slTotal({ value: [], 'odata.count': 5 })).toBe(5);
});
it('sin total usa la cantidad de filas', () => {
expect(slTotal({ value: [1, 2, 3] })).toBe(3);
});
});
+17
View File
@@ -0,0 +1,17 @@
/** Respuesta de lista del Service Layer a través de `GET /api/sl/:recurso`. */
export interface SlListResponse<T> {
value: T[];
/** Total de registros que cumplen el filtro (SAP lo devuelve como texto o número). */
'odata.count'?: string | number;
}
export function slTotal(res: SlListResponse<unknown>): number {
return Number(res['odata.count'] ?? res.value.length);
}
/** Error que devuelve el backend (SAP traducido o validación propia). */
export interface ApiError {
statusCode: number;
message: string;
sapCode?: number | string;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "ES2022",
"lib": ["ES2022"],
"declaration": true,
"sourceMap": true,
"strict": true,
"isolatedModules": true,
"skipLibCheck": true,
"rootDir": "src",
"outDir": "dist",
"types": []
},
"include": ["src"],
"exclude": ["src/**/*.spec.ts"]
}
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globals: true, include: ['src/**/*.spec.ts'] } });