Aplicación web de Devolución a Proveedores para SAP Business One
Reemplaza al addon .NET Framework (SolucionLibros). Genera Devoluciones de mercancías enlazadas a sus Entradas por compra a partir de un listado pegado desde Excel. - libs/shared (@dp/shared): modelos y lógica de dominio pura (FIFO, consolidación por proveedor, parseo del portapapeles), portada del addon. - apps/api (NestJS): cliente del Service Layer con cola por sesión, login con la sesión de SAP, metadatos (UDT/UDF/UDO) y botón Reconstruir, catálogos, planificaciones (pegar, procesar, reprocesar, marcar líneas, crear Devoluciones, cerrar), listado paginado con filtros y descarga de PDF de la Guía de despacho desde BES. Sirve también la web compilada, con HTTPS opcional. - apps/web (Angular): login, Configuración → Metadatos y Planificaciones (listado, nueva, detalle). - deploy/windows y scripts/publicar.mjs: despliegue como servicio de Windows con WinSW. Verificado contra el Service Layer de SQL Server. Sin probar aún: SAP HANA y la instalación del servicio en Windows (ver CLAUDE.md, sección "Sin verificar"). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
287b187c14
126 files changed
+20033
No files matched your search
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,44 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# Web — Devolución a Proveedores
|
||||
|
||||
Frontend en **Angular 22** (componentes standalone, signals, sin zone.js, CSS) de la aplicación
|
||||
Devolución a Proveedores para SAP Business One. Consume la API NestJS de [`../api`](../api/README.md).
|
||||
|
||||
La documentación general del proyecto (decisiones, flujo, estado) está en
|
||||
[`../../CLAUDE.md`](../../CLAUDE.md). Este archivo cubre solo cómo ejecutar y mantener la web.
|
||||
|
||||
## Puesta en marcha
|
||||
|
||||
Desde la raíz del monorepo (`devolucion-proveedores-web/`), con la API ya levantada:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev:web # compila @dp/shared y levanta Angular en http://localhost:4200
|
||||
```
|
||||
|
||||
`ng serve` redirige `/api` a `http://localhost:3000` (ver `src/proxy.conf.json`), así que no hace falta
|
||||
configurar CORS en desarrollo. Con Fortinet, exportar antes `NODE_OPTIONS=--use-system-ca` y
|
||||
`NG_CLI_ANALYTICS=false`.
|
||||
|
||||
> **Importante:** `@dp/shared` se consume compilado. Si lo cambia, ejecute `npm run build:shared`
|
||||
> **y reinicie `ng serve`**: Vite sirve una copia vieja y la página falla con *"does not provide an
|
||||
> export named …"*. Los tests no lo detectan.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Comando | Qué hace |
|
||||
|---|---|
|
||||
| `ng serve` / `npm start` | Servidor de desarrollo con recarga. |
|
||||
| `ng build` | Compila a `dist/web`. |
|
||||
| `ng test` | Tests (Vitest + jsdom). Para una sola pasada: `npx ng test --watch=false`. |
|
||||
|
||||
## En producción
|
||||
|
||||
La web **no se despliega por separado**: `ng build` genera `dist/web/browser` y la API (NestJS) la sirve
|
||||
junto con `/api`, por HTTPS y en el mismo origen. Por eso las llamadas son siempre relativas (`/api/...`).
|
||||
`npm run publicar` (en la raíz) arma la carpeta de despliegue; ver
|
||||
[`../../deploy/windows/DEPLOY.md`](../../deploy/windows/DEPLOY.md).
|
||||
|
||||
## Pantallas
|
||||
|
||||
| Ruta | Pantalla |
|
||||
|---|---|
|
||||
| `/login` | Usuario y contraseña de SAP B1, con la casilla **"Verificar los metadatos de SAP al ingresar"**. |
|
||||
| `/planificaciones` | Listado con filtros (estado, almacén, fechas) y paginación (**Atrás** / **Siguiente**, filas por página). Los filtros y la página quedan en la URL. |
|
||||
| `/planificaciones/nueva` | Pasos 1 y 2: elegir almacén y fecha, pegar el listado desde Excel (botón *Pegar desde el portapapeles* o `Ctrl/Cmd+V`), validar y procesar (FIFO). Con `?reemplaza=<id>` rehace una planificación. |
|
||||
| `/planificaciones/:docEntry` | Pasos 3 y 4: datos de las Devoluciones y transporte, grilla Padre/Hijo con casillas, **Crear devoluciones**, **Rehacer FIFO**, **Descargar PDF** y **Cerrar planificación**. |
|
||||
| `/configuracion/metadatos` | Estado de tablas, campos y objeto de usuario en SAP, y el botón **Reconstruir**. |
|
||||
|
||||
Flujo de rutas: `authGuard` exige sesión; `metadataGuard` verifica los metadatos una vez por sesión y,
|
||||
si falta algo, lleva a *Configuración → Metadatos* (se omite si el usuario desmarcó la casilla del login).
|
||||
Una respuesta 401 en cualquier llamada devuelve al login.
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
src/app/
|
||||
├── core/ servicios HTTP (auth, metadata, planificaciones), guards, interceptor y errores
|
||||
├── layout/ shell (barra superior) y stepper de los 4 pasos
|
||||
└── pages/
|
||||
├── login/
|
||||
├── metadatos/
|
||||
└── planificaciones/ listado · nueva/ · detalle/
|
||||
```
|
||||
|
||||
Cada página tiene su `.ts`, `.html` y `.css`; los tests (`*.spec.ts`) están junto a lo que prueban.
|
||||
La lógica de negocio pura (FIFO, qué líneas se pueden marcar, consolidación por proveedor) se importa
|
||||
de `@dp/shared`, la misma que usa la API.
|
||||
|
||||
## Cosas que conviene saber
|
||||
|
||||
- **El navegador solo conoce la cookie `dp_sid`**; la sesión de SAP vive en el servidor y se pierde si
|
||||
la API se reinicia (habrá que volver a iniciar sesión).
|
||||
- **Descarga de PDF**: la API devuelve los archivos en base64; la pantalla arma `Blob`s y enlaces
|
||||
(`URL.createObjectURL`). Con un solo archivo lo descarga directo; con varios deja un enlace por archivo.
|
||||
- **Pegar desde el portapapeles** usa `navigator.clipboard.readText()`: solo funciona en `localhost` o
|
||||
HTTPS, y el navegador pide permiso. Si lo niega, se puede pegar a mano dentro del cuadro de texto.
|
||||
- **Estilos en CSS**, no SCSS: en macOS 12 el compilador nativo de Sass exige macOS 14 o superior. Las
|
||||
variables de color y espaciado están en `src/styles.css`.
|
||||
- **Tests sin zone.js**: tras un `flush` de `HttpTestingController` hay que esperar con
|
||||
`await new Promise((r) => setTimeout(r))` y llamar a `fixture.detectChanges()`; `whenStable()` no
|
||||
alcanza para las promesas de `firstValueFrom`.
|
||||
|
||||
## Diagnóstico rápido
|
||||
|
||||
| Síntoma | Causa probable |
|
||||
|---|---|
|
||||
| Pantalla en blanco con *"does not provide an export named …"* en la consola | `@dp/shared` cambió y `ng serve` quedó con la copia vieja. Reiniciarlo. |
|
||||
| Vuelve al login sin avisar | Sesión de SAP vencida o API reiniciada. |
|
||||
| `http proxy error: /api/… ECONNREFUSED` en la terminal | La API no está levantada (o se está reiniciando). |
|
||||
| La lista de almacenes sale vacía | Mirar el recuadro rojo de la pantalla: indica qué llamada falló. |
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"web": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "css"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "css",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.css"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"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": "src/proxy.conf.json"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"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,
|
||||
"packageManager": "npm@10.9.9",
|
||||
"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",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"@dp/shared": "*"
|
||||
},
|
||||
"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.3"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,13 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor } from './core/auth.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard, guestGuard, metadataGuard } from './core/guards';
|
||||
import { Shell } from './layout/shell';
|
||||
import { LoginPage } from './pages/login/login';
|
||||
import { MetadatosPage } from './pages/metadatos/metadatos';
|
||||
import { PlanificacionDetallePage } from './pages/planificaciones/detalle/detalle';
|
||||
import { NuevaPlanificacionPage } from './pages/planificaciones/nueva/nueva';
|
||||
import { PlanificacionesPage } from './pages/planificaciones/planificaciones';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: 'login', component: LoginPage, canActivate: [guestGuard] },
|
||||
{
|
||||
path: '',
|
||||
component: Shell,
|
||||
canActivate: [authGuard],
|
||||
children: [
|
||||
{ path: 'configuracion/metadatos', component: MetadatosPage },
|
||||
{
|
||||
path: '',
|
||||
canActivate: [metadataGuard],
|
||||
children: [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'planificaciones' },
|
||||
{ path: 'planificaciones', component: PlanificacionesPage },
|
||||
{ path: 'planificaciones/nueva', component: NuevaPlanificacionPage },
|
||||
{ path: 'planificaciones/:docEntry', component: PlanificacionDetallePage },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
||||
@@ -0,0 +1,76 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { App } from './app';
|
||||
import { routes } from './app.routes';
|
||||
import { MetadataService } from './core/metadata.service';
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r));
|
||||
|
||||
describe('App', () => {
|
||||
let http: HttpTestingController;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideRouter(routes), provideHttpClient(), provideHttpClientTesting()],
|
||||
}).compileComponents();
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
router = TestBed.inject(Router);
|
||||
});
|
||||
|
||||
it('sin sesión redirige al login', async () => {
|
||||
TestBed.createComponent(App);
|
||||
const navegacion = router.navigateByUrl('/planificaciones');
|
||||
await tick();
|
||||
http.expectOne('/api/auth/me').flush({ message: 'Debe iniciar sesión.' }, { status: 401, statusText: 'Unauthorized' });
|
||||
await navegacion;
|
||||
expect(router.url).toBe('/login');
|
||||
});
|
||||
|
||||
it('con sesión y metadatos incompletos lleva a Configuración', async () => {
|
||||
TestBed.createComponent(App);
|
||||
const navegacion = router.navigateByUrl('/planificaciones');
|
||||
await tick();
|
||||
http.expectOne('/api/auth/me').flush({ userName: 'manager', companyDb: 'TEST', version: '1000' });
|
||||
await tick();
|
||||
http.expectOne('/api/metadata/status').flush({ completo: false, elementos: [] });
|
||||
await navegacion;
|
||||
expect(router.url).toBe('/configuracion/metadatos');
|
||||
});
|
||||
|
||||
it('con la verificación de metadatos desactivada entra sin consultarla', async () => {
|
||||
TestBed.inject(MetadataService).fijarVerificacionAlIngresar(false);
|
||||
TestBed.createComponent(App);
|
||||
const navegacion = router.navigateByUrl('/planificaciones');
|
||||
await tick();
|
||||
http.expectOne('/api/auth/me').flush({ userName: 'manager', companyDb: 'TEST', version: '1000' });
|
||||
await navegacion;
|
||||
|
||||
http.expectNone('/api/metadata/status');
|
||||
expect(router.url).toBe('/planificaciones');
|
||||
});
|
||||
|
||||
it('desactivada pero con un estado ya conocido incompleto, igual lleva a Configuración', async () => {
|
||||
const metadata = TestBed.inject(MetadataService);
|
||||
metadata.fijarVerificacionAlIngresar(false);
|
||||
metadata.estado.set({ completo: false, elementos: [] });
|
||||
TestBed.createComponent(App);
|
||||
const navegacion = router.navigateByUrl('/planificaciones');
|
||||
await tick();
|
||||
http.expectOne('/api/auth/me').flush({ userName: 'manager', companyDb: 'TEST', version: '1000' });
|
||||
await navegacion;
|
||||
|
||||
expect(router.url).toBe('/configuracion/metadatos');
|
||||
});
|
||||
|
||||
it('la elección del login se recuerda en el navegador', () => {
|
||||
const metadata = TestBed.inject(MetadataService);
|
||||
expect(metadata.verificarAlIngresar()).toBe(true);
|
||||
metadata.fijarVerificacionAlIngresar(false);
|
||||
expect(localStorage.getItem('dp.verificarMetadatos')).toBe('false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
imports: [RouterOutlet],
|
||||
selector: 'app-root',
|
||||
template: '<router-outlet />',
|
||||
})
|
||||
export class App {}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
import { AuthService } from './auth.service';
|
||||
import { MetadataService } from './metadata.service';
|
||||
|
||||
/** Si la sesión de SAP expiró (401) en cualquier llamada, vuelve al login. */
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const auth = inject(AuthService);
|
||||
const metadata = inject(MetadataService);
|
||||
const router = inject(Router);
|
||||
|
||||
return next(req).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const esAuth = req.url.startsWith('/api/auth/');
|
||||
if (error instanceof HttpErrorResponse && error.status === 401 && !esAuth) {
|
||||
auth.limpiar();
|
||||
metadata.olvidar();
|
||||
void router.navigateByUrl('/login');
|
||||
}
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import type { InfoConexion, LoginRequest, UsuarioSesion } from '@dp/shared';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private verificada = false;
|
||||
|
||||
readonly usuario = signal<UsuarioSesion | null>(null);
|
||||
readonly autenticado = computed(() => this.usuario() !== null);
|
||||
|
||||
info(): Promise<InfoConexion> {
|
||||
return firstValueFrom(this.http.get<InfoConexion>('/api/auth/info'));
|
||||
}
|
||||
|
||||
async login(datos: LoginRequest): Promise<void> {
|
||||
this.usuario.set(await firstValueFrom(this.http.post<UsuarioSesion>('/api/auth/login', datos)));
|
||||
this.verificada = true;
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await firstValueFrom(this.http.post<void>('/api/auth/logout', {}));
|
||||
} finally {
|
||||
this.limpiar();
|
||||
}
|
||||
}
|
||||
|
||||
/** Recupera la sesión existente (cookie) la primera vez; después usa la que está en memoria. */
|
||||
async asegurarSesion(): Promise<boolean> {
|
||||
if (!this.verificada) {
|
||||
this.verificada = true;
|
||||
try {
|
||||
this.usuario.set(await firstValueFrom(this.http.get<UsuarioSesion>('/api/auth/me')));
|
||||
} catch {
|
||||
this.usuario.set(null);
|
||||
}
|
||||
}
|
||||
return this.autenticado();
|
||||
}
|
||||
|
||||
limpiar(): void {
|
||||
this.usuario.set(null);
|
||||
this.verificada = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from './auth.service';
|
||||
import { MetadataService } from './metadata.service';
|
||||
|
||||
/** Exige sesión de SAP; si no hay, al login. */
|
||||
export const authGuard: CanActivateFn = async () => {
|
||||
const router = inject(Router);
|
||||
return (await inject(AuthService).asegurarSesion()) || router.parseUrl('/login');
|
||||
};
|
||||
|
||||
/** Con sesión activa no tiene sentido volver al login. */
|
||||
export const guestGuard: CanActivateFn = async () => {
|
||||
const router = inject(Router);
|
||||
return !(await inject(AuthService).asegurarSesion()) || router.parseUrl('/');
|
||||
};
|
||||
|
||||
/**
|
||||
* Antes de usar el proceso, verifica que existan en SAP las tablas, campos y objeto de
|
||||
* usuario. Se consulta una vez por sesión (y otra vez tras reconstruir); si falta algo,
|
||||
* lleva a la pantalla de metadatos. Si el usuario desactivó la verificación en el login y
|
||||
* todavía no hay un estado conocido, se omite (Configuración → Metadatos sigue disponible).
|
||||
*/
|
||||
export const metadataGuard: CanActivateFn = async () => {
|
||||
const router = inject(Router);
|
||||
const metadata = inject(MetadataService);
|
||||
|
||||
if (!metadata.estado() && !metadata.verificarAlIngresar()) return true;
|
||||
|
||||
try {
|
||||
const estado = metadata.estado() ?? (await metadata.verificar());
|
||||
return estado.completo || router.parseUrl('/configuracion/metadatos');
|
||||
} catch {
|
||||
return router.parseUrl('/configuracion/metadatos');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
|
||||
/** Mensaje legible de un error de la API (incluye el código SAP si viene). */
|
||||
export function mensajeDeError(error: unknown): string {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
if (error.status === 0) return 'No se pudo conectar con el servidor.';
|
||||
const body = error.error as { message?: string | string[]; sapCode?: number | string } | null;
|
||||
const mensaje = Array.isArray(body?.message) ? body.message.join(' ') : body?.message;
|
||||
if (mensaje) return body?.sapCode != null ? `${mensaje} (SAP ${body.sapCode})` : mensaje;
|
||||
return `Error ${error.status}: ${error.statusText}`;
|
||||
}
|
||||
return error instanceof Error ? error.message : 'Error inesperado.';
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import type { EstadoMetadatos, OpcionesReconstruccion, ResultadoReconstruccion } from '@dp/shared';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
const CLAVE_VERIFICAR = 'dp.verificarMetadatos';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MetadataService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
/**
|
||||
* Si es true (por defecto), tras cada login se verifica en SAP que existan tablas, campos y objeto.
|
||||
* El usuario lo elige en el login; se recuerda en este navegador.
|
||||
*/
|
||||
readonly verificarAlIngresar = signal(localStorage.getItem(CLAVE_VERIFICAR) !== 'false');
|
||||
|
||||
/** Último estado leído de la base SAP; null hasta la primera verificación de la sesión. */
|
||||
readonly estado = signal<EstadoMetadatos | null>(null);
|
||||
|
||||
async verificar(): Promise<EstadoMetadatos> {
|
||||
const estado = await firstValueFrom(this.http.get<EstadoMetadatos>('/api/metadata/status'));
|
||||
this.estado.set(estado);
|
||||
return estado;
|
||||
}
|
||||
|
||||
async reconstruir(opciones: OpcionesReconstruccion): Promise<ResultadoReconstruccion> {
|
||||
const resultado = await firstValueFrom(this.http.post<ResultadoReconstruccion>('/api/metadata/rebuild', opciones));
|
||||
this.estado.set(resultado.estado);
|
||||
return resultado;
|
||||
}
|
||||
|
||||
fijarVerificacionAlIngresar(valor: boolean): void {
|
||||
this.verificarAlIngresar.set(valor);
|
||||
localStorage.setItem(CLAVE_VERIFICAR, String(valor));
|
||||
}
|
||||
|
||||
olvidar(): void {
|
||||
this.estado.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import type {
|
||||
Almacen,
|
||||
CabeceraEditable,
|
||||
CatalogosDevolucion,
|
||||
FiltrosPlanificaciones,
|
||||
PaginaPlanificaciones,
|
||||
Planificacion,
|
||||
RespuestaAbierta,
|
||||
ResultadoCrearDevoluciones,
|
||||
ResultadoPdf,
|
||||
ResultadoPegadoApi,
|
||||
ResultadoProcesar,
|
||||
ResumenPlanificacion,
|
||||
} from '@dp/shared';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
/** Llamadas a la API de planificaciones y catálogos. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlanificacionesService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
catalogos(): Promise<CatalogosDevolucion> {
|
||||
return firstValueFrom(this.http.get<CatalogosDevolucion>('/api/catalogos'));
|
||||
}
|
||||
|
||||
/** Solo los almacenes (más liviano que el catálogo completo). */
|
||||
almacenes(): Promise<Almacen[]> {
|
||||
return firstValueFrom(this.http.get<Almacen[]>('/api/catalogos/almacenes'));
|
||||
}
|
||||
|
||||
/** Una página del listado; los filtros vacíos no se envían. */
|
||||
listar(filtros: FiltrosPlanificaciones = {}): Promise<PaginaPlanificaciones> {
|
||||
let params = new HttpParams();
|
||||
for (const [clave, valor] of Object.entries(filtros)) {
|
||||
if (valor !== undefined && valor !== '') params = params.set(clave, String(valor));
|
||||
}
|
||||
return firstValueFrom(this.http.get<PaginaPlanificaciones>('/api/planificaciones', { params }));
|
||||
}
|
||||
|
||||
/** La planificación abierta o null si no hay ninguna. */
|
||||
async abierta(): Promise<ResumenPlanificacion | null> {
|
||||
return (await firstValueFrom(this.http.get<RespuestaAbierta>('/api/planificaciones/abierta'))).abierta;
|
||||
}
|
||||
|
||||
obtener(docEntry: number): Promise<Planificacion> {
|
||||
return firstValueFrom(this.http.get<Planificacion>(`/api/planificaciones/${docEntry}`));
|
||||
}
|
||||
|
||||
pegar(whsCode: string, texto: string): Promise<ResultadoPegadoApi> {
|
||||
return firstValueFrom(this.http.post<ResultadoPegadoApi>('/api/planificaciones/pegar', { whsCode, texto }));
|
||||
}
|
||||
|
||||
procesar(datos: { whsCode: string; docDate: string; texto: string }): Promise<ResultadoProcesar> {
|
||||
return firstValueFrom(this.http.post<ResultadoProcesar>('/api/planificaciones', datos));
|
||||
}
|
||||
|
||||
/** Rehace el FIFO de una planificación abierta sin Devoluciones: crea una nueva y cierra la anterior. */
|
||||
reprocesar(docEntry: number, datos: { whsCode: string; docDate: string; texto: string }): Promise<ResultadoProcesar> {
|
||||
return firstValueFrom(this.http.post<ResultadoProcesar>(`/api/planificaciones/${docEntry}/reprocesar`, datos));
|
||||
}
|
||||
|
||||
actualizarCabecera(docEntry: number, cambios: CabeceraEditable): Promise<Planificacion> {
|
||||
return firstValueFrom(this.http.patch<Planificacion>(`/api/planificaciones/${docEntry}/cabecera`, cambios));
|
||||
}
|
||||
|
||||
cambiarSeleccion(docEntry: number, lineId: number, applied: boolean): Promise<Planificacion> {
|
||||
return firstValueFrom(this.http.patch<Planificacion>(`/api/planificaciones/${docEntry}/lineas/${lineId}`, { applied }));
|
||||
}
|
||||
|
||||
crearDevoluciones(docEntry: number): Promise<ResultadoCrearDevoluciones> {
|
||||
return firstValueFrom(this.http.post<ResultadoCrearDevoluciones>(`/api/planificaciones/${docEntry}/devoluciones`, {}));
|
||||
}
|
||||
|
||||
/** Pide a BES el PDF de la Guía de despacho de cada Devolución de la planificación. */
|
||||
pdf(docEntry: number, unir: boolean): Promise<ResultadoPdf> {
|
||||
return firstValueFrom(this.http.post<ResultadoPdf>(`/api/planificaciones/${docEntry}/pdf`, { unir }));
|
||||
}
|
||||
|
||||
cerrar(docEntry: number): Promise<Planificacion> {
|
||||
return firstValueFrom(this.http.post<Planificacion>(`/api/planificaciones/${docEntry}/cerrar`, {}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { AuthService } from '../core/auth.service';
|
||||
import { MetadataService } from '../core/metadata.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell',
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||
template: `
|
||||
<header class="barra">
|
||||
<strong class="marca">Devolución a Proveedores</strong>
|
||||
<nav>
|
||||
<a routerLink="/planificaciones" routerLinkActive="activo">Planificaciones</a>
|
||||
<a routerLink="/configuracion/metadatos" routerLinkActive="activo">Configuración</a>
|
||||
</nav>
|
||||
@if (auth.usuario(); as u) {
|
||||
<div class="usuario">
|
||||
<span>{{ u.userName }}</span>
|
||||
<span class="muted">· {{ u.companyDb }}</span>
|
||||
<button class="btn" type="button" (click)="salir()">Salir</button>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
<main class="contenido">
|
||||
<router-outlet />
|
||||
</main>
|
||||
`,
|
||||
styles: `
|
||||
.barra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
height: 56px;
|
||||
padding: 0 24px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.marca {
|
||||
font-size: 15px;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
nav a {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
nav a:hover {
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
nav a.activo {
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary);
|
||||
}
|
||||
.usuario {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.contenido {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class Shell {
|
||||
protected readonly auth = inject(AuthService);
|
||||
private readonly metadata = inject(MetadataService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
async salir(): Promise<void> {
|
||||
await this.auth.logout();
|
||||
this.metadata.olvidar();
|
||||
await this.router.navigateByUrl('/login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
export const PASOS_PROCESO = ['Pegar', 'Procesar', 'Crear devoluciones', 'Descargar PDF'] as const;
|
||||
|
||||
/** Indicador de los 4 pasos del proceso. `actual` va de 1 a 4; los anteriores salen como hechos. */
|
||||
@Component({
|
||||
selector: 'app-stepper',
|
||||
template: `
|
||||
<ol>
|
||||
@for (paso of pasos; track paso; let i = $index) {
|
||||
<li [class.hecho]="i + 1 < actual()" [class.actual]="i + 1 === actual()">
|
||||
<span class="numero">{{ i + 1 < actual() ? '✓' : i + 1 }}</span>
|
||||
<span>{{ paso }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
`,
|
||||
styles: `
|
||||
ol {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.numero {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--neutral-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
li.actual {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
li.actual .numero {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
li.hecho {
|
||||
color: var(--ok);
|
||||
}
|
||||
li.hecho .numero {
|
||||
background: var(--ok-soft);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class Stepper {
|
||||
readonly actual = input.required<number>();
|
||||
protected readonly pasos = PASOS_PROCESO;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
.pantalla {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
form {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.sociedad {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-alt);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.opcion {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
small {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<main class="pantalla">
|
||||
<form class="card" [formGroup]="form" (ngSubmit)="ingresar()">
|
||||
<header>
|
||||
<h1>Devolución a Proveedores</h1>
|
||||
<p class="muted">Ingrese con su usuario de SAP Business One</p>
|
||||
</header>
|
||||
|
||||
@if (info(); as i) {
|
||||
<div class="sociedad">
|
||||
<span class="muted">Sociedad</span>
|
||||
<strong>{{ i.companyDb }}</strong>
|
||||
<span class="chip neutral">{{ i.dbType }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="field">
|
||||
<label for="userName">Usuario</label>
|
||||
<input id="userName" formControlName="userName" autocomplete="username" autofocus />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Contraseña</label>
|
||||
<input id="password" type="password" formControlName="password" autocomplete="current-password" />
|
||||
</div>
|
||||
|
||||
<label class="opcion">
|
||||
<input type="checkbox" formControlName="verificarMetadatos" />
|
||||
<span>
|
||||
Verificar los metadatos de SAP al ingresar
|
||||
<small class="muted">Desactivarlo agiliza el ingreso. Puede verificarlos cuando quiera en Configuración → Metadatos.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@if (error(); as e) {
|
||||
<div class="alert error" role="alert">{{ e }}</div>
|
||||
}
|
||||
|
||||
<button class="btn primary" type="submit" [disabled]="form.invalid || enviando()">
|
||||
{{ enviando() ? 'Ingresando…' : 'Ingresar' }}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import type { InfoConexion } from '@dp/shared';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { mensajeDeError } from '../../core/http-error';
|
||||
import { MetadataService } from '../../core/metadata.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
imports: [ReactiveFormsModule],
|
||||
templateUrl: './login.html',
|
||||
styleUrl: './login.css',
|
||||
})
|
||||
export class LoginPage implements OnInit {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly metadata = inject(MetadataService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
protected readonly info = signal<InfoConexion | null>(null);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly enviando = signal(false);
|
||||
|
||||
protected readonly form = inject(FormBuilder).nonNullable.group({
|
||||
userName: ['', Validators.required],
|
||||
password: ['', Validators.required],
|
||||
verificarMetadatos: [this.metadata.verificarAlIngresar()],
|
||||
});
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
try {
|
||||
this.info.set(await this.auth.info());
|
||||
} catch (e) {
|
||||
this.error.set(mensajeDeError(e));
|
||||
}
|
||||
}
|
||||
|
||||
async ingresar(): Promise<void> {
|
||||
if (this.form.invalid || this.enviando()) return;
|
||||
|
||||
this.enviando.set(true);
|
||||
this.error.set(null);
|
||||
try {
|
||||
const { userName, password, verificarMetadatos } = this.form.getRawValue();
|
||||
await this.auth.login({ userName, password });
|
||||
// Salvo que el usuario lo desactive, tras cada login se verifica que existan tablas, campos y objeto en SAP.
|
||||
this.metadata.fijarVerificacionAlIngresar(verificarMetadatos);
|
||||
this.metadata.olvidar();
|
||||
await this.router.navigateByUrl('/');
|
||||
} catch (e) {
|
||||
this.error.set(mensajeDeError(e));
|
||||
this.form.controls.password.reset();
|
||||
} finally {
|
||||
this.enviando.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.encabezado {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.acciones {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
|
||||
p {
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--error);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<section class="encabezado">
|
||||
<div>
|
||||
<h1>Metadatos de SAP</h1>
|
||||
<p class="muted">Tablas, campos y objeto de usuario que el proceso necesita en la base de SAP Business One.</p>
|
||||
</div>
|
||||
<button class="btn" type="button" (click)="verificar()" [disabled]="cargando() || reconstruyendo()">
|
||||
{{ cargando() ? 'Verificando…' : 'Volver a verificar' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@if (error(); as e) {
|
||||
<div class="alert error" role="alert">{{ e }}</div>
|
||||
}
|
||||
|
||||
@if (estado(); as est) {
|
||||
@if (est.completo) {
|
||||
<div class="alert ok">
|
||||
La base está al día.
|
||||
<a routerLink="/planificaciones">Ir a Planificaciones</a>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="alert warn">
|
||||
Faltan elementos o hay diferencias en la base SAP. El proceso no se puede usar hasta corregirlos.
|
||||
@if (faltanDependencias()) {
|
||||
<br />Hay dependencias externas faltantes: las crea otro addon o la localización, no esta aplicación.
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (pendientes().length > 0) {
|
||||
<section class="card acciones">
|
||||
<div>
|
||||
<h2>Reconstruir</h2>
|
||||
<p class="muted">
|
||||
Crea en SAP lo que falta y corrige las diferencias ({{ pendientes().length }} elemento(s)).
|
||||
</p>
|
||||
@if (hayDestructivos()) {
|
||||
<label class="check">
|
||||
<input type="checkbox" [ngModel]="permitirDestructivos()" (ngModelChange)="permitirDestructivos.set($event)" />
|
||||
Autorizar cambios que borran datos (campos con tipo distinto y campos sobrantes)
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
<button class="btn primary" type="button" (click)="reconstruir()" [disabled]="reconstruyendo() || cargando()">
|
||||
{{ reconstruyendo() ? 'Reconstruyendo…' : 'Reconstruir' }}
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (pasos(); as ps) {
|
||||
<section class="card">
|
||||
<h2>Resultado</h2>
|
||||
@if (ps.length === 0) {
|
||||
<p class="muted">No había nada que hacer.</p>
|
||||
} @else {
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr><th>Acción</th><th>Elemento</th><th>Resultado</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (p of ps; track $index) {
|
||||
<tr>
|
||||
<td>{{ p.accion }}</td>
|
||||
<td><code>{{ p.nombre }}</code></td>
|
||||
<td>
|
||||
<span class="chip" [class.ok]="p.ok" [class.falta]="!p.ok">{{ p.ok ? 'OK' : 'Error' }}</span>
|
||||
@if (p.mensaje) {
|
||||
<span class="muted"> {{ p.mensaje }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@for (g of grupos(); track g.tipo) {
|
||||
<section class="card">
|
||||
<h2>{{ g.titulo }}</h2>
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr><th>Nombre</th><th>Descripción</th><th>Estado</th><th>Detalle</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (e of g.elementos; track e.nombre) {
|
||||
<tr>
|
||||
<td><code>{{ e.nombre }}</code></td>
|
||||
<td>{{ e.descripcion }}</td>
|
||||
<td><span class="chip" [class]="claseEstado(e)">{{ e.estado }}</span></td>
|
||||
<td class="muted">{{ e.detalle }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
}
|
||||
} @else if (cargando()) {
|
||||
<p class="muted">Verificando la base SAP…</p>
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import type { EstadoMetadato, ResultadoPaso } from '@dp/shared';
|
||||
import { mensajeDeError } from '../../core/http-error';
|
||||
import { MetadataService } from '../../core/metadata.service';
|
||||
|
||||
const GRUPOS: { tipo: EstadoMetadato['tipo']; titulo: string }[] = [
|
||||
{ tipo: 'tabla', titulo: 'Tablas de usuario' },
|
||||
{ tipo: 'campo', titulo: 'Campos de usuario' },
|
||||
{ tipo: 'objeto', titulo: 'Objeto de usuario' },
|
||||
{ tipo: 'dependencia', titulo: 'Dependencias externas (solo verificación)' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadatos',
|
||||
imports: [FormsModule, RouterLink],
|
||||
templateUrl: './metadatos.html',
|
||||
styleUrl: './metadatos.css',
|
||||
})
|
||||
export class MetadatosPage implements OnInit {
|
||||
private readonly metadata = inject(MetadataService);
|
||||
|
||||
protected readonly estado = this.metadata.estado;
|
||||
protected readonly cargando = signal(false);
|
||||
protected readonly reconstruyendo = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly pasos = signal<ResultadoPaso[] | null>(null);
|
||||
protected readonly permitirDestructivos = signal(false);
|
||||
|
||||
protected readonly grupos = computed(() => {
|
||||
const elementos = this.estado()?.elementos ?? [];
|
||||
return GRUPOS.map((g) => ({ ...g, elementos: elementos.filter((e) => e.tipo === g.tipo) })).filter(
|
||||
(g) => g.elementos.length > 0,
|
||||
);
|
||||
});
|
||||
|
||||
protected readonly pendientes = computed(() =>
|
||||
(this.estado()?.elementos ?? []).filter((e) => e.estado !== 'OK' && e.tipo !== 'dependencia'),
|
||||
);
|
||||
|
||||
protected readonly hayDestructivos = computed(() => this.pendientes().some((e) => e.destructivo));
|
||||
|
||||
protected readonly faltanDependencias = computed(() =>
|
||||
(this.estado()?.elementos ?? []).some((e) => e.tipo === 'dependencia' && e.estado !== 'OK'),
|
||||
);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.verificar();
|
||||
}
|
||||
|
||||
async verificar(): Promise<void> {
|
||||
this.cargando.set(true);
|
||||
this.error.set(null);
|
||||
try {
|
||||
await this.metadata.verificar();
|
||||
} catch (e) {
|
||||
this.error.set(mensajeDeError(e));
|
||||
} finally {
|
||||
this.cargando.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async reconstruir(): Promise<void> {
|
||||
const destructivo = this.permitirDestructivos() && this.hayDestructivos();
|
||||
const pregunta = destructivo
|
||||
? 'Se crearán los elementos que faltan y se BORRARÁN y volverán a crear los campos marcados. Los datos de esas columnas se perderán. ¿Desea continuar?'
|
||||
: 'Se crearán y corregirán en SAP los elementos que faltan. ¿Desea continuar?';
|
||||
if (!confirm(pregunta)) return;
|
||||
|
||||
this.reconstruyendo.set(true);
|
||||
this.error.set(null);
|
||||
this.pasos.set(null);
|
||||
try {
|
||||
const resultado = await this.metadata.reconstruir({ permitirDestructivos: this.permitirDestructivos() });
|
||||
this.pasos.set(resultado.pasos);
|
||||
} catch (e) {
|
||||
this.error.set(mensajeDeError(e));
|
||||
} finally {
|
||||
this.reconstruyendo.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected claseEstado(e: EstadoMetadato): string {
|
||||
return e.estado.toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.encabezado {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
a.btn {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
form.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rejilla {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.nota {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.acciones {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.titulo-grilla {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
tr.padre td {
|
||||
background: var(--surface-alt);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tr.hijo.deshabilitada td {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.centro {
|
||||
text-align: center;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.texto-error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.pie {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.alert ul {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.barra-pdf {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.opcion-pdf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nota a.btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
@if (cargando()) {
|
||||
<p class="muted">Cargando…</p>
|
||||
}
|
||||
|
||||
@if (error(); as e) {
|
||||
<div class="alert error" role="alert">{{ e }}</div>
|
||||
}
|
||||
|
||||
@if (cabecera(); as c) {
|
||||
<section class="encabezado">
|
||||
<div>
|
||||
<h1>Planificación Nº {{ c.docNum }}</h1>
|
||||
<p class="muted">
|
||||
Almacén {{ c.whsCode }} ·
|
||||
<span class="chip" [class.neutral]="c.status === 'O'" [class.ok]="c.status === 'C'">
|
||||
{{ c.status === 'O' ? 'Abierta' : 'Cerrada' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<a class="btn" routerLink="/planificaciones">Volver</a>
|
||||
</section>
|
||||
|
||||
<app-stepper [actual]="paso()" />
|
||||
|
||||
@if (advertencias().length > 0) {
|
||||
<div class="alert warn">
|
||||
<strong>Avisos del procesamiento:</strong>
|
||||
<ul>
|
||||
@for (a of advertencias(); track $index) {
|
||||
<li>{{ a }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (aviso(); as a) {
|
||||
<div class="alert ok">{{ a }}</div>
|
||||
}
|
||||
|
||||
<form class="card" [formGroup]="form" (ngSubmit)="guardarCabecera()">
|
||||
<h2>Datos de las Devoluciones</h2>
|
||||
<div class="rejilla">
|
||||
<div class="field">
|
||||
<label for="docDate">Fecha</label>
|
||||
<input id="docDate" type="date" formControlName="docDate" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="series">Serie</label>
|
||||
<select id="series" formControlName="series">
|
||||
<option [ngValue]="0" disabled>Seleccione…</option>
|
||||
@for (s of catalogos()?.series ?? []; track s.series) {
|
||||
<option [ngValue]="s.series">{{ s.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="tipoDespacho">Tipo de despacho</label>
|
||||
<select id="tipoDespacho" formControlName="tipoDespacho">
|
||||
<option value="" disabled>Seleccione…</option>
|
||||
@for (o of catalogos()?.tiposDespacho ?? []; track o.value) {
|
||||
<option [value]="o.value">{{ o.value }} · {{ o.description }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="indicadorTraslado">Indicador de traslado</label>
|
||||
<select id="indicadorTraslado" formControlName="indicadorTraslado">
|
||||
<option value="" disabled>Seleccione…</option>
|
||||
@for (o of catalogos()?.indicadoresTraslado ?? []; track o.value) {
|
||||
<option [value]="o.value">{{ o.value }} · {{ o.description }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="printingLines">Líneas por documento</label>
|
||||
<input id="printingLines" type="number" min="1" formControlName="printingLines" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Transporte</h3>
|
||||
<div class="rejilla">
|
||||
<div class="field">
|
||||
<label for="rutTransporte">RUT transportista</label>
|
||||
<input id="rutTransporte" type="text" formControlName="rutTransporte" maxlength="20" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="patente">Patente</label>
|
||||
<input id="patente" type="text" formControlName="patente" maxlength="20" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="rutChofer">RUT chofer</label>
|
||||
<input id="rutChofer" type="text" formControlName="rutChofer" maxlength="20" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nombreChofer">Nombre chofer</label>
|
||||
<input id="nombreChofer" type="text" formControlName="nombreChofer" maxlength="100" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="fechaSalida">Fecha salida</label>
|
||||
<input id="fechaSalida" type="date" formControlName="fechaSalida" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="horaSalida">Hora salida</label>
|
||||
<input id="horaSalida" type="time" formControlName="horaSalida" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="fechaLlegada">Fecha llegada</label>
|
||||
<input id="fechaLlegada" type="date" formControlName="fechaLlegada" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted nota">
|
||||
RUT transportista, patente y datos del chofer se envían a la Devolución. Las fechas y la hora solo se guardan en la
|
||||
planificación.
|
||||
</p>
|
||||
@if (abierta()) {
|
||||
<div class="acciones">
|
||||
<button class="btn" type="submit" [disabled]="form.invalid || ocupado()">Guardar cabecera</button>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
|
||||
<section class="card">
|
||||
<div class="titulo-grilla">
|
||||
<h2>Líneas</h2>
|
||||
<span class="muted">
|
||||
{{ aplicables().length }} marcada(s) pendiente(s) · {{ conDestino() }} con Devolución
|
||||
@if (conError() > 0) {
|
||||
· <span class="texto-error">{{ conError() }} con error</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th><th>Artículo</th><th class="num">Cantidad</th><th class="num">Stock</th>
|
||||
<th>Entrada</th><th>Fecha entrada</th><th class="num">Abierta</th><th class="num">Asignada</th>
|
||||
<th>Proveedor</th><th>Devolución</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (l of lineas(); track l.lineId) {
|
||||
@if (l.tipoFila === 'F') {
|
||||
<tr class="padre">
|
||||
<td></td>
|
||||
<td colspan="1"><code>{{ l.itemCode }}</code> {{ l.itemDescription }}</td>
|
||||
<td class="num">{{ l.quantity }}</td>
|
||||
<td class="num">{{ l.onHand }}</td>
|
||||
<td colspan="6">
|
||||
<span class="chip" [class.ok]="l.status === 'OK'" [class.falta]="l.status === 'SIN_STOCK'">
|
||||
{{ l.status === 'SIN_STOCK' ? 'Sin stock' : 'OK' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @else {
|
||||
<tr class="hijo" [class.deshabilitada]="!l.applied">
|
||||
<td class="centro">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="l.applied"
|
||||
[disabled]="!puedeMarcar(l)"
|
||||
[attr.aria-label]="'Devolver la línea de la Entrada ' + l.baseRef"
|
||||
(change)="alternar(l, $any($event.target).checked)"
|
||||
/>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>Nº {{ l.baseRef }}</td>
|
||||
<td>{{ l.baseDocDate }}</td>
|
||||
<td class="num">{{ l.openQty }}</td>
|
||||
<td class="num">{{ l.allocatedQty }}</td>
|
||||
<td>{{ l.cardCode }} <span class="muted">{{ l.cardName }}</span></td>
|
||||
<td>
|
||||
@if (l.targetEntry != null) {
|
||||
<span class="chip ok">Devolución {{ l.targetEntry }}</span>
|
||||
} @else if (l.errMsg) {
|
||||
<span class="texto-error" [title]="l.errMsg">{{ l.errCode }}: {{ l.errMsg }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (resultado(); as r) {
|
||||
<section class="card">
|
||||
<h2>Resultado de la creación</h2>
|
||||
@if (r.errorGuardado) {
|
||||
<div class="alert error" role="alert">
|
||||
Las Devoluciones se crearon, pero no se pudo guardar el detalle en la planificación: {{ r.errorGuardado }}
|
||||
</div>
|
||||
}
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Proveedor</th><th class="num">Líneas</th><th>Resultado</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (d of r.devoluciones; track d.numero) {
|
||||
<tr>
|
||||
<td>{{ d.numero }}</td>
|
||||
<td>{{ d.cardCode }} <span class="muted">{{ d.cardName }}</span></td>
|
||||
<td class="num">{{ d.lineas }}</td>
|
||||
<td>
|
||||
@if (d.docEntry != null) {
|
||||
<span class="chip ok">Devolución Nº {{ d.docNum }}</span>
|
||||
} @else {
|
||||
<span class="texto-error">{{ d.error }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (conDestino() > 0) {
|
||||
<section class="card">
|
||||
<h2>Descargar PDF</h2>
|
||||
<p class="muted nota">
|
||||
Guía de despacho electrónica (DTE 52) de cada Devolución creada, desde el web service de BES.
|
||||
</p>
|
||||
<div class="barra-pdf">
|
||||
<label class="opcion-pdf">
|
||||
<input type="checkbox" [checked]="pdfUnir()" (change)="pdfUnir.set($any($event.target).checked)" [disabled]="pdfCargando()" />
|
||||
Unir todos los PDF en uno solo
|
||||
</label>
|
||||
<button class="btn primary" type="button" (click)="descargarPdf()" [disabled]="pdfCargando()">
|
||||
{{ pdfCargando() ? 'Consultando BES…' : 'Descargar PDF' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (pdfResultado(); as r) {
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr><th>Devolución</th><th>Folio consultado</th><th>Resultado</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (d of r.devoluciones; track d.docEntry) {
|
||||
<tr>
|
||||
<td>{{ d.docEntry }}</td>
|
||||
<td>{{ d.folio ?? '—' }}</td>
|
||||
<td>
|
||||
@if (d.error) {
|
||||
<span class="texto-error">{{ d.error }}</span>
|
||||
} @else if (r.unificado) {
|
||||
<span class="chip ok">Incluida en el PDF unificado</span>
|
||||
} @else if (urlDe(d.folio); as url) {
|
||||
<a [href]="url" [attr.download]="nombreDe(d.folio)">Descargar {{ nombreDe(d.folio) }}</a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (r.unificado && pdfDescargas()[0]; as u) {
|
||||
<p class="nota">
|
||||
<a class="btn primary" [href]="u.url" [attr.download]="u.nombre">Descargar {{ u.nombre }}</a>
|
||||
</p>
|
||||
}
|
||||
@if (r.archivos.length === 0) {
|
||||
<div class="alert error" role="alert">No se pudo descargar ningún PDF. Revise los mensajes de cada Devolución.</div>
|
||||
} @else if (r.devoluciones.some((d) => d.error)) {
|
||||
<div class="alert warn">Algunas Devoluciones no tienen PDF (ver detalle). Puede volver a intentarlo.</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="pie">
|
||||
<span></span>
|
||||
@if (abierta()) {
|
||||
<div class="acciones">
|
||||
@if (conDestino() === 0) {
|
||||
<a class="btn" routerLink="/planificaciones/nueva" [queryParams]="{ reemplaza: c.docEntry }">Rehacer FIFO</a>
|
||||
}
|
||||
<button class="btn" type="button" (click)="cerrar()" [disabled]="ocupado()">Cerrar planificación</button>
|
||||
<button class="btn primary" type="button" (click)="crearDevoluciones()" [disabled]="!puedeCrear()">
|
||||
{{ ocupado() ? 'Procesando…' : 'Crear devoluciones' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { PlanificacionDetallePage } from './detalle';
|
||||
|
||||
const linea = (extra: Record<string, unknown>) => ({
|
||||
tipoFila: 'S', itemCode: 'A1', itemDescription: 'Libro', quantity: null, onHand: null, status: null,
|
||||
baseType: 20, baseEntry: 15, baseLine: 0, baseRef: 101, baseDocDate: '2026-01-31', openQty: 10, allocatedQty: 4,
|
||||
applied: true, cardCode: 'P1', cardName: 'Proveedor', targetType: null, targetEntry: null, errCode: '', errMsg: '',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const plan = (status: 'O' | 'C', lineas: unknown[]) => ({
|
||||
cabecera: {
|
||||
docEntry: 5, docNum: 5, status, whsCode: '01', docDate: '2026-10-12', series: 15, printingLines: 11,
|
||||
tipoDespacho: '1', indicadorTraslado: '7',
|
||||
},
|
||||
lineas,
|
||||
});
|
||||
|
||||
const CATALOGOS = {
|
||||
almacenes: [],
|
||||
series: [{ series: 15, name: 'Primario', nextNumber: 33, esPorDefecto: true }],
|
||||
tiposDespacho: [{ value: '1', description: 'Por cuenta del receptor' }],
|
||||
indicadoresTraslado: [{ value: '7', description: 'Guía de devolución' }],
|
||||
};
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r));
|
||||
|
||||
describe('PlanificacionDetallePage', () => {
|
||||
let http: HttpTestingController;
|
||||
|
||||
async function crear(p: object) {
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: new Map([['docEntry', '5']]) } } },
|
||||
],
|
||||
}).compileComponents();
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
|
||||
const fixture = TestBed.createComponent(PlanificacionDetallePage);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/planificaciones/5').flush(p);
|
||||
http.expectOne('/api/catalogos').flush(CATALOGOS);
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('una línea con Devolución ya creada tiene el checkbox bloqueado', async () => {
|
||||
const fixture = await crear(
|
||||
plan('O', [
|
||||
linea({ lineId: 1 }),
|
||||
linea({ lineId: 2, targetType: 21, targetEntry: 33 }),
|
||||
]),
|
||||
);
|
||||
const cajas = (fixture.nativeElement as HTMLElement).querySelectorAll<HTMLInputElement>('tbody input[type=checkbox]');
|
||||
|
||||
expect(cajas).toHaveLength(2);
|
||||
expect(cajas[0].disabled).toBe(false);
|
||||
expect(cajas[1].disabled).toBe(true);
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Devolución 33');
|
||||
});
|
||||
|
||||
it('desmarcar una línea llama a la API con applied=false', async () => {
|
||||
const fixture = await crear(plan('O', [linea({ lineId: 1 })]));
|
||||
const caja = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('tbody input[type=checkbox]')!;
|
||||
|
||||
caja.checked = false;
|
||||
caja.dispatchEvent(new Event('change'));
|
||||
const req = http.expectOne('/api/planificaciones/5/lineas/1');
|
||||
expect(req.request.method).toBe('PATCH');
|
||||
expect(req.request.body).toEqual({ applied: false });
|
||||
req.flush(plan('O', [linea({ lineId: 1, applied: false })]));
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(caja.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('crear devoluciones guarda la cabecera y luego pide crearlas', async () => {
|
||||
vi.spyOn(globalThis, 'confirm').mockReturnValue(true);
|
||||
const fixture = await crear(plan('O', [linea({ lineId: 1 })]));
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Crear devoluciones'))!.click();
|
||||
http.expectOne((r) => r.method === 'PATCH' && r.url === '/api/planificaciones/5/cabecera').flush(plan('O', [linea({ lineId: 1 })]));
|
||||
await tick();
|
||||
http.expectOne('/api/planificaciones/5/devoluciones').flush({
|
||||
devoluciones: [{ numero: 1, cardCode: 'P1', cardName: 'Proveedor', lineas: 1, docEntry: 33, docNum: 33 }],
|
||||
planificacion: plan('O', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]),
|
||||
});
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.textContent).toContain('Devolución Nº 33');
|
||||
});
|
||||
|
||||
it('una planificación cerrada no permite cambios', async () => {
|
||||
const fixture = await crear(plan('C', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]));
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
const textos = [...el.querySelectorAll('button')].map((b) => b.textContent);
|
||||
|
||||
expect(textos.some((t) => t?.includes('Crear devoluciones'))).toBe(false);
|
||||
expect(textos.some((t) => t?.includes('Cerrar planificación'))).toBe(false);
|
||||
expect(el.querySelector<HTMLInputElement>('#docDate')!.disabled).toBe(true);
|
||||
});
|
||||
|
||||
const botonRehacer = (fixture: Awaited<ReturnType<typeof crear>>) =>
|
||||
[...(fixture.nativeElement as HTMLElement).querySelectorAll('a')].find((a) => a.textContent?.includes('Rehacer FIFO'));
|
||||
|
||||
it('abierta y sin Devoluciones: ofrece Rehacer FIFO', async () => {
|
||||
const fixture = await crear(plan('O', [linea({ lineId: 1 })]));
|
||||
expect(botonRehacer(fixture)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('con una Devolución ya creada no ofrece Rehacer FIFO', async () => {
|
||||
const fixture = await crear(plan('O', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]));
|
||||
expect(botonRehacer(fixture)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('cerrada no ofrece Rehacer FIFO', async () => {
|
||||
const fixture = await crear(plan('C', [linea({ lineId: 1 })]));
|
||||
expect(botonRehacer(fixture)).toBeFalsy();
|
||||
});
|
||||
|
||||
describe('descarga de PDF (paso 4)', () => {
|
||||
const conDevolucion = () => plan('C', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]);
|
||||
const botonPdf = (el: HTMLElement) => [...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Descargar PDF'));
|
||||
const pdfB64 = btoa('%PDF-1.4 prueba');
|
||||
let clics: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
clics = [];
|
||||
URL.createObjectURL = vi.fn(() => 'blob:prueba');
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
clics.push(this.download);
|
||||
});
|
||||
});
|
||||
|
||||
it('sin Devoluciones creadas no se ofrece', async () => {
|
||||
const fixture = await crear(plan('O', [linea({ lineId: 1 })]));
|
||||
expect(botonPdf(fixture.nativeElement)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('con Devoluciones se ofrece, incluso con la planificación cerrada', async () => {
|
||||
const fixture = await crear(conDevolucion());
|
||||
expect(botonPdf(fixture.nativeElement)).toBeDefined();
|
||||
});
|
||||
|
||||
it('un solo PDF: lo pide con unir=true y lo descarga directo', async () => {
|
||||
const fixture = await crear(conDevolucion());
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
botonPdf(el)!.click();
|
||||
const req = http.expectOne('/api/planificaciones/5/pdf');
|
||||
expect(req.request.body).toEqual({ unir: true });
|
||||
req.flush({ devoluciones: [{ docEntry: 33, folio: 5001 }], archivos: [{ nombre: 'DTE_5001.pdf', base64: pdfB64, folios: [5001] }], unificado: false });
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(clics).toEqual(['DTE_5001.pdf']);
|
||||
expect(el.textContent).toContain('Descargar DTE_5001.pdf');
|
||||
});
|
||||
|
||||
it('varios PDF sin unir: no descarga solo, deja un enlace por Devolución y muestra el error de la que falló', async () => {
|
||||
const fixture = await crear(plan('C', [linea({ lineId: 1, targetEntry: 33 }), linea({ lineId: 2, targetEntry: 34 })]));
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
el.querySelector<HTMLInputElement>('.opcion-pdf input')!.click();
|
||||
botonPdf(el)!.click();
|
||||
const req = http.expectOne('/api/planificaciones/5/pdf');
|
||||
expect(req.request.body).toEqual({ unir: false });
|
||||
req.flush({
|
||||
devoluciones: [{ docEntry: 33, folio: 5001 }, { docEntry: 34, folio: 5002, error: 'Documento no emitido' }],
|
||||
archivos: [{ nombre: 'DTE_5001.pdf', base64: pdfB64, folios: [5001] }],
|
||||
unificado: false,
|
||||
});
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.textContent).toContain('Documento no emitido');
|
||||
expect(el.textContent).toContain('Algunas Devoluciones no tienen PDF');
|
||||
expect(el.querySelectorAll('table.grid a[download]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('unificado: un único enlace con todos los folios', async () => {
|
||||
const fixture = await crear(plan('C', [linea({ lineId: 1, targetEntry: 33 }), linea({ lineId: 2, targetEntry: 34 })]));
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
botonPdf(el)!.click();
|
||||
http.expectOne('/api/planificaciones/5/pdf').flush({
|
||||
devoluciones: [{ docEntry: 33, folio: 5001 }, { docEntry: 34, folio: 5002 }],
|
||||
archivos: [{ nombre: 'DTE_Unificado_5.pdf', base64: pdfB64, folios: [5001, 5002] }],
|
||||
unificado: true,
|
||||
});
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(clics).toEqual(['DTE_Unificado_5.pdf']);
|
||||
expect(el.textContent).toContain('Incluida en el PDF unificado');
|
||||
});
|
||||
|
||||
it('si el servicio no entrega ningún PDF lo dice', async () => {
|
||||
const fixture = await crear(conDevolucion());
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
botonPdf(el)!.click();
|
||||
http.expectOne('/api/planificaciones/5/pdf').flush({ devoluciones: [{ docEntry: 33, folio: 5001, error: 'Usuario inválido' }], archivos: [], unificado: false });
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.textContent).toContain('No se pudo descargar ningún PDF');
|
||||
expect(clics).toEqual([]);
|
||||
});
|
||||
|
||||
it('un error de la API (por ejemplo sin configuración DTE) se muestra', async () => {
|
||||
const fixture = await crear(conDevolucion());
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
botonPdf(el)!.click();
|
||||
http.expectOne('/api/planificaciones/5/pdf').flush({ message: 'No se encontró configuración en la tabla @DTE_CONFC' }, { status: 400, statusText: 'Bad Request' });
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.alert.error')?.textContent).toContain('@DTE_CONFC');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import type {
|
||||
CatalogosDevolucion,
|
||||
LineaPlanificacion,
|
||||
Planificacion,
|
||||
ResultadoCrearDevoluciones,
|
||||
ResultadoPdf,
|
||||
} from '@dp/shared';
|
||||
import { consolidarDocumentos, extraerLineasAplicables, puedeCambiarSeleccion } from '@dp/shared';
|
||||
import { mensajeDeError } from '../../../core/http-error';
|
||||
import { PlanificacionesService } from '../../../core/planificaciones.service';
|
||||
import { Stepper } from '../../../layout/stepper';
|
||||
|
||||
interface DescargaPdf {
|
||||
nombre: string;
|
||||
folios: number[];
|
||||
url: string;
|
||||
}
|
||||
|
||||
function base64ABytes(base64: string): Uint8Array<ArrayBuffer> {
|
||||
const binario = atob(base64);
|
||||
const bytes = new Uint8Array(binario.length);
|
||||
for (let i = 0; i < binario.length; i++) bytes[i] = binario.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function iniciarDescarga(d: DescargaPdf): void {
|
||||
const a = document.createElement('a');
|
||||
a.href = d.url;
|
||||
a.download = d.nombre;
|
||||
a.click();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-planificacion-detalle',
|
||||
imports: [ReactiveFormsModule, RouterLink, Stepper],
|
||||
templateUrl: './detalle.html',
|
||||
styleUrl: './detalle.css',
|
||||
})
|
||||
export class PlanificacionDetallePage implements OnInit, OnDestroy {
|
||||
private readonly api = inject(PlanificacionesService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
private docEntry = 0;
|
||||
|
||||
protected readonly plan = signal<Planificacion | null>(null);
|
||||
protected readonly catalogos = signal<CatalogosDevolucion | null>(null);
|
||||
protected readonly cargando = signal(true);
|
||||
protected readonly ocupado = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly aviso = signal<string | null>(null);
|
||||
protected readonly advertencias = signal<string[]>([]);
|
||||
protected readonly resultado = signal<ResultadoCrearDevoluciones | null>(null);
|
||||
|
||||
// Paso 4: descarga de PDF
|
||||
protected readonly pdfUnir = signal(true);
|
||||
protected readonly pdfCargando = signal(false);
|
||||
protected readonly pdfResultado = signal<ResultadoPdf | null>(null);
|
||||
protected readonly pdfDescargas = signal<DescargaPdf[]>([]);
|
||||
|
||||
protected readonly form = inject(FormBuilder).nonNullable.group({
|
||||
docDate: ['', Validators.required],
|
||||
series: [0, [Validators.required, Validators.min(1)]],
|
||||
tipoDespacho: ['', Validators.required],
|
||||
indicadorTraslado: ['', Validators.required],
|
||||
printingLines: [11, [Validators.required, Validators.min(1)]],
|
||||
rutTransporte: [''],
|
||||
patente: [''],
|
||||
rutChofer: [''],
|
||||
nombreChofer: [''],
|
||||
fechaSalida: [''],
|
||||
horaSalida: [''],
|
||||
fechaLlegada: [''],
|
||||
});
|
||||
|
||||
protected readonly cabecera = computed(() => this.plan()?.cabecera ?? null);
|
||||
protected readonly abierta = computed(() => this.cabecera()?.status === 'O');
|
||||
protected readonly lineas = computed(() => this.plan()?.lineas ?? []);
|
||||
|
||||
protected readonly aplicables = computed(() => extraerLineasAplicables(this.lineas()));
|
||||
protected readonly nDocumentos = computed(() =>
|
||||
consolidarDocumentos(this.aplicables(), this.cabecera()?.printingLines ?? 0).length,
|
||||
);
|
||||
protected readonly conDestino = computed(() => this.lineas().filter((l) => l.tipoFila === 'S' && l.targetEntry != null).length);
|
||||
protected readonly conError = computed(() => this.aplicables().filter((l) => l.errMsg).length);
|
||||
|
||||
/** 3 mientras falten Devoluciones por crear; 4 (PDF) cuando ya no queda nada pendiente. */
|
||||
protected readonly paso = computed(() => (this.conDestino() > 0 && this.aplicables().length === 0 ? 4 : 3));
|
||||
|
||||
protected readonly puedeCrear = computed(
|
||||
() => this.abierta() && this.aplicables().length > 0 && this.form.valid && !this.ocupado(),
|
||||
);
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.liberarDescargas();
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.docEntry = Number(this.route.snapshot.paramMap.get('docEntry'));
|
||||
this.advertencias.set((history.state as { advertencias?: string[] } | null)?.advertencias ?? []);
|
||||
|
||||
try {
|
||||
const [plan, catalogos] = await Promise.all([this.api.obtener(this.docEntry), this.api.catalogos()]);
|
||||
this.catalogos.set(catalogos);
|
||||
this.mostrar(plan);
|
||||
} catch (e) {
|
||||
this.error.set(mensajeDeError(e));
|
||||
} finally {
|
||||
this.cargando.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reemplaza el estado en pantalla con lo que devolvió la API. */
|
||||
private mostrar(plan: Planificacion): void {
|
||||
this.plan.set(plan);
|
||||
const c = plan.cabecera;
|
||||
this.form.reset({
|
||||
docDate: c.docDate,
|
||||
series: c.series,
|
||||
tipoDespacho: c.tipoDespacho,
|
||||
indicadorTraslado: c.indicadorTraslado,
|
||||
printingLines: c.printingLines || 11,
|
||||
rutTransporte: c.rutTransporte ?? '',
|
||||
patente: c.patente ?? '',
|
||||
rutChofer: c.rutChofer ?? '',
|
||||
nombreChofer: c.nombreChofer ?? '',
|
||||
fechaSalida: c.fechaSalida ?? '',
|
||||
horaSalida: c.horaSalida ?? '',
|
||||
fechaLlegada: c.fechaLlegada ?? '',
|
||||
});
|
||||
if (plan.cabecera.status === 'C') this.form.disable();
|
||||
else this.form.enable();
|
||||
}
|
||||
|
||||
protected puedeMarcar(l: LineaPlanificacion): boolean {
|
||||
return this.abierta() && puedeCambiarSeleccion(l) && !this.ocupado();
|
||||
}
|
||||
|
||||
protected async alternar(l: LineaPlanificacion, marcada: boolean): Promise<void> {
|
||||
if (l.lineId == null) return;
|
||||
await this.ejecutar(async () => this.mostrar(await this.api.cambiarSeleccion(this.docEntry, l.lineId!, marcada)));
|
||||
}
|
||||
|
||||
protected async guardarCabecera(): Promise<void> {
|
||||
if (this.form.invalid) return;
|
||||
await this.ejecutar(async () => {
|
||||
this.mostrar(await this.api.actualizarCabecera(this.docEntry, this.form.getRawValue()));
|
||||
this.aviso.set('Cabecera guardada.');
|
||||
});
|
||||
}
|
||||
|
||||
protected async crearDevoluciones(): Promise<void> {
|
||||
if (!this.puedeCrear()) return;
|
||||
const lineas = this.aplicables().length;
|
||||
const docs = this.nDocumentos();
|
||||
if (!confirm(`Se crearán ${docs} Devolución(es) con ${lineas} línea(s) marcada(s) en SAP. ¿Desea continuar?`)) return;
|
||||
|
||||
await this.ejecutar(async () => {
|
||||
// Se guarda primero la cabecera para que las Devoluciones salgan con lo que se ve en pantalla.
|
||||
await this.api.actualizarCabecera(this.docEntry, this.form.getRawValue());
|
||||
const r = await this.api.crearDevoluciones(this.docEntry);
|
||||
this.resultado.set(r);
|
||||
this.mostrar(r.planificacion);
|
||||
});
|
||||
}
|
||||
|
||||
/** Pide los PDF de las Devoluciones. Con un solo archivo lo descarga directo; con varios deja un enlace por archivo. */
|
||||
protected async descargarPdf(): Promise<void> {
|
||||
this.pdfCargando.set(true);
|
||||
this.error.set(null);
|
||||
this.liberarDescargas();
|
||||
this.pdfResultado.set(null);
|
||||
try {
|
||||
const r = await this.api.pdf(this.docEntry, this.pdfUnir());
|
||||
this.pdfResultado.set(r);
|
||||
const descargas = r.archivos.map((a) => ({
|
||||
nombre: a.nombre,
|
||||
folios: a.folios,
|
||||
url: URL.createObjectURL(new Blob([base64ABytes(a.base64)], { type: 'application/pdf' })),
|
||||
}));
|
||||
this.pdfDescargas.set(descargas);
|
||||
if (descargas.length === 1) iniciarDescarga(descargas[0]);
|
||||
} catch (e) {
|
||||
this.error.set(mensajeDeError(e));
|
||||
} finally {
|
||||
this.pdfCargando.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected urlDe(folio: number | undefined): string | null {
|
||||
return this.pdfDescargas().find((d) => folio != null && d.folios.length === 1 && d.folios[0] === folio)?.url ?? null;
|
||||
}
|
||||
|
||||
protected nombreDe(folio: number | undefined): string {
|
||||
return this.pdfDescargas().find((d) => folio != null && d.folios.length === 1 && d.folios[0] === folio)?.nombre ?? '';
|
||||
}
|
||||
|
||||
private liberarDescargas(): void {
|
||||
for (const d of this.pdfDescargas()) URL.revokeObjectURL(d.url);
|
||||
this.pdfDescargas.set([]);
|
||||
}
|
||||
|
||||
protected async cerrar(): Promise<void> {
|
||||
const pendientes = this.aplicables().length;
|
||||
const aviso = pendientes > 0 ? ` Quedan ${pendientes} línea(s) marcada(s) sin Devolución.` : '';
|
||||
if (!confirm(`Se cerrará la planificación y ya no se podrá modificar.${aviso} ¿Desea continuar?`)) return;
|
||||
await this.ejecutar(async () => this.mostrar(await this.api.cerrar(this.docEntry)));
|
||||
}
|
||||
|
||||
private async ejecutar(accion: () => Promise<void>): Promise<void> {
|
||||
this.ocupado.set(true);
|
||||
this.error.set(null);
|
||||
this.aviso.set(null);
|
||||
try {
|
||||
await accion();
|
||||
} catch (e) {
|
||||
this.error.set(mensajeDeError(e));
|
||||
} finally {
|
||||
this.ocupado.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.encabezado {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
a.btn {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.formulario {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.fila {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.field {
|
||||
min-width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
select,
|
||||
textarea {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--primary-soft);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.ayuda {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.acciones,
|
||||
.acciones-finales {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.acciones-finales {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.nota {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.alert ul {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<section class="encabezado">
|
||||
<div>
|
||||
<h1>{{ reemplaza() ? 'Rehacer planificación Nº ' + reemplazaNumero() : 'Nueva planificación' }}</h1>
|
||||
<p class="muted">Pegue los artículos y cantidades desde Excel; se valida el stock del almacén antes de procesar.</p>
|
||||
</div>
|
||||
<a class="btn" routerLink="/planificaciones">Volver</a>
|
||||
</section>
|
||||
|
||||
<app-stepper [actual]="paso()" />
|
||||
|
||||
@if (error(); as e) {
|
||||
<div class="alert error" role="alert">{{ e }}</div>
|
||||
}
|
||||
|
||||
@if (reemplaza()) {
|
||||
<div class="alert warn">
|
||||
Se creará una planificación nueva con este listado y la Nº {{ reemplazaNumero() }} se cerrará. Se conservan la serie, el
|
||||
despacho, el traslado y los datos de transporte ya cargados. Edite el listado, o el almacén, antes de procesar.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (bloqueada() && abierta(); as a) {
|
||||
<div class="alert warn">
|
||||
Hay una planificación abierta (Nº {{ a.docNum }}). Ciérrela antes de crear otra.
|
||||
<a [routerLink]="['/planificaciones', a.docEntry]">Ir a la planificación</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="card formulario">
|
||||
<div class="fila">
|
||||
<div class="field">
|
||||
<label for="almacen">Almacén</label>
|
||||
<select id="almacen" [ngModel]="whsCode()" (ngModelChange)="cambiarAlmacen($event)" [disabled]="cargando() || bloqueada()">
|
||||
<option value="" disabled>Seleccione…</option>
|
||||
@for (a of almacenes(); track a.code) {
|
||||
<option [value]="a.code">{{ a.code }} · {{ a.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="fecha">Fecha del documento</label>
|
||||
<input id="fecha" type="date" [ngModel]="docDate()" (ngModelChange)="docDate.set($event)" [disabled]="bloqueada()" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="texto">Artículos (código y cantidad, separados por tabulador)</label>
|
||||
<textarea
|
||||
id="texto"
|
||||
rows="8"
|
||||
spellcheck="false"
|
||||
placeholder="Copie las columnas desde Excel y péguelas aquí. Código	Cantidad A00001	5"
|
||||
[ngModel]="texto()"
|
||||
(ngModelChange)="cambiarTexto($event)"
|
||||
[disabled]="bloqueada()"
|
||||
></textarea>
|
||||
<span class="muted ayuda">
|
||||
Con 2 columnas se toma la segunda como cantidad; con 3 o más, la tercera (la segunda se ignora). Una primera fila sin
|
||||
cantidad numérica se toma como encabezado.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="acciones">
|
||||
<button class="btn" type="button" (click)="pegarDesdePortapapeles()" [disabled]="bloqueada() || validando() || procesando()">
|
||||
Pegar desde el portapapeles
|
||||
</button>
|
||||
<button class="btn" type="button" (click)="validar()" [disabled]="!puedeValidar()">
|
||||
{{ validando() ? 'Validando…' : 'Validar' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (resultado(); as r) {
|
||||
@if (r.errores.length > 0) {
|
||||
<div class="alert error" role="alert">
|
||||
<strong>Corrija antes de procesar:</strong>
|
||||
<ul>
|
||||
@for (e of r.errores; track $index) {
|
||||
<li>{{ e }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (r.lineas.length > 0) {
|
||||
<section class="card">
|
||||
<h2>Artículos validados</h2>
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Código</th><th>Descripción</th><th class="num">A devolver</th><th class="num">Stock</th><th>Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (l of r.lineas; track l.itemCode) {
|
||||
<tr>
|
||||
<td><code>{{ l.itemCode }}</code></td>
|
||||
<td>{{ l.itemDescription }}</td>
|
||||
<td class="num">{{ l.quantity }}</td>
|
||||
<td class="num">{{ l.onHand }}</td>
|
||||
<td>
|
||||
<span class="chip" [class.ok]="l.status === 'OK'" [class.falta]="l.status === 'SIN_STOCK'">
|
||||
{{ l.status === 'OK' ? 'OK' : 'Sin stock' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@if (sinStock() > 0) {
|
||||
<p class="muted nota">
|
||||
{{ sinStock() }} artículo(s) sin stock suficiente quedarán fuera al procesar.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="acciones-finales">
|
||||
<button class="btn primary" type="button" (click)="procesar()" [disabled]="!puedeProcesar()">
|
||||
{{ procesando() ? 'Procesando…' : reemplaza() ? 'Procesar y reemplazar' : 'Procesar (asignación FIFO)' }}
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { NuevaPlanificacionPage } from './nueva';
|
||||
|
||||
const CATALOGOS = {
|
||||
almacenes: [{ code: '01', name: 'Almacén general' }],
|
||||
series: [],
|
||||
tiposDespacho: [],
|
||||
indicadoresTraslado: [],
|
||||
};
|
||||
|
||||
const padre = (itemCode: string, status: 'OK' | 'SIN_STOCK') => ({
|
||||
tipoFila: 'F', itemCode, itemDescription: 'Libro', quantity: 5, onHand: 10, status,
|
||||
baseType: null, baseEntry: null, baseLine: null, baseRef: null, baseDocDate: null, openQty: null, allocatedQty: null,
|
||||
applied: false, cardCode: '', cardName: '', targetType: null, targetEntry: null, errCode: '', errMsg: '',
|
||||
});
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r));
|
||||
|
||||
describe('NuevaPlanificacionPage', () => {
|
||||
let http: HttpTestingController;
|
||||
|
||||
async function crear(abierta: object | null = null, reemplaza?: { docEntry: number; plan: object }) {
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
...(reemplaza
|
||||
? [{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({ reemplaza: String(reemplaza.docEntry) }) } } }]
|
||||
: []),
|
||||
],
|
||||
}).compileComponents();
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
|
||||
const fixture = TestBed.createComponent(NuevaPlanificacionPage);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/catalogos').flush(CATALOGOS);
|
||||
http.expectOne('/api/planificaciones/abierta').flush({ abierta });
|
||||
if (reemplaza) http.expectOne(`/api/planificaciones/${reemplaza.docEntry}`).flush(reemplaza.plan);
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
async function escribir(fixture: Awaited<ReturnType<typeof crear>>, texto: string) {
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
const almacen = el.querySelector<HTMLSelectElement>('#almacen')!;
|
||||
almacen.value = '01';
|
||||
almacen.dispatchEvent(new Event('change'));
|
||||
const area = el.querySelector<HTMLTextAreaElement>('#texto')!;
|
||||
area.value = texto;
|
||||
area.dispatchEvent(new Event('input'));
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
return el;
|
||||
}
|
||||
|
||||
it('valida lo pegado y habilita Procesar solo si no hay errores', async () => {
|
||||
const fixture = await crear();
|
||||
const el = await escribir(fixture, 'A1\t5');
|
||||
|
||||
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Validar')!.click();
|
||||
const req = http.expectOne('/api/planificaciones/pegar');
|
||||
expect(req.request.body).toEqual({ whsCode: '01', texto: 'A1\t5' });
|
||||
req.flush({ lineas: [padre('A1', 'OK')], errores: [] });
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.textContent).toContain('A1');
|
||||
const procesar = [...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Procesar'));
|
||||
expect(procesar?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('el botón Pegar lee el portapapeles y valida', async () => {
|
||||
const leer = vi.fn().mockResolvedValue('A1\t5');
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { readText: leer }, configurable: true });
|
||||
const fixture = await crear();
|
||||
const el = await escribir(fixture, '');
|
||||
|
||||
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Pegar'))!.click();
|
||||
await tick();
|
||||
const req = http.expectOne('/api/planificaciones/pegar');
|
||||
expect(req.request.body).toEqual({ whsCode: '01', texto: 'A1\t5' });
|
||||
req.flush({ lineas: [padre('A1', 'OK')], errores: [] });
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(leer).toHaveBeenCalled();
|
||||
expect(el.querySelector<HTMLTextAreaElement>('#texto')!.value).toBe('A1\t5');
|
||||
expect(el.textContent).toContain('A1');
|
||||
});
|
||||
|
||||
it('si el navegador niega el portapapeles muestra cómo pegar a mano', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { readText: vi.fn().mockRejectedValue(new Error('denied')) }, configurable: true });
|
||||
const fixture = await crear();
|
||||
const el = await escribir(fixture, '');
|
||||
|
||||
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Pegar'))!.click();
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.textContent).toContain('No se pudo leer el portapapeles');
|
||||
http.expectNone('/api/planificaciones/pegar');
|
||||
});
|
||||
|
||||
it('con errores de validación no deja procesar', async () => {
|
||||
const fixture = await crear();
|
||||
const el = await escribir(fixture, 'X9\t5');
|
||||
|
||||
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Validar')!.click();
|
||||
http.expectOne('/api/planificaciones/pegar').flush({ lineas: [], errores: ['X9: el artículo no existe.'] });
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.textContent).toContain('X9: el artículo no existe.');
|
||||
const procesar = [...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Procesar'));
|
||||
expect(procesar?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('procesa y navega al detalle de la planificación creada', async () => {
|
||||
const fixture = await crear();
|
||||
const router = TestBed.inject(Router);
|
||||
const navegar = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
const el = await escribir(fixture, 'A1\t5');
|
||||
|
||||
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Validar')!.click();
|
||||
http.expectOne('/api/planificaciones/pegar').flush({ lineas: [padre('A1', 'OK')], errores: [] });
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Procesar'))!.click();
|
||||
const req = http.expectOne('/api/planificaciones');
|
||||
expect(req.request.body).toMatchObject({ whsCode: '01', texto: 'A1\t5' });
|
||||
req.flush({ planificacion: { cabecera: { docEntry: 7 }, lineas: [] }, advertencias: ['aviso'] });
|
||||
await tick();
|
||||
|
||||
expect(navegar).toHaveBeenCalledWith(['/planificaciones', 7], { state: { advertencias: ['aviso'] } });
|
||||
});
|
||||
|
||||
it('si falla la consulta de la abierta, los almacenes igual se muestran', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()],
|
||||
}).compileComponents();
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
const fixture = TestBed.createComponent(NuevaPlanificacionPage);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/catalogos').flush(CATALOGOS);
|
||||
http.expectOne('/api/planificaciones/abierta').flush({ message: 'boom' }, { status: 500, statusText: 'Error' });
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
expect(el.querySelectorAll('#almacen option')).toHaveLength(2);
|
||||
expect(el.textContent).toContain('No se pudo consultar la planificación abierta');
|
||||
});
|
||||
|
||||
it('con una planificación abierta bloquea el formulario', async () => {
|
||||
const fixture = await crear({ docEntry: 3, docNum: 3, status: 'O', whsCode: '01', docDate: '2026-10-12' });
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
expect(el.textContent).toContain('Hay una planificación abierta (Nº 3)');
|
||||
expect(el.querySelector<HTMLSelectElement>('#almacen')!.disabled).toBe(true);
|
||||
});
|
||||
|
||||
describe('rehacer una planificación (?reemplaza=)', () => {
|
||||
const resumen = { docEntry: 3, docNum: 3, status: 'O', whsCode: '02', docDate: '2026-10-10' };
|
||||
const planAbierta = (extra: Record<string, unknown> = {}) => ({
|
||||
cabecera: { docEntry: 3, docNum: 3, status: 'O', whsCode: '02', docDate: '2026-10-10' },
|
||||
lineas: [padre('A1', 'OK'), { ...padre('', 'OK'), tipoFila: 'S', ...extra }],
|
||||
});
|
||||
|
||||
it('precarga almacén, fecha y listado, y no la bloquea la propia planificación abierta', async () => {
|
||||
const fixture = await crear(resumen, { docEntry: 3, plan: planAbierta() });
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
expect(el.textContent).toContain('Rehacer planificación Nº 3');
|
||||
expect(el.querySelector<HTMLTextAreaElement>('#texto')!.value).toBe('A1\t5');
|
||||
expect(el.querySelector<HTMLSelectElement>('#almacen')!.disabled).toBe(false);
|
||||
expect(el.textContent).not.toContain('Ciérrela antes de crear otra');
|
||||
});
|
||||
|
||||
it('procesar llama a /reprocesar y avisa que la anterior quedó cerrada', async () => {
|
||||
const fixture = await crear(resumen, { docEntry: 3, plan: planAbierta() });
|
||||
const navegar = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Validar')!.click();
|
||||
http.expectOne('/api/planificaciones/pegar').flush({ lineas: [padre('A1', 'OK')], errores: [] });
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Procesar y reemplazar'))!.click();
|
||||
const req = http.expectOne('/api/planificaciones/3/reprocesar');
|
||||
expect(req.request.body).toMatchObject({ texto: 'A1\t5' });
|
||||
req.flush({ planificacion: { cabecera: { docEntry: 4 }, lineas: [] }, advertencias: [], reemplazada: { docNum: 3 } });
|
||||
await tick();
|
||||
|
||||
expect(navegar).toHaveBeenCalledWith(['/planificaciones', 4], {
|
||||
state: { advertencias: ['Esta planificación reemplaza a la Nº 3, que quedó cerrada.'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('si la anterior ya tiene una Devolución, no se puede rehacer', async () => {
|
||||
const fixture = await crear(resumen, { docEntry: 3, plan: planAbierta({ targetEntry: 33, targetType: 21 }) });
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
expect(el.textContent).toContain('ya no se puede rehacer');
|
||||
expect(el.textContent).not.toContain('Rehacer planificación');
|
||||
expect(el.querySelector<HTMLTextAreaElement>('#texto')!.value).toBe('');
|
||||
});
|
||||
|
||||
it('si la anterior está cerrada, no se puede rehacer', async () => {
|
||||
const plan = { ...planAbierta(), cabecera: { docEntry: 3, docNum: 3, status: 'C', whsCode: '02', docDate: '2026-10-10' } };
|
||||
const fixture = await crear(null, { docEntry: 3, plan });
|
||||
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain('ya no se puede rehacer');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import type { Almacen, Planificacion, ResultadoPegadoApi, ResumenPlanificacion } from '@dp/shared';
|
||||
import { mensajeDeError } from '../../../core/http-error';
|
||||
import { PlanificacionesService } from '../../../core/planificaciones.service';
|
||||
import { Stepper } from '../../../layout/stepper';
|
||||
|
||||
/** Fecha de hoy en formato yyyy-MM-dd según la hora local (no UTC). */
|
||||
function hoy(): string {
|
||||
const d = new Date();
|
||||
const dos = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${dos(d.getMonth() + 1)}-${dos(d.getDate())}`;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-nueva-planificacion',
|
||||
imports: [FormsModule, RouterLink, Stepper],
|
||||
templateUrl: './nueva.html',
|
||||
styleUrl: './nueva.css',
|
||||
})
|
||||
export class NuevaPlanificacionPage implements OnInit {
|
||||
private readonly api = inject(PlanificacionesService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
protected readonly almacenes = signal<Almacen[]>([]);
|
||||
protected readonly abierta = signal<ResumenPlanificacion | null>(null);
|
||||
protected readonly cargando = signal(true);
|
||||
|
||||
/** docEntry de la planificación que se está rehaciendo (query param `reemplaza`), si aplica. */
|
||||
protected readonly reemplaza = signal<number | null>(null);
|
||||
protected readonly reemplazaNumero = signal<number | null>(null);
|
||||
/** Hay otra planificación abierta distinta de la que se rehace: no se puede continuar. */
|
||||
protected readonly bloqueada = computed(() => {
|
||||
const a = this.abierta();
|
||||
return !!a && a.docEntry !== this.reemplaza();
|
||||
});
|
||||
|
||||
protected readonly whsCode = signal('');
|
||||
protected readonly docDate = signal(hoy());
|
||||
protected readonly texto = signal('');
|
||||
|
||||
protected readonly resultado = signal<ResultadoPegadoApi | null>(null);
|
||||
protected readonly validando = signal(false);
|
||||
protected readonly procesando = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
|
||||
protected readonly paso = computed(() => (this.resultado() ? 2 : 1));
|
||||
protected readonly puedeValidar = computed(
|
||||
() => !!this.whsCode() && this.texto().trim().length > 0 && !this.validando() && !this.procesando() && !this.bloqueada(),
|
||||
);
|
||||
protected readonly conStock = computed(() => (this.resultado()?.lineas ?? []).filter((l) => l.status === 'OK').length);
|
||||
protected readonly sinStock = computed(() => (this.resultado()?.lineas ?? []).filter((l) => l.status === 'SIN_STOCK').length);
|
||||
protected readonly puedeProcesar = computed(() => {
|
||||
const r = this.resultado();
|
||||
return !!r && r.errores.length === 0 && this.conStock() > 0 && !this.procesando() && !this.bloqueada();
|
||||
});
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const reemplaza = Number(this.route.snapshot.queryParamMap.get('reemplaza')) || null;
|
||||
this.reemplaza.set(reemplaza);
|
||||
|
||||
// Cada carga va por separado: si falla una, la otra se muestra igual y el error dice cuál.
|
||||
const [catalogos, abierta, anterior] = await Promise.allSettled([
|
||||
this.api.catalogos(),
|
||||
this.api.abierta(),
|
||||
reemplaza ? this.api.obtener(reemplaza) : Promise.resolve(null),
|
||||
]);
|
||||
const errores: string[] = [];
|
||||
|
||||
if (catalogos.status === 'fulfilled') this.almacenes.set(catalogos.value.almacenes);
|
||||
else errores.push(`No se pudieron cargar los almacenes: ${mensajeDeError(catalogos.reason)}`);
|
||||
|
||||
if (abierta.status === 'fulfilled') this.abierta.set(abierta.value);
|
||||
else errores.push(`No se pudo consultar la planificación abierta: ${mensajeDeError(abierta.reason)}`);
|
||||
|
||||
if (anterior.status === 'fulfilled' && anterior.value) this.precargar(anterior.value);
|
||||
else if (anterior.status === 'rejected') errores.push(`No se pudo leer la planificación a rehacer: ${mensajeDeError(anterior.reason)}`);
|
||||
|
||||
if (errores.length > 0) this.error.set(errores.join(' '));
|
||||
this.cargando.set(false);
|
||||
}
|
||||
|
||||
/** Deja listo el formulario con el almacén, la fecha y el listado de la planificación que se rehace. */
|
||||
private precargar(plan: Planificacion): void {
|
||||
if (plan.cabecera.status !== 'O' || plan.lineas.some((l) => l.targetEntry != null)) {
|
||||
this.reemplaza.set(null);
|
||||
this.error.set(`La planificación Nº ${plan.cabecera.docNum} ya no se puede rehacer (está cerrada o tiene Devoluciones creadas).`);
|
||||
return;
|
||||
}
|
||||
this.reemplazaNumero.set(plan.cabecera.docNum ?? null);
|
||||
this.whsCode.set(plan.cabecera.whsCode);
|
||||
this.docDate.set(plan.cabecera.docDate || hoy());
|
||||
this.texto.set(
|
||||
plan.lineas
|
||||
.filter((l) => l.tipoFila === 'F')
|
||||
.map((l) => `${l.itemCode}\t${l.quantity}`)
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
/** Cualquier cambio en el almacén o en el texto invalida la validación anterior. */
|
||||
protected cambiarAlmacen(codigo: string): void {
|
||||
this.whsCode.set(codigo);
|
||||
this.resultado.set(null);
|
||||
}
|
||||
|
||||
protected cambiarTexto(texto: string): void {
|
||||
this.texto.set(texto);
|
||||
this.resultado.set(null);
|
||||
}
|
||||
|
||||
/** Lee el portapapeles del navegador, lo deja en el área de texto y lo valida. */
|
||||
async pegarDesdePortapapeles(): Promise<void> {
|
||||
if (!this.whsCode()) {
|
||||
this.error.set('Seleccione primero el almacén.');
|
||||
return;
|
||||
}
|
||||
this.error.set(null);
|
||||
let texto: string;
|
||||
try {
|
||||
texto = await navigator.clipboard.readText();
|
||||
} catch {
|
||||
this.error.set(
|
||||
'No se pudo leer el portapapeles. Permita el acceso cuando el navegador lo pida, o pegue con Ctrl/Cmd+V dentro del cuadro de texto.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (texto.trim().length === 0) {
|
||||
this.error.set('El portapapeles está vacío. Copie primero las celdas desde Excel.');
|
||||
return;
|
||||
}
|
||||
this.cambiarTexto(texto);
|
||||
await this.validar();
|
||||
}
|
||||
|
||||
async validar(): Promise<void> {
|
||||
if (!this.puedeValidar()) return;
|
||||
this.validando.set(true);
|
||||
this.error.set(null);
|
||||
try {
|
||||
this.resultado.set(await this.api.pegar(this.whsCode(), this.texto()));
|
||||
} catch (e) {
|
||||
this.resultado.set(null);
|
||||
this.error.set(mensajeDeError(e));
|
||||
} finally {
|
||||
this.validando.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async procesar(): Promise<void> {
|
||||
if (!this.puedeProcesar()) return;
|
||||
this.procesando.set(true);
|
||||
this.error.set(null);
|
||||
try {
|
||||
const datos = { whsCode: this.whsCode(), docDate: this.docDate(), texto: this.texto() };
|
||||
const anterior = this.reemplaza();
|
||||
const { planificacion, advertencias, reemplazada } = anterior
|
||||
? await this.api.reprocesar(anterior, datos)
|
||||
: await this.api.procesar(datos);
|
||||
|
||||
const avisos = [...advertencias];
|
||||
if (reemplazada?.errorCierre) {
|
||||
avisos.push(
|
||||
`La planificación Nº ${reemplazada.docNum} no se pudo cerrar (${reemplazada.errorCierre}). Ciérrela a mano para poder crear otras.`,
|
||||
);
|
||||
} else if (reemplazada) {
|
||||
avisos.push(`Esta planificación reemplaza a la Nº ${reemplazada.docNum}, que quedó cerrada.`);
|
||||
}
|
||||
await this.router.navigate(['/planificaciones', planificacion.cabecera.docEntry], { state: { advertencias: avisos } });
|
||||
} catch (e) {
|
||||
this.error.set(mensajeDeError(e));
|
||||
} finally {
|
||||
this.procesando.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.encabezado {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
a.btn {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.filtros {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px 16px;
|
||||
|
||||
.field {
|
||||
min-width: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
.limpiar {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.derecha {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
table.recargando {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.paginacion {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.controles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tamano {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-right: 8px;
|
||||
|
||||
select {
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
background: var(--surface);
|
||||
}
|
||||
}
|
||||
|
||||
.btn.numero {
|
||||
min-width: 36px;
|
||||
padding: 0 8px;
|
||||
|
||||
&.activo {
|
||||
background: var(--primary-soft);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<section class="encabezado">
|
||||
<div>
|
||||
<h1>Planificaciones</h1>
|
||||
<p class="muted">Devoluciones a proveedores agrupadas por ejecución. Solo puede haber una abierta a la vez.</p>
|
||||
</div>
|
||||
@if (abierta()) {
|
||||
<button class="btn primary" type="button" disabled title="Cierre la planificación abierta para crear otra">
|
||||
Nueva planificación
|
||||
</button>
|
||||
} @else {
|
||||
<a class="btn primary" routerLink="nueva">Nueva planificación</a>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (error(); as e) {
|
||||
<div class="alert error" role="alert">{{ e }}</div>
|
||||
}
|
||||
|
||||
@if (abierta(); as a) {
|
||||
<div class="alert warn">
|
||||
Hay una planificación abierta (Nº {{ a.docNum }}).
|
||||
<a [routerLink]="[a.docEntry]">Continuar con ella</a> o ciérrela para crear otra.
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="card filtros" aria-label="Filtros">
|
||||
<div class="field">
|
||||
<label for="f-estado">Estado</label>
|
||||
<select id="f-estado" [ngModel]="status()" (ngModelChange)="status.set($event); filtrar()">
|
||||
<option value="">Todas</option>
|
||||
<option value="O">Abiertas</option>
|
||||
<option value="C">Cerradas</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-almacen">Almacén</label>
|
||||
<select id="f-almacen" [ngModel]="whsCode()" (ngModelChange)="whsCode.set($event); filtrar()">
|
||||
<option value="">Todos</option>
|
||||
@for (a of almacenes(); track a.code) {
|
||||
<option [value]="a.code">{{ a.code }} · {{ a.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-desde">Fecha desde</label>
|
||||
<input id="f-desde" type="date" [ngModel]="desde()" [max]="hasta() || null" (ngModelChange)="desde.set($event); filtrar()" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-hasta">Fecha hasta</label>
|
||||
<input id="f-hasta" type="date" [ngModel]="hasta()" [min]="desde() || null" (ngModelChange)="hasta.set($event); filtrar()" />
|
||||
</div>
|
||||
<div class="limpiar">
|
||||
<button class="btn" type="button" (click)="limpiar()" [disabled]="!hayFiltros()">Limpiar filtros</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
@if (cargando() && !datos()) {
|
||||
<p class="muted">Cargando…</p>
|
||||
} @else if (filas().length === 0) {
|
||||
<p class="muted">
|
||||
{{ hayFiltros() ? 'Ninguna planificación cumple los filtros.' : 'Todavía no hay planificaciones.' }}
|
||||
</p>
|
||||
} @else {
|
||||
<table class="grid" [class.recargando]="cargando()">
|
||||
<thead>
|
||||
<tr><th>Nº</th><th>Fecha</th><th>Almacén</th><th>Estado</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (p of filas(); track p.docEntry) {
|
||||
<tr>
|
||||
<td>{{ p.docNum }}</td>
|
||||
<td>{{ p.docDate }}</td>
|
||||
<td>{{ p.whsCode }}</td>
|
||||
<td>
|
||||
<span class="chip" [class.ok]="p.status === 'C'" [class.neutral]="p.status === 'O'">
|
||||
{{ p.status === 'O' ? 'Abierta' : 'Cerrada' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="derecha"><a [routerLink]="[p.docEntry]">Ver</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (rango(); as r) {
|
||||
<nav class="paginacion" aria-label="Paginación">
|
||||
<span class="muted resumen">Mostrando {{ r.primero }}–{{ r.ultimo }} de {{ r.total }}</span>
|
||||
|
||||
<div class="controles">
|
||||
<label class="tamano muted" for="f-tamano">
|
||||
Por página
|
||||
<select id="f-tamano" [ngModel]="tamano()" (ngModelChange)="cambiarTamano($event)">
|
||||
@for (t of tamanos; track t) {
|
||||
<option [ngValue]="t">{{ t }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button class="btn" type="button" (click)="irA(pagina() - 1)" [disabled]="pagina() <= 1 || cargando()">Atrás</button>
|
||||
@for (n of numeros(); track n) {
|
||||
<button
|
||||
class="btn numero"
|
||||
type="button"
|
||||
[class.activo]="n === pagina()"
|
||||
[attr.aria-current]="n === pagina() ? 'page' : null"
|
||||
(click)="irA(n)"
|
||||
[disabled]="cargando()"
|
||||
>
|
||||
{{ n }}
|
||||
</button>
|
||||
}
|
||||
<button class="btn" type="button" (click)="irA(pagina() + 1)" [disabled]="pagina() >= totalPaginas() || cargando()">
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,174 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, TestRequest, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { PlanificacionesPage } from './planificaciones';
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r));
|
||||
|
||||
const fila = (n: number, status: 'O' | 'C' = 'C') => ({ docEntry: n, docNum: n, status, whsCode: '01', docDate: '2026-10-12' });
|
||||
|
||||
const pagina = (pag: number, total: number, tamano = 20) => ({
|
||||
items: Array.from({ length: Math.min(tamano, total - (pag - 1) * tamano) }, (_, i) => fila(total - (pag - 1) * tamano - i)),
|
||||
total,
|
||||
pagina: pag,
|
||||
tamano,
|
||||
totalPaginas: Math.max(Math.ceil(total / tamano), 1),
|
||||
});
|
||||
|
||||
describe('PlanificacionesPage (listado)', () => {
|
||||
let http: HttpTestingController;
|
||||
|
||||
async function crear(primera: object = pagina(1, 45)) {
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()],
|
||||
}).compileComponents();
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
|
||||
const fixture = TestBed.createComponent(PlanificacionesPage);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/catalogos/almacenes').flush([{ code: '01', name: 'Almacén general' }, { code: '02', name: 'Bodega Secundaria' }]);
|
||||
http.expectOne('/api/planificaciones/abierta').flush({ abierta: null });
|
||||
peticion().flush(primera);
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
/** La petición pendiente al listado (se espera exactamente una). */
|
||||
const peticion = (): TestRequest => http.expectOne((r) => r.url === '/api/planificaciones');
|
||||
|
||||
const boton = (el: HTMLElement, texto: string) =>
|
||||
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === texto)!;
|
||||
|
||||
async function responder(fixture: Awaited<ReturnType<typeof crear>>, cuerpo: object): Promise<TestRequest> {
|
||||
const req = peticion();
|
||||
req.flush(cuerpo);
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
return req;
|
||||
}
|
||||
|
||||
it('carga la primera página y muestra el rango', async () => {
|
||||
const fixture = await crear();
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
expect(el.querySelectorAll('tbody tr')).toHaveLength(20);
|
||||
expect(el.textContent).toContain('Mostrando 1–20 de 45');
|
||||
expect(boton(el, 'Atrás').disabled).toBe(true);
|
||||
expect(boton(el, 'Siguiente').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('el pedido inicial lleva pagina=1 y tamano=20 y no manda filtros vacíos', async () => {
|
||||
await TestBed.configureTestingModule({ providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
TestBed.createComponent(PlanificacionesPage).detectChanges();
|
||||
http.expectOne('/api/catalogos/almacenes').flush([]);
|
||||
http.expectOne('/api/planificaciones/abierta').flush({ abierta: null });
|
||||
const { params } = peticion().request;
|
||||
|
||||
expect(params.get('pagina')).toBe('1');
|
||||
expect(params.get('tamano')).toBe('20');
|
||||
expect(params.has('status')).toBe(false);
|
||||
expect(params.has('whsCode')).toBe(false);
|
||||
});
|
||||
|
||||
it('Siguiente pide la página 2 y Atrás vuelve a la 1', async () => {
|
||||
const fixture = await crear();
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
boton(el, 'Siguiente').click();
|
||||
const req = await responder(fixture, pagina(2, 45));
|
||||
expect(req.request.params.get('pagina')).toBe('2');
|
||||
expect(el.textContent).toContain('Mostrando 21–40 de 45');
|
||||
expect(boton(el, 'Atrás').disabled).toBe(false);
|
||||
|
||||
boton(el, 'Atrás').click();
|
||||
const atras = await responder(fixture, pagina(1, 45));
|
||||
expect(atras.request.params.get('pagina')).toBe('1');
|
||||
});
|
||||
|
||||
it('en la última página Siguiente queda deshabilitado', async () => {
|
||||
const fixture = await crear();
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
boton(el, 'Siguiente').click();
|
||||
await responder(fixture, pagina(2, 45));
|
||||
boton(el, 'Siguiente').click();
|
||||
await responder(fixture, pagina(3, 45));
|
||||
|
||||
expect(el.textContent).toContain('Mostrando 41–45 de 45');
|
||||
expect(boton(el, 'Siguiente').disabled).toBe(true);
|
||||
expect(el.querySelectorAll('tbody tr')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('al cambiar un filtro vuelve a la página 1 con ese filtro', async () => {
|
||||
const fixture = await crear();
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
boton(el, 'Siguiente').click();
|
||||
await responder(fixture, pagina(2, 45));
|
||||
|
||||
const estado = el.querySelector<HTMLSelectElement>('#f-estado')!;
|
||||
estado.value = 'C';
|
||||
estado.dispatchEvent(new Event('change'));
|
||||
const req = await responder(fixture, pagina(1, 7));
|
||||
|
||||
expect(req.request.params.get('status')).toBe('C');
|
||||
expect(req.request.params.get('pagina')).toBe('1');
|
||||
expect(el.textContent).toContain('Mostrando 1–7 de 7');
|
||||
});
|
||||
|
||||
it('filtra por almacén y por rango de fechas', async () => {
|
||||
const fixture = await crear();
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
const almacen = el.querySelector<HTMLSelectElement>('#f-almacen')!;
|
||||
almacen.value = '02';
|
||||
almacen.dispatchEvent(new Event('change'));
|
||||
await responder(fixture, pagina(1, 3));
|
||||
|
||||
const desde = el.querySelector<HTMLInputElement>('#f-desde')!;
|
||||
desde.value = '2026-10-01';
|
||||
desde.dispatchEvent(new Event('input'));
|
||||
const req = await responder(fixture, pagina(1, 2));
|
||||
|
||||
expect(req.request.params.get('whsCode')).toBe('02');
|
||||
expect(req.request.params.get('desde')).toBe('2026-10-01');
|
||||
});
|
||||
|
||||
it('sin resultados con filtros lo dice, y Limpiar filtros los quita', async () => {
|
||||
const fixture = await crear();
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
const estado = el.querySelector<HTMLSelectElement>('#f-estado')!;
|
||||
estado.value = 'O';
|
||||
estado.dispatchEvent(new Event('change'));
|
||||
await responder(fixture, pagina(1, 0));
|
||||
expect(el.textContent).toContain('Ninguna planificación cumple los filtros');
|
||||
expect(el.querySelector('.paginacion')).toBeNull();
|
||||
|
||||
boton(el, 'Limpiar filtros').click();
|
||||
const req = await responder(fixture, pagina(1, 45));
|
||||
expect(req.request.params.has('status')).toBe(false);
|
||||
expect(el.textContent).toContain('Mostrando 1–20 de 45');
|
||||
});
|
||||
|
||||
it('si la página pedida quedó fuera de rango, va a la última', async () => {
|
||||
const fixture = await crear();
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
|
||||
boton(el, 'Siguiente').click();
|
||||
// SAP responde vacío para una página que ya no existe (total 25 → 2 páginas).
|
||||
const vacia = { items: [], total: 25, pagina: 3, tamano: 20, totalPaginas: 2 };
|
||||
peticion().flush(vacia);
|
||||
await tick();
|
||||
const req = peticion();
|
||||
expect(req.request.params.get('pagina')).toBe('2');
|
||||
req.flush(pagina(2, 25));
|
||||
await tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.textContent).toContain('Mostrando 21–25 de 25');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import type { Almacen, FiltrosPlanificaciones, PaginaPlanificaciones, ResumenPlanificacion } from '@dp/shared';
|
||||
import { TAMANO_PAGINA_DEFECTO } from '@dp/shared';
|
||||
import { mensajeDeError } from '../../core/http-error';
|
||||
import { PlanificacionesService } from '../../core/planificaciones.service';
|
||||
|
||||
const TAMANOS = [10, 20, 50, 100];
|
||||
|
||||
@Component({
|
||||
selector: 'app-planificaciones',
|
||||
imports: [FormsModule, RouterLink],
|
||||
templateUrl: './planificaciones.html',
|
||||
styleUrl: './planificaciones.css',
|
||||
})
|
||||
export class PlanificacionesPage implements OnInit {
|
||||
private readonly api = inject(PlanificacionesService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
protected readonly tamanos = TAMANOS;
|
||||
protected readonly almacenes = signal<Almacen[]>([]);
|
||||
protected readonly abierta = signal<ResumenPlanificacion | null>(null);
|
||||
protected readonly datos = signal<PaginaPlanificaciones | null>(null);
|
||||
protected readonly cargando = signal(true);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
|
||||
// Filtros y página actual (se reflejan en la URL).
|
||||
protected readonly status = signal<'' | 'O' | 'C'>('');
|
||||
protected readonly whsCode = signal('');
|
||||
protected readonly desde = signal('');
|
||||
protected readonly hasta = signal('');
|
||||
protected readonly pagina = signal(1);
|
||||
protected readonly tamano = signal(TAMANO_PAGINA_DEFECTO);
|
||||
|
||||
protected readonly filas = computed(() => this.datos()?.items ?? []);
|
||||
protected readonly totalPaginas = computed(() => this.datos()?.totalPaginas ?? 1);
|
||||
protected readonly hayFiltros = computed(() => !!(this.status() || this.whsCode() || this.desde() || this.hasta()));
|
||||
protected readonly rango = computed(() => {
|
||||
const d = this.datos();
|
||||
if (!d || d.total === 0) return null;
|
||||
const primero = (d.pagina - 1) * d.tamano + 1;
|
||||
return { primero, ultimo: Math.min(primero + d.items.length - 1, d.total), total: d.total };
|
||||
});
|
||||
/** Hasta 5 números de página alrededor de la actual. */
|
||||
protected readonly numeros = computed(() => {
|
||||
const total = this.totalPaginas();
|
||||
const inicio = Math.max(1, Math.min(this.pagina() - 2, total - 4));
|
||||
return Array.from({ length: Math.min(5, total) }, (_, i) => inicio + i);
|
||||
});
|
||||
|
||||
private peticion = 0;
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const q = this.route.snapshot.queryParamMap;
|
||||
const estado = q.get('estado');
|
||||
this.status.set(estado === 'O' || estado === 'C' ? estado : '');
|
||||
this.whsCode.set(q.get('almacen') ?? '');
|
||||
this.desde.set(q.get('desde') ?? '');
|
||||
this.hasta.set(q.get('hasta') ?? '');
|
||||
this.pagina.set(Math.max(Number(q.get('pagina')) || 1, 1));
|
||||
this.tamano.set(TAMANOS.includes(Number(q.get('tamano'))) ? Number(q.get('tamano')) : TAMANO_PAGINA_DEFECTO);
|
||||
|
||||
// Los almacenes y la planificación abierta no condicionan el listado: si fallan, el listado igual se ve.
|
||||
void this.api.almacenes().then((a) => this.almacenes.set(a)).catch(() => undefined);
|
||||
void this.api.abierta().then((a) => this.abierta.set(a)).catch(() => undefined);
|
||||
await this.cargar();
|
||||
}
|
||||
|
||||
protected filtrar(): void {
|
||||
this.pagina.set(1);
|
||||
void this.cargar();
|
||||
}
|
||||
|
||||
protected limpiar(): void {
|
||||
this.status.set('');
|
||||
this.whsCode.set('');
|
||||
this.desde.set('');
|
||||
this.hasta.set('');
|
||||
this.filtrar();
|
||||
}
|
||||
|
||||
protected irA(pagina: number): void {
|
||||
if (pagina < 1 || pagina > this.totalPaginas() || pagina === this.pagina()) return;
|
||||
this.pagina.set(pagina);
|
||||
void this.cargar();
|
||||
}
|
||||
|
||||
protected cambiarTamano(tamano: number): void {
|
||||
this.tamano.set(Number(tamano));
|
||||
this.filtrar();
|
||||
}
|
||||
|
||||
private filtros(): FiltrosPlanificaciones {
|
||||
return {
|
||||
status: this.status() || undefined,
|
||||
whsCode: this.whsCode() || undefined,
|
||||
desde: this.desde() || undefined,
|
||||
hasta: this.hasta() || undefined,
|
||||
pagina: this.pagina(),
|
||||
tamano: this.tamano(),
|
||||
};
|
||||
}
|
||||
|
||||
private async cargar(): Promise<void> {
|
||||
const mia = ++this.peticion;
|
||||
this.cargando.set(true);
|
||||
this.error.set(null);
|
||||
try {
|
||||
const datos = await this.api.listar(this.filtros());
|
||||
if (mia !== this.peticion) return; // llegó una respuesta más nueva: se descarta esta
|
||||
|
||||
// Si la página pedida quedó fuera de rango (por ejemplo desde una URL vieja), se va a la última.
|
||||
if (datos.items.length === 0 && datos.total > 0 && datos.pagina > datos.totalPaginas) {
|
||||
this.pagina.set(datos.totalPaginas);
|
||||
await this.cargar();
|
||||
return;
|
||||
}
|
||||
this.datos.set(datos);
|
||||
this.sincronizarUrl();
|
||||
} catch (e) {
|
||||
if (mia === this.peticion) this.error.set(mensajeDeError(e));
|
||||
} finally {
|
||||
if (mia === this.peticion) this.cargando.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deja los filtros y la página en la URL para que al refrescar o volver atrás se conserven. */
|
||||
private sincronizarUrl(): void {
|
||||
void this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
replaceUrl: true,
|
||||
queryParams: {
|
||||
estado: this.status() || null,
|
||||
almacen: this.whsCode() || null,
|
||||
desde: this.desde() || null,
|
||||
hasta: this.hasta() || null,
|
||||
pagina: this.pagina() > 1 ? this.pagina() : null,
|
||||
tamano: this.tamano() !== TAMANO_PAGINA_DEFECTO ? this.tamano() : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Devolución a Proveedores</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
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,6 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:3000",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
:root {
|
||||
--bg: #f4f6f8;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f9fafb;
|
||||
--border: #dde2e8;
|
||||
--text: #1d2733;
|
||||
--text-muted: #5b6876;
|
||||
--primary: #0a6ed1;
|
||||
--primary-hover: #0857a6;
|
||||
--primary-soft: #e6f1fb;
|
||||
--ok: #1e7e45;
|
||||
--ok-soft: #e4f4ea;
|
||||
--warn: #9a5b00;
|
||||
--warn-soft: #fdf1dc;
|
||||
--error: #b42318;
|
||||
--error-soft: #fdecea;
|
||||
--neutral-soft: #eef1f4;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 10%);
|
||||
--font: 'Segoe UI', system-ui, -apple-system, Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: var(--error);
|
||||
border-color: var(--error);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--primary-soft);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&.error {
|
||||
background: var(--error-soft);
|
||||
border-color: #f5c2bd;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
&.warn {
|
||||
background: var(--warn-soft);
|
||||
border-color: #f3d29b;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
&.ok {
|
||||
background: var(--ok-soft);
|
||||
border-color: #b5e0c4;
|
||||
color: var(--ok);
|
||||
}
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
|
||||
&.ok {
|
||||
background: var(--ok-soft);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
&.falta,
|
||||
&.diferente {
|
||||
background: var(--error-soft);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
&.sobrante {
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
background: var(--neutral-soft);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
table.grid {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--surface-alt);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.field select {
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* 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,14 @@
|
||||
/* 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"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user