Files
basic-erp/apps/web/src/app/shared/grid/lines-grid.ts
T

249 lines
9.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,
output,
signal,
} from '@angular/core';
import { formatNumber, parseDecimal } from '../../core/format';
import { SlLookupApi } from '../../core/sl/sl-lookup';
import type { ListRow } from '../list/list.types';
import { ColResize } from '../table/col-resize';
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, ColResize],
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');
/** Avisos de la grilla (ej. un código escrito que no existe), para la barra de estado del formulario. */
readonly notice = output<string>();
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 readonly lookupMultiple = computed(() => !!this.lookupTarget()?.column.lookup?.multiple);
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] ?? '')) {
if (column.lookup && text.trim() !== '') void this.resolveTyped(row, column, input, text.trim());
else this.update(row, column.key, text);
}
}
/** Códigos que se están buscando (Enter y `change` confirman la misma celda: una sola consulta). */
private readonly resolving = new Set<string>();
/**
* Un código escrito a mano se busca en la misma lista que abre "…" y completa la línea igual que
* al elegirlo (descripción del artículo, etc.). Si no existe, se avisa y la celda vuelve a su valor.
*/
private async resolveTyped(row: number, column: GridColumn, input: HTMLInputElement, code: string): Promise<void> {
const lookup = column.lookup!;
const id = `${row}:${column.key}:${code}`;
if (this.resolving.has(id)) return;
this.resolving.add(id);
try {
const found = await this.injector.get(SlLookupApi).find(lookup.config, code);
const line = this.lines()[row];
if (!line) return;
if (found) this.replace(row, lookup.apply(found, line), column.key);
else {
input.value = this.cellText(line, column);
this.notice.emit(`No existe «${code}» en ${lookup.config.title.toLowerCase()}.`);
}
} catch {
this.notice.emit('No se pudo comprobar el código.');
} finally {
this.resolving.delete(id);
}
}
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;
const lookup = target.column.lookup;
this.replace(target.row, lookup.apply(picked, line), target.column.key);
const rest = rows.slice(1).map((r) => {
const added = lookup.apply(r, this.newLine()());
return this.recalc()?.(added, target.column.key) ?? added;
});
if (rest.length) {
this.lines.update((ls) => [...ls.slice(0, target.row + 1), ...rest, ...ls.slice(target.row + 1)]);
}
}
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;
}),
]);
}
}