feat(web): grilla de líneas editable y página de componentes de desarrollo

- sbo-lines-grid: columnas declarativas (texto / número con decimales /
  solo lectura / calculadas), celdas con lista de valores (botón "..." y
  F4) cuyo apply rellena la línea, añadir y quitar línea, y "Elegir
  artículos..." para agregar varias líneas a la vez.
- Teclado: Enter confirma y baja a la misma columna (crea línea en la
  última fila), flechas arriba/abajo cambian de fila. Las celdas se
  confirman al salir de ellas; un número inválido conserva el anterior.
- Documento cerrado: grilla de solo lectura, sin botones ni listas.
- Columnas estándar de línea de SAP (artículo, descripción, cantidad,
  almacén, precio, % dto., impuesto, total) con TaxCode / SalesTaxCodes,
  verificado en el Service Layer real; recalcLine (cantidad x precio x
  (1 - dto)).
- Corrige el foco al crear una línea con Enter: se pedía antes de que la
  fila existiera en el DOM (ahora espera al render).
- /dev/componentes: página de componentes solo para desarrollo
  (canMatch isDevMode), sin entrada en el menú y sin escribir en SAP.
- Verificado en Chrome contra SAP real: artículo elegido de la lista,
  5 x 10,000 con 10 % = 45,000.00, Enter/flechas, varios artículos a la
  vez, quitar línea y subtotal del pie.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 20:17:52 -05:00
1 parent 26f0649da9
commit 0a7bfb850a
11 files changed
+1150

No files matched your search

