diff --git a/CLAUDE.md b/CLAUDE.md index 363fac2..3315f84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,10 @@ Cada menú abre primero una **lista de los últimos documentos** (barra de filtr Módulo genérico `apps/api/src/sapdb/` (en `main`, opcional como el Gateway): ejecuta consultas **registradas** de solo lectura directamente en la base de SAP, para lo que el Service Layer no da bien (por ejemplo, el stock de un lote por almacén o el almacén de origen de un traslado). Hoy el motor es **HANA** (`hdb`, JavaScript puro); la interfaz `SapDbAdapter` deja listo sumar **MSSQL** (SAP10 de pruebas) con otro adaptador, y cada consulta declara su SQL por motor. En `main` solo existe `ping`; **las consultas de negocio van en la rama de cada cliente** (`sapdb.extensions.ts`, vacío en `main`) porque dependen de sus tablas y campos de usuario. El frontend nunca manda SQL: pide la consulta por nombre con parámetros tipados (`POST /api/sapdb/queries/:name`); el SQL usa `:nombre` y el servidor lo cambia por `?` (nunca se concatena). Al arrancar se rechaza una consulta que no sea un único `SELECT`/`WITH`. Cola propia (`SAPDB_MAX_CONCURRENCY` 2), tope de filas y tiempo máximo; auditoría `db_query` (nombres, no valores). Variables `SAPDB_*` en el `.env`. Verificado contra `SBO_TAGUMEDICA_21` (conexión, esquema y parámetro). **Aviso:** el `.env` del mini-erp usa el usuario `SYSTEM` de HANA (superusuario): conviene un usuario de la base que solo pueda leer; el módulo igualmente solo ejecuta los `SELECT` registrados. +## Ver el nombre del campo de SAP («Campos») + +El mini-erp mostraba el nombre del campo de la base junto a cada casillero (`dbField`). Aquí es general: el botón **Campos** de la barra superior (`FieldNames`, se recuerda en `localStorage` `erp_fields`) muestra, debajo de cada etiqueta y en la cabecera de las tablas (listas, líneas de documento, tablas de filas), el nombre con el que el Service Layer lee y guarda el campo (`ItemCode`, `U_SIS_ESTLOT`); sin activarlo, el nombre sale siempre como ayuda al pasar el cursor. Los campos lo reciben con `dbName` (`FieldBase`/`SboField`); los formularios de maestros y documentos lo pasan solos desde su configuración declarativa. Pendiente: los formularios de pagos y de asientos (sus campos están escritos a mano) y las filas con varios controles (folio, grupos). + ## Cómo verificar cambios de interfaz - Probar contra el SAP real con la extensión de Chrome (`list_connected_browsers` debe mostrar un navegador). Si no conecta, usar Chrome headless por CDP. diff --git a/apps/web/src/app/core/field-names.spec.ts b/apps/web/src/app/core/field-names.spec.ts new file mode 100644 index 0000000..084e7c3 --- /dev/null +++ b/apps/web/src/app/core/field-names.spec.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { TextField } from '../shared/form'; +import { FieldNames } from './field-names'; + +@Component({ imports: [TextField], template: '' }) +class Host {} + +describe('Ver campos', () => { + beforeEach(() => { + localStorage.clear(); + TestBed.resetTestingModule(); + }); + + it('el nombre del campo de SAP siempre sale como ayuda de la etiqueta, y debajo solo con «Ver campos»', () => { + const names = TestBed.inject(FieldNames); + const fixture = TestBed.createComponent(Host); + fixture.detectChanges(); + const label = () => fixture.nativeElement.querySelector('label') as HTMLElement; + expect(label().getAttribute('title')).toBe('ItemName'); + expect(label().querySelector('.db')).toBeNull(); + names.toggle(); + fixture.detectChanges(); + expect(label().querySelector('.db')?.textContent).toBe('ItemName'); + }); + + it('se recuerda en el navegador', () => { + TestBed.inject(FieldNames).toggle(); + expect(localStorage.getItem('erp_fields')).toBe('1'); + TestBed.resetTestingModule(); + expect(TestBed.inject(FieldNames).show()).toBe(true); + }); +}); diff --git a/apps/web/src/app/core/field-names.ts b/apps/web/src/app/core/field-names.ts new file mode 100644 index 0000000..514613b --- /dev/null +++ b/apps/web/src/app/core/field-names.ts @@ -0,0 +1,31 @@ +import { Injectable, signal } from '@angular/core'; + +const KEY = 'erp_fields'; + +/** + * «Ver campos»: muestra, junto a cada etiqueta, el nombre del campo de SAP (`ItemCode`, `U_SIS_ESTLOT`) con el que se lee + * y se guarda. Útil para quien configura el sistema o arma una consulta; el nombre también sale siempre como ayuda al + * pasar el cursor sobre la etiqueta. Se recuerda en el navegador. + */ +@Injectable({ providedIn: 'root' }) +export class FieldNames { + readonly show = signal(this.read()); + + toggle(): void { + const next = !this.show(); + this.show.set(next); + try { + localStorage.setItem(KEY, next ? '1' : '0'); + } catch { + /* sin almacenamiento: vale para esta sesión */ + } + } + + private read(): boolean { + try { + return localStorage.getItem(KEY) === '1'; + } catch { + return false; + } + } +} diff --git a/apps/web/src/app/features/documents/document-form.page.html b/apps/web/src/app/features/documents/document-form.page.html index 9f75f83..4e42c9f 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -133,7 +133,7 @@ @switch (f.type) { @case ('partner') { } @case ('folio') { - + } @case ('group') { - + @for (c of f.cells; track c.key) { } @case ('series') { - + } @case ('currency') { - + + + } @default { (v === null || v === undefined ? '' : Strin @switch (field().type) { @case ('checkbox') { (v === null || v === undefined ? '' : Strin } @case ('lookup') { (v === null || v === undefined ? '' : Strin } @case ('warehouse') { (v === null || v === undefined ? '' : Strin } @case ('select') { (v === null || v === undefined ? '' : Strin } @case ('number') { (v === null || v === undefined ? '' : Strin /> } @case ('date') { - + } @case ('textarea') { - + } @default { { + const f = this.field(); + return 'key' in f ? f.key : undefined; + }); protected readonly str = computed(() => (this.field().type === 'date' ? text(this.value()).slice(0, 10) : text(this.value()))); protected readonly num = computed(() => (this.value() === '' || this.value() === null || this.value() === undefined ? null : Number(this.value()))); protected readonly required = computed(() => { diff --git a/apps/web/src/app/layout/main-layout.css b/apps/web/src/app/layout/main-layout.css index 4ef5ea6..bd83b78 100644 --- a/apps/web/src/app/layout/main-layout.css +++ b/apps/web/src/app/layout/main-layout.css @@ -31,6 +31,7 @@ color: var(--shell-quiet); } .out, +.fields, .burger { background: transparent; color: var(--shell-ink); @@ -42,7 +43,16 @@ .burger { padding: 2px 8px; } +.fields { + margin-right: 10px; + font-size: 12px; + padding: 1px 8px; +} +.fields.on { + background: rgba(255, 255, 255, 0.18); +} .out:hover, +.fields:hover, .burger:hover { background: rgba(255, 255, 255, 0.08); } diff --git a/apps/web/src/app/layout/main-layout.html b/apps/web/src/app/layout/main-layout.html index f0319c6..e1f65bd 100644 --- a/apps/web/src/app/layout/main-layout.html +++ b/apps/web/src/app/layout/main-layout.html @@ -5,6 +5,7 @@ Basic ERP {{ session.company() }} + {{ session.fullName() }} diff --git a/apps/web/src/app/layout/main-layout.ts b/apps/web/src/app/layout/main-layout.ts index 16a9259..ba4ad2d 100644 --- a/apps/web/src/app/layout/main-layout.ts +++ b/apps/web/src/app/layout/main-layout.ts @@ -1,3 +1,4 @@ +import { FieldNames } from '../core/field-names'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { CompanySettingsService } from '../core/company/company-settings'; @@ -31,6 +32,7 @@ export class MainLayout { private readonly auth = inject(AuthService); protected readonly session = inject(SessionStore); protected readonly menu = inject(MenuService); + protected readonly fieldNames = inject(FieldNames); /** En pantallas estrechas el menú se abre encima del contenido. */ protected readonly sidebarOpen = signal(false); diff --git a/apps/web/src/app/shared/form/field-base.ts b/apps/web/src/app/shared/form/field-base.ts index 99efffa..cbc2471 100644 --- a/apps/web/src/app/shared/form/field-base.ts +++ b/apps/web/src/app/shared/form/field-base.ts @@ -8,4 +8,6 @@ export abstract class FieldBase { readonly readOnly = input(false, { alias: 'readonly', transform: booleanAttribute }); readonly disabled = input(false, { transform: booleanAttribute }); readonly error = input(); + /** Nombre del campo de SAP (ayuda y «Ver campos»). */ + readonly dbName = input(); } diff --git a/apps/web/src/app/shared/form/fields.ts b/apps/web/src/app/shared/form/fields.ts index 26bd9c0..e27cbf0 100644 --- a/apps/web/src/app/shared/form/fields.ts +++ b/apps/web/src/app/shared/form/fields.ts @@ -13,7 +13,7 @@ import { SboField } from './sbo-field'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [SboField, TextInput], template: ` - + + `, @@ -52,7 +52,7 @@ export class CheckboxField extends FieldBase { changeDetection: ChangeDetectionStrategy.OnPush, imports: [SboField, TextareaInput], template: ` - + + + `, @@ -115,7 +115,7 @@ export class DateField extends FieldBase { changeDetection: ChangeDetectionStrategy.OnPush, imports: [SboField, SelectInput], template: ` - + (); template: ` {{ label() }}{{ label() }}@if (dbName() && names.show()) { + {{ dbName() }} + }
@@ -65,6 +69,12 @@ let nextId = 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; } + .db { + display: block; + margin-top: -2px; + font: 10.5px/1.2 var(--mono, monospace); + color: var(--quiet); + } label.link { cursor: pointer; } @@ -83,7 +93,10 @@ let nextId = 0; `, }) export class SboField { + protected readonly names = inject(FieldNames); readonly label = input.required(); + /** Nombre del campo de SAP; sale como ayuda del cursor y, con «Ver campos», debajo de la etiqueta. */ + readonly dbName = input(); readonly required = input(false, { transform: booleanAttribute }); readonly action = input(false, { transform: booleanAttribute }); /** La etiqueta es un enlace (ej. abrir el socio de negocio). */ diff --git a/apps/web/src/app/shared/grid/lines-grid.css b/apps/web/src/app/shared/grid/lines-grid.css index c1b93b5..e080afe 100644 --- a/apps/web/src/app/shared/grid/lines-grid.css +++ b/apps/web/src/app/shared/grid/lines-grid.css @@ -151,3 +151,10 @@ tr.none td { position: absolute; left: -9999px; } + +th .db { + display: block; + font: 10.5px/1.2 var(--mono, monospace); + font-weight: 400; + color: var(--quiet); +} diff --git a/apps/web/src/app/shared/grid/lines-grid.html b/apps/web/src/app/shared/grid/lines-grid.html index 7e41d0e..e2e1b81 100644 --- a/apps/web/src/app/shared/grid/lines-grid.html +++ b/apps/web/src/app/shared/grid/lines-grid.html @@ -15,7 +15,9 @@ # @for (c of columns(); track c.key; let j = $index) { - {{ c.title }} + {{ c.title }}@if (names.show()) { + {{ c.key }} + } @if (hasDetails() && j === detailAt()) { Lotes / Series } diff --git a/apps/web/src/app/shared/grid/lines-grid.ts b/apps/web/src/app/shared/grid/lines-grid.ts index 8b3d60d..f5cd035 100644 --- a/apps/web/src/app/shared/grid/lines-grid.ts +++ b/apps/web/src/app/shared/grid/lines-grid.ts @@ -12,6 +12,7 @@ import { output, signal, } from '@angular/core'; +import { FieldNames } from '../../core/field-names'; import { formatNumber, parseDecimal } from '../../core/format'; import { SlLookupApi } from '../../core/sl/sl-lookup'; import type { ListRow } from '../list/list.types'; @@ -57,6 +58,7 @@ export class LinesGrid { private readonly host = inject>(ElementRef); private readonly injector = inject(Injector); + protected readonly names = inject(FieldNames); constructor() { // las celdas de código con texto propio (la cuenta con su formato) piden los textos de las líneas que se cargan diff --git a/apps/web/src/app/shared/grid/rows-table.ts b/apps/web/src/app/shared/grid/rows-table.ts index 81b2396..af0b95b 100644 --- a/apps/web/src/app/shared/grid/rows-table.ts +++ b/apps/web/src/app/shared/grid/rows-table.ts @@ -1,6 +1,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, computed, effect, inject, input, model, signal } from '@angular/core'; import { API_URL } from '../../core/api'; +import { FieldNames } from '../../core/field-names'; import { formatNumber, parseDecimal } from '../../core/format'; import { SlLookupApi } from '../../core/sl/sl-lookup'; import type { ListRow } from '../list/list.types'; @@ -31,7 +32,9 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) # @for (c of columns(); track c.key) { - {{ c.title }} + {{ c.title }}@if (fieldNames.show()) { + {{ c.key }} + } } @if (canRemove()) { @@ -145,6 +148,7 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) .menu button:hover { background: var(--btn-h); } table { width: 100%; min-width: max-content; border-collapse: collapse; } th { padding: 4px 6px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; font-weight: 600; text-align: left; white-space: nowrap; } + th .db { display: block; font: 10.5px/1.2 var(--mono, monospace); font-weight: 400; color: var(--quiet); } th.req::after { content: ' *'; color: var(--err); } td { padding: 0 2px; height: 27px; border: 1px solid var(--line-soft); white-space: nowrap; } td.ro { background: var(--field-ro); } @@ -181,6 +185,7 @@ export class RowsTable { return rows.filter((r) => keys.some((k) => Number(r[k]) !== 0 && Number.isFinite(Number(r[k])))); }); + protected readonly fieldNames = inject(FieldNames); private readonly lookupApi = inject(SlLookupApi); private readonly http = inject(HttpClient); /** Nombres de los catálogos de las columnas `nameOf`, por recurso. */ diff --git a/apps/web/src/app/shared/list/data-table.css b/apps/web/src/app/shared/list/data-table.css index e07aabd..50d7c9f 100644 --- a/apps/web/src/app/shared/list/data-table.css +++ b/apps/web/src/app/shared/list/data-table.css @@ -75,3 +75,10 @@ tr.none td { width: 30px; text-align: center; } + +th .db { + display: block; + font: 10.5px/1.2 var(--mono, monospace); + font-weight: 400; + color: var(--quiet); +} diff --git a/apps/web/src/app/shared/list/data-table.html b/apps/web/src/app/shared/list/data-table.html index 6ff8dcc..85423c0 100644 --- a/apps/web/src/app/shared/list/data-table.html +++ b/apps/web/src/app/shared/list/data-table.html @@ -19,10 +19,13 @@ [style.text-align]="align(c)" [class.pick]="searchable(c)" [class.active]="c.key === activeColumnKey()" - [attr.title]="searchable(c) ? 'Buscar en esta columna' : null" + [attr.title]="(searchable(c) ? 'Buscar en esta columna · ' : '') + c.key" (click)="columnPicked.emit(c)" > {{ c.title }} + @if (names.show()) { + {{ c.key }} + } } diff --git a/apps/web/src/app/shared/list/data-table.ts b/apps/web/src/app/shared/list/data-table.ts index a2fc458..6dbfa78 100644 --- a/apps/web/src/app/shared/list/data-table.ts +++ b/apps/web/src/app/shared/list/data-table.ts @@ -1,4 +1,5 @@ -import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; +import { FieldNames } from '../../core/field-names'; import { ColResize } from '../table/col-resize'; import { Badge } from '../window/badge'; import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type Tone } from './list.types'; @@ -15,6 +16,8 @@ import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type To styleUrl: './data-table.css', }) export class DataTable { + protected readonly names = inject(FieldNames); + readonly columns = input.required(); readonly rows = input.required(); /** Campo único de cada fila. */