diff --git a/CLAUDE.md b/CLAUDE.md
index 25b3e34..7d3631e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -11,7 +11,7 @@ ERP base para SAP Business One (Service Layer). Monorepo npm workspaces:
- Solo campos **estándar** de SAP Business One. Ningún campo de usuario (`U_*`) salvo `U_WEB_USER` / `U_WEB_PASS` de `EmployeesInfo`, usados para el login web.
- Los formularios se construyen con componentes reutilizables y configuración declarativa; no se duplica markup por documento.
-- Los colores y el aspecto salen de variables CSS (temas). Tema por defecto: claro, con la paleta de `tools/Cotizador Aberio · Oferta de ventas.html`.
+- Los colores y el aspecto salen de variables CSS (temas). Tema por defecto: claro, con la paleta de `tools/oferta_venta.html` (mockup de ejemplo: tiene algunos campos no estándar, pero se conserva la idea: filas de etiqueta + controles, máx. 2 columnas).
## Criterios de la interfaz
diff --git a/apps/web/README.md b/apps/web/README.md
index cc75349..abc508e 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -76,30 +76,36 @@ columns: VhColumn[] = [
### 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.
+Una **fila** (`sbo-field`) es: etiqueta + uno o varios controles en la misma línea. La cabecera de un formulario tiene **como máximo 2 columnas** (`.sbo-hdr` con dos `.sbo-col`). Las combinaciones del mockup (Cliente = input + "…", Canal / Zona = dos mitades, Serie + Número = 72 px + resto, select + número + botón) salen de combinar controles:
```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).
+**Controles combinables** (sin etiqueta, dentro de `sbo-field`; ocupan el espacio sobrante o un `width` fijo): `sbo-input`, `sbo-textarea`, `sbo-number`, `sbo-date`, `sbo-select` y el botón nativo ``. Todos con enlace por señales `[(value)]`, `readonly`, `disabled`, `required`, `width` y `ariaLabel` (obligatorio en los que no son el primero de la fila: el primero toma el `id` de la etiqueta). El `error` de la fila marca todos sus controles (borde rojo + mensaje, `aria-invalid`/`aria-describedby`).
+
+**Atajos con etiqueta** (un control por fila): `sbo-text-field`, `sbo-textarea-field`, `sbo-number-field`, `sbo-date-field`, `sbo-select-field` y `sbo-lookup-field` (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`.
+
+Detalles: `sbo-number` muestra `1,234.50` fuera de foco y acepta `12,5` o `1,234.50` al escribir (`decimals` redondea); `sbo-date` usa texto ISO `yyyy-MM-dd`; `sbo-select` devuelve el valor con su tipo original.
+
+Estilos globales en `styles/forms.css` (`.sbo-in`, `.sbo-sel`, `.sbo-ta`, `.sbo-btn`, `.sbo-group`, `.sbo-hdr`, `.sbo-col`). El ancho de etiqueta se ajusta con `--label-w` (165 px por defecto).
diff --git a/apps/web/src/app/shared/form/control-base.ts b/apps/web/src/app/shared/form/control-base.ts
new file mode 100644
index 0000000..2505994
--- /dev/null
+++ b/apps/web/src/app/shared/form/control-base.ts
@@ -0,0 +1,35 @@
+import { Directive, ElementRef, booleanAttribute, computed, inject, input } from '@angular/core';
+import { SboField } from './sbo-field';
+
+let nextId = 0;
+
+/**
+ * Base de los controles sin etiqueta que se combinan dentro de un `sbo-field`.
+ * Por defecto ocupan el espacio sobrante de la fila; con `width` toman un ancho fijo.
+ */
+@Directive({
+ host: {
+ '[style.flex]': "width() ? '0 0 ' + width() : '1 1 0'",
+ '[style.min-width]': "'0'",
+ },
+})
+export abstract class ControlBase {
+ protected readonly field = inject(SboField, { optional: true });
+ private readonly host = inject>(ElementRef);
+
+ readonly readOnly = input(false, { alias: 'readonly', transform: booleanAttribute });
+ readonly disabled = input(false, { transform: booleanAttribute });
+ readonly required = input(false, { transform: booleanAttribute });
+ /** Ancho fijo (ej. `72px`) para controles estrechos como la serie; sin él, reparte el espacio. */
+ readonly width = input();
+ /** Obligatorio en controles que no son el primero de la fila (el primero usa la etiqueta). */
+ readonly ariaLabel = input();
+
+ protected readonly id = this.field?.claimId() ?? `sbo-c-${++nextId}`;
+ protected readonly invalid = computed(() => !!this.field?.error());
+ protected readonly describedBy = computed(() => (this.invalid() ? (this.field?.errorId ?? null) : null));
+
+ focus(): void {
+ this.host.nativeElement.querySelector('input,select,textarea')?.focus();
+ }
+}
diff --git a/apps/web/src/app/shared/form/controls.ts b/apps/web/src/app/shared/form/controls.ts
new file mode 100644
index 0000000..192c856
--- /dev/null
+++ b/apps/web/src/app/shared/form/controls.ts
@@ -0,0 +1,191 @@
+import { ChangeDetectionStrategy, Component, computed, input, model, signal } from '@angular/core';
+import { formatNumber, parseDecimal } from '../../core/format';
+import { ControlBase } from './control-base';
+
+@Component({
+ selector: 'sbo-input',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ `,
+ styles: ':host { display: block; }',
+})
+export class TextInput extends ControlBase {
+ readonly value = model('');
+ readonly maxlength = input();
+ readonly placeholder = input('');
+}
+
+@Component({
+ selector: 'sbo-textarea',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ `,
+ styles: ':host { display: block; }',
+})
+export class TextareaInput extends ControlBase {
+ readonly value = model('');
+ readonly maxlength = input();
+ readonly placeholder = input('');
+ readonly rows = input(3);
+}
+
+/**
+ * Número o importe. Fuera de foco se muestra formateado (`1,234.50`); al editar se
+ * escribe en texto libre y se interpreta al salir (acepta `12,5` y `1,234.50`).
+ */
+@Component({
+ selector: 'sbo-number',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ `,
+ styles: ':host { display: block; }',
+})
+export class NumberInput extends ControlBase {
+ readonly value = model(null);
+ /** Decimales fijos al mostrar y redondear (importes: 2). Sin él: hasta 3, sin ceros sobrantes. */
+ readonly decimals = input();
+
+ protected readonly editing = signal(false);
+ protected readonly draft = signal('');
+ protected readonly display = computed(() =>
+ this.editing() ? this.draft() : formatNumber(this.value(), this.decimals()),
+ );
+
+ protected startEditing(event: FocusEvent): void {
+ if (this.readOnly()) return;
+ const v = this.value();
+ this.draft.set(v === null ? '' : String(v));
+ this.editing.set(true);
+ // se selecciona el contenido una vez que el valor editable reemplazó al formateado
+ const input = event.target as HTMLInputElement;
+ queueMicrotask(() => input.select());
+ }
+
+ protected finishEditing(): void {
+ if (!this.editing()) return;
+ this.editing.set(false);
+ const text = this.draft().trim();
+ if (text === '') {
+ this.value.set(null);
+ return;
+ }
+ const parsed = parseDecimal(text);
+ if (parsed === null) return; // no es un número: se conserva el valor anterior
+ const d = this.decimals();
+ this.value.set(d === undefined ? parsed : Math.round((parsed + Number.EPSILON) * 10 ** d) / 10 ** d);
+ }
+}
+
+/** Fecha como texto ISO `yyyy-MM-dd` (el formato del Service Layer). Selector nativo: sin librerías. */
+@Component({
+ selector: 'sbo-date',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ `,
+ styles: ':host { display: block; }',
+})
+export class DateInput extends ControlBase {
+ readonly value = model('');
+}
+
+export interface SelectOption {
+ value: string | number;
+ label: string;
+}
+
+@Component({
+ selector: 'sbo-select',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ @if (placeholder() !== undefined) {
+ {{ placeholder() }}
+ }
+ @for (o of options(); track o.value) {
+ {{ o.label }}
+ }
+
+ `,
+ styles: ':host { display: block; }',
+})
+export class SelectInput extends ControlBase {
+ readonly options = input.required();
+ readonly value = model(null);
+ /** Opción vacía inicial (ej. "— Seleccione —"); sin él, no se ofrece. */
+ readonly placeholder = input();
+
+ protected choose(raw: string): void {
+ const option = this.options().find((o) => String(o.value) === raw);
+ this.value.set(option ? option.value : null);
+ }
+}
diff --git a/apps/web/src/app/shared/form/date-field.ts b/apps/web/src/app/shared/form/date-field.ts
deleted file mode 100644
index dd0dff1..0000000
--- a/apps/web/src/app/shared/form/date-field.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-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
index f9b3a7f..99efffa 100644
--- a/apps/web/src/app/shared/form/field-base.ts
+++ b/apps/web/src/app/shared/form/field-base.ts
@@ -1,8 +1,6 @@
import { Directive, booleanAttribute, input } from '@angular/core';
-let nextId = 0;
-
-/** Entradas comunes de todos los campos de formulario. */
+/** Entradas comunes de los campos con etiqueta (un solo control por fila). */
@Directive()
export abstract class FieldBase {
readonly label = input.required();
@@ -10,6 +8,4 @@ export abstract class FieldBase {
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
index 5603159..a8c680b 100644
--- a/apps/web/src/app/shared/form/fields.spec.ts
+++ b/apps/web/src/app/shared/form/fields.spec.ts
@@ -3,13 +3,11 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common
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 { Component, signal } from '@angular/core';
+import { DateInput, NumberInput, SelectInput, TextInput } from './controls';
+import { DateField, NumberField, SelectField, TextField, TextareaField } from './fields';
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();
@@ -26,14 +24,13 @@ describe('SboField', () => {
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.htmlFor).toMatch(/^sbo-f-\d+$/);
expect(label.classList.contains('req')).toBe(true);
expect(label.classList.contains('ga')).toBe(true);
expect(fixture.nativeElement.querySelector('.err').textContent).toContain('Obligatorio');
@@ -241,7 +238,7 @@ describe('LookupField', () => {
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 button = () => fixture.nativeElement.querySelector('.sbo-group button') as HTMLButtonElement;
const dialog = () => fixture.nativeElement.querySelector('.dlg') as HTMLElement | null;
beforeEach(async () => {
@@ -299,7 +296,7 @@ describe('LookupField', () => {
});
it('F4 abre la lista y al cerrarla el foco vuelve al campo', async () => {
- input().dispatchEvent(new KeyboardEvent('keydown', { key: 'F4', cancelable: true }));
+ input().dispatchEvent(new KeyboardEvent('keydown', { key: 'F4', cancelable: true, bubbles: true }));
await tick(fixture);
ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners').flush({ value: [], 'odata.count': '0' });
await tick(fixture);
@@ -314,7 +311,7 @@ describe('LookupField', () => {
fixture.componentRef.setInput('readonly', true);
await tick(fixture);
expect(button().disabled).toBe(true);
- input().dispatchEvent(new KeyboardEvent('keydown', { key: 'F4' }));
+ input().dispatchEvent(new KeyboardEvent('keydown', { key: 'F4', bubbles: true }));
await tick(fixture);
expect(dialog()).toBeNull();
ctrl.expectNone('/api/sl/BusinessPartners');
@@ -329,3 +326,100 @@ describe('LookupField', () => {
expect(link).toHaveBeenCalledTimes(1);
});
});
+
+// Filas con varios controles, como en el mockup (Cliente = input + botón, Número = serie + número...).
+@Component({
+ imports: [SboField, TextInput, NumberInput, DateInput, SelectInput],
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+ …
+
+ `,
+})
+class RowsHost {
+ serie = signal('COT');
+ numero = signal('000123');
+ monedas = [
+ { value: 'PEN', label: 'S/' },
+ { value: 'USD', label: 'US$' },
+ ];
+ moneda = signal('PEN');
+ importe = signal(10);
+ clicks = signal(0);
+}
+
+describe('filas con varios controles', () => {
+ let fixture: ComponentFixture;
+ const fields = () => [...fixture.nativeElement.querySelectorAll('sbo-field')] as HTMLElement[];
+ const controls = (field: HTMLElement) => [...field.querySelectorAll('input,select')] as (HTMLInputElement | HTMLSelectElement)[];
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({ imports: [RowsHost] }).compileComponents();
+ fixture = TestBed.createComponent(RowsHost);
+ await tick(fixture);
+ });
+
+ it('los controles de una fila comparten línea', () => {
+ for (const f of fields()) expect(f.querySelector('.sbo-group')).toBeTruthy();
+ expect(controls(fields()[1])).toHaveLength(2);
+ expect(controls(fields()[2])).toHaveLength(2);
+ expect(fields()[2].querySelectorAll('button')).toHaveLength(1);
+ });
+
+ it('un control con width toma ancho fijo y el resto reparte el espacio', () => {
+ const [serie, numero] = [...fields()[0].querySelectorAll('sbo-input')] as HTMLElement[];
+ expect(serie.style.flex).toBe('0 0 72px');
+ expect(numero.style.flex).toContain('1 1 0');
+ });
+
+ it('la etiqueta apunta solo al primer control; los demás se nombran con ariaLabel', () => {
+ const [serie, numero] = controls(fields()[0]);
+ expect((fields()[0].querySelector('label') as HTMLLabelElement).htmlFor).toBe(serie.id);
+ expect(serie.getAttribute('aria-label')).toBe('Serie');
+ expect(numero.getAttribute('aria-label')).toBe('Número correlativo');
+ expect(numero.id).not.toBe(serie.id);
+ });
+
+ it('el error de la fila marca todos sus controles y los describe', () => {
+ for (const c of controls(fields()[0])) {
+ expect(c.getAttribute('aria-invalid')).toBe('true');
+ expect(c.getAttribute('aria-describedby')).toBeTruthy();
+ }
+ expect(fields()[0].querySelector('.err')?.textContent).toContain('Serie inválida');
+ expect(controls(fields()[1])[0].hasAttribute('aria-invalid')).toBe(false);
+ });
+
+ it('mezcla select + número + botón con enlace bidireccional independiente', async () => {
+ const [select, importe] = controls(fields()[2]);
+ (select as HTMLSelectElement).value = 'USD';
+ select.dispatchEvent(new Event('change'));
+ importe.dispatchEvent(new Event('focus'));
+ await tick(fixture);
+ typeInto(importe as HTMLInputElement, '99,5');
+ importe.dispatchEvent(new Event('blur'));
+ fields()[2].querySelector('button')!.click();
+ await tick(fixture);
+ expect(fixture.componentInstance.moneda()).toBe('USD');
+ expect(fixture.componentInstance.importe()).toBe(99.5);
+ expect(fixture.componentInstance.clicks()).toBe(1);
+ });
+
+ it('un control fuera de un sbo-field también funciona (con su propio id)', async () => {
+ const solo = TestBed.createComponent(TextInput);
+ solo.componentRef.setInput('value', 'x');
+ await tick(solo);
+ const input = solo.nativeElement.querySelector('input') as HTMLInputElement;
+ expect(input.id).toMatch(/^sbo-c-\d+$/);
+ expect(input.value).toBe('x');
+ });
+});
diff --git a/apps/web/src/app/shared/form/fields.ts b/apps/web/src/app/shared/form/fields.ts
new file mode 100644
index 0000000..76c2a28
--- /dev/null
+++ b/apps/web/src/app/shared/form/fields.ts
@@ -0,0 +1,120 @@
+import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
+import { FieldBase } from './field-base';
+import { DateInput, NumberInput, SelectInput, type SelectOption, TextInput, TextareaInput } from './controls';
+import { SboField } from './sbo-field';
+
+/*
+ * Campos con etiqueta de un solo control: atajo de `` + el control correspondiente.
+ * Para filas con varios controles (input + botón, serie + número...) usar `sbo-field` directamente.
+ */
+
+@Component({
+ selector: 'sbo-text-field',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [SboField, TextInput],
+ template: `
+
+
+
+ `,
+ styles: ':host { display: block; }',
+})
+export class TextField extends FieldBase {
+ readonly value = model('');
+ readonly maxlength = input();
+ readonly placeholder = input('');
+}
+
+@Component({
+ selector: 'sbo-textarea-field',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [SboField, TextareaInput],
+ template: `
+
+
+
+ `,
+ styles: ':host { display: block; }',
+})
+export class TextareaField extends FieldBase {
+ readonly value = model('');
+ readonly maxlength = input();
+ readonly placeholder = input('');
+ readonly rows = input(3);
+}
+
+@Component({
+ selector: 'sbo-number-field',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [SboField, NumberInput],
+ template: `
+
+
+
+ `,
+ styles: ':host { display: block; }',
+})
+export class NumberField extends FieldBase {
+ readonly value = model(null);
+ readonly decimals = input();
+}
+
+@Component({
+ selector: 'sbo-date-field',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [SboField, DateInput],
+ template: `
+
+
+
+ `,
+ styles: ':host { display: block; }',
+})
+export class DateField extends FieldBase {
+ readonly value = model('');
+}
+
+@Component({
+ selector: 'sbo-select-field',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [SboField, SelectInput],
+ template: `
+
+
+
+ `,
+ styles: ':host { display: block; }',
+})
+export class SelectField extends FieldBase {
+ readonly options = input.required();
+ readonly value = model(null);
+ readonly placeholder = input();
+}
diff --git a/apps/web/src/app/shared/form/index.ts b/apps/web/src/app/shared/form/index.ts
index 6b5411e..6ba4387 100644
--- a/apps/web/src/app/shared/form/index.ts
+++ b/apps/web/src/app/shared/form/index.ts
@@ -1,7 +1,6 @@
-export { DateField } from './date-field';
-export { LookupField } from './lookup-field';
-export { NumberField } from './number-field';
+// Fila y controles combinables
+export { DateInput, NumberInput, type SelectOption, SelectInput, TextInput, TextareaInput } from './controls';
export { SboField } from './sbo-field';
-export { type SelectOption, SelectField } from './select-field';
-export { TextField } from './text-field';
-export { TextareaField } from './textarea-field';
+// Atajos con etiqueta (un control por fila)
+export { DateField, NumberField, SelectField, TextField, TextareaField } from './fields';
+export { LookupField } from './lookup-field';
diff --git a/apps/web/src/app/shared/form/lookup-field.ts b/apps/web/src/app/shared/form/lookup-field.ts
index c37e6db..a1e0401 100644
--- a/apps/web/src/app/shared/form/lookup-field.ts
+++ b/apps/web/src/app/shared/form/lookup-field.ts
@@ -1,7 +1,6 @@
import {
ChangeDetectionStrategy,
Component,
- type ElementRef,
booleanAttribute,
input,
model,
@@ -11,6 +10,7 @@ import {
} from '@angular/core';
import { ValueHelp } from '../value-help/value-help';
import type { VhLookup, VhRow } from '../value-help/value-help.types';
+import { TextInput } from './controls';
import { FieldBase } from './field-base';
import { SboField } from './sbo-field';
@@ -21,42 +21,34 @@ import { SboField } from './sbo-field';
@Component({
selector: 'sbo-lookup-field',
changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [SboField, ValueHelp],
+ imports: [SboField, TextInput, ValueHelp],
template: `
-
-
-
- …
-
-
+
+
+ …
+
();
protected readonly listOpen = signal(false);
- private readonly box = viewChild.required>('box');
+ private readonly box = viewChild.required('box');
protected openList(): void {
if (!this.readOnly() && !this.disabled()) this.listOpen.set(true);
@@ -89,7 +81,7 @@ export class LookupField extends FieldBase {
protected closeList(): void {
this.listOpen.set(false);
- this.box().nativeElement.focus();
+ this.box().focus();
}
protected onPicked(rows: VhRow[]): void {
diff --git a/apps/web/src/app/shared/form/number-field.ts b/apps/web/src/app/shared/form/number-field.ts
deleted file mode 100644
index fa8b351..0000000
--- a/apps/web/src/app/shared/form/number-field.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { ChangeDetectionStrategy, Component, computed, input, model, signal } from '@angular/core';
-import { formatNumber, parseDecimal } from '../../core/format';
-import { FieldBase } from './field-base';
-import { SboField } from './sbo-field';
-
-/**
- * Número o importe. Fuera de foco se muestra formateado (`1,234.50`); al editar se
- * escribe en texto libre y se interpreta al salir (acepta `12,5` y `1,234.50`).
- */
-@Component({
- selector: 'sbo-number-field',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [SboField],
- template: `
-
-
-
- `,
- styles: ':host { display: block; }',
-})
-export class NumberField extends FieldBase {
- readonly value = model(null);
- /** Decimales fijos al mostrar y redondear (importes: 2). Sin él: hasta 3, sin ceros sobrantes. */
- readonly decimals = input();
-
- protected readonly editing = signal(false);
- protected readonly draft = signal('');
- protected readonly display = computed(() =>
- this.editing() ? this.draft() : formatNumber(this.value(), this.decimals()),
- );
-
- protected startEditing(event: FocusEvent): void {
- if (this.readOnly()) return;
- const v = this.value();
- this.draft.set(v === null ? '' : String(v));
- this.editing.set(true);
- // se selecciona el contenido una vez que el valor editable reemplazó al formateado
- const input = event.target as HTMLInputElement;
- queueMicrotask(() => input.select());
- }
-
- protected finishEditing(): void {
- if (!this.editing()) return;
- this.editing.set(false);
- const text = this.draft().trim();
- if (text === '') {
- this.value.set(null);
- return;
- }
- const parsed = parseDecimal(text);
- if (parsed === null) return; // no es un número: se conserva el valor anterior
- const d = this.decimals();
- this.value.set(d === undefined ? parsed : Math.round((parsed + Number.EPSILON) * 10 ** d) / 10 ** d);
- }
-}
diff --git a/apps/web/src/app/shared/form/sbo-field.ts b/apps/web/src/app/shared/form/sbo-field.ts
index 590e049..9289fb4 100644
--- a/apps/web/src/app/shared/form/sbo-field.ts
+++ b/apps/web/src/app/shared/form/sbo-field.ts
@@ -1,8 +1,13 @@
import { ChangeDetectionStrategy, Component, booleanAttribute, input, output } from '@angular/core';
+let nextId = 0;
+
/**
- * Fila de formulario SBO: etiqueta a la izquierda y el control proyectado a la derecha.
+ * Fila de formulario SBO: etiqueta a la izquierda y, a la derecha, uno o varios
+ * controles en una misma línea (input + botón, select + botón, serie + número...).
* `action` pinta la flecha dorada de SAP (campo que enlaza a otro objeto o a una lista).
+ *
+ * El primer control de la fila toma el `id` al que apunta la etiqueta (ver `claimId`).
*/
@Component({
selector: 'sbo-field',
@@ -10,7 +15,7 @@ import { ChangeDetectionStrategy, Component, booleanAttribute, input, output } f
template: `
{{ label() }}
-
+
@if (error()) {
-
{{ error() }}
+
{{ error() }}
}
@@ -79,8 +84,6 @@ import { ChangeDetectionStrategy, Component, booleanAttribute, input, output } f
})
export class SboField {
readonly label = input.required();
- /** `id` del control, para enlazar la etiqueta. */
- readonly for = input();
readonly required = input(false, { transform: booleanAttribute });
readonly action = input(false, { transform: booleanAttribute });
/** La etiqueta es un enlace (ej. abrir el socio de negocio). */
@@ -88,4 +91,15 @@ export class SboField {
readonly error = input();
readonly labelClick = output();
+
+ readonly controlId = `sbo-f-${++nextId}`;
+ readonly errorId = `${this.controlId}-err`;
+ private claimed = false;
+
+ /** El primer control de la fila recibe el id de la etiqueta; los demás deben llevar su `ariaLabel`. */
+ claimId(): string | null {
+ if (this.claimed) return null;
+ this.claimed = true;
+ return this.controlId;
+ }
}
diff --git a/apps/web/src/app/shared/form/select-field.ts b/apps/web/src/app/shared/form/select-field.ts
deleted file mode 100644
index f694d5f..0000000
--- a/apps/web/src/app/shared/form/select-field.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
-import { FieldBase } from './field-base';
-import { SboField } from './sbo-field';
-
-export interface SelectOption {
- value: string | number;
- label: string;
-}
-
-@Component({
- selector: 'sbo-select-field',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [SboField],
- template: `
-
-
- @if (placeholder() !== undefined) {
- {{ placeholder() }}
- }
- @for (o of options(); track o.value) {
- {{ o.label }}
- }
-
-
- `,
- styles: ':host { display: block; }',
-})
-export class SelectField extends FieldBase {
- readonly options = input.required();
- readonly value = model(null);
- /** Opción vacía inicial (ej. "— Seleccione —"); sin él, no se ofrece. */
- readonly placeholder = input();
-
- protected choose(raw: string): void {
- const option = this.options().find((o) => String(o.value) === raw);
- this.value.set(option ? option.value : null);
- }
-}
diff --git a/apps/web/src/app/shared/form/text-field.ts b/apps/web/src/app/shared/form/text-field.ts
deleted file mode 100644
index 93a00bd..0000000
--- a/apps/web/src/app/shared/form/text-field.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
-import { FieldBase } from './field-base';
-import { SboField } from './sbo-field';
-
-@Component({
- selector: 'sbo-text-field',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [SboField],
- template: `
-
-
-
- `,
- styles: ':host { display: block; }',
-})
-export class TextField extends FieldBase {
- readonly value = model('');
- readonly maxlength = input();
- readonly placeholder = input('');
-}
diff --git a/apps/web/src/app/shared/form/textarea-field.ts b/apps/web/src/app/shared/form/textarea-field.ts
deleted file mode 100644
index 39e6fab..0000000
--- a/apps/web/src/app/shared/form/textarea-field.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
-import { FieldBase } from './field-base';
-import { SboField } from './sbo-field';
-
-@Component({
- selector: 'sbo-textarea-field',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [SboField],
- template: `
-
-
-
- `,
- styles: ':host { display: block; }',
-})
-export class TextareaField extends FieldBase {
- readonly value = model('');
- readonly maxlength = input();
- readonly placeholder = input('');
- readonly rows = input(3);
-}
diff --git a/apps/web/src/styles/forms.css b/apps/web/src/styles/forms.css
index 7910a4a..a105d6a 100644
--- a/apps/web/src/styles/forms.css
+++ b/apps/web/src/styles/forms.css
@@ -43,24 +43,26 @@
border-color: var(--err);
}
-/* Campo con botón "…" (elegir de la lista) */
-.sbo-pick {
- display: grid;
- grid-template-columns: minmax(0, 1fr) 26px;
- gap: 2px;
+/* Controles de una misma fila (dentro de sbo-field): input + botón, serie + número... */
+.sbo-group {
+ display: flex;
+ gap: 4px;
+ align-items: flex-start;
}
-.sbo-pick button {
+.sbo-btn {
+ flex: none;
height: 23px;
- padding: 0;
+ min-width: 26px;
+ padding: 0 6px;
border: 1px solid var(--field-b);
border-radius: 2px;
background: var(--btn);
cursor: pointer;
}
-.sbo-pick button:hover:not(:disabled) {
+.sbo-btn:hover:not(:disabled) {
background: var(--btn-h);
}
-.sbo-pick button:disabled {
+.sbo-btn:disabled {
opacity: 0.45;
cursor: default;
}
@@ -76,12 +78,6 @@
gap: 4px;
align-content: start;
}
-/* Dos controles en una fila (ej. serie + número) */
-.sbo-pair {
- display: grid;
- grid-template-columns: 72px minmax(0, 1fr);
- gap: 4px;
-}
@media (max-width: 900px) {
.sbo-hdr {
grid-template-columns: 1fr;