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:
GeorgeandClaude Sonnet 5 committed 2026-09-16 08:25:01 -05:00
commit cf793c88f2
57 files changed
+10675

No files matched your search

+17
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -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"
}
]
}
+42
View File
@@ -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)"
}
}
}
}
]
}
+64
View File
@@ -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.
+71
View File
@@ -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"
}
}
}
}
}
+7995
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -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

+13
View File
@@ -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()],
};
+26
View File
@@ -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;
}
+16
View File
@@ -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>
+30
View File
@@ -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' },
];
+24
View File
@@ -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');
});
});
+13
View File
@@ -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 {}
+36
View File
@@ -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}`);
}
}
+117
View File
@@ -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 });
},
});
}
}
+12
View File
@@ -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 });
},
});
});
}
}
+35
View File
@@ -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);
}
}
+28
View File
@@ -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`, {});
}
}
+11
View File
@@ -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;
}
+13
View File
@@ -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>
+30
View File
@@ -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 }));
}
}
+102
View File
@@ -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;
}
+188
View File
@@ -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
/>
}
+171
View File
@@ -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;
}
}
+73
View File
@@ -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>
+13
View File
@@ -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>();
}
+20
View File
@@ -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>
+6
View File
@@ -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));
+38
View File
@@ -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
View File
@@ -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;
}
+14
View File
@@ -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"
]
}
+31
View File
@@ -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"
}
]
}
+14
View File
@@ -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"
]
}