diff --git a/apps/web/README.md b/apps/web/README.md
index 91b3030..cc75349 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -73,3 +73,33 @@ columns: VhColumn[] = [
- `type`: `text` (defecto), `number`, `currency` (2 decimales), `date` (dd/MM/yyyy); `format: (row) => string` para algo a medida.
- Teclado: Enter en la búsqueda, Enter sobre una fila, Esc para cerrar.
- Cada apertura reinicia búsqueda, página y selección. Las peticiones obsoletas se cancelan solas (`httpResource`).
+
+### Campos de formulario (`shared/form`)
+
+Cada campo trae su etiqueta (fila SBO: etiqueta a la izquierda, control a la derecha) y se enlaza con señales: `[(value)]`.
+
+| Componente | Valor | Notas |
+|---|---|---|
+| `sbo-text-field` | `string` | `maxlength`, `placeholder` |
+| `sbo-textarea-field` | `string` | `rows` |
+| `sbo-number-field` | `number \| null` | `decimals`; muestra `1,234.50` fuera de foco y acepta `12,5` o `1,234.50` al escribir |
+| `sbo-date-field` | `string` ISO `yyyy-MM-dd` | selector nativo |
+| `sbo-select-field` | `string \| number \| null` | `[options]`, `placeholder`; devuelve el tipo original |
+| `sbo-lookup-field` | `string` | flecha dorada + botón "…" / F4 que abre `sbo-value-help` según `[lookup]` (`VhLookup`); emite `(picked)` con la fila; `linkable` + `(linkClick)` |
+
+Comunes: `label`, `required`, `readonly`, `disabled`, `error` (borde rojo + mensaje). `sbo-field` es la fila vacía para proyectar cualquier otro control.
+
+```html
+
+
+
+
+
+
+
+
+
+
+```
+
+Estilos globales en `styles/forms.css` (`.sbo-in`, `.sbo-sel`, `.sbo-ta`, `.sbo-pick`, `.sbo-hdr`, `.sbo-col`, `.sbo-pair`). El ancho de etiqueta se ajusta con `--label-w` (165 px por defecto).
diff --git a/apps/web/src/app/core/format.spec.ts b/apps/web/src/app/core/format.spec.ts
index 272bc4d..ee52c53 100644
--- a/apps/web/src/app/core/format.spec.ts
+++ b/apps/web/src/app/core/format.spec.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { formatDate, formatNumber } from './format';
+import { formatDate, formatNumber, parseDecimal } from './format';
describe('formatNumber', () => {
it('importes con decimales fijos y miles', () => {
@@ -24,3 +24,17 @@ describe('formatDate', () => {
for (const v of [null, undefined, '', 'ayer', 20260105]) expect(formatDate(v)).toBe('');
});
});
+
+describe('parseDecimal', () => {
+ it('acepta punto decimal, coma decimal y coma de miles', () => {
+ expect(parseDecimal('1234.5')).toBe(1234.5);
+ expect(parseDecimal('1,234.50')).toBe(1234.5);
+ expect(parseDecimal('12,5')).toBe(12.5);
+ expect(parseDecimal('1,234,567')).toBe(1234567);
+ expect(parseDecimal(' -7 ')).toBe(-7);
+ expect(parseDecimal('S/ 99.90')).toBe(99.9);
+ });
+ it('lo que no es número da null', () => {
+ for (const v of ['', ' ', 'abc', '1.2.3', '--']) expect(parseDecimal(v), v).toBeNull();
+ });
+});
diff --git a/apps/web/src/app/core/format.ts b/apps/web/src/app/core/format.ts
index 06818d5..c471ee1 100644
--- a/apps/web/src/app/core/format.ts
+++ b/apps/web/src/app/core/format.ts
@@ -22,3 +22,17 @@ export function formatDate(value: unknown): string {
const m = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
return m ? `${m[3]}/${m[2]}/${m[1]}` : '';
}
+
+/**
+ * Interpreta lo que escribe el usuario: `1,234.50` (coma de miles) o `12,5` (coma decimal).
+ * Devuelve `null` si no es un número.
+ */
+export function parseDecimal(text: string): number | null {
+ const t = text.trim();
+ if (!t) return null;
+ const normalized = /,\d{3}(\D|$)/.test(t) ? t.replace(/,/g, '') : t.replace(/,/g, '.');
+ const cleaned = normalized.replace(/[^\d.-]/g, '');
+ if (!/^-?\d*\.?\d+$|^-?\d+\.$/.test(cleaned)) return null;
+ const n = Number.parseFloat(cleaned);
+ return Number.isFinite(n) ? n : null;
+}
diff --git a/apps/web/src/app/shared/form/date-field.ts b/apps/web/src/app/shared/form/date-field.ts
new file mode 100644
index 0000000..dd0dff1
--- /dev/null
+++ b/apps/web/src/app/shared/form/date-field.ts
@@ -0,0 +1,29 @@
+import { ChangeDetectionStrategy, Component, model } from '@angular/core';
+import { FieldBase } from './field-base';
+import { SboField } from './sbo-field';
+
+/** Fecha como texto ISO `yyyy-MM-dd` (el formato que usa el Service Layer). Selector nativo: sin librerías. */
+@Component({
+ selector: 'sbo-date-field',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [SboField],
+ template: `
+
+
+
+ `,
+ styles: ':host { display: block; }',
+})
+export class DateField extends FieldBase {
+ readonly value = model('');
+}
diff --git a/apps/web/src/app/shared/form/field-base.ts b/apps/web/src/app/shared/form/field-base.ts
new file mode 100644
index 0000000..f9b3a7f
--- /dev/null
+++ b/apps/web/src/app/shared/form/field-base.ts
@@ -0,0 +1,15 @@
+import { Directive, booleanAttribute, input } from '@angular/core';
+
+let nextId = 0;
+
+/** Entradas comunes de todos los campos de formulario. */
+@Directive()
+export abstract class FieldBase {
+ readonly label = input.required();
+ readonly required = input(false, { transform: booleanAttribute });
+ readonly readOnly = input(false, { alias: 'readonly', transform: booleanAttribute });
+ readonly disabled = input(false, { transform: booleanAttribute });
+ readonly error = input();
+
+ protected readonly id = `sbo-f-${++nextId}`;
+}
diff --git a/apps/web/src/app/shared/form/fields.spec.ts b/apps/web/src/app/shared/form/fields.spec.ts
new file mode 100644
index 0000000..5603159
--- /dev/null
+++ b/apps/web/src/app/shared/form/fields.spec.ts
@@ -0,0 +1,331 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { type ComponentFixture, TestBed } from '@angular/core/testing';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { VhLookup } from '../value-help/value-help.types';
+import { DateField } from './date-field';
+import { LookupField } from './lookup-field';
+import { NumberField } from './number-field';
+import { SboField } from './sbo-field';
+import { SelectField } from './select-field';
+import { TextField } from './text-field';
+import { TextareaField } from './textarea-field';
+
+const tick = async (fixture: ComponentFixture) => {
+ fixture.detectChanges();
+ await new Promise((resolve) => setTimeout(resolve));
+ fixture.detectChanges();
+};
+const typeInto = (input: HTMLInputElement | HTMLTextAreaElement, value: string) => {
+ input.value = value;
+ input.dispatchEvent(new Event('input'));
+};
+
+describe('SboField', () => {
+ it('muestra etiqueta, obligatorio, flecha dorada y error enlazados al control', async () => {
+ await TestBed.configureTestingModule({ imports: [SboField] }).compileComponents();
+ const fixture = TestBed.createComponent(SboField);
+ fixture.componentRef.setInput('label', 'Cliente');
+ fixture.componentRef.setInput('for', 'x1');
+ fixture.componentRef.setInput('required', true);
+ fixture.componentRef.setInput('action', true);
+ fixture.componentRef.setInput('error', 'Obligatorio');
+ await tick(fixture);
+ const label = fixture.nativeElement.querySelector('label') as HTMLLabelElement;
+ expect(label.textContent?.trim()).toBe('Cliente');
+ expect(label.htmlFor).toBe('x1');
+ expect(label.classList.contains('req')).toBe(true);
+ expect(label.classList.contains('ga')).toBe(true);
+ expect(fixture.nativeElement.querySelector('.err').textContent).toContain('Obligatorio');
+ });
+});
+
+describe('TextField', () => {
+ let fixture: ComponentFixture;
+ const input = () => fixture.nativeElement.querySelector('input') as HTMLInputElement;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({ imports: [TextField] }).compileComponents();
+ fixture = TestBed.createComponent(TextField);
+ fixture.componentRef.setInput('label', 'Nombre');
+ });
+
+ it('refleja el valor y lo actualiza al escribir (enlace bidireccional)', async () => {
+ fixture.componentRef.setInput('value', 'ACME');
+ await tick(fixture);
+ expect(input().value).toBe('ACME');
+ typeInto(input(), 'Nuevo');
+ expect(fixture.componentInstance.value()).toBe('Nuevo');
+ });
+
+ it('la etiqueta apunta al control', async () => {
+ await tick(fixture);
+ expect(fixture.nativeElement.querySelector('label').htmlFor).toBe(input().id);
+ });
+
+ it('solo lectura, maxlength, obligatorio y error', async () => {
+ fixture.componentRef.setInput('readonly', true);
+ fixture.componentRef.setInput('maxlength', 20);
+ fixture.componentRef.setInput('required', true);
+ fixture.componentRef.setInput('error', 'Falta');
+ await tick(fixture);
+ expect(input().readOnly).toBe(true);
+ expect(input().getAttribute('maxlength')).toBe('20');
+ expect(input().required).toBe(true);
+ expect(input().getAttribute('aria-invalid')).toBe('true');
+ expect(input().getAttribute('aria-describedby')).toBe(`${input().id}-err`);
+ });
+
+ it('cada campo tiene un id distinto', async () => {
+ const other = TestBed.createComponent(TextField);
+ other.componentRef.setInput('label', 'Otro');
+ await tick(fixture);
+ await tick(other);
+ expect(input().id).not.toBe((other.nativeElement.querySelector('input') as HTMLInputElement).id);
+ });
+});
+
+describe('TextareaField', () => {
+ it('actualiza el valor y respeta solo lectura', async () => {
+ await TestBed.configureTestingModule({ imports: [TextareaField] }).compileComponents();
+ const fixture = TestBed.createComponent(TextareaField);
+ fixture.componentRef.setInput('label', 'Comentarios');
+ fixture.componentRef.setInput('value', 'hola');
+ await tick(fixture);
+ const ta = fixture.nativeElement.querySelector('textarea') as HTMLTextAreaElement;
+ expect(ta.value).toBe('hola');
+ typeInto(ta, 'adiós');
+ expect(fixture.componentInstance.value()).toBe('adiós');
+ fixture.componentRef.setInput('readonly', true);
+ await tick(fixture);
+ expect(ta.readOnly).toBe(true);
+ });
+});
+
+describe('NumberField', () => {
+ let fixture: ComponentFixture;
+ const input = () => fixture.nativeElement.querySelector('input') as HTMLInputElement;
+ const edit = async (text: string) => {
+ input().dispatchEvent(new Event('focus'));
+ await tick(fixture);
+ typeInto(input(), text);
+ input().dispatchEvent(new Event('blur'));
+ await tick(fixture);
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({ imports: [NumberField] }).compileComponents();
+ fixture = TestBed.createComponent(NumberField);
+ fixture.componentRef.setInput('label', 'Importe');
+ fixture.componentRef.setInput('decimals', 2);
+ });
+
+ it('fuera de foco muestra el valor formateado; al editar, el valor crudo', async () => {
+ fixture.componentRef.setInput('value', 1234.5);
+ await tick(fixture);
+ expect(input().value).toBe('1,234.50');
+ input().dispatchEvent(new Event('focus'));
+ await tick(fixture);
+ expect(input().value).toBe('1234.5');
+ });
+
+ it('interpreta coma decimal, coma de miles y redondea a los decimales indicados', async () => {
+ await edit('12,5');
+ expect(fixture.componentInstance.value()).toBe(12.5);
+ await edit('1,234.567');
+ expect(fixture.componentInstance.value()).toBe(1234.57);
+ expect(input().value).toBe('1,234.57');
+ });
+
+ it('vacío da null y un texto inválido conserva el valor anterior', async () => {
+ fixture.componentRef.setInput('value', 50);
+ await tick(fixture);
+ await edit('abc');
+ expect(fixture.componentInstance.value()).toBe(50);
+ await edit('');
+ expect(fixture.componentInstance.value()).toBeNull();
+ expect(input().value).toBe('');
+ });
+
+ it('en solo lectura no entra en edición', async () => {
+ fixture.componentRef.setInput('value', 7);
+ fixture.componentRef.setInput('readonly', true);
+ await tick(fixture);
+ input().dispatchEvent(new Event('focus'));
+ await tick(fixture);
+ expect(input().value).toBe('7.00');
+ expect(input().readOnly).toBe(true);
+ });
+
+ it('sin decimales fijos no agrega ceros', async () => {
+ fixture.componentRef.setInput('decimals', undefined);
+ fixture.componentRef.setInput('value', 12);
+ await tick(fixture);
+ expect(input().value).toBe('12');
+ });
+});
+
+describe('DateField', () => {
+ it('lee y escribe fechas ISO', async () => {
+ await TestBed.configureTestingModule({ imports: [DateField] }).compileComponents();
+ const fixture = TestBed.createComponent(DateField);
+ fixture.componentRef.setInput('label', 'Fecha');
+ fixture.componentRef.setInput('value', '2026-03-05');
+ await tick(fixture);
+ const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
+ expect(input.type).toBe('date');
+ expect(input.value).toBe('2026-03-05');
+ typeInto(input, '2026-12-31');
+ expect(fixture.componentInstance.value()).toBe('2026-12-31');
+ });
+});
+
+describe('SelectField', () => {
+ let fixture: ComponentFixture;
+ const select = () => fixture.nativeElement.querySelector('select') as HTMLSelectElement;
+ const choose = async (value: string) => {
+ select().value = value;
+ select().dispatchEvent(new Event('change'));
+ await tick(fixture);
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({ imports: [SelectField] }).compileComponents();
+ fixture = TestBed.createComponent(SelectField);
+ fixture.componentRef.setInput('label', 'Condición de pago');
+ fixture.componentRef.setInput('options', [
+ { value: -1, label: 'Contado' },
+ { value: 1, label: 'Crédito 30 días' },
+ ]);
+ });
+
+ it('muestra las opciones y marca la elegida', async () => {
+ fixture.componentRef.setInput('value', 1);
+ await tick(fixture);
+ expect([...select().options].map((o) => o.textContent?.trim())).toEqual(['Contado', 'Crédito 30 días']);
+ expect(select().value).toBe('1');
+ });
+
+ it('devuelve el valor con su tipo original (número, no texto)', async () => {
+ await tick(fixture);
+ await choose('-1');
+ expect(fixture.componentInstance.value()).toBe(-1);
+ });
+
+ it('con placeholder ofrece la opción vacía que da null', async () => {
+ fixture.componentRef.setInput('placeholder', '— Seleccione —');
+ fixture.componentRef.setInput('value', 1);
+ await tick(fixture);
+ expect(select().options[0].textContent?.trim()).toBe('— Seleccione —');
+ await choose('');
+ expect(fixture.componentInstance.value()).toBeNull();
+ });
+
+ it('solo lectura lo deshabilita', async () => {
+ fixture.componentRef.setInput('readonly', true);
+ await tick(fixture);
+ expect(select().disabled).toBe(true);
+ });
+});
+
+describe('LookupField', () => {
+ const lookup: VhLookup = {
+ title: 'Socios de negocio',
+ resource: 'BusinessPartners',
+ columns: [
+ { key: 'CardCode', title: 'Código' },
+ { key: 'CardName', title: 'Nombre' },
+ ],
+ baseFilter: "CardType eq 'cCustomer'",
+ };
+ let fixture: ComponentFixture;
+ let ctrl: HttpTestingController;
+ const input = () => fixture.nativeElement.querySelector('input.sbo-in') as HTMLInputElement;
+ const button = () => fixture.nativeElement.querySelector('.sbo-pick button') as HTMLButtonElement;
+ const dialog = () => fixture.nativeElement.querySelector('.dlg') as HTMLElement | null;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [LookupField],
+ providers: [provideHttpClient(), provideHttpClientTesting()],
+ }).compileComponents();
+ ctrl = TestBed.inject(HttpTestingController);
+ fixture = TestBed.createComponent(LookupField);
+ fixture.componentRef.setInput('label', 'Cliente');
+ fixture.componentRef.setInput('lookup', lookup);
+ await tick(fixture);
+ });
+
+ const openAndReply = async (rows: object[]) => {
+ button().click();
+ await tick(fixture);
+ ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners').flush({ value: rows, 'odata.count': String(rows.length) });
+ await tick(fixture);
+ };
+
+ it('tiene la flecha dorada y el botón "…" cerrado por defecto', () => {
+ expect(fixture.nativeElement.querySelector('label.ga')).toBeTruthy();
+ expect(button().textContent?.trim()).toBe('…');
+ expect(dialog()).toBeNull();
+ });
+
+ it('escribir el código a mano actualiza el valor sin abrir la lista', async () => {
+ typeInto(input(), 'C0001');
+ expect(fixture.componentInstance.value()).toBe('C0001');
+ expect(dialog()).toBeNull();
+ });
+
+ it('elegir de la lista rellena el valor, emite la fila y cierra', async () => {
+ const picked = vi.fn();
+ fixture.componentInstance.picked.subscribe(picked);
+ await openAndReply([
+ { CardCode: 'C1', CardName: 'ACME' },
+ { CardCode: 'C2', CardName: 'Beta' },
+ ]);
+ expect(dialog()).toBeTruthy();
+ (fixture.nativeElement.querySelectorAll('tbody tr')[1] as HTMLElement).click();
+ await tick(fixture);
+ expect(fixture.componentInstance.value()).toBe('C2');
+ expect(picked).toHaveBeenCalledWith({ CardCode: 'C2', CardName: 'Beta' });
+ expect(dialog()).toBeNull();
+ });
+
+ it('usa valueKey si se indica otro campo', async () => {
+ fixture.componentRef.setInput('lookup', { ...lookup, valueKey: 'CardName' });
+ await openAndReply([{ CardCode: 'C1', CardName: 'ACME' }]);
+ (fixture.nativeElement.querySelector('tbody tr') as HTMLElement).click();
+ await tick(fixture);
+ expect(fixture.componentInstance.value()).toBe('ACME');
+ });
+
+ it('F4 abre la lista y al cerrarla el foco vuelve al campo', async () => {
+ input().dispatchEvent(new KeyboardEvent('keydown', { key: 'F4', cancelable: true }));
+ await tick(fixture);
+ ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners').flush({ value: [], 'odata.count': '0' });
+ await tick(fixture);
+ expect(dialog()).toBeTruthy();
+ dialog()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ await tick(fixture);
+ expect(dialog()).toBeNull();
+ expect(document.activeElement === input() || fixture.nativeElement.contains(document.activeElement)).toBe(true);
+ });
+
+ it('en solo lectura el botón está deshabilitado y F4 no abre', async () => {
+ fixture.componentRef.setInput('readonly', true);
+ await tick(fixture);
+ expect(button().disabled).toBe(true);
+ input().dispatchEvent(new KeyboardEvent('keydown', { key: 'F4' }));
+ await tick(fixture);
+ expect(dialog()).toBeNull();
+ ctrl.expectNone('/api/sl/BusinessPartners');
+ });
+
+ it('la etiqueta enlazable emite linkClick', async () => {
+ const link = vi.fn();
+ fixture.componentRef.setInput('linkable', true);
+ fixture.componentInstance.linkClick.subscribe(link);
+ await tick(fixture);
+ (fixture.nativeElement.querySelector('label') as HTMLElement).click();
+ expect(link).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/web/src/app/shared/form/index.ts b/apps/web/src/app/shared/form/index.ts
new file mode 100644
index 0000000..6b5411e
--- /dev/null
+++ b/apps/web/src/app/shared/form/index.ts
@@ -0,0 +1,7 @@
+export { DateField } from './date-field';
+export { LookupField } from './lookup-field';
+export { NumberField } from './number-field';
+export { SboField } from './sbo-field';
+export { type SelectOption, SelectField } from './select-field';
+export { TextField } from './text-field';
+export { TextareaField } from './textarea-field';
diff --git a/apps/web/src/app/shared/form/lookup-field.ts b/apps/web/src/app/shared/form/lookup-field.ts
new file mode 100644
index 0000000..c37e6db
--- /dev/null
+++ b/apps/web/src/app/shared/form/lookup-field.ts
@@ -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: `
+
+