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

+91
View File
@@ -0,0 +1,91 @@
import { ChangeDetectionStrategy, Component, booleanAttribute, input, output } from '@angular/core';
/**
* Fila de formulario SBO: etiqueta a la izquierda y el control proyectado a la derecha.
* `action` pinta la flecha dorada de SAP (campo que enlaza a otro objeto o a una lista).
*/
@Component({
selector: 'sbo-field',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="f">
<label
[attr.for]="for()"
[class.req]="required()"
[class.ga]="action()"
[class.link]="linkable()"
(click)="labelClick.emit()"
>{{ label() }}</label
>
<div class="ctl">
<ng-content />
@if (error()) {
<span class="err" role="alert" [id]="for() + '-err'">{{ error() }}</span>
}
</div>
</div>
`,
styles: `
:host {
display: block;
}
.f {
display: grid;
grid-template-columns: var(--label-w, 165px) minmax(0, 1fr);
align-items: start;
gap: 8px;
min-height: 24px;
}
label {
padding-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
label.req::after {
content: ' *';
color: var(--err);
}
label.ga {
position: relative;
padding-left: 14px;
}
label.ga::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
border-left: 8px solid var(--gold);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
}
label.link {
cursor: pointer;
}
label.link:hover {
text-decoration: underline;
}
.ctl {
min-width: 0;
}
.err {
display: block;
margin-top: 1px;
font-size: 11.5px;
color: var(--err);
}
`,
})
export class SboField {
readonly label = input.required<string>();
/** `id` del control, para enlazar la etiqueta. */
readonly for = input<string>();
readonly required = input(false, { transform: booleanAttribute });
readonly action = input(false, { transform: booleanAttribute });
/** La etiqueta es un enlace (ej. abrir el socio de negocio). */
readonly linkable = input(false, { transform: booleanAttribute });
readonly error = input<string>();
readonly labelClick = output<void>();
}