@@ -0,0 +1,37 @@
import type { ListRow } from '../list/list.types';
import type { VhLookup } from '../value-help/value-help.types';
/** Una línea del documento (los campos de `DocumentLines` del Service Layer). */
export type GridLine = Record<string, unknown>;
/** Lista de valores de una celda: al elegir, `apply` copia a la línea los campos de la fila elegida. */
export interface GridLookup {
config: VhLookup;
apply: (picked: ListRow, line: GridLine) => GridLine;
}
export interface GridColumn {
/** Campo de la línea. */
key: string;
title: string;
width?: string;
/** `number` se alinea a la derecha, se muestra con formato y se interpreta al salir de la celda. */
type?: 'text' | 'number';
/** Decimales fijos para `number` (importes: 2); sin él, hasta 3 sin ceros sobrantes. */
decimals?: number;
/** No editable (siempre, o según la línea). */
readonly?: boolean | ((line: GridLine) => boolean);
required?: boolean;
/** Celda con botón "…" / F4 que abre una lista de valores. */
lookup?: GridLookup;
/** Texto calculado a mostrar (columnas de solo lectura). */
format?: (line: GridLine) => string;
mono?: boolean;
}
/** Varias líneas a la vez desde una lista de selección múltiple (ej. "Catálogo completo"). */
export interface GridMultiAdd {
label: string;
lookup: VhLookup;
toLine: (picked: ListRow) => GridLine;
}
+138
View File
@@ -0,0 +1,138 @@
:host {
display: block;
}
.bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.count {
font-size: 12px;
color: var(--quiet);
}
.sp {
flex: 1;
}
.gridwrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--panel);
}
.grid {
width: 100%;
min-width: 900px;
border-collapse: collapse;
}
th {
padding: 4px 6px;
background: var(--grid-head);
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
font-size: 12.5px;
font-weight: 600;
text-align: left;
white-space: nowrap;
}
th.num {
text-align: right;
}
th.req::after {
content: ' *';
color: var(--err);
}
td {
height: 27px;
padding: 0 2px;
border-right: 1px solid var(--line-soft);
border-bottom: 1px solid var(--line-soft);
white-space: nowrap;
}
tbody tr:nth-child(even) td {
background: var(--row-alt);
}
tbody tr:focus-within td {
background: var(--row-sel);
}
td.rn,
th.rn {
width: 34px;
text-align: center;
color: var(--quiet);
background: var(--grid-head);
}
tbody tr td.rn {
background: var(--grid-head);
}
td.ro {
background: var(--field-ro);
}
.cell {
width: 100%;
height: 22px;
padding: 1px 4px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
}
.cell.num {
text-align: right;
}
.cell.mono {
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.cell:hover:not([readonly]) {
border-color: var(--field-b);
}
.cell:focus {
background: var(--field);
border-color: var(--focus);
outline: none;
}
.cell[readonly] {
cursor: default;
}
.pick {
display: grid;
grid-template-columns: minmax(0, 1fr) 22px;
}
.pick button {
padding: 0;
background: var(--btn);
border: 1px solid var(--field-b);
border-radius: var(--radius);
cursor: pointer;
}
.pick button:hover {
background: var(--btn-h);
}
td.del,
th.del {
width: 30px;
text-align: center;
}
td.del button {
padding: 2px 6px;
background: none;
border: none;
color: var(--quiet);
font-size: 15px;
line-height: 1;
cursor: pointer;
border-radius: var(--radius);
}
td.del button:hover {
color: var(--err);
background: var(--err-bg);
}
tr.none td {
padding: 18px;
text-align: center;
color: var(--quiet);
background: transparent;
}
.sr {
position: absolute;
left: -9999px;
}
@@ -0,0 +1,113 @@
<div class="bar">
<span class="count">{{ lines().length }} {{ lines().length === 1 ? 'línea' : 'líneas' }}</span>
@if (!readonly()) {
<span class="sp"></span>
@if (multiAdd(); as m) {
<button type="button" class="btn" (click)="multiOpen.set(true)">{{ m.label }}</button>
}
<button type="button" class="btn main" (click)="add()">+ {{ addLabel() }}</button>
}
</div>
<div class="gridwrap">
<table class="grid">
<thead>
<tr>
<th class="rn">#</th>
@for (c of columns(); track c.key) {
<th [style.width]="c.width" [class.num]="c.type === 'number'" [class.req]="c.required">{{ c.title }}</th>
}
@if (!readonly()) {
<th class="del"><span class="sr">Quitar</span></th>
}
</tr>
</thead>
<tbody>
@for (line of lines(); track $index; let i = $index) {
<tr>
<td class="rn">{{ i + 1 }}</td>
@for (c of columns(); track c.key; let j = $index) {
<td [class.ro]="isReadonly(line, c)">
@if (c.lookup && !isReadonly(line, c)) {
<div class="pick">
<input
class="cell"
type="text"
autocomplete="off"
[class.mono]="c.mono"
[attr.data-r]="i"
[attr.data-c]="j"
[attr.aria-label]="c.title + ' de la línea ' + (i + 1)"
[value]="cellText(line, c)"
(change)="commit(i, c, $any($event.target))"
(keydown)="onKey($event, i, j)"
/>
<button type="button" tabindex="-1" title="Lista de valores (F4)" [attr.aria-label]="'Abrir la lista de ' + c.title" (click)="openLookup(i, c)">…</button>
</div>
} @else {
<input
class="cell"
type="text"
autocomplete="off"
[class.num]="c.type === 'number'"
[class.mono]="c.mono"
[readOnly]="isReadonly(line, c)"
[attr.data-r]="i"
[attr.data-c]="j"
[attr.aria-label]="c.title + ' de la línea ' + (i + 1)"
[attr.inputmode]="c.type === 'number' ? 'decimal' : null"
[attr.title]="cellText(line, c) || null"
[value]="cellText(line, c)"
(focus)="c.type === 'number' ? numberFocus($event, line, c) : null"
(change)="commit(i, c, $any($event.target))"
(blur)="c.type === 'number' ? commit(i, c, $any($event.target)) : null"
(keydown)="onKey($event, i, j)"
/>
}
</td>
}
@if (!readonly()) {
<td class="del">
<button type="button" [attr.aria-label]="'Quitar la línea ' + (i + 1)" title="Quitar línea" (click)="remove(i)">×</button>
</td>
}
</tr>
} @empty {
<tr class="none">
<td [attr.colspan]="columns().length + (readonly() ? 1 : 2)">
{{ readonly() ? 'El documento no tiene líneas.' : 'No hay líneas. Pulse «Añadir línea» para comenzar.' }}
</td>
</tr>
}
</tbody>
</table>
</div>
@if (lookup(); as cfg) {
<sbo-value-help
[open]="true"
[title]="cfg.title"
[resource]="cfg.resource"
[columns]="cfg.columns"
[baseFilter]="cfg.baseFilter"
[orderby]="cfg.orderby"
[rowKey]="cfg.valueKey"
(selected)="lookupPicked($event)"
(closed)="lookupClosed()"
/>
}
@if (multiOpen(); as _) {
@if (multiAdd(); as m) {
<sbo-value-help
[open]="true"
[title]="m.lookup.title"
[resource]="m.lookup.resource"
[columns]="m.lookup.columns"
[baseFilter]="m.lookup.baseFilter"
[orderby]="m.lookup.orderby"
selection="multiple"
(selected)="multiPicked($event)"
(closed)="multiOpen.set(false)"
/>
}
}
@@ -0,0 +1,302 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Component, signal } from '@angular/core';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, newDocumentLine, recalcLine } from '../../features/documents/line-columns';
import type { GridLine } from './grid.types';
import { LinesGrid } from './lines-grid';
@Component({
imports: [LinesGrid],
template: `
<sbo-lines-grid
[columns]="columns"
[(lines)]="lines"
[readonly]="readonly()"
[newLine]="newLine"
[recalc]="recalc"
[multiAdd]="multi"
/>
`,
})
class Host {
columns = DOCUMENT_LINE_COLUMNS;
lines = signal<readonly GridLine[]>([]);
readonly = signal(false);
newLine = newDocumentLine;
recalc = recalcLine;
multi = ITEMS_MULTI_ADD;
}
const line = (code: string, qty = 1, price = 100): GridLine => recalcLine({ ...newDocumentLine(), ItemCode: code, ItemDescription: `Desc ${code}`, Quantity: qty, Price: price });
describe('LinesGrid', () => {
let fixture: ComponentFixture<Host>;
let ctrl: HttpTestingController;
let el: HTMLElement;
const q = <T extends HTMLElement>(s: string) => el.querySelector<T>(s)!;
const qa = (s: string) => [...el.querySelectorAll<HTMLElement>(s)];
const cell = (r: number, c: number) => q<HTMLInputElement>(`input[data-r="${r}"][data-c="${c}"]`);
const lines = () => fixture.componentInstance.lines();
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
const edit = async (r: number, c: number, text: string) => {
const input = cell(r, c);
input.dispatchEvent(new Event('focus'));
input.value = text;
input.dispatchEvent(new Event('change'));
input.dispatchEvent(new Event('blur'));
await tick();
};
const key = async (r: number, c: number, k: string) => {
cell(r, c).dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
await tick();
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Host],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(Host);
el = fixture.nativeElement;
await tick();
});
it('sin líneas muestra el aviso y el contador en 0', () => {
expect(q('tr.none').textContent).toContain('No hay líneas');
expect(q('.count').textContent).toContain('0 líneas');
});
it('muestra los encabezados con * en los obligatorios', () => {
expect(qa('thead th').map((t) => t.textContent?.trim())).toEqual(['#', 'Nº de artículo', 'Descripción', 'Cantidad', 'Almacén', 'Precio', '% Dto.', 'Impuesto', 'Total', 'Quitar']);
expect(qa('thead th.req').map((t) => t.textContent?.trim())).toEqual(['Nº de artículo', 'Cantidad']);
});
describe('añadir y quitar', () => {
it('"+ Añadir línea" crea una línea con sus valores por defecto y enfoca el primer campo editable', async () => {
(qa('.bar .btn.main')[0] as HTMLButtonElement).click();
await tick();
expect(lines()).toHaveLength(1);
expect(lines()[0]).toMatchObject({ Quantity: 1, LineTotal: 0 });
expect(q('.count').textContent).toContain('1 línea');
expect(document.activeElement).toBe(cell(0, 0));
});
it('"×" quita la línea y renumera', async () => {
fixture.componentInstance.lines.set([line('A'), line('B'), line('C')]);
await tick();
(qa('td.del button')[1] as HTMLButtonElement).click();
await tick();
expect(lines().map((l) => l['ItemCode'])).toEqual(['A', 'C']);
expect(qa('td.rn').map((c) => c.textContent?.trim())).toEqual(['1', '2']);
});
});
describe('edición de celdas', () => {
beforeEach(async () => {
fixture.componentInstance.lines.set([line('A', 2, 50)]);
await tick();
});
it('muestra números con formato y el total calculado', () => {
expect(cell(0, 4).value).toBe('50.00');
expect(cell(0, 7).value).toBe('100.00');
});
it('editar la cantidad recalcula el total al salir de la celda', async () => {
await edit(0, 2, '3');
expect(lines()[0]['Quantity']).toBe(3);
expect(lines()[0]['LineTotal']).toBe(150);
expect(cell(0, 7).value).toBe('150.00');
});
it('acepta coma decimal y redondea el precio a 2 decimales', async () => {
await edit(0, 4, '12,3456');
expect(lines()[0]['Price']).toBe(12.35);
expect(cell(0, 4).value).toBe('12.35');
});
it('el % de descuento afecta al total', async () => {
await edit(0, 5, '10');
expect(lines()[0]['LineTotal']).toBe(90);
});
it('un texto que no es número conserva el valor anterior', async () => {
await edit(0, 2, 'abc');
expect(lines()[0]['Quantity']).toBe(2);
expect(cell(0, 2).value).toBe('2');
});
it('vaciar un número lo deja en null', async () => {
await edit(0, 4, '');
expect(lines()[0]['Price']).toBeNull();
expect(lines()[0]['LineTotal']).toBe(0);
});
it('en foco muestra el valor crudo para editarlo', async () => {
cell(0, 4).dispatchEvent(new Event('focus'));
await tick();
expect(cell(0, 4).value).toBe('50');
});
it('editar texto (descripción) no cambia otras celdas', async () => {
await edit(0, 1, 'Otra descripción');
expect(lines()[0]['ItemDescription']).toBe('Otra descripción');
expect(lines()[0]['LineTotal']).toBe(100);
});
it('el total es de solo lectura', () => {
expect(cell(0, 7).readOnly).toBe(true);
expect(qa('td')[8].classList.contains('ro')).toBe(true);
});
it('sin cambios no se emite una línea nueva', async () => {
const before = lines();
await edit(0, 2, '2');
expect(lines()).toBe(before);
});
});
describe('teclado', () => {
beforeEach(async () => {
fixture.componentInstance.lines.set([line('A'), line('B')]);
await tick();
});
it('Enter baja a la misma columna de la fila siguiente', async () => {
cell(0, 2).focus();
await key(0, 2, 'Enter');
expect(document.activeElement).toBe(cell(1, 2));
});
it('Enter en la última fila crea una línea y se queda en esa columna', async () => {
await key(1, 2, 'Enter');
expect(lines()).toHaveLength(3);
expect(document.activeElement).toBe(cell(2, 0));
});
it('Enter confirma el valor escrito antes de moverse', async () => {
cell(0, 2).dispatchEvent(new Event('focus'));
cell(0, 2).value = '7';
await key(0, 2, 'Enter');
expect(lines()[0]['Quantity']).toBe(7);
});
it('↑ y ↓ cambian de fila en la misma columna', async () => {
cell(0, 1).focus();
await key(0, 1, 'ArrowDown');
expect(document.activeElement).toBe(cell(1, 1));
await key(1, 1, 'ArrowUp');
expect(document.activeElement).toBe(cell(0, 1));
await key(0, 1, 'ArrowUp'); // ya en la primera: no se mueve
expect(document.activeElement).toBe(cell(0, 1));
});
});
describe('listas de valores en celdas', () => {
beforeEach(async () => {
fixture.componentInstance.lines.set([line('A', 2, 50)]);
await tick();
});
it('las celdas con lista muestran el botón "…"', () => {
expect(qa('.pick button')).toHaveLength(3); // artículo, almacén, impuesto
});
it('elegir un artículo rellena código y descripción y conserva el resto', async () => {
(qa('.pick button')[0] as HTMLButtonElement).click();
await tick();
ctrl
.expectOne((r) => r.url === '/api/sl/Items')
.flush({ value: [{ ItemCode: 'I00002', ItemName: 'DVD+R Disc 50-Pack' }], 'odata.count': '1' });
await tick();
(q('.dlg tbody tr') as HTMLElement).click();
await tick();
expect(lines()[0]).toMatchObject({ ItemCode: 'I00002', ItemDescription: 'DVD+R Disc 50-Pack', Quantity: 2, Price: 50, LineTotal: 100 });
expect(el.querySelector('.dlg')).toBeNull();
expect(document.activeElement).toBe(cell(0, 0));
});
it('F4 abre la lista de la celda; una celda sin lista no', async () => {
await key(0, 3, 'F4'); // almacén
ctrl.expectOne((r) => r.url === '/api/sl/Warehouses').flush({ value: [], 'odata.count': '0' });
await tick();
expect(q('.dlg')).toBeTruthy();
q('.dlg').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await tick();
await key(0, 1, 'F4'); // descripción: sin lista
ctrl.expectNone((r) => r.url.startsWith('/api/sl/'));
});
it('elegir un impuesto solo cambia el código', async () => {
(qa('.pick button')[2] as HTMLButtonElement).click();
await tick();
ctrl.expectOne((r) => r.url === '/api/sl/SalesTaxCodes').flush({ value: [{ Code: 'IVA', Name: 'IVA', Rate: 19 }], 'odata.count': '1' });
await tick();
(q('.dlg tbody tr') as HTMLElement).click();
await tick();
expect(lines()[0]['TaxCode']).toBe('IVA');
expect(lines()[0]['ItemCode']).toBe('A');
});
});
describe('"Elegir artículos…" (varias líneas a la vez)', () => {
it('agrega una línea por artículo marcado, con su total calculado', async () => {
fixture.componentInstance.lines.set([line('A')]);
await tick();
(qa('.bar .btn')[0] as HTMLButtonElement).click();
await tick();
ctrl
.expectOne((r) => r.url === '/api/sl/Items')
.flush({
value: [
{ ItemCode: 'X1', ItemName: 'Uno' },
{ ItemCode: 'X2', ItemName: 'Dos' },
],
'odata.count': '2',
});
await tick();
const rows = qa('.dlg tbody tr');
rows[0].click();
rows[1].click();
await tick();
(qa('.dlg footer .btn.main')[0] as HTMLButtonElement).click();
await tick();
expect(lines().map((l) => l['ItemCode'])).toEqual(['A', 'X1', 'X2']);
expect(lines()[1]).toMatchObject({ ItemDescription: 'Uno', Quantity: 1, LineTotal: 0 });
expect(el.querySelector('.dlg')).toBeNull();
});
});
describe('documento cerrado (solo lectura)', () => {
beforeEach(async () => {
fixture.componentInstance.lines.set([line('A', 2, 50)]);
fixture.componentInstance.readonly.set(true);
await tick();
});
it('no hay botones de añadir, quitar ni listas', () => {
expect(el.querySelector('.bar .btn')).toBeNull();
expect(el.querySelector('td.del')).toBeNull();
expect(el.querySelector('.pick')).toBeNull();
});
it('todas las celdas son de solo lectura y no se pueden editar', async () => {
expect(qa('tbody input').every((i) => (i as HTMLInputElement).readOnly)).toBe(true);
await edit(0, 2, '9');
expect(lines()[0]['Quantity']).toBe(2);
});
it('sin líneas dice que el documento no tiene líneas', async () => {
fixture.componentInstance.lines.set([]);
await tick();
expect(q('tr.none').textContent).toContain('no tiene líneas');
});
});
});
+205
View File
@@ -0,0 +1,205 @@
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 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');
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 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<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;
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;
}),
]);
}
}