feat(web): resolver al salir del campo el socio y los códigos de línea escritos a mano

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-14 11:42:51 -05:00
1 parent 4b35aa6bfd
commit cafe4bc035
9 files changed
+232 -4

No files matched your search

@@ -325,6 +325,67 @@ describe('LookupField', () => {
(fixture.nativeElement.querySelector('label') as HTMLElement).click();
expect(link).toHaveBeenCalledTimes(1);
});
describe('código escrito a mano (resolveOnLeave)', () => {
const leave = async () => {
input().dispatchEvent(new Event('focusout', { bubbles: true }));
await tick(fixture);
};
const find = (code: string) =>
ctrl.expectOne(
(r) =>
r.url === '/api/sl/BusinessPartners' &&
r.params.get('$filter') === `(CardType eq 'cCustomer') and (CardCode eq '${code}')` &&
r.params.get('$top') === '1',
);
it('por defecto no consulta nada al salir', async () => {
typeInto(input(), 'C0001');
await leave();
ctrl.expectNone((r) => r.url === '/api/sl/BusinessPartners');
});
it('al salir busca el código con el filtro base y lo completa como si se eligiera', async () => {
fixture.componentRef.setInput('resolveOnLeave', true);
const picked: unknown[] = [];
fixture.componentInstance.picked.subscribe((r) => picked.push(r));
typeInto(input(), 'c0001');
await leave();
find('c0001').flush({ value: [{ CardCode: 'C0001', CardName: 'ACME' }] });
await tick(fixture);
expect(fixture.componentInstance.value()).toBe('C0001'); // el código canónico de SAP
expect(picked).toEqual([{ CardCode: 'C0001', CardName: 'ACME' }]);
expect(fixture.nativeElement.querySelector('.err')).toBeNull();
});
it('si no existe, avisa y el aviso desaparece al seguir escribiendo', async () => {
fixture.componentRef.setInput('resolveOnLeave', true);
typeInto(input(), 'ZZZ');
await leave();
find('ZZZ').flush({ value: [] });
await tick(fixture);
expect(fixture.nativeElement.querySelector('.err')?.textContent).toContain('No existe «ZZZ»');
typeInto(input(), 'ZZ');
await tick(fixture);
expect(fixture.nativeElement.querySelector('.err')).toBeNull();
});
it('no repite la consulta si el código ya se resolvió', async () => {
fixture.componentRef.setInput('resolveOnLeave', true);
typeInto(input(), 'C0001');
await leave();
find('C0001').flush({ value: [{ CardCode: 'C0001', CardName: 'ACME' }] });
await tick(fixture);
await leave();
ctrl.expectNone((r) => r.url === '/api/sl/BusinessPartners');
});
it('un campo vacío no consulta', async () => {
fixture.componentRef.setInput('resolveOnLeave', true);
await leave();
ctrl.expectNone((r) => r.url === '/api/sl/BusinessPartners');
});
});
});
// Filas con varios controles, como en el mockup (Cliente = input + botón, Número = serie + número...).
+43 -1
View File
@@ -2,12 +2,16 @@ import {
ChangeDetectionStrategy,
Component,
booleanAttribute,
computed,
Injector,
inject,
input,
model,
output,
signal,
viewChild,
} from '@angular/core';
import { SlLookupApi } from '../../core/sl/sl-lookup';
import { ValueHelp } from '../value-help/value-help';
import type { VhLookup, VhRow } from '../value-help/value-help.types';
import { TextInput } from './controls';
@@ -26,7 +30,7 @@ import { SboField } from './sbo-field';
<sbo-field
[label]="label()"
[required]="required()"
[error]="error()"
[error]="error() || missingText()"
action
[linkable]="linkable()"
(labelClick)="linkClick.emit()"
@@ -38,6 +42,7 @@ import { SboField } from './sbo-field';
[disabled]="disabled()"
[required]="required()"
(keydown.f4)="$event.preventDefault(); openList()"
(focusout)="resolveTyped()"
/>
<button
type="button"
@@ -69,10 +74,22 @@ export class LookupField extends FieldBase {
/** La etiqueta abre el objeto enlazado (evento `linkClick`). */
readonly linkable = input(false, { transform: booleanAttribute });
/** Al salir del campo con un código escrito a mano, lo busca en la lista (como si se hubiera elegido). */
readonly resolveOnLeave = input(false, { transform: booleanAttribute });
readonly picked = output<VhRow>();
readonly linkClick = output<void>();
protected readonly listOpen = signal(false);
/** Código que no existe; el aviso desaparece en cuanto el texto cambia. */
private readonly missing = signal<{ code: string; text: string } | null>(null);
protected readonly missingText = computed(() => {
const m = this.missing();
return m && m.code === this.value().trim() ? m.text : '';
});
private readonly injector = inject(Injector);
/** Último código ya resuelto, para no repetir la consulta al salir y volver a entrar sin cambios. */
private resolved = '';
private readonly box = viewChild.required<TextInput>('box');
protected openList(): void {
@@ -87,8 +104,33 @@ export class LookupField extends FieldBase {
protected onPicked(rows: VhRow[]): void {
const row = rows[0];
if (!row) return;
this.apply(row);
}
private apply(row: VhRow): void {
const key = this.lookup().valueKey ?? this.lookup().columns[0]?.key ?? '';
this.missing.set(null);
this.value.set(String(row[key] ?? ''));
this.resolved = this.value();
this.picked.emit(row);
}
protected async resolveTyped(): Promise<void> {
const code = this.value().trim();
if (!this.resolveOnLeave() || this.readOnly() || this.disabled() || this.listOpen()) return;
if (!code) {
this.missing.set(null);
this.resolved = '';
return;
}
if (code === this.resolved) return;
try {
const row = await this.injector.get(SlLookupApi).find(this.lookup(), code);
if (this.value().trim() !== code) return; // el usuario siguió escribiendo
if (row) this.apply(row);
else this.missing.set({ code, text: `No existe «${code}» en ${this.lookup().title.toLowerCase()}.` });
} catch {
this.missing.set({ code, text: 'No se pudo comprobar el código.' });
}
}
}
@@ -299,4 +299,51 @@ describe('LinesGrid', () => {
expect(q('tr.none').textContent).toContain('no tiene líneas');
});
});
describe('código escrito a mano', () => {
it('busca el artículo y completa la descripción como al elegirlo', async () => {
fixture.componentInstance.lines.set([{ ...newDocumentLine() }]);
await tick();
await edit(0, 0, 'A00001');
const req = ctrl.expectOne((r) => r.url === '/api/sl/Items' && r.params.get('$filter') === "ItemCode eq 'A00001'");
expect(req.request.params.get('$top')).toBe('1');
req.flush({ value: [{ ItemCode: 'A00001', ItemName: 'IBM Infoprint 1312' }] });
await tick();
expect(lines()[0]).toMatchObject({ ItemCode: 'A00001', ItemDescription: 'IBM Infoprint 1312' });
});
it('Enter y change sobre la misma celda hacen una sola consulta', async () => {
fixture.componentInstance.lines.set([{ ...newDocumentLine() }]);
await tick();
const input = cell(0, 0);
input.value = 'A00001';
await key(0, 0, 'Enter');
input.dispatchEvent(new Event('change'));
await tick();
ctrl.expectOne((r) => r.url === '/api/sl/Items').flush({ value: [{ ItemCode: 'A00001', ItemName: 'X' }] });
});
it('si no existe, avisa y la celda vuelve a su valor', async () => {
fixture.componentInstance.lines.set([{ ...newDocumentLine() }]);
const notices: string[] = [];
await tick();
el.querySelector('sbo-lines-grid')!.dispatchEvent(new Event('x')); // sin efecto: solo asegura el DOM
const grid = fixture.debugElement.query((d) => d.name === 'sbo-lines-grid').componentInstance as LinesGrid;
grid.notice.subscribe((n) => notices.push(n));
await edit(0, 0, 'NOEXISTE');
ctrl.expectOne((r) => r.url === '/api/sl/Items').flush({ value: [] });
await tick();
expect(notices).toEqual(['No existe «NOEXISTE» en artículos.']);
expect(cell(0, 0).value).toBe('');
expect(lines()[0]['ItemCode']).toBe('');
});
it('vaciar la celda no consulta: limpia el valor', async () => {
fixture.componentInstance.lines.set([line('A1')]);
await tick();
await edit(0, 0, '');
ctrl.expectNone((r) => r.url === '/api/sl/Items');
expect(lines()[0]['ItemCode']).toBe('');
});
});
});
+34 -1
View File
@@ -8,9 +8,11 @@ import {
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 { ValueHelp } from '../value-help/value-help';
import type { GridColumn, GridLine, GridMultiAdd } from './grid.types';
@@ -43,6 +45,8 @@ export class LinesGrid {
readonly recalc = input<((line: GridLine, changedKey: string) => GridLine) | undefined>(undefined);
readonly multiAdd = input<GridMultiAdd>();
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);
@@ -94,7 +98,36 @@ export class LinesGrid {
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);
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) 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);
}
}