feat(web): grilla de líneas editable y página de componentes de desarrollo

- sbo-lines-grid: columnas declarativas (texto / número con decimales /
  solo lectura / calculadas), celdas con lista de valores (botón "..." y
  F4) cuyo apply rellena la línea, añadir y quitar línea, y "Elegir
  artículos..." para agregar varias líneas a la vez.
- Teclado: Enter confirma y baja a la misma columna (crea línea en la
  última fila), flechas arriba/abajo cambian de fila. Las celdas se
  confirman al salir de ellas; un número inválido conserva el anterior.
- Documento cerrado: grilla de solo lectura, sin botones ni listas.
- Columnas estándar de línea de SAP (artículo, descripción, cantidad,
  almacén, precio, % dto., impuesto, total) con TaxCode / SalesTaxCodes,
  verificado en el Service Layer real; recalcLine (cantidad x precio x
  (1 - dto)).
- Corrige el foco al crear una línea con Enter: se pedía antes de que la
  fila existiera en el DOM (ahora espera al render).
- /dev/componentes: página de componentes solo para desarrollo
  (canMatch isDevMode), sin entrada en el menú y sin escribir en SAP.
- Verificado en Chrome contra SAP real: artículo elegido de la lista,
  5 x 10,000 con 10 % = 45,000.00, Enter/flechas, varios artículos a la
  vez, quitar línea y subtotal del pie.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 20:17:52 -05:00
