Files
basic-erp/apps/web/src/app/shared/form/controls.ts
T
GeorgeandClaude Sonnet 5.5 68caf401a7 refactor(web): filas de formulario con varios controles combinables
El mockup muestra que una fila es etiqueta + uno o varios controles en la
misma línea (Cliente = input + "...", Canal / Zona = dos mitades, Serie +
Número = 72 px + resto, select + número + botón) y que la cabecera tiene a
lo sumo 2 columnas. Los campos de un solo control no lo permitían.

- Controles sin etiqueta combinables dentro de sbo-field: sbo-input,
  sbo-textarea, sbo-number, sbo-date, sbo-select y el botón .sbo-btn;
  ocupan el espacio sobrante o un width fijo.
- El primer control de la fila toma el id de la etiqueta (claimId); los
  demás se nombran con ariaLabel. El error de la fila marca todos sus
  controles (aria-invalid / aria-describedby).
- Los campos con etiqueta (sbo-text-field, sbo-number-field, ...,
  sbo-lookup-field) pasan a ser atajos de un solo control con la misma API.
- CSS global: .sbo-group y .sbo-btn reemplazan a .sbo-pick/.sbo-pair.
- CLAUDE.md apunta al mockup renombrado (tools/oferta_venta.html).
- Verificado en Chrome contra SAP real, con una cabecera que reproduce las
  filas compuestas del mockup.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:49:16 -05:00

192 lines
5.8 KiB
TypeScript

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: `
<input
class="sbo-in"
type="text"
autocomplete="off"
[id]="id"
[value]="value()"
[readOnly]="readOnly()"
[disabled]="disabled()"
[required]="required()"
[attr.maxlength]="maxlength()"
[placeholder]="placeholder()"
[attr.aria-label]="ariaLabel() ?? null"
[attr.aria-invalid]="invalid() ? 'true' : null"
[attr.aria-describedby]="describedBy()"
(input)="value.set($any($event.target).value)"
/>
`,
styles: ':host { display: block; }',
})
export class TextInput extends ControlBase {
readonly value = model('');
readonly maxlength = input<number>();
readonly placeholder = input('');
}
@Component({
selector: 'sbo-textarea',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<textarea
class="sbo-ta"
[id]="id"
[value]="value()"
[readOnly]="readOnly()"
[disabled]="disabled()"
[required]="required()"
[attr.maxlength]="maxlength()"
[placeholder]="placeholder()"
[rows]="rows()"
[attr.aria-label]="ariaLabel() ?? null"
[attr.aria-invalid]="invalid() ? 'true' : null"
[attr.aria-describedby]="describedBy()"
(input)="value.set($any($event.target).value)"
></textarea>
`,
styles: ':host { display: block; }',
})
export class TextareaInput extends ControlBase {
readonly value = model('');
readonly maxlength = input<number>();
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: `
<input
class="sbo-in num"
type="text"
inputmode="decimal"
autocomplete="off"
[id]="id"
[value]="display()"
[readOnly]="readOnly()"
[disabled]="disabled()"
[required]="required()"
[attr.aria-label]="ariaLabel() ?? null"
[attr.aria-invalid]="invalid() ? 'true' : null"
[attr.aria-describedby]="describedBy()"
(focus)="startEditing($event)"
(input)="draft.set($any($event.target).value)"
(blur)="finishEditing()"
/>
`,
styles: ':host { display: block; }',
})
export class NumberInput extends ControlBase {
readonly value = model<number | null>(null);
/** Decimales fijos al mostrar y redondear (importes: 2). Sin él: hasta 3, sin ceros sobrantes. */
readonly decimals = input<number>();
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: `
<input
class="sbo-in"
type="date"
[id]="id"
[value]="value()"
[readOnly]="readOnly()"
[disabled]="disabled()"
[required]="required()"
[attr.aria-label]="ariaLabel() ?? null"
[attr.aria-invalid]="invalid() ? 'true' : null"
[attr.aria-describedby]="describedBy()"
(input)="value.set($any($event.target).value)"
/>
`,
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: `
<select
class="sbo-sel"
[id]="id"
[disabled]="disabled() || readOnly()"
[required]="required()"
[attr.aria-label]="ariaLabel() ?? null"
[attr.aria-invalid]="invalid() ? 'true' : null"
[attr.aria-describedby]="describedBy()"
(change)="choose($any($event.target).value)"
>
@if (placeholder() !== undefined) {
<option value="" [selected]="value() === null">{{ placeholder() }}</option>
}
@for (o of options(); track o.value) {
<option [value]="o.value" [selected]="o.value === value()">{{ o.label }}</option>
}
</select>
`,
styles: ':host { display: block; }',
})
export class SelectInput extends ControlBase {
readonly options = input.required<readonly SelectOption[]>();
readonly value = model<string | number | null>(null);
/** Opción vacía inicial (ej. "— Seleccione —"); sin él, no se ofrece. */
readonly placeholder = input<string>();
protected choose(raw: string): void {
const option = this.options().find((o) => String(o.value) === raw);
this.value.set(option ? option.value : null);
}
}