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 lines = model([]); /** 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(); 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(); private readonly host = inject>(ElementRef); private readonly injector = inject(Injector); protected readonly lookupTarget = signal(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(); /** * 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 { 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(`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; }), ]); } }