1 parent 26f0649da9
commit 0a7bfb850a
11 files changed
+1150

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
Injector,
afterNextRender,
computed,
inject,
input,
model,
signal,
} from '@angular/core';
import { formatNumber, parseDecimal } from '../../core/format';
import type { ListRow } from '../list/list.types';
import { ValueHelp } from '../value-help/value-help';
import type { GridColumn, GridLine, GridMultiAdd } from './grid.types';
interface LookupTarget {
row: number;
column: GridColumn;
}
/**
* Grilla editable de líneas de un documento. Las celdas se confirman al salir de ellas o con Enter
* (que baja a la misma columna de la fila siguiente y crea una línea al llegar a la última).
* Flechas ↑/↓ cambian de fila; F4 abre la lista de valores de la celda.
*/
@Component({
selector: 'sbo-lines-grid',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ValueHelp],
templateUrl: './lines-grid.html',
styleUrl: './lines-grid.css',
})
export class LinesGrid {
readonly columns = input.required<readonly GridColumn[]>();
readonly lines = model<readonly GridLine[]>([]);
/** Documento cerrado o cancelado: sin edición ni botones de añadir/quitar. */
readonly readonly = input(false);
/** Línea nueva con sus valores por defecto. */
readonly newLine = input<() => GridLine>(() => ({}));
/** Recalcula la línea tras un cambio (ej. total = cantidad × precio). */
readonly recalc = input<((line: GridLine, changedKey: string) => GridLine) | undefined>(undefined);
readonly multiAdd = input<GridMultiAdd>();
readonly addLabel = input('Añadir línea');
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);
protected readonly lookupTarget = signal<LookupTarget | null>(null);
protected readonly multiOpen = signal(false);
protected readonly lookup = computed(() => this.lookupTarget()?.column.lookup?.config);
protected cellText(line: GridLine, column: GridColumn): string {
if (column.format) return column.format(line);
const value = line[column.key];
if (column.type === 'number') return formatNumber(value, column.decimals);
return value === null || value === undefined ? '' : String(value);
}
protected isReadonly(line: GridLine, column: GridColumn): boolean {
if (this.readonly() || column.format) return true;
return typeof column.readonly === 'function' ? column.readonly(line) : !!column.readonly;
}
protected firstEditable(): number {
return this.columns().findIndex((c) => !c.format && !c.readonly);
}
// --- edición ---------------------------------------------------------------------------
protected numberFocus(event: FocusEvent, line: GridLine, column: GridColumn): void {
if (this.isReadonly(line, column)) return;
const input = event.target as HTMLInputElement;
const value = line[column.key];
input.value = value === null || value === undefined ? '' : String(value);
queueMicrotask(() => input.select());
}
/** Confirma el valor de una celda; si no cambió, no hace nada. */
protected commit(row: number, column: GridColumn, input: HTMLInputElement): void {
const line = this.lines()[row];
if (!line || this.isReadonly(line, column)) return;
const text = input.value;
if (column.type === 'number') {
const parsed = text.trim() === '' ? null : parseDecimal(text);
if (text.trim() !== '' && parsed === null) {
input.value = this.cellText(line, column); // no es un número: se conserva el anterior
return;
}
const d = column.decimals;
const value = parsed === null || d === undefined ? parsed : Math.round((parsed + Number.EPSILON) * 10 ** d) / 10 ** d;
this.update(row, column.key, value);
input.value = formatNumber(value, column.decimals);
} else if (text !== String(line[column.key] ?? '')) {
this.update(row, column.key, text);
}
}
private update(row: number, key: string, value: unknown): void {
const current = this.lines()[row];
if (!current || current[key] === value) return;
this.replace(row, { ...current, [key]: value }, key);
}
private replace(row: number, line: GridLine, key: string): void {
const next = this.recalc()?.(line, key) ?? line;
this.lines.update((ls) => ls.map((l, i) => (i === row ? next : l)));
}
// --- filas -----------------------------------------------------------------------------
protected add(): void {
if (this.readonly()) return;
const row = this.lines().length;
this.lines.update((ls) => [...ls, this.recalc()?.(this.newLine()(), '') ?? this.newLine()()]);
this.focusCell(row, this.firstEditable());
}
protected remove(row: number): void {
if (this.readonly()) return;
this.lines.update((ls) => ls.filter((_, i) => i !== row));
}
// --- teclado ---------------------------------------------------------------------------
protected onKey(event: KeyboardEvent, row: number, col: number): void {
const column = this.columns()[col];
const input = event.target as HTMLInputElement;
switch (event.key) {
case 'Enter': {
event.preventDefault();
this.commit(row, column, input);
if (row + 1 >= this.lines().length) this.add();
else this.focusCell(row + 1, col);
break;
}
case 'ArrowDown':
if (row + 1 < this.lines().length) {
event.preventDefault();
this.commit(row, column, input);
this.focusCell(row + 1, col);
}
break;
case 'ArrowUp':
if (row > 0) {
event.preventDefault();
this.commit(row, column, input);
this.focusCell(row - 1, col);
}
break;
case 'F4':
if (column.lookup) {
event.preventDefault();
this.openLookup(row, column);
}
break;
}
}
/** Enfoca una celda; si la fila aún no está dibujada (línea recién creada), espera al render. */
protected focusCell(row: number, col: number): void {
if (col < 0) return;
const focus = (): boolean => {
const cell = this.host.nativeElement.querySelector<HTMLInputElement>(`input[data-r="${row}"][data-c="${col}"]`);
cell?.focus();
return !!cell;
};
if (!focus()) afterNextRender(() => void focus(), { injector: this.injector });
}
// --- listas de valores -----------------------------------------------------------------
protected openLookup(row: number, column: GridColumn): void {
const line = this.lines()[row];
if (line && !this.isReadonly(line, column)) this.lookupTarget.set({ row, column });
}
protected lookupPicked(rows: ListRow[]): void {
const target = this.lookupTarget();
const picked = rows[0];
const line = target ? this.lines()[target.row] : undefined;
if (!target || !picked || !line || !target.column.lookup) return;
this.replace(target.row, target.column.lookup.apply(picked, line), target.column.key);
}
protected lookupClosed(): void {
const target = this.lookupTarget();
this.lookupTarget.set(null);
if (target) this.focusCell(target.row, this.columns().indexOf(target.column));
}
protected multiPicked(rows: ListRow[]): void {
const config = this.multiAdd();
if (!config || rows.length === 0) return;
this.lines.update((ls) => [
...ls,
...rows.map((r) => {
const line = config.toLine(r);
return this.recalc()?.(line, '') ?? line;
}),
]);
}
}