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

340 lines
14 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,
effect,
inject,
input,
model,
output,
signal,
} from '@angular/core';
import { FieldNames, isSapField } from '../../core/field-names';
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, GridDetail, GridLine, GridMultiAdd } from './grid.types';
import { LineDetailDialog } from './line-detail-dialog';
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, LineDetailDialog],
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>();
/** Detalles de línea (lotes, series): una columna de botones que abre su ventana de edición. */
readonly details = input<readonly GridDetail[]>([]);
/** Dado el detalle y las filas en edición, el aviso bajo la tabla de la ventana (ej. «suman 8 de 10»). */
readonly detailHint = input<((detail: GridDetail, line: GridLine, rows: readonly Record<string, unknown>[]) => { text: string; bad: boolean }) | undefined>(undefined);
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 names = inject(FieldNames);
protected readonly isSap = isSapField;
constructor() {
// las celdas de código con texto propio (la cuenta con su formato) piden los textos de las líneas que se cargan
effect(() => {
const lines = this.lines();
for (const c of this.columns()) {
const codes = c.lookup?.config.codes;
if (codes) codes.ensure(lines.map((l) => String(l[c.key] ?? '')).filter((v) => v !== ''));
}
});
}
protected readonly lookupTarget = signal<LookupTarget | null>(null);
protected readonly detailTarget = signal<{ row: number; detail: GridDetail } | null>(null);
protected readonly detailHintFn = computed(() => {
const t = this.detailTarget();
const hint = this.detailHint();
const line = t ? this.lines()[t.row] : undefined;
return t && hint && line ? (rows: readonly Record<string, unknown>[]) => hint(t.detail, line, rows) : undefined;
});
protected readonly multiOpen = signal(false);
protected readonly lookup = computed(() => this.lookupTarget()?.column.lookup?.config);
protected readonly hasDetails = computed(() => this.details().length > 0);
/** Posición de la columna «Lotes / Series»: justo después de la cantidad (si no hay, al final). */
protected readonly detailAt = computed(() => {
const cols = this.columns();
const q = cols.findIndex((c) => c.key === 'Quantity');
return q >= 0 ? q : cols.length - 1;
});
/** Filas con que se abre la ventana (estable mientras no cambie la línea: la ventana edita una copia). */
protected readonly detailInitial = computed<readonly Record<string, unknown>[]>(() => {
const t = this.detailTarget();
const line = t ? this.lines()[t.row] : undefined;
return (line?.[t?.detail.key ?? ''] as Record<string, unknown>[] | undefined) ?? [];
});
/**
* Ancho mínimo de la tabla: la suma de los anchos de sus columnas (las que no declaran uno, como la
* descripción, reservan 220 px), más el número de fila y el botón de quitar. Si no cabe en pantalla, la
* grilla se desplaza en horizontal en lugar de apretar las columnas hasta cortar su contenido.
*/
protected readonly tableMinWidth = computed(() => {
const px = (c: GridColumn): number => {
const w = parseFloat(c.width ?? '');
return Number.isFinite(w) ? w : 220;
};
return this.columns().reduce((sum, c) => sum + px(c), 34 + (this.readonly() ? 0 : 30) + (this.hasDetails() ? 130 : 0));
});
protected readonly lookupMultiple = computed(() => !!this.lookupTarget()?.column.lookup?.multiple);
/** Alineación de una columna: la declarada o, si no, derecha para las numéricas e izquierda para las demás. */
protected alignOf(column: GridColumn): 'left' | 'center' | 'right' {
return column.align ?? (column.type === 'number' ? 'right' : 'left');
}
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);
if (column.type === 'date') return typeof value === 'string' ? value.slice(0, 10) : '';
const codes = column.lookup?.config.codes;
if (codes && typeof value === 'string' && value !== '') return codes.display(value);
return value === null || value === undefined ? '' : String(value);
}
protected isReadonly(line: GridLine, column: GridColumn): boolean {
if (this.readonly() || column.format || column.locked) 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] ?? '') && text !== this.cellText(line, column)) {
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) {
lookup.config.codes?.remember(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;
const spread = row === 0 && this.columns().some((c) => c.key === key && c.propagate) && next[key] !== this.lines()[0]?.[key];
this.lines.update((ls) =>
ls.map((l, i) => {
if (i === row) return next;
if (!spread || i === 0) return l;
const copy = { ...l, [key]: next[key] };
return this.recalc()?.(copy, key) ?? copy;
}),
);
}
// --- 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 });
}
// --- detalles de línea (lotes, series) ----------------------------------------------------
/** Los detalles que aplican a una línea. */
protected detailsOf(line: GridLine): readonly GridDetail[] {
return this.details().filter((d) => d.applies(line));
}
protected openDetail(row: number, detail: GridDetail): void {
this.detailTarget.set({ row, detail });
}
protected detailSaved(rows: Record<string, unknown>[]): void {
const t = this.detailTarget();
if (!t) return;
this.lines.update((ls) => ls.map((l, i) => (i === t.row ? { ...l, [t.detail.key]: rows } : l)));
this.detailTarget.set(null);
}
// --- 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;
lookup.config.codes?.remember(picked);
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;
}),
]);
}
}