Initial commit: sap-intercompany-viewer
Frontend Angular 22 (standalone, signals, Material 3) para sap-intercompany-api. Pantallas: log de eventos/intentos, CRUD de databases, listado/alta/detalle de scenarios. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
cf793c88f2
57 files changed
+10675
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
|
||||||
+44
@@ -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
|
||||||
+12
@@ -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,64 @@
|
|||||||
|
# sap-intercompany-viewer
|
||||||
|
|
||||||
|
Frontend (Angular 22, standalone components + signals, Angular Material)
|
||||||
|
para [`sap-intercompany-api`](../sap-intercompany-api) — reemplaza a
|
||||||
|
`sap-webhook-viewer` (retirado del repo) consolidando en un solo visor
|
||||||
|
todo lo relacionado a la replicación entre bases SAP: log de eventos,
|
||||||
|
bases de datos, y escenarios.
|
||||||
|
|
||||||
|
## Pantallas
|
||||||
|
|
||||||
|
- **`/log`** (default) — eventos recibidos (`webhook_events`) con filtros
|
||||||
|
por fecha, business object, escenario y estado. Cada fila es
|
||||||
|
expandible: muestra los `replication_attempts` (escenario, origen →
|
||||||
|
destino, verbo, HTTP status, error, result key). Botón **Reprocesar**
|
||||||
|
con confirmación explícita (avisa que reintenta TODOS los escenarios y
|
||||||
|
destinos aplicables, no solo el que falló).
|
||||||
|
- **`/databases`** — listado + alta/edición (diálogo) + activar/desactivar
|
||||||
|
de bases SAP. El `password` nunca se muestra (la API tampoco lo
|
||||||
|
devuelve); al editar, dejarlo vacío significa "no cambiarlo".
|
||||||
|
- **`/scenarios`** — listado de escenarios (como `scenario:list` del CLI
|
||||||
|
del worker).
|
||||||
|
- **`/scenarios/new`** — alta de escenario (todos los campos de
|
||||||
|
`scenario:add`, incluido modo `payload` con `routingField`). Al crear,
|
||||||
|
la API escribe el scaffold del `.ts` y esta pantalla redirige al
|
||||||
|
detalle mostrando dónde completar `filter`/`transform`.
|
||||||
|
- **`/scenarios/:code`** — detalle: configuración, destinos (agregar/
|
||||||
|
quitar), código fuente de `filter`/`transform` en solo lectura, y
|
||||||
|
mapeos de valor (listado + alta).
|
||||||
|
|
||||||
|
## Qué NO hace (deliberado, v1)
|
||||||
|
|
||||||
|
- **No edita el `.ts` de un escenario.** Se muestra en solo lectura; la
|
||||||
|
edición de `filter`/`transform` es siempre en el editor del usuario
|
||||||
|
(VSCode) — decisión explícita, ver `CLAUDE.md` en la raíz del repo.
|
||||||
|
- **No resalta sintaxis** en el código fuente mostrado (bloque monoespaciado
|
||||||
|
simple) — para no sumar una dependencia extra en esta v1; se puede
|
||||||
|
agregar Prism/highlight.js más adelante si hace falta.
|
||||||
|
- **No tiene un formulario de edición completo para un escenario
|
||||||
|
existente** — solo activar/desactivar. Editar el resto de los campos
|
||||||
|
(`PATCH /scenarios/:code`, ya expuesto por la API) queda para una
|
||||||
|
iteración futura si se necesita.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
ng serve # http://localhost:4200 — requiere sap-intercompany-api corriendo en :3001
|
||||||
|
```
|
||||||
|
|
||||||
|
La URL base de la API está fija en `src/app/core/api.ts`
|
||||||
|
(`http://localhost:3001`) — simplificación deliberada para esta v1 (Angular
|
||||||
|
CLI ya no genera `environments/` por default); si hace falta apuntar a otro
|
||||||
|
host, cambiar esa constante.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Angular 22, standalone components (sin NgModules), signals para estado
|
||||||
|
local de componente.
|
||||||
|
- Angular Material 3 (tema `azure-blue`). Sin `@angular/animations`: es un
|
||||||
|
paquete deprecado en esta versión de Angular (reemplazado por
|
||||||
|
`animate.enter`/`animate.leave` nativos), y Material 3 no lo necesita
|
||||||
|
para su UI base.
|
||||||
|
- `HttpClient` con un wrapper mínimo (`core/api.ts`) — sin librerías de
|
||||||
|
manejo de estado (NgRx, etc.), no hace falta para esta escala.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"cli": {
|
||||||
|
"packageManager": "npm"
|
||||||
|
},
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"sap-intercompany-viewer": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:application",
|
||||||
|
"options": {
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["src/material-theme.scss", "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": "sap-intercompany-viewer:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "sap-intercompany-viewer:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:unit-test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+7995
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "sap-intercompany-viewer",
|
||||||
|
"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.8",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/cdk": "^22.1.4",
|
||||||
|
"@angular/common": "^22.1.0",
|
||||||
|
"@angular/compiler": "^22.1.0",
|
||||||
|
"@angular/core": "^22.1.0",
|
||||||
|
"@angular/forms": "^22.1.0",
|
||||||
|
"@angular/material": "^22.1.4",
|
||||||
|
"@angular/platform-browser": "^22.1.0",
|
||||||
|
"@angular/router": "^22.1.0",
|
||||||
|
"highlight.js": "^11.12.0",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/build": "^22.1.6",
|
||||||
|
"@angular/cli": "^22.1.6",
|
||||||
|
"@angular/compiler-cli": "^22.1.0",
|
||||||
|
"jsdom": "^28.0.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vitest": "^4.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
// Sin provideAnimationsAsync a propósito: depende de @angular/animations,
|
||||||
|
// deprecado en esta versión de Angular (22) a favor de animate.enter/leave
|
||||||
|
// nativos en template. Angular Material 3 ya no lo necesita para su UI base
|
||||||
|
// (ripples/transiciones corren por CSS) — si en el futuro hace falta una
|
||||||
|
// animación puntual, usar la sintaxis nativa en vez de reinstalar el paquete.
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes), provideHttpClient()],
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
margin-right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a.active {
|
||||||
|
background-color: rgba(255, 255, 255, 0.28);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 48px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<mat-toolbar color="primary">
|
||||||
|
<span class="brand">
|
||||||
|
<mat-icon>sync_alt</mat-icon>
|
||||||
|
sap-intercompany-viewer
|
||||||
|
</span>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a mat-button routerLink="/dashboard" routerLinkActive="active">Dashboard</a>
|
||||||
|
<a mat-button routerLink="/log" routerLinkActive="active">Log</a>
|
||||||
|
<a mat-button routerLink="/scenarios" routerLinkActive="active">Escenarios</a>
|
||||||
|
<a mat-button routerLink="/databases" routerLinkActive="active">Bases</a>
|
||||||
|
</nav>
|
||||||
|
</mat-toolbar>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
loadComponent: () => import('./dashboard/dashboard-page/dashboard-page').then((m) => m.DashboardPage),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'log',
|
||||||
|
loadComponent: () => import('./log/log-page/log-page').then((m) => m.LogPage),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'databases',
|
||||||
|
loadComponent: () => import('./databases/databases-list/databases-list').then((m) => m.DatabasesList),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'scenarios',
|
||||||
|
loadComponent: () => import('./scenarios/scenarios-list/scenarios-list').then((m) => m.ScenariosList),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'scenarios/new',
|
||||||
|
loadComponent: () => import('./scenarios/scenario-new/scenario-new').then((m) => m.ScenarioNew),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'scenarios/:code',
|
||||||
|
loadComponent: () => import('./scenarios/scenario-detail/scenario-detail').then((m) => m.ScenarioDetail),
|
||||||
|
},
|
||||||
|
{ path: '**', redirectTo: 'dashboard' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [App],
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render title', async () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
await fixture.whenStable();
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, sap-intercompany-viewer');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [RouterOutlet, RouterLink, RouterLinkActive, MatToolbarModule, MatButtonModule, MatIconModule],
|
||||||
|
selector: 'app-root',
|
||||||
|
styleUrl: './app.css',
|
||||||
|
templateUrl: './app.html',
|
||||||
|
})
|
||||||
|
export class App {}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Service, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
|
||||||
|
// Simplificación deliberada para esta v1: URL fija (sin environments.ts,
|
||||||
|
// que Angular CLI ya no genera por default) — sap-intercompany-api siempre
|
||||||
|
// corre en el mismo host, puerto 3001.
|
||||||
|
export const API_BASE_URL = 'http://localhost:3001';
|
||||||
|
|
||||||
|
function toHttpParams(params?: Record<string, string | undefined>): HttpParams {
|
||||||
|
let httpParams = new HttpParams();
|
||||||
|
for (const [key, value] of Object.entries(params ?? {})) {
|
||||||
|
if (value !== undefined && value !== '') httpParams = httpParams.set(key, value);
|
||||||
|
}
|
||||||
|
return httpParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class Api {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
get<T>(path: string, params?: Record<string, string | undefined>) {
|
||||||
|
return this.http.get<T>(`${API_BASE_URL}${path}`, { params: toHttpParams(params) });
|
||||||
|
}
|
||||||
|
|
||||||
|
post<T>(path: string, body: unknown) {
|
||||||
|
return this.http.post<T>(`${API_BASE_URL}${path}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
patch<T>(path: string, body: unknown) {
|
||||||
|
return this.http.patch<T>(`${API_BASE_URL}${path}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete<T>(path: string) {
|
||||||
|
return this.http.delete<T>(`${API_BASE_URL}${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
export interface DatabaseRecord {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
baseUrl: string;
|
||||||
|
companyDb: string;
|
||||||
|
username: string;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KeyStrategy = 'portable' | 'udfLink';
|
||||||
|
export type KeyType = 'string' | 'number';
|
||||||
|
export type DestinationMode = 'fixed' | 'payload';
|
||||||
|
|
||||||
|
export interface ScenarioDestinationRecord {
|
||||||
|
id: number;
|
||||||
|
scenarioId: number;
|
||||||
|
databaseCode: string;
|
||||||
|
createdAt: string;
|
||||||
|
database?: { code: string; name: string; active: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenarioRecord {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
businessObject: string;
|
||||||
|
destinationBusinessObject: string | null;
|
||||||
|
originDatabaseCode: string;
|
||||||
|
destinationMode: DestinationMode;
|
||||||
|
keyField: string;
|
||||||
|
keyType: KeyType;
|
||||||
|
keyStrategy: KeyStrategy;
|
||||||
|
matchField: string | null;
|
||||||
|
routingField: string | null;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
destinations: ScenarioDestinationRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenarioValueMappingRecord {
|
||||||
|
id: number;
|
||||||
|
scenarioId: number;
|
||||||
|
fieldName: string;
|
||||||
|
databaseCode: string;
|
||||||
|
sourceValue: string;
|
||||||
|
targetValue: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenarioSource {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventStatus = 'pending' | 'processing' | 'done' | 'partial' | 'failed' | 'filtered';
|
||||||
|
|
||||||
|
export interface WebhookEventRecord {
|
||||||
|
id: string;
|
||||||
|
businessObject: string;
|
||||||
|
operation: string;
|
||||||
|
subject: string;
|
||||||
|
payload: unknown;
|
||||||
|
receivedAt: string;
|
||||||
|
status: EventStatus;
|
||||||
|
_count?: { attempts: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplicationAttemptRecord {
|
||||||
|
id: number;
|
||||||
|
eventId: string;
|
||||||
|
scenarioId: number;
|
||||||
|
originDatabase: string;
|
||||||
|
destinationDatabase: string;
|
||||||
|
verbUsed: string;
|
||||||
|
httpStatus: number | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
payloadSent: unknown;
|
||||||
|
resultKey: string | null;
|
||||||
|
attemptedAt: string;
|
||||||
|
scenario?: { code: string; name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookEventDetail extends WebhookEventRecord {
|
||||||
|
attempts: ReplicationAttemptRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventFilters {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
businessObject?: string;
|
||||||
|
scenarioCode?: string;
|
||||||
|
status?: EventStatus[];
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResult<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
activeScenarios: number;
|
||||||
|
pendingEvents: number;
|
||||||
|
doneToday: number;
|
||||||
|
failedToday: number;
|
||||||
|
doneLast7Days: number;
|
||||||
|
failedLast7Days: number;
|
||||||
|
totalDatabases: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
.tiles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mat-sys-surface-container-low, rgba(0, 0, 0, 0.03));
|
||||||
|
border-left: 4px solid #757575;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile mat-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-value {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-label {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-success {
|
||||||
|
border-left-color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-success mat-icon {
|
||||||
|
color: #2e7d32;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-danger {
|
||||||
|
border-left-color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-danger mat-icon {
|
||||||
|
color: #c62828;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<h1>Dashboard</h1>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<mat-spinner diameter="32" />
|
||||||
|
} @else {
|
||||||
|
<div class="tiles">
|
||||||
|
@for (tile of tiles(); track tile.label) {
|
||||||
|
<div class="tile" [class.tile-success]="tile.tone === 'success'" [class.tile-danger]="tile.tone === 'danger'">
|
||||||
|
<mat-icon>{{ tile.icon }}</mat-icon>
|
||||||
|
<div class="tile-body">
|
||||||
|
<div class="tile-value">{{ tile.value }}</div>
|
||||||
|
<div class="tile-label">{{ tile.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { Dashboard } from '../dashboard';
|
||||||
|
import { DashboardStats } from '../../core/models';
|
||||||
|
|
||||||
|
type TileTone = 'neutral' | 'success' | 'danger';
|
||||||
|
|
||||||
|
interface Tile {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
icon: string;
|
||||||
|
tone: TileTone;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [MatIconModule, MatProgressSpinnerModule],
|
||||||
|
selector: 'app-dashboard-page',
|
||||||
|
styleUrl: './dashboard-page.css',
|
||||||
|
templateUrl: './dashboard-page.html',
|
||||||
|
})
|
||||||
|
export class DashboardPage implements OnInit {
|
||||||
|
private readonly dashboardApi = inject(Dashboard);
|
||||||
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
protected readonly loading = signal(true);
|
||||||
|
protected readonly stats = signal<DashboardStats | null>(null);
|
||||||
|
|
||||||
|
protected readonly tiles = computed<Tile[]>(() => {
|
||||||
|
const s = this.stats();
|
||||||
|
if (!s) return [];
|
||||||
|
return [
|
||||||
|
{ label: 'Escenarios activos', value: s.activeScenarios, icon: 'schema', tone: 'neutral' },
|
||||||
|
{ label: 'Bases registradas', value: s.totalDatabases, icon: 'storage', tone: 'neutral' },
|
||||||
|
{ label: 'Mensajes sin procesar', value: s.pendingEvents, icon: 'inbox', tone: 'neutral' },
|
||||||
|
{ label: 'Procesados hoy', value: s.doneToday, icon: 'check_circle', tone: 'success' },
|
||||||
|
{ label: 'Fallas hoy', value: s.failedToday, icon: 'error', tone: 'danger' },
|
||||||
|
{ label: 'Procesados (últimos 7 días)', value: s.doneLast7Days, icon: 'check_circle', tone: 'success' },
|
||||||
|
{ label: 'Fallas (últimos 7 días)', value: s.failedLast7Days, icon: 'error', tone: 'danger' },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.dashboardApi.getStats().subscribe({
|
||||||
|
next: (stats) => {
|
||||||
|
this.stats.set(stats);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.snackBar.open('No se pudieron cargar los indicadores', 'Cerrar', { duration: 4000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Service, inject } from '@angular/core';
|
||||||
|
import { Api } from '../core/api';
|
||||||
|
import { DashboardStats } from '../core/models';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class Dashboard {
|
||||||
|
private readonly api = inject(Api);
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
return this.api.get<DashboardStats>('/dashboard/stats');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 360px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<h2 mat-dialog-title>{{ isEdit ? 'Editar base de datos' : 'Nueva base de datos' }}</h2>
|
||||||
|
|
||||||
|
<form [formGroup]="form" (ngSubmit)="save()">
|
||||||
|
<mat-dialog-content class="fields">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Code</mat-label>
|
||||||
|
<input matInput formControlName="code" placeholder="ej. SUB_05" />
|
||||||
|
@if (isEdit) {
|
||||||
|
<mat-hint>No se puede cambiar una vez creado</mat-hint>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Nombre</mat-label>
|
||||||
|
<input matInput formControlName="name" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Base URL (Service Layer)</mat-label>
|
||||||
|
<input matInput formControlName="baseUrl" placeholder="https://sap10:50000/b1s/v2" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Company DB</mat-label>
|
||||||
|
<input matInput formControlName="companyDb" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Usuario</mat-label>
|
||||||
|
<input matInput formControlName="username" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Password{{ isEdit ? ' (dejar vacío para no cambiarla)' : '' }}</mat-label>
|
||||||
|
<input matInput type="password" formControlName="password" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (isEdit) {
|
||||||
|
<mat-slide-toggle formControlName="active">Activa</mat-slide-toggle>
|
||||||
|
}
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button type="button" (click)="cancel()">Cancelar</button>
|
||||||
|
<button mat-flat-button color="primary" type="submit">Guardar</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { DatabaseRecord } from '../../core/models';
|
||||||
|
|
||||||
|
export interface DatabaseDialogData {
|
||||||
|
database: DatabaseRecord | null; // null = alta
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DatabaseDialogResult {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
baseUrl: string;
|
||||||
|
companyDb: string;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
|
],
|
||||||
|
selector: 'app-database-dialog',
|
||||||
|
styleUrl: './database-dialog.css',
|
||||||
|
templateUrl: './database-dialog.html',
|
||||||
|
})
|
||||||
|
export class DatabaseDialog {
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly dialogRef = inject(MatDialogRef<DatabaseDialog, DatabaseDialogResult>);
|
||||||
|
protected readonly data = inject<DatabaseDialogData>(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
|
protected readonly isEdit = this.data.database !== null;
|
||||||
|
|
||||||
|
protected readonly form = this.fb.nonNullable.group({
|
||||||
|
code: [{ value: this.data.database?.code ?? '', disabled: this.isEdit }, Validators.required],
|
||||||
|
name: [this.data.database?.name ?? '', Validators.required],
|
||||||
|
baseUrl: [this.data.database?.baseUrl ?? '', Validators.required],
|
||||||
|
companyDb: [this.data.database?.companyDb ?? '', Validators.required],
|
||||||
|
username: [this.data.database?.username ?? '', Validators.required],
|
||||||
|
password: ['', this.isEdit ? [] : [Validators.required]],
|
||||||
|
active: [this.data.database?.active ?? true],
|
||||||
|
});
|
||||||
|
|
||||||
|
save() {
|
||||||
|
if (this.form.invalid) {
|
||||||
|
this.form.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = this.form.getRawValue();
|
||||||
|
const result: DatabaseDialogResult = {
|
||||||
|
code: value.code,
|
||||||
|
name: value.name,
|
||||||
|
baseUrl: value.baseUrl,
|
||||||
|
companyDb: value.companyDb,
|
||||||
|
username: value.username,
|
||||||
|
active: value.active,
|
||||||
|
};
|
||||||
|
if (value.password) result.password = value.password;
|
||||||
|
this.dialogRef.close(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<div class="header">
|
||||||
|
<h1>Bases de datos</h1>
|
||||||
|
<button mat-flat-button color="primary" (click)="openCreate()">Nueva base de datos</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<mat-spinner diameter="32" />
|
||||||
|
} @else {
|
||||||
|
<table mat-table [dataSource]="databases()" class="mat-elevation-z1">
|
||||||
|
<ng-container matColumnDef="code">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Code</th>
|
||||||
|
<td mat-cell *matCellDef="let db">{{ db.code }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="name">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Nombre</th>
|
||||||
|
<td mat-cell *matCellDef="let db">{{ db.name }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="baseUrl">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Base URL</th>
|
||||||
|
<td mat-cell *matCellDef="let db">{{ db.baseUrl }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="companyDb">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Company DB</th>
|
||||||
|
<td mat-cell *matCellDef="let db">{{ db.companyDb }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="username">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Usuario</th>
|
||||||
|
<td mat-cell *matCellDef="let db">{{ db.username }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="active">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Estado</th>
|
||||||
|
<td mat-cell *matCellDef="let db">
|
||||||
|
<mat-chip-set>
|
||||||
|
<mat-chip [class]="db.active ? 'chip-active' : 'chip-inactive'">
|
||||||
|
{{ db.active ? 'Activa' : 'Inactiva' }}
|
||||||
|
</mat-chip>
|
||||||
|
</mat-chip-set>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th mat-header-cell *matHeaderCellDef></th>
|
||||||
|
<td mat-cell *matCellDef="let db">
|
||||||
|
<button mat-icon-button (click)="openEdit(db)" aria-label="Editar">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button (click)="toggleActive(db)" [attr.aria-label]="db.active ? 'Desactivar' : 'Activar'">
|
||||||
|
<mat-icon>{{ db.active ? 'toggle_on' : 'toggle_off' }}</mat-icon>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
@if (databases().length === 0) {
|
||||||
|
<app-empty-state icon="storage" message="No hay bases de datos registradas todavía." />
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { Databases } from '../databases';
|
||||||
|
import { DatabaseDialog, DatabaseDialogData, DatabaseDialogResult } from '../database-dialog/database-dialog';
|
||||||
|
import { DatabaseRecord } from '../../core/models';
|
||||||
|
import { EmptyState } from '../../shared/empty-state/empty-state';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [MatTableModule, MatButtonModule, MatIconModule, MatChipsModule, MatProgressSpinnerModule, EmptyState],
|
||||||
|
selector: 'app-databases-list',
|
||||||
|
styleUrl: './databases-list.css',
|
||||||
|
templateUrl: './databases-list.html',
|
||||||
|
})
|
||||||
|
export class DatabasesList implements OnInit {
|
||||||
|
private readonly databasesApi = inject(Databases);
|
||||||
|
private readonly dialog = inject(MatDialog);
|
||||||
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
protected readonly loading = signal(true);
|
||||||
|
protected readonly databases = signal<DatabaseRecord[]>([]);
|
||||||
|
protected readonly displayedColumns = ['code', 'name', 'baseUrl', 'companyDb', 'username', 'active', 'actions'];
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private load() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.databasesApi.list().subscribe({
|
||||||
|
next: (dbs) => {
|
||||||
|
this.databases.set(dbs);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.snackBar.open('No se pudo cargar la lista de bases de datos', 'Cerrar', { duration: 4000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openCreate() {
|
||||||
|
this.openDialog({ database: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
openEdit(database: DatabaseRecord) {
|
||||||
|
this.openDialog({ database });
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleActive(database: DatabaseRecord) {
|
||||||
|
this.databasesApi.update(database.code, { active: !database.active }).subscribe({
|
||||||
|
next: () => this.load(),
|
||||||
|
error: () => this.snackBar.open('No se pudo actualizar la base de datos', 'Cerrar', { duration: 4000 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private openDialog(data: DatabaseDialogData) {
|
||||||
|
const ref = this.dialog.open<DatabaseDialog, DatabaseDialogData, DatabaseDialogResult>(DatabaseDialog, { data });
|
||||||
|
ref.afterClosed().subscribe((result) => {
|
||||||
|
if (!result) return;
|
||||||
|
const request = data.database
|
||||||
|
? this.databasesApi.update(data.database.code, result)
|
||||||
|
: this.databasesApi.create({ ...result, password: result.password ?? '' });
|
||||||
|
request.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Base de datos guardada', 'Cerrar', { duration: 3000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
const message = err?.error?.message ?? 'No se pudo guardar la base de datos';
|
||||||
|
this.snackBar.open(message, 'Cerrar', { duration: 5000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Service, inject } from '@angular/core';
|
||||||
|
import { Api } from '../core/api';
|
||||||
|
import { DatabaseRecord } from '../core/models';
|
||||||
|
|
||||||
|
export interface CreateDatabaseInput {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
baseUrl: string;
|
||||||
|
companyDb: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateDatabaseInput = Partial<Omit<CreateDatabaseInput, 'code'>> & { active?: boolean };
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class Databases {
|
||||||
|
private readonly api = inject(Api);
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return this.api.get<DatabaseRecord[]>('/databases');
|
||||||
|
}
|
||||||
|
|
||||||
|
get(code: string) {
|
||||||
|
return this.api.get<DatabaseRecord>(`/databases/${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input: CreateDatabaseInput) {
|
||||||
|
return this.api.post<DatabaseRecord>('/databases', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(code: string, input: UpdateDatabaseInput) {
|
||||||
|
return this.api.patch<DatabaseRecord>(`/databases/${code}`, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Service, inject } from '@angular/core';
|
||||||
|
import { Api } from '../core/api';
|
||||||
|
import { EventFilters, PagedResult, WebhookEventDetail, WebhookEventRecord } from '../core/models';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class Events {
|
||||||
|
private readonly api = inject(Api);
|
||||||
|
|
||||||
|
list(filters: EventFilters) {
|
||||||
|
return this.api.get<PagedResult<WebhookEventRecord>>('/events', {
|
||||||
|
from: filters.from,
|
||||||
|
to: filters.to,
|
||||||
|
businessObject: filters.businessObject,
|
||||||
|
scenarioCode: filters.scenarioCode,
|
||||||
|
status: filters.status?.length ? filters.status.join(',') : undefined,
|
||||||
|
page: filters.page ? String(filters.page) : undefined,
|
||||||
|
pageSize: filters.pageSize ? String(filters.pageSize) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string) {
|
||||||
|
return this.api.get<WebhookEventDetail>(`/events/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
reprocess(id: string) {
|
||||||
|
return this.api.post<WebhookEventRecord>(`/events/${id}/reprocess`, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.json {
|
||||||
|
max-width: 70vw;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||||
|
|
||||||
|
<mat-dialog-content>
|
||||||
|
<pre class="json">{{ formatted }}</pre>
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button (click)="copy()" matTooltip="Copiar JSON al portapapeles">
|
||||||
|
<mat-icon>content_copy</mat-icon>
|
||||||
|
Copiar
|
||||||
|
</button>
|
||||||
|
<button mat-button mat-dialog-close>Cerrar</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
|
||||||
|
export interface JsonDialogData {
|
||||||
|
title: string;
|
||||||
|
json: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [MatButtonModule, MatDialogModule, MatIconModule, MatTooltipModule],
|
||||||
|
selector: 'app-json-dialog',
|
||||||
|
styleUrl: './json-dialog.css',
|
||||||
|
templateUrl: './json-dialog.html',
|
||||||
|
})
|
||||||
|
export class JsonDialog {
|
||||||
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
protected readonly data = inject<JsonDialogData>(MAT_DIALOG_DATA);
|
||||||
|
protected readonly formatted = JSON.stringify(this.data.json, null, 2);
|
||||||
|
|
||||||
|
copy() {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(this.formatted)
|
||||||
|
.then(() => this.snackBar.open('JSON copiado al portapapeles', 'Cerrar', { duration: 2500 }))
|
||||||
|
.catch(() => this.snackBar.open('No se pudo copiar el JSON', 'Cerrar', { duration: 4000 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row.hidden-row {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-cell {
|
||||||
|
border-bottom: none !important;
|
||||||
|
padding: 0 16px 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempts-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempts-table th,
|
||||||
|
.attempts-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-cell {
|
||||||
|
color: #b3261e;
|
||||||
|
max-width: 320px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
opacity: 0.7;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reprocess-button {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reprocess-button mat-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
--mat-chip-label-text-color: #fff;
|
||||||
|
--mat-chip-elevated-container-color: var(--status-color, #757575);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
--status-color: #757575;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-processing {
|
||||||
|
--status-color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-done {
|
||||||
|
--status-color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-partial {
|
||||||
|
--status-color: #f9a825;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-failed {
|
||||||
|
--status-color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-filtered {
|
||||||
|
--status-color: #6a1b9a;
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<h1>Log de replicación</h1>
|
||||||
|
|
||||||
|
<form [formGroup]="filtersForm" (ngSubmit)="applyFilters()" class="filters">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Desde</mat-label>
|
||||||
|
<input matInput type="date" formControlName="from" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Hasta</mat-label>
|
||||||
|
<input matInput type="date" formControlName="to" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Business object</mat-label>
|
||||||
|
<input matInput formControlName="businessObject" placeholder="ej. BusinessPartners" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Escenario</mat-label>
|
||||||
|
<mat-select formControlName="scenarioCode">
|
||||||
|
<mat-option value="">Todos</mat-option>
|
||||||
|
@for (s of scenarios(); track s.code) {
|
||||||
|
<mat-option [value]="s.code">{{ s.code }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Estado</mat-label>
|
||||||
|
<mat-select formControlName="status" multiple>
|
||||||
|
@for (status of statuses; track status) {
|
||||||
|
<mat-option [value]="status">{{ status }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
<mat-hint>Sin selección = todos</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button mat-flat-button color="primary" type="submit">Filtrar</button>
|
||||||
|
<button mat-button type="button" (click)="clearFilters()">Limpiar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<mat-spinner diameter="32" />
|
||||||
|
} @else {
|
||||||
|
<table mat-table [dataSource]="events()" multiTemplateDataRows class="mat-elevation-z1">
|
||||||
|
<ng-container matColumnDef="receivedAt">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Recibido</th>
|
||||||
|
<td mat-cell *matCellDef="let e">{{ e.receivedAt | date: 'short' }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="businessObject">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Business object</th>
|
||||||
|
<td mat-cell *matCellDef="let e">{{ e.businessObject }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="operation">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Operación</th>
|
||||||
|
<td mat-cell *matCellDef="let e">{{ e.operation }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="subject">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Subject</th>
|
||||||
|
<td mat-cell *matCellDef="let e">{{ e.subject }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="status">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Estado</th>
|
||||||
|
<td mat-cell *matCellDef="let e">
|
||||||
|
<mat-chip-set>
|
||||||
|
<mat-chip [class]="'status-chip status-' + e.status">{{ e.status }}</mat-chip>
|
||||||
|
</mat-chip-set>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="attempts">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Intentos</th>
|
||||||
|
<td mat-cell *matCellDef="let e">{{ e._count?.attempts ?? 0 }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="message">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Mensaje</th>
|
||||||
|
<td mat-cell *matCellDef="let e">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="viewJson('Payload recibido de SAP', e.payload); $event.stopPropagation()"
|
||||||
|
aria-label="Ver mensaje recibido"
|
||||||
|
matTooltip="Ver mensaje recibido de SAP"
|
||||||
|
>
|
||||||
|
<mat-icon>data_object</mat-icon>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th mat-header-cell *matHeaderCellDef></th>
|
||||||
|
<td mat-cell *matCellDef="let e">
|
||||||
|
<button mat-icon-button (click)="toggleExpand(e); $event.stopPropagation()" aria-label="Ver detalle">
|
||||||
|
<mat-icon>{{ expandedEventId() === e.id ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="primary"
|
||||||
|
class="reprocess-button"
|
||||||
|
(click)="reprocess(e); $event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Reprocesar
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="expandedDetail">
|
||||||
|
<td mat-cell *matCellDef="let e" [attr.colspan]="displayedColumns.length" class="detail-cell">
|
||||||
|
@if (expandedEventId() === e.id) {
|
||||||
|
<div class="detail">
|
||||||
|
@if (expandedLoading()) {
|
||||||
|
<mat-spinner diameter="24" />
|
||||||
|
} @else if (expandedAttempts().length === 0) {
|
||||||
|
<p class="empty">Sin intentos de replicación todavía (evento sin escenario aplicable, o pendiente).</p>
|
||||||
|
} @else {
|
||||||
|
<table class="attempts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Escenario</th>
|
||||||
|
<th>Origen → Destino</th>
|
||||||
|
<th>Verbo</th>
|
||||||
|
<th>HTTP</th>
|
||||||
|
<th>Result key</th>
|
||||||
|
<th>Error</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Payload</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of expandedAttempts(); track a.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ a.scenario?.code ?? a.scenarioId }}</td>
|
||||||
|
<td>{{ a.originDatabase }} → {{ a.destinationDatabase }}</td>
|
||||||
|
<td>{{ a.verbUsed }}</td>
|
||||||
|
<td>{{ a.httpStatus ?? '—' }}</td>
|
||||||
|
<td>{{ a.resultKey ?? '—' }}</td>
|
||||||
|
<td class="error-cell">{{ a.errorMessage ?? '—' }}</td>
|
||||||
|
<td>{{ a.attemptedAt | date: 'short' }}</td>
|
||||||
|
<td>
|
||||||
|
@if (a.payloadSent) {
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="viewJson('Payload enviado a ' + a.destinationDatabase, a.payloadSent)"
|
||||||
|
aria-label="Ver payload enviado"
|
||||||
|
matTooltip="Ver payload enviado"
|
||||||
|
>
|
||||||
|
<mat-icon>data_object</mat-icon>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="event-row" (click)="toggleExpand(row)"></tr>
|
||||||
|
<tr
|
||||||
|
mat-row
|
||||||
|
*matRowDef="let row; columns: ['expandedDetail']"
|
||||||
|
class="detail-row"
|
||||||
|
[class.hidden-row]="expandedEventId() !== row.id"
|
||||||
|
></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
@if (events().length === 0) {
|
||||||
|
<app-empty-state icon="inbox" message="No hay eventos que coincidan con los filtros." />
|
||||||
|
}
|
||||||
|
|
||||||
|
<mat-paginator
|
||||||
|
[length]="total()"
|
||||||
|
[pageIndex]="pageIndex()"
|
||||||
|
[pageSize]="pageSize()"
|
||||||
|
[pageSizeOptions]="pageSizeOptions"
|
||||||
|
(page)="onPageChange($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
/>
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { Events } from '../events';
|
||||||
|
import { JsonDialog, JsonDialogData } from '../json-dialog/json-dialog';
|
||||||
|
import { Scenarios } from '../../scenarios/scenarios';
|
||||||
|
import { EmptyState } from '../../shared/empty-state/empty-state';
|
||||||
|
import { EventStatus, ReplicationAttemptRecord, ScenarioRecord, WebhookEventRecord } from '../../core/models';
|
||||||
|
|
||||||
|
const STATUSES: EventStatus[] = ['pending', 'processing', 'done', 'partial', 'failed', 'filtered'];
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatChipsModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
EmptyState,
|
||||||
|
],
|
||||||
|
selector: 'app-log-page',
|
||||||
|
styleUrl: './log-page.css',
|
||||||
|
templateUrl: './log-page.html',
|
||||||
|
})
|
||||||
|
export class LogPage implements OnInit {
|
||||||
|
private readonly eventsApi = inject(Events);
|
||||||
|
private readonly scenariosApi = inject(Scenarios);
|
||||||
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
protected readonly statuses = STATUSES;
|
||||||
|
protected readonly loading = signal(true);
|
||||||
|
protected readonly events = signal<WebhookEventRecord[]>([]);
|
||||||
|
protected readonly scenarios = signal<ScenarioRecord[]>([]);
|
||||||
|
protected readonly displayedColumns = [
|
||||||
|
'receivedAt',
|
||||||
|
'businessObject',
|
||||||
|
'operation',
|
||||||
|
'subject',
|
||||||
|
'status',
|
||||||
|
'attempts',
|
||||||
|
'message',
|
||||||
|
'actions',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected readonly total = signal(0);
|
||||||
|
protected readonly pageIndex = signal(0);
|
||||||
|
protected readonly pageSize = signal(25);
|
||||||
|
protected readonly pageSizeOptions = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
protected readonly expandedEventId = signal<string | null>(null);
|
||||||
|
protected readonly expandedAttempts = signal<ReplicationAttemptRecord[]>([]);
|
||||||
|
protected readonly expandedLoading = signal(false);
|
||||||
|
|
||||||
|
protected readonly filtersForm = this.fb.nonNullable.group({
|
||||||
|
from: [''],
|
||||||
|
to: [''],
|
||||||
|
businessObject: [''],
|
||||||
|
scenarioCode: [''],
|
||||||
|
status: [[] as EventStatus[]],
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.scenariosApi.list().subscribe((scenarios) => this.scenarios.set(scenarios));
|
||||||
|
this.search();
|
||||||
|
}
|
||||||
|
|
||||||
|
search() {
|
||||||
|
this.loading.set(true);
|
||||||
|
const value = this.filtersForm.getRawValue();
|
||||||
|
this.eventsApi
|
||||||
|
.list({
|
||||||
|
from: value.from ? new Date(value.from).toISOString() : undefined,
|
||||||
|
to: value.to ? new Date(value.to).toISOString() : undefined,
|
||||||
|
businessObject: value.businessObject || undefined,
|
||||||
|
scenarioCode: value.scenarioCode || undefined,
|
||||||
|
status: value.status,
|
||||||
|
page: this.pageIndex() + 1,
|
||||||
|
pageSize: this.pageSize(),
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
this.events.set(result.data);
|
||||||
|
this.total.set(result.total);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.snackBar.open('No se pudo cargar el log de eventos', 'Cerrar', { duration: 4000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters() {
|
||||||
|
this.pageIndex.set(0);
|
||||||
|
this.search();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilters() {
|
||||||
|
this.filtersForm.reset({ from: '', to: '', businessObject: '', scenarioCode: '', status: [] });
|
||||||
|
this.pageIndex.set(0);
|
||||||
|
this.search();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageChange(event: PageEvent) {
|
||||||
|
this.pageIndex.set(event.pageIndex);
|
||||||
|
this.pageSize.set(event.pageSize);
|
||||||
|
this.search();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleExpand(event: WebhookEventRecord) {
|
||||||
|
if (this.expandedEventId() === event.id) {
|
||||||
|
this.expandedEventId.set(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.expandedEventId.set(event.id);
|
||||||
|
this.expandedLoading.set(true);
|
||||||
|
this.eventsApi.get(event.id).subscribe({
|
||||||
|
next: (detail) => {
|
||||||
|
this.expandedAttempts.set(detail.attempts);
|
||||||
|
this.expandedLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.expandedLoading.set(false);
|
||||||
|
this.snackBar.open('No se pudieron cargar los intentos de este evento', 'Cerrar', { duration: 4000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
viewJson(title: string, json: unknown) {
|
||||||
|
this.dialog.open<JsonDialog, JsonDialogData>(JsonDialog, { data: { title, json } });
|
||||||
|
}
|
||||||
|
|
||||||
|
reprocess(event: WebhookEventRecord) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`¿Reprocesar el evento ${event.id}?\n\nEsto va a reintentar TODOS los escenarios y destinos aplicables, no solo el que falló.`,
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
this.eventsApi.reprocess(event.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Evento puesto en pending. El worker lo va a reprocesar en su próximo ciclo.', 'Cerrar', {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
this.search();
|
||||||
|
},
|
||||||
|
error: () => this.snackBar.open('No se pudo reprocesar el evento', 'Cerrar', { duration: 4000 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mat-sys-surface-container-low, rgba(0, 0, 0, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 4px 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-grid dt {
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-grid dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.destinations-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.destinations-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--mat-sys-surface-container, rgba(0, 0, 0, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-destination {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-path {
|
||||||
|
font-family: monospace;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
max-height: 480px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source.source-gray {
|
||||||
|
background: #e8e8e8;
|
||||||
|
color: #24292e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
@if (loading()) {
|
||||||
|
<mat-spinner diameter="32" />
|
||||||
|
} @else if (scenario(); as s) {
|
||||||
|
<div class="header">
|
||||||
|
<a mat-icon-button routerLink="/scenarios" aria-label="Volver a escenarios" matTooltip="Volver a escenarios">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
</a>
|
||||||
|
<h1>{{ s.code }}</h1>
|
||||||
|
<mat-chip-set>
|
||||||
|
<mat-chip [class]="s.active ? 'chip-active' : 'chip-inactive'">
|
||||||
|
{{ s.active ? 'Activo' : 'Inactivo' }}
|
||||||
|
</mat-chip>
|
||||||
|
</mat-chip-set>
|
||||||
|
<button mat-button (click)="toggleActive()">{{ s.active ? 'Desactivar' : 'Activar' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Configuración</h2>
|
||||||
|
<dl class="config-grid">
|
||||||
|
<dt>Nombre</dt>
|
||||||
|
<dd>{{ s.name }}</dd>
|
||||||
|
<dt>Business object</dt>
|
||||||
|
<dd>{{ s.businessObject }}{{ s.destinationBusinessObject ? ' → ' + s.destinationBusinessObject : '' }}</dd>
|
||||||
|
<dt>Base origen</dt>
|
||||||
|
<dd>{{ s.originDatabaseCode }}</dd>
|
||||||
|
<dt>Key field</dt>
|
||||||
|
<dd>{{ s.keyField }} ({{ s.keyType }})</dd>
|
||||||
|
<dt>Key strategy</dt>
|
||||||
|
<dd>{{ s.keyStrategy }}{{ s.matchField ? ' — match field: ' + s.matchField : '' }}</dd>
|
||||||
|
<dt>Destination mode</dt>
|
||||||
|
<dd>{{ s.destinationMode }}{{ s.routingField ? ' — routing field: ' + s.routingField : '' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Destinos</h2>
|
||||||
|
<ul class="destinations-list">
|
||||||
|
@for (d of s.destinations; track d.id) {
|
||||||
|
<li>
|
||||||
|
{{ d.databaseCode }}
|
||||||
|
<button mat-icon-button (click)="removeDestination(d.databaseCode)" aria-label="Quitar destino">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
} @empty {
|
||||||
|
<li class="empty">
|
||||||
|
{{ s.destinationMode === 'payload' ? 'Destino resuelto por payload (routingField), no aplica lista fija.' : 'Sin destinos configurados.' }}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@if (s.destinationMode === 'fixed') {
|
||||||
|
<div class="add-destination">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Agregar destino</mat-label>
|
||||||
|
<mat-select (selectionChange)="newDestinationCode.set($event.value)">
|
||||||
|
@for (db of availableDestinations(); track db.code) {
|
||||||
|
<mat-option [value]="db.code">{{ db.code }} — {{ db.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-button (click)="addDestination()">Agregar</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="source-header">
|
||||||
|
<h2>Código fuente (solo lectura)</h2>
|
||||||
|
@if (sourceContent()) {
|
||||||
|
<div class="source-actions">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="toggleSourceBackground()"
|
||||||
|
[attr.aria-label]="grayBackground() ? 'Fondo oscuro' : 'Fondo gris'"
|
||||||
|
[matTooltip]="grayBackground() ? 'Fondo oscuro' : 'Fondo gris (más contraste)'"
|
||||||
|
>
|
||||||
|
<mat-icon>contrast</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button (click)="copySource()" aria-label="Copiar código" matTooltip="Copiar código">
|
||||||
|
<mat-icon>content_copy</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (sourceContent(); as content) {
|
||||||
|
<p class="source-path">{{ sourcePath() }}</p>
|
||||||
|
<pre class="source" [class.source-gray]="grayBackground()"><code class="hljs" [innerHTML]="highlightedSource()"></code></pre>
|
||||||
|
<p class="hint">Para editar filter/transform, abrí este archivo en tu editor (VSCode) — no se edita desde acá.</p>
|
||||||
|
} @else {
|
||||||
|
<p class="empty">No se encontró el archivo .ts de este escenario.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Mapeos de valor</h2>
|
||||||
|
<table mat-table [dataSource]="mappings()" class="mat-elevation-z1">
|
||||||
|
<ng-container matColumnDef="fieldName">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Campo</th>
|
||||||
|
<td mat-cell *matCellDef="let m">{{ m.fieldName }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="databaseCode">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Destino</th>
|
||||||
|
<td mat-cell *matCellDef="let m">{{ m.databaseCode }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="sourceValue">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Valor origen</th>
|
||||||
|
<td mat-cell *matCellDef="let m">{{ m.sourceValue }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="targetValue">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Valor destino</th>
|
||||||
|
<td mat-cell *matCellDef="let m">{{ m.targetValue }}</td>
|
||||||
|
</ng-container>
|
||||||
|
<tr mat-header-row *matHeaderRowDef="mappingsColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: mappingsColumns"></tr>
|
||||||
|
</table>
|
||||||
|
@if (mappings().length === 0) {
|
||||||
|
<p class="empty">No hay mapeos cargados todavía.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form [formGroup]="mappingForm" (ngSubmit)="addMapping()" class="mapping-form">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Campo</mat-label>
|
||||||
|
<input matInput formControlName="fieldName" placeholder="ej. GroupCode" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Destino</mat-label>
|
||||||
|
<mat-select formControlName="databaseCode">
|
||||||
|
@for (d of s.destinations; track d.id) {
|
||||||
|
<mat-option [value]="d.databaseCode">{{ d.databaseCode }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Valor origen</mat-label>
|
||||||
|
<input matInput formControlName="sourceValue" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Valor destino</mat-label>
|
||||||
|
<input matInput formControlName="targetValue" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-flat-button color="primary" type="submit">Agregar mapeo</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||||
|
import { DomSanitizer } from '@angular/platform-browser';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import hljs from 'highlight.js/lib/core';
|
||||||
|
import typescript from 'highlight.js/lib/languages/typescript';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { Databases } from '../../databases/databases';
|
||||||
|
import { Scenarios } from '../scenarios';
|
||||||
|
import { DatabaseRecord, ScenarioRecord, ScenarioValueMappingRecord } from '../../core/models';
|
||||||
|
|
||||||
|
hljs.registerLanguage('typescript', typescript);
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [
|
||||||
|
ReactiveFormsModule,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatChipsModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
],
|
||||||
|
selector: 'app-scenario-detail',
|
||||||
|
styleUrl: './scenario-detail.css',
|
||||||
|
templateUrl: './scenario-detail.html',
|
||||||
|
})
|
||||||
|
export class ScenarioDetail implements OnInit {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly scenariosApi = inject(Scenarios);
|
||||||
|
private readonly databasesApi = inject(Databases);
|
||||||
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
|
protected readonly loading = signal(true);
|
||||||
|
protected readonly scenario = signal<ScenarioRecord | null>(null);
|
||||||
|
protected readonly sourceContent = signal<string | null>(null);
|
||||||
|
protected readonly sourcePath = signal<string | null>(null);
|
||||||
|
protected readonly grayBackground = signal(false);
|
||||||
|
protected readonly highlightedSource = computed(() => {
|
||||||
|
const content = this.sourceContent();
|
||||||
|
if (!content) return null;
|
||||||
|
return this.sanitizer.bypassSecurityTrustHtml(hljs.highlight(content, { language: 'typescript' }).value);
|
||||||
|
});
|
||||||
|
protected readonly mappings = signal<ScenarioValueMappingRecord[]>([]);
|
||||||
|
protected readonly databases = signal<DatabaseRecord[]>([]);
|
||||||
|
|
||||||
|
protected readonly mappingsColumns = ['fieldName', 'databaseCode', 'sourceValue', 'targetValue'];
|
||||||
|
protected readonly newDestinationCode = signal('');
|
||||||
|
|
||||||
|
protected readonly mappingForm = this.fb.nonNullable.group({
|
||||||
|
fieldName: ['', Validators.required],
|
||||||
|
databaseCode: ['', Validators.required],
|
||||||
|
sourceValue: ['', Validators.required],
|
||||||
|
targetValue: ['', Validators.required],
|
||||||
|
});
|
||||||
|
|
||||||
|
private code = '';
|
||||||
|
|
||||||
|
toggleSourceBackground() {
|
||||||
|
this.grayBackground.update((v) => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
copySource() {
|
||||||
|
const content = this.sourceContent();
|
||||||
|
if (!content) return;
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(content)
|
||||||
|
.then(() => this.snackBar.open('Código copiado al portapapeles', 'Cerrar', { duration: 2500 }))
|
||||||
|
.catch(() => this.snackBar.open('No se pudo copiar el código', 'Cerrar', { duration: 4000 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.code = this.route.snapshot.paramMap.get('code') ?? '';
|
||||||
|
this.databasesApi.list().subscribe((dbs) => this.databases.set(dbs));
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private load() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.scenariosApi.get(this.code).subscribe({
|
||||||
|
next: (scenario) => {
|
||||||
|
this.scenario.set(scenario);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.snackBar.open(`No se encontró el escenario '${this.code}'`, 'Cerrar', { duration: 4000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scenariosApi.getSource(this.code).subscribe({
|
||||||
|
next: (source) => {
|
||||||
|
this.sourceContent.set(source.content);
|
||||||
|
this.sourcePath.set(source.path);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.sourceContent.set(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loadMappings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadMappings() {
|
||||||
|
this.scenariosApi.listMappings(this.code).subscribe((mappings) => this.mappings.set(mappings));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected availableDestinations(): DatabaseRecord[] {
|
||||||
|
const current = new Set(this.scenario()?.destinations.map((d) => d.databaseCode));
|
||||||
|
return this.databases().filter((db) => !current.has(db.code));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleActive() {
|
||||||
|
const scenario = this.scenario();
|
||||||
|
if (!scenario) return;
|
||||||
|
this.scenariosApi.update(scenario.code, { active: !scenario.active }).subscribe({
|
||||||
|
next: () => this.load(),
|
||||||
|
error: () => this.snackBar.open('No se pudo actualizar el escenario', 'Cerrar', { duration: 4000 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addDestination() {
|
||||||
|
const databaseCode = this.newDestinationCode();
|
||||||
|
if (!databaseCode) return;
|
||||||
|
this.scenariosApi.addDestination(this.code, databaseCode).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.newDestinationCode.set('');
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: () => this.snackBar.open('No se pudo agregar el destino', 'Cerrar', { duration: 4000 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeDestination(databaseCode: string) {
|
||||||
|
this.scenariosApi.removeDestination(this.code, databaseCode).subscribe({
|
||||||
|
next: () => this.load(),
|
||||||
|
error: () => this.snackBar.open('No se pudo quitar el destino', 'Cerrar', { duration: 4000 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addMapping() {
|
||||||
|
if (this.mappingForm.invalid) {
|
||||||
|
this.mappingForm.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.scenariosApi.addMapping(this.code, this.mappingForm.getRawValue()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.mappingForm.reset({ fieldName: '', databaseCode: '', sourceValue: '', targetValue: '' });
|
||||||
|
this.loadMappings();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
const message = err?.error?.message ?? 'No se pudo guardar el mapeo';
|
||||||
|
this.snackBar.open(message, 'Cerrar', { duration: 4000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mat-sys-surface-container-low, rgba(0, 0, 0, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<h1>Nuevo escenario</h1>
|
||||||
|
|
||||||
|
<form [formGroup]="form" (ngSubmit)="save()" class="form">
|
||||||
|
<section class="card">
|
||||||
|
<h2>Identificación</h2>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Code</mat-label>
|
||||||
|
<input matInput formControlName="code" placeholder="ej. BP_CLIENTES" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Nombre</mat-label>
|
||||||
|
<input matInput formControlName="name" />
|
||||||
|
</mat-form-field>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Objeto de negocio</h2>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Business object (origen)</mat-label>
|
||||||
|
<input matInput formControlName="businessObject" placeholder="ej. BusinessPartners" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Business object destino (opcional)</mat-label>
|
||||||
|
<input matInput formControlName="destinationBusinessObject" placeholder="dejar vacío si es el mismo" />
|
||||||
|
</mat-form-field>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Origen y clave</h2>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Base origen</mat-label>
|
||||||
|
<mat-select formControlName="originDatabaseCode">
|
||||||
|
@for (db of databases(); track db.code) {
|
||||||
|
<mat-option [value]="db.code">{{ db.code }} — {{ db.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Key field</mat-label>
|
||||||
|
<input matInput formControlName="keyField" placeholder="ej. CardCode" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Key type</mat-label>
|
||||||
|
<mat-select formControlName="keyType">
|
||||||
|
<mat-option value="string">string (va entre comillas en la URL)</mat-option>
|
||||||
|
<mat-option value="number">number (va sin comillas)</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Key strategy</mat-label>
|
||||||
|
<mat-select formControlName="keyStrategy">
|
||||||
|
<mat-option value="portable">portable (misma clave en origen y destino)</mat-option>
|
||||||
|
<mat-option value="udfLink">udfLink (la clave la asigna cada base, se linkea por UDF)</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (needsMatchField) {
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Match field (UDF en destino)</mat-label>
|
||||||
|
<input matInput formControlName="matchField" placeholder="ej. U_HQ_SlpCode" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Destino</h2>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Destination mode</mat-label>
|
||||||
|
<mat-select formControlName="destinationMode">
|
||||||
|
<mat-option value="fixed">fixed (destinos fijos — datos maestros)</mat-option>
|
||||||
|
<mat-option value="payload">payload (destino en el mensaje — transacciones)</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (needsRoutingField) {
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Routing field (UDF en origen)</mat-label>
|
||||||
|
<input matInput formControlName="routingField" placeholder="ej. U_ITC_Target" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button mat-flat-button color="primary" type="submit" [disabled]="saving()">Crear escenario</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { Databases } from '../../databases/databases';
|
||||||
|
import { Scenarios } from '../scenarios';
|
||||||
|
import { DatabaseRecord } from '../../core/models';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [ReactiveFormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule],
|
||||||
|
selector: 'app-scenario-new',
|
||||||
|
styleUrl: './scenario-new.css',
|
||||||
|
templateUrl: './scenario-new.html',
|
||||||
|
})
|
||||||
|
export class ScenarioNew implements OnInit {
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly databasesApi = inject(Databases);
|
||||||
|
private readonly scenariosApi = inject(Scenarios);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
protected readonly databases = signal<DatabaseRecord[]>([]);
|
||||||
|
protected readonly saving = signal(false);
|
||||||
|
|
||||||
|
protected readonly form = this.fb.nonNullable.group({
|
||||||
|
code: ['', Validators.required],
|
||||||
|
name: ['', Validators.required],
|
||||||
|
businessObject: ['', Validators.required],
|
||||||
|
destinationBusinessObject: [''],
|
||||||
|
originDatabaseCode: ['', Validators.required],
|
||||||
|
keyField: ['', Validators.required],
|
||||||
|
keyType: ['string' as 'string' | 'number', Validators.required],
|
||||||
|
keyStrategy: ['portable' as 'portable' | 'udfLink', Validators.required],
|
||||||
|
matchField: [''],
|
||||||
|
destinationMode: ['fixed' as 'fixed' | 'payload', Validators.required],
|
||||||
|
routingField: [''],
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.databasesApi.list().subscribe((dbs) => this.databases.set(dbs.filter((d) => d.active)));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get needsMatchField() {
|
||||||
|
return this.form.controls.keyStrategy.value === 'udfLink';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get needsRoutingField() {
|
||||||
|
return this.form.controls.destinationMode.value === 'payload';
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
if (this.needsMatchField && !this.form.controls.matchField.value) {
|
||||||
|
this.snackBar.open("keyStrategy 'udfLink' necesita matchField", 'Cerrar', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.needsRoutingField && !this.form.controls.routingField.value) {
|
||||||
|
this.snackBar.open("destinationMode 'payload' necesita routingField", 'Cerrar', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.form.invalid) {
|
||||||
|
this.form.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = this.form.getRawValue();
|
||||||
|
this.saving.set(true);
|
||||||
|
this.scenariosApi
|
||||||
|
.create({
|
||||||
|
code: value.code,
|
||||||
|
name: value.name,
|
||||||
|
businessObject: value.businessObject,
|
||||||
|
destinationBusinessObject: value.destinationBusinessObject || null,
|
||||||
|
originDatabaseCode: value.originDatabaseCode,
|
||||||
|
keyField: value.keyField,
|
||||||
|
keyType: value.keyType,
|
||||||
|
keyStrategy: value.keyStrategy,
|
||||||
|
matchField: value.matchField || null,
|
||||||
|
destinationMode: value.destinationMode,
|
||||||
|
routingField: value.routingField || null,
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
this.saving.set(false);
|
||||||
|
const message = result.scaffoldedFilePath
|
||||||
|
? `Escenario creado. Completá filter/transform en ${result.scaffoldedFilePath} antes de esperar resultados reales.`
|
||||||
|
: 'Escenario creado.';
|
||||||
|
this.snackBar.open(message, 'Cerrar', { duration: 8000 });
|
||||||
|
this.router.navigate(['/scenarios', result.scenario.code]);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.saving.set(false);
|
||||||
|
const message = err?.error?.message ?? 'No se pudo crear el escenario';
|
||||||
|
this.snackBar.open(message, 'Cerrar', { duration: 5000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<div class="header">
|
||||||
|
<h1>Escenarios</h1>
|
||||||
|
<a mat-flat-button color="primary" routerLink="/scenarios/new">Nuevo escenario</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<mat-spinner diameter="32" />
|
||||||
|
} @else {
|
||||||
|
<table mat-table [dataSource]="scenarios()" class="mat-elevation-z1">
|
||||||
|
<ng-container matColumnDef="code">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Code</th>
|
||||||
|
<td mat-cell *matCellDef="let s">
|
||||||
|
<a [routerLink]="['/scenarios', s.code]">{{ s.code }}</a>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="businessObject">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Business Object</th>
|
||||||
|
<td mat-cell *matCellDef="let s">{{ businessObjectLabel(s) }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="origin">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Origen</th>
|
||||||
|
<td mat-cell *matCellDef="let s">{{ s.originDatabaseCode }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="destinations">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Destinos</th>
|
||||||
|
<td mat-cell *matCellDef="let s">{{ destinationsLabel(s) }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="keyField">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Key field</th>
|
||||||
|
<td mat-cell *matCellDef="let s">{{ keyFieldLabel(s) }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="active">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Estado</th>
|
||||||
|
<td mat-cell *matCellDef="let s">
|
||||||
|
<mat-chip-set>
|
||||||
|
<mat-chip [class]="s.active ? 'chip-active' : 'chip-inactive'">
|
||||||
|
{{ s.active ? 'Activo' : 'Inactivo' }}
|
||||||
|
</mat-chip>
|
||||||
|
</mat-chip-set>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
@if (scenarios().length === 0) {
|
||||||
|
<app-empty-state icon="schema" message="No hay escenarios registrados todavía." />
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { Scenarios } from '../scenarios';
|
||||||
|
import { ScenarioRecord } from '../../core/models';
|
||||||
|
import { EmptyState } from '../../shared/empty-state/empty-state';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [RouterLink, MatTableModule, MatButtonModule, MatChipsModule, MatProgressSpinnerModule, EmptyState],
|
||||||
|
selector: 'app-scenarios-list',
|
||||||
|
styleUrl: './scenarios-list.css',
|
||||||
|
templateUrl: './scenarios-list.html',
|
||||||
|
})
|
||||||
|
export class ScenariosList implements OnInit {
|
||||||
|
private readonly scenariosApi = inject(Scenarios);
|
||||||
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
protected readonly loading = signal(true);
|
||||||
|
protected readonly scenarios = signal<ScenarioRecord[]>([]);
|
||||||
|
protected readonly displayedColumns = ['code', 'businessObject', 'origin', 'destinations', 'keyField', 'active'];
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.scenariosApi.list().subscribe({
|
||||||
|
next: (scenarios) => {
|
||||||
|
this.scenarios.set(scenarios);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.snackBar.open('No se pudo cargar la lista de escenarios', 'Cerrar', { duration: 4000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destinationsLabel(scenario: ScenarioRecord): string {
|
||||||
|
if (scenario.destinationMode === 'payload') return `(vía payload: ${scenario.routingField})`;
|
||||||
|
return scenario.destinations.map((d) => d.databaseCode).join(', ') || '(sin destinos)';
|
||||||
|
}
|
||||||
|
|
||||||
|
keyFieldLabel(scenario: ScenarioRecord): string {
|
||||||
|
return scenario.keyStrategy === 'udfLink' ? `${scenario.keyField} (vía ${scenario.matchField})` : scenario.keyField;
|
||||||
|
}
|
||||||
|
|
||||||
|
businessObjectLabel(scenario: ScenarioRecord): string {
|
||||||
|
return scenario.destinationBusinessObject
|
||||||
|
? `${scenario.businessObject} → ${scenario.destinationBusinessObject}`
|
||||||
|
: scenario.businessObject;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Service, inject } from '@angular/core';
|
||||||
|
import { Api } from '../core/api';
|
||||||
|
import {
|
||||||
|
DestinationMode,
|
||||||
|
KeyStrategy,
|
||||||
|
KeyType,
|
||||||
|
ScenarioDestinationRecord,
|
||||||
|
ScenarioRecord,
|
||||||
|
ScenarioSource,
|
||||||
|
ScenarioValueMappingRecord,
|
||||||
|
} from '../core/models';
|
||||||
|
|
||||||
|
export interface CreateScenarioInput {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
businessObject: string;
|
||||||
|
destinationBusinessObject?: string | null;
|
||||||
|
originDatabaseCode: string;
|
||||||
|
keyField: string;
|
||||||
|
keyType?: KeyType;
|
||||||
|
keyStrategy?: KeyStrategy;
|
||||||
|
matchField?: string | null;
|
||||||
|
destinationMode?: DestinationMode;
|
||||||
|
routingField?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateScenarioInput = Partial<Omit<CreateScenarioInput, 'code'>> & { active?: boolean };
|
||||||
|
|
||||||
|
export interface CreateScenarioResult {
|
||||||
|
scenario: ScenarioRecord;
|
||||||
|
scaffoldedFilePath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class Scenarios {
|
||||||
|
private readonly api = inject(Api);
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return this.api.get<ScenarioRecord[]>('/scenarios');
|
||||||
|
}
|
||||||
|
|
||||||
|
get(code: string) {
|
||||||
|
return this.api.get<ScenarioRecord>(`/scenarios/${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSource(code: string) {
|
||||||
|
return this.api.get<ScenarioSource>(`/scenarios/${code}/source`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input: CreateScenarioInput) {
|
||||||
|
return this.api.post<CreateScenarioResult>('/scenarios', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(code: string, input: UpdateScenarioInput) {
|
||||||
|
return this.api.patch<ScenarioRecord>(`/scenarios/${code}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
addDestination(code: string, databaseCode: string) {
|
||||||
|
return this.api.post<ScenarioDestinationRecord>(`/scenarios/${code}/destinations`, { databaseCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
removeDestination(code: string, databaseCode: string) {
|
||||||
|
return this.api.delete<void>(`/scenarios/${code}/destinations/${databaseCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
listMappings(code: string) {
|
||||||
|
return this.api.get<ScenarioValueMappingRecord[]>(`/scenarios/${code}/mappings`);
|
||||||
|
}
|
||||||
|
|
||||||
|
addMapping(code: string, input: { fieldName: string; databaseCode: string; sourceValue: string; targetValue: string }) {
|
||||||
|
return this.api.post<ScenarioValueMappingRecord>(`/scenarios/${code}/mappings`, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 48px 16px;
|
||||||
|
opacity: 0.6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state mat-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="empty-state">
|
||||||
|
<mat-icon>{{ icon() }}</mat-icon>
|
||||||
|
<p>{{ message() }}</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [MatIconModule],
|
||||||
|
selector: 'app-empty-state',
|
||||||
|
styleUrl: './empty-state.css',
|
||||||
|
templateUrl: './empty-state.html',
|
||||||
|
})
|
||||||
|
export class EmptyState {
|
||||||
|
icon = input('inbox');
|
||||||
|
message = input.required<string>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>SapIntercompanyViewer</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
|
</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,38 @@
|
|||||||
|
// Include theming for Angular Material with `mat.theme()`.
|
||||||
|
// This Sass mixin will define CSS variables that are used for styling Angular Material
|
||||||
|
// components according to the Material 3 design spec.
|
||||||
|
// Learn more about theming and how to use it for your application's
|
||||||
|
// custom components at https://material.angular.dev/guide/theming
|
||||||
|
@use '@angular/material' as mat;
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
@include mat.theme(
|
||||||
|
(
|
||||||
|
color: (
|
||||||
|
primary: mat.$azure-palette,
|
||||||
|
tertiary: mat.$blue-palette,
|
||||||
|
),
|
||||||
|
typography: Roboto,
|
||||||
|
density: 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
// Default the application to a light color theme. This can be changed to
|
||||||
|
// `dark` to enable the dark color theme, or to `light dark` to defer to the
|
||||||
|
// user's system settings.
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
// Set a default background, font and text colors for the application using
|
||||||
|
// Angular Material's system-level CSS variables. Learn more about these
|
||||||
|
// variables at https://material.angular.dev/guide/system-variables
|
||||||
|
background-color: var(--mat-sys-surface);
|
||||||
|
color: var(--mat-sys-on-surface);
|
||||||
|
font: var(--mat-sys-body-medium);
|
||||||
|
|
||||||
|
// Reset the user agent margin.
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
/* You can add global styles to this file, and also import other style files */
|
||||||
|
|
||||||
|
/* Tema de highlight.js para el código fuente de escenarios (scenario-detail).
|
||||||
|
Va acá y no en el CSS del componente porque el contenido se inyecta vía
|
||||||
|
[innerHTML] — Angular no le agrega el atributo _ngcontent-xxx a ese HTML,
|
||||||
|
así que un CSS con encapsulación (scoped al componente) nunca matchea. */
|
||||||
|
@import 'highlight.js/styles/github-dark.css';
|
||||||
|
|
||||||
|
/* Centra el spinner de carga en vez de dejarlo pegado arriba a la izquierda
|
||||||
|
— se usa igual en las 3 pantallas de listado (log, escenarios, bases). */
|
||||||
|
mat-spinner {
|
||||||
|
margin: 48px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover para cualquier mat-table de la app — ayuda a seguir una fila ancha
|
||||||
|
horizontalmente. Sin zebra (nth-child): en /log cada fila visible tiene
|
||||||
|
una fila de detalle oculta intercalada al lado, así que toda fila visible
|
||||||
|
cae siempre en posición impar y nth-child(even) nunca la alcanzaría. */
|
||||||
|
tr[mat-row]:not(.detail-row):hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chip de estado activo/inactivo, consistente entre /databases y /scenarios
|
||||||
|
(antes cada tabla usaba el color "primary" default de Material). */
|
||||||
|
.chip-active {
|
||||||
|
--mat-chip-label-text-color: #fff;
|
||||||
|
--mat-chip-elevated-container-color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-inactive {
|
||||||
|
--mat-chip-label-text-color: #fff;
|
||||||
|
--mat-chip-elevated-container-color: #757575;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source .hljs {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modo "gris" (claro) del visor de código fuente: highlight.js no soporta
|
||||||
|
dos temas activos a la vez (ambos definen las mismas clases .hljs-*), así
|
||||||
|
que los colores del tema claro ("github", ver highlight.js/styles/github.css)
|
||||||
|
se repiten acá scopeados bajo .source-gray para pisar al tema oscuro. */
|
||||||
|
.source-gray .hljs {
|
||||||
|
color: #24292e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-gray .hljs-doctag,
|
||||||
|
.source-gray .hljs-keyword,
|
||||||
|
.source-gray .hljs-meta .hljs-keyword,
|
||||||
|
.source-gray .hljs-template-tag,
|
||||||
|
.source-gray .hljs-template-variable,
|
||||||
|
.source-gray .hljs-type,
|
||||||
|
.source-gray .hljs-variable.language_ {
|
||||||
|
color: #d73a49;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-gray .hljs-title,
|
||||||
|
.source-gray .hljs-title.class_,
|
||||||
|
.source-gray .hljs-title.class_.inherited__,
|
||||||
|
.source-gray .hljs-title.function_ {
|
||||||
|
color: #6f42c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-gray .hljs-attr,
|
||||||
|
.source-gray .hljs-attribute,
|
||||||
|
.source-gray .hljs-literal,
|
||||||
|
.source-gray .hljs-meta,
|
||||||
|
.source-gray .hljs-number,
|
||||||
|
.source-gray .hljs-operator,
|
||||||
|
.source-gray .hljs-variable,
|
||||||
|
.source-gray .hljs-selector-attr,
|
||||||
|
.source-gray .hljs-selector-class,
|
||||||
|
.source-gray .hljs-selector-id {
|
||||||
|
color: #005cc5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-gray .hljs-regexp,
|
||||||
|
.source-gray .hljs-string,
|
||||||
|
.source-gray .hljs-meta .hljs-string {
|
||||||
|
color: #032f62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-gray .hljs-built_in,
|
||||||
|
.source-gray .hljs-symbol {
|
||||||
|
color: #e36209;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-gray .hljs-comment,
|
||||||
|
.source-gray .hljs-code,
|
||||||
|
.source-gray .hljs-formula {
|
||||||
|
color: #6a737d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-gray .hljs-name,
|
||||||
|
.source-gray .hljs-quote,
|
||||||
|
.source-gray .hljs-selector-tag,
|
||||||
|
.source-gray .hljs-selector-pseudo {
|
||||||
|
color: #22863a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-gray .hljs-subst {
|
||||||
|
color: #24292e;
|
||||||
|
}
|
||||||
@@ -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