Files
basic-erp/apps/web/src/app/shared/grid/lines-grid.ts
T
GeorgeandClaude Sonnet 5.5 0a7bfb850a 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>
2026-10-13 20:17:52 -05:00

206 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}),
]);
}
}