feat(web): campos de formulario SBO reutilizables

- sbo-field: fila etiqueta + control, con obligatorio (*), flecha dorada,
  etiqueta enlazable y mensaje de error.
- sbo-text-field, sbo-textarea-field, sbo-number-field, sbo-date-field,
  sbo-select-field y sbo-lookup-field, con enlace bidireccional por señales
  ([(value)]) y entradas comunes (label, required, readonly, disabled, error).
- sbo-number-field: formateado fuera de foco (1,234.50), acepta 12,5 y
  1,234.50 al escribir, redondea a los decimales indicados y conserva el
  valor si el texto no es un número (parseDecimal).
- sbo-select-field devuelve el valor con su tipo original (número o texto).
- sbo-lookup-field: botón "…" y F4 abren sbo-value-help (VhLookup); elegir
  rellena el campo, emite la fila y devuelve el foco; escribir el código a
  mano sigue siendo válido.
- Estilos globales de formulario (forms.css) tomados del mockup, con
  ancho de etiqueta configurable (--label-w).
- Verificado en Chrome contra SAP real, en claro y oscuro: cliente elegido
  de la lista rellena código, nombre y RUC; edición de números; selects.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 19:45:47 -05:00
1 parent 3d5a81e806
commit aa2c483723
16 files changed
+917 -1

No files matched your search

@@ -0,0 +1,102 @@
import {
ChangeDetectionStrategy,
Component,
type ElementRef,
booleanAttribute,
input,
model,
output,
signal,
viewChild,
} from '@angular/core';
import { ValueHelp } from '../value-help/value-help';
import type { VhLookup, VhRow } from '../value-help/value-help.types';
import { FieldBase } from './field-base';
import { SboField } from './sbo-field';
/**
* Campo con botón "…" que abre una lista de valores (flecha dorada de SAP).
* Escribir el código a mano también es válido; elegir de la lista lo rellena y emite `picked`.
*/
@Component({
selector: 'sbo-lookup-field',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, ValueHelp],
template: `
<sbo-field
[label]="label()"
[for]="id"
[required]="required()"
[error]="error()"
action
[linkable]="linkable()"
(labelClick)="linkClick.emit()"
>
<div class="sbo-pick">
<input
#box
class="sbo-in"
type="text"
autocomplete="off"
[id]="id"
[value]="value()"
[readOnly]="readOnly()"
[disabled]="disabled()"
[required]="required()"
[attr.aria-invalid]="error() ? 'true' : null"
(input)="value.set($any($event.target).value)"
(keydown.f4)="$event.preventDefault(); openList()"
/>
<button
type="button"
title="Lista de valores (F4)"
[attr.aria-label]="'Abrir la lista de ' + label()"
[disabled]="readOnly() || disabled()"
(click)="openList()"
>
…
</button>
</div>
</sbo-field>
<sbo-value-help
[open]="listOpen()"
[title]="lookup().title"
[resource]="lookup().resource"
[columns]="lookup().columns"
[baseFilter]="lookup().baseFilter"
[orderby]="lookup().orderby"
(selected)="onPicked($event)"
(closed)="closeList()"
/>
`,
styles: ':host { display: block; }',
})
export class LookupField extends FieldBase {
readonly value = model('');
readonly lookup = input.required<VhLookup>();
/** La etiqueta abre el objeto enlazado (evento `linkClick`). */
readonly linkable = input(false, { transform: booleanAttribute });
readonly picked = output<VhRow>();
readonly linkClick = output<void>();
protected readonly listOpen = signal(false);
private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box');
protected openList(): void {
if (!this.readOnly() && !this.disabled()) this.listOpen.set(true);
}
protected closeList(): void {
this.listOpen.set(false);
this.box().nativeElement.focus();
}
protected onPicked(rows: VhRow[]): void {
const row = rows[0];
if (!row) return;
const key = this.lookup().valueKey ?? this.lookup().columns[0]?.key ?? '';
this.value.set(String(row[key] ?? ''));
this.picked.emit(row);
}
}