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:
1 parent
c0990ca83e
commit
f1156929c7
40 files changed
+6231
-5
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './auth.js';
|
||||
export * from './permissions.js';
|
||||
export * from './sl.js';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({ test: { globals: true, include: ['src/**/*.spec.ts'] } });
|
||||
Reference in new issue
Block a user