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
@@ -13,6 +13,15 @@ ERP base para SAP Business One (Service Layer). Monorepo npm workspaces:
|
||||
- Los formularios se construyen con componentes reutilizables y configuración declarativa; no se duplica markup por documento.
|
||||
- Los colores y el aspecto salen de variables CSS (temas). Tema por defecto: claro, con la paleta de `tools/Cotizador Aberio · Oferta de ventas.html`.
|
||||
|
||||
## Criterios de la interfaz
|
||||
|
||||
Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos, zoneless + `OnPush`, rutas con carga diferida, presupuesto de bundle en `angular.json`, listas largas paginadas en el servidor.
|
||||
|
||||
## Comandos
|
||||
|
||||
- `npm install` (npm 11) instala todo y compila `libs/sap-types` (postinstall).
|
||||
- `npm run dev:api` · `npm run dev:web` · `npm run build` · `npm test`.
|
||||
|
||||
## Flujo de trabajo por cliente (branches)
|
||||
|
||||
1. `main` es la versión base/genérica, sin personalizaciones de cliente.
|
||||
|
||||
@@ -9,4 +9,13 @@ libs/sap-types Tipos compartidos
|
||||
tools/ Mockups y pruebas
|
||||
```
|
||||
|
||||
Requisitos: Node 22+ y npm 10+. `npm install` en la raíz instala todos los workspaces.
|
||||
Requisitos: Node 22+ y **npm 11+** (npm 10 falla al resolver las dependencias de Nest 12: `npx npm@11 install`).
|
||||
|
||||
```bash
|
||||
npm install # instala todos los workspaces y compila libs/sap-types
|
||||
npm run dev:api # http://localhost:3000/api/health
|
||||
npm run dev:web # http://localhost:4200
|
||||
npm test # libs + api + web
|
||||
```
|
||||
|
||||
Configuración del backend: `apps/api/.env` (ver `apps/api/.env.example`).
|
||||
@@ -22,6 +22,7 @@
|
||||
"test:e2e": "vitest run --config ./vitest.config.e2e.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@basic-erp/sap-types": "*",
|
||||
"@nestjs/common": "^12.0.1",
|
||||
"@nestjs/config": "^12.0.1",
|
||||
"@nestjs/core": "^12.0.1",
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
MethodNotAllowedException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { isPathAllowed } from '@basic-erp/sap-types';
|
||||
import type { AuthUser } from '../auth/auth.types.js';
|
||||
import { isPathAllowed } from '../permissions/path-matcher.js';
|
||||
import { PermissionsService } from '../permissions/permissions.service.js';
|
||||
import { SapClientService } from '../sap/sap-client.service.js';
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:3000",
|
||||
"secure": false,
|
||||
"changeOrigin": false
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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)],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<header class="shell">
|
||||
<span class="brand">Basic ERP <small>SAP Business One</small></span>
|
||||
<sbo-theme-switcher />
|
||||
</header>
|
||||
<router-outlet />
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [];
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
@@ -0,0 +1,3 @@
|
||||
@import './styles/themes/light.css';
|
||||
@import './styles/themes/dark.css';
|
||||
@import './styles/base.css';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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';
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { isPathAllowed, matchesPattern } from './path-matcher.js';
|
||||
import { isPathAllowed, matchesPattern } from './permissions.js';
|
||||
|
||||
describe('matchesPattern', () => {
|
||||
it('"*" permite todo', () => expect(matchesPattern('*', '/lo/que/sea')).toBe(true));
|
||||
@@ -1,3 +1,14 @@
|
||||
/** 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;
|
||||
@@ -9,8 +20,8 @@ export function matchesPattern(pattern: string, path: string): boolean {
|
||||
}
|
||||
|
||||
/** `required`: una ruta o varias (basta con una). `null` = cualquier sesión. */
|
||||
export function isPathAllowed(patterns: string[], required: string | string[] | null): boolean {
|
||||
export function isPathAllowed(patterns: readonly string[], required: string | readonly string[] | null): boolean {
|
||||
if (required === null) return true;
|
||||
const paths = Array.isArray(required) ? required : [required];
|
||||
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'] } });
|
||||
Generated
+5513
File diff suppressed because it is too large.
Load diff
@@ -10,5 +10,13 @@
|
||||
"engines": {
|
||||
"node": ">=22",
|
||||
"npm": ">=11"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "npm run build -w libs/sap-types",
|
||||
"build:libs": "npm run build -w libs/sap-types",
|
||||
"build": "npm run build:libs && npm run build -w apps/api && npm run build -w apps/web",
|
||||
"test": "npm run build:libs && npm test -w libs/sap-types -w apps/api -w apps/web",
|
||||
"dev:api": "npm run start:dev -w apps/api",
|
||||
"dev:web": "npm start -w apps/web"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user