feat(web): botón «Campos» que muestra el nombre del campo de SAP junto a cada etiqueta y en las cabeceras de tablas (ayuda del cursor siempre)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-20 08:59:58 -05:00
1 parent 6887f9e474
commit 6ad824c6c1
19 files changed
+168 -37

No files matched your search

+4
View File
@@ -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. 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 ## 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. - 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.
+34
View File
@@ -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: '<sbo-text-field label="Descripción" dbName="ItemName" [value]="\'x\'" />' })
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);
});
});
+31
View File
@@ -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;
}
}
}
@@ -133,7 +133,7 @@
@switch (f.type) { @switch (f.type) {
@case ('partner') { @case ('partner') {
<sbo-lookup-field <sbo-lookup-field
[label]="f.label" [label]="f.label" [dbName]="f.key"
[required]="!!f.required" [required]="!!f.required"
[readonly]="readOnly() || !isNew() || locked(f)" [readonly]="readOnly() || !isNew() || locked(f)"
[value]="str(header()[f.key])" [value]="str(header()[f.key])"
@@ -145,7 +145,7 @@
} }
@case ('checkbox') { @case ('checkbox') {
<sbo-checkbox-field <sbo-checkbox-field
[label]="f.label" [label]="f.label" [dbName]="f.key"
[readonly]="readOnly() || !!f.readonly || locked(f)" [readonly]="readOnly() || !!f.readonly || locked(f)"
[value]="header()[f.key] === 'tYES'" [value]="header()[f.key] === 'tYES'"
(valueChange)="setHeader(f.key, $event ? 'tYES' : 'tNO')" (valueChange)="setHeader(f.key, $event ? 'tYES' : 'tNO')"
@@ -153,7 +153,7 @@
} }
@case ('lookup') { @case ('lookup') {
<sbo-lookup-field <sbo-lookup-field
[label]="f.label" [label]="f.label" [dbName]="f.key"
[required]="!!f.required" [required]="!!f.required"
[readonly]="readOnly() || locked(f)" [readonly]="readOnly() || locked(f)"
[value]="str(header()[f.key])" [value]="str(header()[f.key])"
@@ -165,7 +165,7 @@
} }
@case ('warehouse') { @case ('warehouse') {
<sbo-lookup-field <sbo-lookup-field
[label]="f.label" [label]="f.label" [dbName]="f.key"
[required]="!!f.required" [required]="!!f.required"
[readonly]="readOnly() || locked(f)" [readonly]="readOnly() || locked(f)"
[value]="str(header()[f.key])" [value]="str(header()[f.key])"
@@ -176,7 +176,7 @@
} }
@case ('date') { @case ('date') {
<sbo-date-field <sbo-date-field
[label]="f.label" [label]="f.label" [dbName]="f.key"
[required]="!!f.required" [required]="!!f.required"
[readonly]="readOnly() || locked(f)" [readonly]="readOnly() || locked(f)"
[value]="str(header()[f.key])" [value]="str(header()[f.key])"
@@ -185,7 +185,7 @@
} }
@case ('select') { @case ('select') {
<sbo-select-field <sbo-select-field
[label]="f.label" [label]="f.label" [dbName]="f.key"
[required]="!!f.required" [required]="!!f.required"
[readonly]="readOnly() || locked(f)" [readonly]="readOnly() || locked(f)"
[options]="f.options" [options]="f.options"
@@ -196,7 +196,7 @@
} }
@case ('number') { @case ('number') {
<sbo-number-field <sbo-number-field
[label]="f.label" [label]="f.label" [dbName]="f.key"
[decimals]="f.decimals" [decimals]="f.decimals"
[readonly]="readOnly() || locked(f)" [readonly]="readOnly() || locked(f)"
[value]="+str(header()[f.key])" [value]="+str(header()[f.key])"
@@ -205,14 +205,14 @@
} }
@case ('textarea') { @case ('textarea') {
<sbo-textarea-field <sbo-textarea-field
[label]="f.label" [label]="f.label" [dbName]="f.key"
[readonly]="readOnly() || locked(f)" [readonly]="readOnly() || locked(f)"
[value]="str(header()[f.key])" [value]="str(header()[f.key])"
(valueChange)="setHeader(f.key, $event)" (valueChange)="setHeader(f.key, $event)"
/> />
} }
@case ('folio') { @case ('folio') {
<sbo-field [label]="f.label"> <sbo-field [label]="f.label" [dbName]="f.key">
<sbo-input <sbo-input
width="90px" width="90px"
[maxlength]="10" [maxlength]="10"
@@ -229,7 +229,7 @@
</sbo-field> </sbo-field>
} }
@case ('group') { @case ('group') {
<sbo-field [label]="f.label"> <sbo-field [label]="f.label" [dbName]="f.key">
@for (c of f.cells; track c.key) { @for (c of f.cells; track c.key) {
<sbo-input <sbo-input
[width]="c.width" [width]="c.width"
@@ -246,7 +246,7 @@
</sbo-field> </sbo-field>
} }
@case ('series') { @case ('series') {
<sbo-field [label]="f.label"> <sbo-field [label]="f.label" [dbName]="f.key">
<sbo-select <sbo-select
width="170px" width="170px"
ariaLabel="Serie de numeración" ariaLabel="Serie de numeración"
@@ -277,7 +277,7 @@
} }
@case ('contact') { @case ('contact') {
<sbo-select-field <sbo-select-field
[label]="f.label" [label]="f.label" [dbName]="f.key"
[readonly]="readOnly() || locked(f)" [readonly]="readOnly() || locked(f)"
[options]="contactOptions()" [options]="contactOptions()"
placeholder="" placeholder=""
@@ -287,7 +287,7 @@
} }
@case ('address') { @case ('address') {
<sbo-select-field <sbo-select-field
[label]="f.label" [label]="f.label" [dbName]="f.key"
[readonly]="readOnly() || locked(f)" [readonly]="readOnly() || locked(f)"
[options]="f.side === 'ship' ? shipOptions() : billOptions()" [options]="f.side === 'ship' ? shipOptions() : billOptions()"
placeholder="" placeholder=""
@@ -302,7 +302,7 @@
</sbo-field> </sbo-field>
} }
@case ('currency') { @case ('currency') {
<sbo-field [label]="f.label"> <sbo-field [label]="f.label" [dbName]="f.key">
<sbo-select <sbo-select
width="170px" width="170px"
ariaLabel="Moneda del documento" ariaLabel="Moneda del documento"
@@ -322,7 +322,7 @@
} }
@case ('rounding') { @case ('rounding') {
@if (roundingEnabled()) { @if (roundingEnabled()) {
<sbo-field [label]="f.label"> <sbo-field [label]="f.label" [dbName]="f.key">
<sbo-checkbox <sbo-checkbox
ariaLabel="Redondear el total del documento" ariaLabel="Redondear el total del documento"
[readonly]="readOnly() || locked(f)" [readonly]="readOnly() || locked(f)"
@@ -340,11 +340,11 @@
} }
} }
@case ('computed') { @case ('computed') {
<sbo-text-field [label]="f.label" readonly [value]="computedValue(f.source)" /> <sbo-text-field [label]="f.label" [dbName]="f.key" readonly [value]="computedValue(f.source)" />
} }
@default { @default {
<sbo-text-field <sbo-text-field
[label]="f.label" [label]="f.label" [dbName]="f.key"
[readonly]="readOnly() || !!f.readonly || locked(f)" [readonly]="readOnly() || !!f.readonly || locked(f)"
[value]="str(header()[f.key])" [value]="str(header()[f.key])"
(valueChange)="setHeader(f.key, $event)" (valueChange)="setHeader(f.key, $event)"
@@ -18,7 +18,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
@switch (field().type) { @switch (field().type) {
@case ('checkbox') { @case ('checkbox') {
<sbo-checkbox-field <sbo-checkbox-field
[label]="field().label" [label]="field().label" [dbName]="dbName()"
[readonly]="readOnly() || !!ro()" [readonly]="readOnly() || !!ro()"
[value]="value() === 'tYES'" [value]="value() === 'tYES'"
(valueChange)="changed.emit($event ? 'tYES' : 'tNO')" (valueChange)="changed.emit($event ? 'tYES' : 'tNO')"
@@ -26,7 +26,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
} }
@case ('lookup') { @case ('lookup') {
<sbo-lookup-field <sbo-lookup-field
[label]="field().label" [label]="field().label" [dbName]="dbName()"
[required]="required()" [required]="required()"
[readonly]="readOnly()" [readonly]="readOnly()"
[value]="str()" [value]="str()"
@@ -37,7 +37,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
} }
@case ('warehouse') { @case ('warehouse') {
<sbo-lookup-field <sbo-lookup-field
[label]="field().label" [label]="field().label" [dbName]="dbName()"
[required]="required()" [required]="required()"
[readonly]="readOnly()" [readonly]="readOnly()"
[value]="str()" [value]="str()"
@@ -48,7 +48,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
} }
@case ('select') { @case ('select') {
<sbo-select-field <sbo-select-field
[label]="field().label" [label]="field().label" [dbName]="dbName()"
[required]="required()" [required]="required()"
[readonly]="readOnly()" [readonly]="readOnly()"
[options]="options()" [options]="options()"
@@ -59,7 +59,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
} }
@case ('number') { @case ('number') {
<sbo-number-field <sbo-number-field
[label]="field().label" [label]="field().label" [dbName]="dbName()"
[decimals]="decimals()" [decimals]="decimals()"
[readonly]="readOnly() || !!ro()" [readonly]="readOnly() || !!ro()"
[value]="num()" [value]="num()"
@@ -67,14 +67,14 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
/> />
} }
@case ('date') { @case ('date') {
<sbo-date-field [label]="field().label" [required]="required()" [readonly]="readOnly()" [value]="str()" (valueChange)="changed.emit($event)" /> <sbo-date-field [label]="field().label" [dbName]="dbName()" [required]="required()" [readonly]="readOnly()" [value]="str()" (valueChange)="changed.emit($event)" />
} }
@case ('textarea') { @case ('textarea') {
<sbo-textarea-field [label]="field().label" [readonly]="readOnly()" [value]="str()" (valueChange)="changed.emit($event)" /> <sbo-textarea-field [label]="field().label" [dbName]="dbName()" [readonly]="readOnly()" [value]="str()" (valueChange)="changed.emit($event)" />
} }
@default { @default {
<sbo-text-field <sbo-text-field
[label]="field().label" [label]="field().label" [dbName]="dbName()"
[required]="required()" [required]="required()"
[readonly]="readOnly() || !!ro()" [readonly]="readOnly() || !!ro()"
[value]="str()" [value]="str()"
@@ -102,6 +102,10 @@ export class MasterField {
return f.type === 'lookup' && f.lookup.valueType === 'number' && /^\d+$/.test(code.trim()) ? Number(code) : code; return f.type === 'lookup' && f.lookup.valueType === 'number' && /^\d+$/.test(code.trim()) ? Number(code) : code;
} }
/** El texto del campo; una fecha de SAP llega como `2031-10-31T00:00:00Z` y el selector de fecha pide `yyyy-MM-dd`. */ /** El texto del campo; una fecha de SAP llega como `2031-10-31T00:00:00Z` y el selector de fecha pide `yyyy-MM-dd`. */
protected readonly dbName = computed(() => {
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 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 num = computed(() => (this.value() === '' || this.value() === null || this.value() === undefined ? null : Number(this.value())));
protected readonly required = computed(() => { protected readonly required = computed(() => {
+10
View File
@@ -31,6 +31,7 @@
color: var(--shell-quiet); color: var(--shell-quiet);
} }
.out, .out,
.fields,
.burger { .burger {
background: transparent; background: transparent;
color: var(--shell-ink); color: var(--shell-ink);
@@ -42,7 +43,16 @@
.burger { .burger {
padding: 2px 8px; padding: 2px 8px;
} }
.fields {
margin-right: 10px;
font-size: 12px;
padding: 1px 8px;
}
.fields.on {
background: rgba(255, 255, 255, 0.18);
}
.out:hover, .out:hover,
.fields:hover,
.burger:hover { .burger:hover {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
} }
+1
View File
@@ -5,6 +5,7 @@
</button> </button>
<a class="brand" routerLink="/">Basic ERP <small>{{ session.company() }}</small></a> <a class="brand" routerLink="/">Basic ERP <small>{{ session.company() }}</small></a>
<span class="sp"></span> <span class="sp"></span>
<button class="fields" type="button" [class.on]="fieldNames.show()" [attr.aria-pressed]="fieldNames.show()" title="Mostrar el nombre del campo de SAP debajo de cada etiqueta" (click)="fieldNames.toggle()">Campos</button>
<sbo-theme-switcher /> <sbo-theme-switcher />
<span class="user">{{ session.fullName() }}</span> <span class="user">{{ session.fullName() }}</span>
<button class="out" type="button" (click)="logout()">Salir</button> <button class="out" type="button" (click)="logout()">Salir</button>
+2
View File
@@ -1,3 +1,4 @@
import { FieldNames } from '../core/field-names';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { CompanySettingsService } from '../core/company/company-settings'; import { CompanySettingsService } from '../core/company/company-settings';
@@ -31,6 +32,7 @@ export class MainLayout {
private readonly auth = inject(AuthService); private readonly auth = inject(AuthService);
protected readonly session = inject(SessionStore); protected readonly session = inject(SessionStore);
protected readonly menu = inject(MenuService); protected readonly menu = inject(MenuService);
protected readonly fieldNames = inject(FieldNames);
/** En pantallas estrechas el menú se abre encima del contenido. */ /** En pantallas estrechas el menú se abre encima del contenido. */
protected readonly sidebarOpen = signal(false); protected readonly sidebarOpen = signal(false);
@@ -8,4 +8,6 @@ export abstract class FieldBase {
readonly readOnly = input(false, { alias: 'readonly', transform: booleanAttribute }); readonly readOnly = input(false, { alias: 'readonly', transform: booleanAttribute });
readonly disabled = input(false, { transform: booleanAttribute }); readonly disabled = input(false, { transform: booleanAttribute });
readonly error = input<string>(); readonly error = input<string>();
/** Nombre del campo de SAP (ayuda y «Ver campos»). */
readonly dbName = input<string>();
} }
+6 -6
View File
@@ -13,7 +13,7 @@ import { SboField } from './sbo-field';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, TextInput], imports: [SboField, TextInput],
template: ` template: `
<sbo-field [label]="label()" [required]="required()" [error]="error()"> <sbo-field [label]="label()" [dbName]="dbName()" [required]="required()" [error]="error()">
<sbo-input <sbo-input
[(value)]="value" [(value)]="value"
[readonly]="readOnly()" [readonly]="readOnly()"
@@ -37,7 +37,7 @@ export class TextField extends FieldBase {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, CheckboxInput], imports: [SboField, CheckboxInput],
template: ` template: `
<sbo-field [label]="label()" [error]="error()"> <sbo-field [label]="label()" [dbName]="dbName()" [error]="error()">
<sbo-checkbox [(value)]="value" [readonly]="readOnly()" [disabled]="disabled()" /> <sbo-checkbox [(value)]="value" [readonly]="readOnly()" [disabled]="disabled()" />
</sbo-field> </sbo-field>
`, `,
@@ -52,7 +52,7 @@ export class CheckboxField extends FieldBase {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, TextareaInput], imports: [SboField, TextareaInput],
template: ` template: `
<sbo-field [label]="label()" [required]="required()" [error]="error()"> <sbo-field [label]="label()" [dbName]="dbName()" [required]="required()" [error]="error()">
<sbo-textarea <sbo-textarea
[(value)]="value" [(value)]="value"
[readonly]="readOnly()" [readonly]="readOnly()"
@@ -78,7 +78,7 @@ export class TextareaField extends FieldBase {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, NumberInput], imports: [SboField, NumberInput],
template: ` template: `
<sbo-field [label]="label()" [required]="required()" [error]="error()"> <sbo-field [label]="label()" [dbName]="dbName()" [required]="required()" [error]="error()">
<sbo-number <sbo-number
[(value)]="value" [(value)]="value"
[decimals]="decimals()" [decimals]="decimals()"
@@ -100,7 +100,7 @@ export class NumberField extends FieldBase {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, DateInput], imports: [SboField, DateInput],
template: ` template: `
<sbo-field [label]="label()" [required]="required()" [error]="error()"> <sbo-field [label]="label()" [dbName]="dbName()" [required]="required()" [error]="error()">
<sbo-date [(value)]="value" [readonly]="readOnly()" [disabled]="disabled()" [required]="required()" /> <sbo-date [(value)]="value" [readonly]="readOnly()" [disabled]="disabled()" [required]="required()" />
</sbo-field> </sbo-field>
`, `,
@@ -115,7 +115,7 @@ export class DateField extends FieldBase {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, SelectInput], imports: [SboField, SelectInput],
template: ` template: `
<sbo-field [label]="label()" [required]="required()" [error]="error()"> <sbo-field [label]="label()" [dbName]="dbName()" [required]="required()" [error]="error()">
<sbo-select <sbo-select
[(value)]="value" [(value)]="value"
[options]="options()" [options]="options()"
@@ -33,6 +33,7 @@ const NAMES = new Map<string, string>();
template: ` template: `
<sbo-field <sbo-field
[label]="label()" [label]="label()"
[dbName]="dbName()"
[required]="required()" [required]="required()"
[error]="error() || missingText()" [error]="error() || missingText()"
action action
+15 -2
View File
@@ -1,4 +1,5 @@
import { ChangeDetectionStrategy, Component, booleanAttribute, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, booleanAttribute, inject, input, output } from '@angular/core';
import { FieldNames } from '../../core/field-names';
let nextId = 0; let nextId = 0;
@@ -19,8 +20,11 @@ let nextId = 0;
[class.req]="required()" [class.req]="required()"
[class.ga]="action()" [class.ga]="action()"
[class.link]="linkable()" [class.link]="linkable()"
[attr.title]="dbName() ?? null"
(click)="labelClick.emit()" (click)="labelClick.emit()"
>{{ label() }}</label >{{ label() }}@if (dbName() && names.show()) {
<span class="db">{{ dbName() }}</span>
}</label
> >
<div class="ctl"> <div class="ctl">
<div class="sbo-group"><ng-content /></div> <div class="sbo-group"><ng-content /></div>
@@ -65,6 +69,12 @@ let nextId = 0;
border-top: 5px solid transparent; border-top: 5px solid transparent;
border-bottom: 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 { label.link {
cursor: pointer; cursor: pointer;
} }
@@ -83,7 +93,10 @@ let nextId = 0;
`, `,
}) })
export class SboField { export class SboField {
protected readonly names = inject(FieldNames);
readonly label = input.required<string>(); readonly label = input.required<string>();
/** Nombre del campo de SAP; sale como ayuda del cursor y, con «Ver campos», debajo de la etiqueta. */
readonly dbName = input<string>();
readonly required = input(false, { transform: booleanAttribute }); readonly required = input(false, { transform: booleanAttribute });
readonly action = input(false, { transform: booleanAttribute }); readonly action = input(false, { transform: booleanAttribute });
/** La etiqueta es un enlace (ej. abrir el socio de negocio). */ /** La etiqueta es un enlace (ej. abrir el socio de negocio). */
@@ -151,3 +151,10 @@ tr.none td {
position: absolute; position: absolute;
left: -9999px; left: -9999px;
} }
th .db {
display: block;
font: 10.5px/1.2 var(--mono, monospace);
font-weight: 400;
color: var(--quiet);
}
+3 -1
View File
@@ -15,7 +15,9 @@
<tr> <tr>
<th class="rn">#</th> <th class="rn">#</th>
@for (c of columns(); track c.key; let j = $index) { @for (c of columns(); track c.key; let j = $index) {
<th sboColResize [style.width]="c.width" [style.text-align]="alignOf(c)" [class.req]="c.required">{{ c.title }}</th> <th sboColResize [style.width]="c.width" [style.text-align]="alignOf(c)" [class.req]="c.required" [attr.title]="c.key">{{ c.title }}@if (names.show()) {
<span class="db">{{ c.key }}</span>
}</th>
@if (hasDetails() && j === detailAt()) { @if (hasDetails() && j === detailAt()) {
<th class="det">Lotes / Series</th> <th class="det">Lotes / Series</th>
} }
@@ -12,6 +12,7 @@ import {
output, output,
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { FieldNames } from '../../core/field-names';
import { formatNumber, parseDecimal } from '../../core/format'; import { formatNumber, parseDecimal } from '../../core/format';
import { SlLookupApi } from '../../core/sl/sl-lookup'; import { SlLookupApi } from '../../core/sl/sl-lookup';
import type { ListRow } from '../list/list.types'; import type { ListRow } from '../list/list.types';
@@ -57,6 +58,7 @@ export class LinesGrid {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef); private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
protected readonly names = inject(FieldNames);
constructor() { constructor() {
// las celdas de código con texto propio (la cuenta con su formato) piden los textos de las líneas que se cargan // las celdas de código con texto propio (la cuenta con su formato) piden los textos de las líneas que se cargan
+6 -1
View File
@@ -1,6 +1,7 @@
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, model, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, effect, inject, input, model, signal } from '@angular/core';
import { API_URL } from '../../core/api'; import { API_URL } from '../../core/api';
import { FieldNames } from '../../core/field-names';
import { formatNumber, parseDecimal } from '../../core/format'; import { formatNumber, parseDecimal } from '../../core/format';
import { SlLookupApi } from '../../core/sl/sl-lookup'; import { SlLookupApi } from '../../core/sl/sl-lookup';
import type { ListRow } from '../list/list.types'; import type { ListRow } from '../list/list.types';
@@ -31,7 +32,9 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
<tr> <tr>
<th class="rn">#</th> <th class="rn">#</th>
@for (c of columns(); track c.key) { @for (c of columns(); track c.key) {
<th [style.width]="c.width" [class.req]="c.required">{{ c.title }}</th> <th [style.width]="c.width" [class.req]="c.required" [attr.title]="c.key">{{ c.title }}@if (fieldNames.show()) {
<span class="db">{{ c.key }}</span>
}</th>
} }
@if (canRemove()) { @if (canRemove()) {
<th class="del"></th> <th class="del"></th>
@@ -145,6 +148,7 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
.menu button:hover { background: var(--btn-h); } .menu button:hover { background: var(--btn-h); }
table { width: 100%; min-width: max-content; border-collapse: collapse; } 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 { 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); } th.req::after { content: ' *'; color: var(--err); }
td { padding: 0 2px; height: 27px; border: 1px solid var(--line-soft); white-space: nowrap; } td { padding: 0 2px; height: 27px; border: 1px solid var(--line-soft); white-space: nowrap; }
td.ro { background: var(--field-ro); } 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])))); 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 lookupApi = inject(SlLookupApi);
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
/** Nombres de los catálogos de las columnas `nameOf`, por recurso. */ /** Nombres de los catálogos de las columnas `nameOf`, por recurso. */
@@ -75,3 +75,10 @@ tr.none td {
width: 30px; width: 30px;
text-align: center; text-align: center;
} }
th .db {
display: block;
font: 10.5px/1.2 var(--mono, monospace);
font-weight: 400;
color: var(--quiet);
}
+4 -1
View File
@@ -19,10 +19,13 @@
[style.text-align]="align(c)" [style.text-align]="align(c)"
[class.pick]="searchable(c)" [class.pick]="searchable(c)"
[class.active]="c.key === activeColumnKey()" [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)" (click)="columnPicked.emit(c)"
> >
{{ c.title }} {{ c.title }}
@if (names.show()) {
<span class="db">{{ c.key }}</span>
}
</th> </th>
} }
</tr> </tr>
+4 -1
View File
@@ -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 { ColResize } from '../table/col-resize';
import { Badge } from '../window/badge'; import { Badge } from '../window/badge';
import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type Tone } from './list.types'; 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', styleUrl: './data-table.css',
}) })
export class DataTable { export class DataTable {
protected readonly names = inject(FieldNames);
readonly columns = input.required<readonly ListColumn[]>(); readonly columns = input.required<readonly ListColumn[]>();
readonly rows = input.required<readonly ListRow[]>(); readonly rows = input.required<readonly ListRow[]>();
/** Campo único de cada fila. */ /** Campo único de cada fila. */