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 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'); 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 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(`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; }), ]); } }