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 ` + + `, +}) +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: `
- +
@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: ` - - - - `, - 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;