2026-10-13 20:17:52 -05:00
|
|
|
|
import {
|
|
|
|
|
|
ChangeDetectionStrategy,
|
|
|
|
|
|
Component,
|
|
|
|
|
|
ElementRef,
|
|
|
|
|
|
Injector,
|
|
|
|
|
|
afterNextRender,
|
|
|
|
|
|
computed,
|
2026-10-19 10:05:55 -05:00
|
|
|
|
effect,
|
2026-10-13 20:17:52 -05:00
|
|
|
|
inject,
|
|
|
|
|
|
input,
|
|
|
|
|
|
model,
|
2026-10-14 11:42:51 -05:00
|
|
|
|
output,
|
2026-10-13 20:17:52 -05:00
|
|
|
|
signal,
|
|
|
|
|
|
} from '@angular/core';
|
2026-10-20 09:09:45 -05:00
|
|
|
|
import { FieldNames, isSapField } from '../../core/field-names';
|
2026-10-13 20:17:52 -05:00
|
|
|
|
import { formatNumber, parseDecimal } from '../../core/format';
|
2026-10-14 11:42:51 -05:00
|
|
|
|
import { SlLookupApi } from '../../core/sl/sl-lookup';
|
2026-10-13 20:17:52 -05:00
|
|
|
|
import type { ListRow } from '../list/list.types';
|
2026-10-14 14:27:36 -05:00
|
|
|
|
import { ColResize } from '../table/col-resize';
|
2026-10-13 20:17:52 -05:00
|
|
|
|
import { ValueHelp } from '../value-help/value-help';
|
2026-10-16 12:25:01 -05:00
|
|
|
|
import type { GridColumn, GridDetail, GridLine, GridMultiAdd } from './grid.types';
|
|
|
|
|
|
import { LineDetailDialog } from './line-detail-dialog';
|
2026-10-13 20:17:52 -05:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-10-16 12:25:01 -05:00
|
|
|
|
imports: [ValueHelp, ColResize, LineDetailDialog],
|
2026-10-13 20:17:52 -05:00
|
|
|
|
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>();
|
2026-10-16 12:25:01 -05:00
|
|
|
|
/** 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);
|
2026-10-13 20:17:52 -05:00
|
|
|
|
readonly addLabel = input('Añadir línea');
|
2026-10-14 11:42:51 -05:00
|
|
|
|
/** Avisos de la grilla (ej. un código escrito que no existe), para la barra de estado del formulario. */
|
|
|
|
|
|
readonly notice = output<string>();
|
2026-10-13 20:17:52 -05:00
|
|
|
|
|
|
|
|
|
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
|
|
|
|
private readonly injector = inject(Injector);
|
2026-10-20 08:59:58 -05:00
|
|
|
|
protected readonly names = inject(FieldNames);
|
2026-10-20 09:09:45 -05:00
|
|
|
|
protected readonly isSap = isSapField;
|
2026-10-13 20:17:52 -05:00
|
|
|
|
|
2026-10-19 10:05:55 -05:00
|
|
|
|
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 !== ''));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 20:17:52 -05:00
|
|
|
|
protected readonly lookupTarget = signal<LookupTarget | null>(null);
|
2026-10-16 12:25:01 -05:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
2026-10-13 20:17:52 -05:00
|
|
|
|
protected readonly multiOpen = signal(false);
|
|
|
|
|
|
protected readonly lookup = computed(() => this.lookupTarget()?.column.lookup?.config);
|
2026-10-16 12:25:01 -05:00
|
|
|
|
protected readonly hasDetails = computed(() => this.details().length > 0);
|
2026-10-16 12:47:51 -05:00
|
|
|
|
/** 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;
|
|
|
|
|
|
});
|
2026-10-16 12:25:01 -05:00
|
|
|
|
/** 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) ?? [];
|
|
|
|
|
|
});
|
2026-10-16 11:09:47 -05:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
};
|
2026-10-16 12:25:01 -05:00
|
|
|
|
return this.columns().reduce((sum, c) => sum + px(c), 34 + (this.readonly() ? 0 : 30) + (this.hasDetails() ? 130 : 0));
|
2026-10-16 11:09:47 -05:00
|
|
|
|
});
|
2026-10-14 14:27:36 -05:00
|
|
|
|
protected readonly lookupMultiple = computed(() => !!this.lookupTarget()?.column.lookup?.multiple);
|
2026-10-13 20:17:52 -05:00
|
|
|
|
|
2026-10-16 11:23:44 -05:00
|
|
|
|
/** 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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 20:17:52 -05:00
|
|
|
|
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);
|
2026-10-19 14:53:59 -05:00
|
|
|
|
if (column.type === 'date') return typeof value === 'string' ? value.slice(0, 10) : '';
|
2026-10-19 10:05:55 -05:00
|
|
|
|
const codes = column.lookup?.config.codes;
|
|
|
|
|
|
if (codes && typeof value === 'string' && value !== '') return codes.display(value);
|
2026-10-13 20:17:52 -05:00
|
|
|
|
return value === null || value === undefined ? '' : String(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
protected isReadonly(line: GridLine, column: GridColumn): boolean {
|
2026-10-16 11:14:52 -05:00
|
|
|
|
if (this.readonly() || column.format || column.locked) return true;
|
2026-10-13 20:17:52 -05:00
|
|
|
|
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);
|
2026-10-19 10:05:55 -05:00
|
|
|
|
} else if (text !== String(line[column.key] ?? '') && text !== this.cellText(line, column)) {
|
2026-10-14 11:42:51 -05:00
|
|
|
|
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;
|
2026-10-19 10:05:55 -05:00
|
|
|
|
if (found) {
|
|
|
|
|
|
lookup.config.codes?.remember(found);
|
|
|
|
|
|
this.replace(row, lookup.apply(found, line), column.key);
|
|
|
|
|
|
}
|
2026-10-14 11:42:51 -05:00
|
|
|
|
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);
|
2026-10-13 20:17:52 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-10-14 14:39:16 -05:00
|
|
|
|
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;
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-10-13 20:17:52 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- 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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-16 12:25:01 -05:00
|
|
|
|
// --- 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 20:17:52 -05:00
|
|
|
|
// --- 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;
|
2026-10-14 14:27:36 -05:00
|
|
|
|
const lookup = target.column.lookup;
|
2026-10-19 10:05:55 -05:00
|
|
|
|
lookup.config.codes?.remember(picked);
|
2026-10-14 14:27:36 -05:00
|
|
|
|
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)]);
|
|
|
|
|
|
}
|
2026-10-13 20:17:52 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|