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:
1 parent
4b35aa6bfd
commit
cafe4bc035
9 files changed
+232
-4
No files matched your search
@@ -56,7 +56,7 @@ Cada menú abre primero una **lista de los últimos documentos** (barra de filtr
|
|||||||
Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compartida y cola, login, permisos SQLite, proxy `/api/sl`) · web (temas, login, layout con menú por permisos, `sbo-value-help`, campos y filas compuestas, listas con filtros, ventana/toolbar/pestañas/menús, grilla de líneas, `/dev/componentes`).
|
Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compartida y cola, login, permisos SQLite, proxy `/api/sl`) · web (temas, login, layout con menú por permisos, `sbo-value-help`, campos y filas compuestas, listas con filtros, ventana/toolbar/pestañas/menús, grilla de líneas, `/dev/componentes`).
|
||||||
|
|
||||||
Pendiente, en orden:
|
Pendiente, en orden:
|
||||||
1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Falta: resolver al salir del campo el nombre del socio y la descripción del artículo cuando se escriben a mano (hoy SAP los completa al guardar), "Copiar de/a", botón PDF, fila del flete, toolbar de ancho completo, formularios de inventario y solicitudes de compra.
|
1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. Falta: "Copiar de/a", botón PDF, fila del flete, toolbar de ancho completo, formularios de inventario y solicitudes de compra.
|
||||||
2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible".
|
2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible".
|
||||||
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
|
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
|
||||||
4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs.
|
4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs.
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ Las tres rutas las gobierna el permiso de menú de la ruta padre. Se generan sol
|
|||||||
Lo usan las pantallas con `partner` en su `DocumentScreen` (ventas y compras); el resto de documentos muestra aún un aviso.
|
Lo usan las pantallas con `partner` en su `DocumentScreen` (ventas y compras); el resto de documentos muestra aún un aviso.
|
||||||
|
|
||||||
- **Cabecera**: socio de negocio (lista de valores filtrada por `CardType`; no se cambia en un documento ya creado), Nº de referencia, fechas y descuento %; **líneas** con `sbo-lines-grid`; **totales** informativos (SAP recalcula al guardar) con las tasas de `SalesTaxCodes` (también en compras: el Service Layer no tiene un maestro propio; `partner.taxResource` permite cambiarlo en una rama de cliente).
|
- **Cabecera**: socio de negocio (lista de valores filtrada por `CardType`; no se cambia en un documento ya creado), Nº de referencia, fechas y descuento %; **líneas** con `sbo-lines-grid`; **totales** informativos (SAP recalcula al guardar) con las tasas de `SalesTaxCodes` (también en compras: el Service Layer no tiene un maestro propio; `partner.taxResource` permite cambiarlo en una rama de cliente).
|
||||||
|
- **Códigos escritos a mano**: al salir del campo (socio con `resolveOnLeave` en `sbo-lookup-field`; artículo, almacén e impuesto en la grilla) se buscan con `SlLookupApi` (`core/sl/sl-lookup.ts`) en la misma lista que abre "…": se normaliza el código y se completa nombre/descripción; si no existe se avisa (en el campo o en la barra de estado) y la celda vuelve a su valor.
|
||||||
- **Crear**: valida sin SAP (socio, fecha, al menos una línea con artículo, cantidad > 0), quita campos vacíos y filas en blanco, y abre el documento creado.
|
- **Crear**: valida sin SAP (socio, fecha, al menos una línea con artículo, cantidad > 0), quita campos vacíos y filas en blanco, y abre el documento creado.
|
||||||
- **Actualizar**: envía solo la cabecera que cambió y, si cambiaron las líneas, todas con `B1S-ReplaceCollectionsOnPatch: true`. Lleva `If-Match` con el `odata.etag` leído: si otro usuario cambió el documento, SAP responde **412**, no se guarda nada y se ofrece "Recargar".
|
- **Actualizar**: envía solo la cabecera que cambió y, si cambiaron las líneas, todas con `B1S-ReplaceCollectionsOnPatch: true`. Lleva `If-Match` con el `odata.etag` leído: si otro usuario cambió el documento, SAP responde **412**, no se guarda nada y se ofrece "Recargar".
|
||||||
- **Cerrado o cancelado**: solo lectura (sin botón de guardar).
|
- **Cerrado o cancelado**: solo lectura (sin botón de guardar).
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { type SlListResponse } from '@basic-erp/sap-types';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import type { ListRow } from '../../shared/list/list.types';
|
||||||
|
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
||||||
|
import { API_URL } from '../api';
|
||||||
|
import { combineFilters, escapeODataString } from './odata';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resuelve un código escrito a mano contra la misma lista de valores que se abre con "…":
|
||||||
|
* mismo recurso, mismo filtro base y mismas columnas, para que escribirlo o elegirlo dé el mismo resultado.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SlLookupApi {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
/** La fila cuyo código es `code` (sin distinguir mayúsculas), o `null` si no existe. */
|
||||||
|
async find(lookup: VhLookup, code: string): Promise<ListRow | null> {
|
||||||
|
const keyField = lookup.valueKey ?? lookup.columns[0]?.key ?? '';
|
||||||
|
const filter = combineFilters(lookup.baseFilter, `${keyField} eq '${escapeODataString(code.trim())}'`);
|
||||||
|
const params = new HttpParams()
|
||||||
|
.set('$select', [...new Set([keyField, ...lookup.columns.map((c) => c.key)])].join(','))
|
||||||
|
.set('$filter', filter ?? '')
|
||||||
|
.set('$top', 1);
|
||||||
|
const res = await firstValueFrom(
|
||||||
|
this.http.get<SlListResponse<ListRow>>(`${API_URL}/sl/${lookup.resource}`, { params }),
|
||||||
|
);
|
||||||
|
return res.value[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,9 @@
|
|||||||
required
|
required
|
||||||
[readonly]="readOnly() || !isNew()"
|
[readonly]="readOnly() || !isNew()"
|
||||||
[value]="str(header()['CardCode'])"
|
[value]="str(header()['CardCode'])"
|
||||||
(valueChange)="setHeader('CardCode', $event)"
|
(valueChange)="onPartnerText($event)"
|
||||||
[lookup]="partnerLookup"
|
[lookup]="partnerLookup"
|
||||||
|
resolveOnLeave
|
||||||
(picked)="onPartner($event)"
|
(picked)="onPartner($event)"
|
||||||
/>
|
/>
|
||||||
<sbo-text-field label="Nombre" readonly [value]="partnerName()" />
|
<sbo-text-field label="Nombre" readonly [value]="partnerName()" />
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
[columns]="columns"
|
[columns]="columns"
|
||||||
[lines]="lines()"
|
[lines]="lines()"
|
||||||
(linesChange)="setLines($event)"
|
(linesChange)="setLines($event)"
|
||||||
|
(notice)="notify($event)"
|
||||||
[readonly]="readOnly()"
|
[readonly]="readOnly()"
|
||||||
[newLine]="newLine"
|
[newLine]="newLine"
|
||||||
[recalc]="recalc"
|
[recalc]="recalc"
|
||||||
|
|||||||
@@ -173,6 +173,17 @@ export class DocumentFormPage {
|
|||||||
this.message.set(null);
|
this.message.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Al escribir otro código el nombre anterior ya no vale; se completa al salir del campo o al elegir de la lista. */
|
||||||
|
protected onPartnerText(code: string): void {
|
||||||
|
if (code === text(this.header()['CardCode'])) return;
|
||||||
|
this.setHeader('CardCode', code);
|
||||||
|
this.partnerName.set('');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected notify(textMessage: string): void {
|
||||||
|
this.message.set({ kind: 'warn', text: textMessage });
|
||||||
|
}
|
||||||
|
|
||||||
protected onPartner(row: Record<string, unknown>): void {
|
protected onPartner(row: Record<string, unknown>): void {
|
||||||
this.partnerName.set(text(row['CardName']));
|
this.partnerName.set(text(row['CardName']));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,6 +325,67 @@ describe('LookupField', () => {
|
|||||||
(fixture.nativeElement.querySelector('label') as HTMLElement).click();
|
(fixture.nativeElement.querySelector('label') as HTMLElement).click();
|
||||||
expect(link).toHaveBeenCalledTimes(1);
|
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...).
|
// Filas con varios controles, como en el mockup (Cliente = input + botón, Número = serie + número...).
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
booleanAttribute,
|
booleanAttribute,
|
||||||
|
computed,
|
||||||
|
Injector,
|
||||||
|
inject,
|
||||||
input,
|
input,
|
||||||
model,
|
model,
|
||||||
output,
|
output,
|
||||||
signal,
|
signal,
|
||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { SlLookupApi } from '../../core/sl/sl-lookup';
|
||||||
import { ValueHelp } from '../value-help/value-help';
|
import { ValueHelp } from '../value-help/value-help';
|
||||||
import type { VhLookup, VhRow } from '../value-help/value-help.types';
|
import type { VhLookup, VhRow } from '../value-help/value-help.types';
|
||||||
import { TextInput } from './controls';
|
import { TextInput } from './controls';
|
||||||
@@ -26,7 +30,7 @@ import { SboField } from './sbo-field';
|
|||||||
<sbo-field
|
<sbo-field
|
||||||
[label]="label()"
|
[label]="label()"
|
||||||
[required]="required()"
|
[required]="required()"
|
||||||
[error]="error()"
|
[error]="error() || missingText()"
|
||||||
action
|
action
|
||||||
[linkable]="linkable()"
|
[linkable]="linkable()"
|
||||||
(labelClick)="linkClick.emit()"
|
(labelClick)="linkClick.emit()"
|
||||||
@@ -38,6 +42,7 @@ import { SboField } from './sbo-field';
|
|||||||
[disabled]="disabled()"
|
[disabled]="disabled()"
|
||||||
[required]="required()"
|
[required]="required()"
|
||||||
(keydown.f4)="$event.preventDefault(); openList()"
|
(keydown.f4)="$event.preventDefault(); openList()"
|
||||||
|
(focusout)="resolveTyped()"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -69,10 +74,22 @@ export class LookupField extends FieldBase {
|
|||||||
/** La etiqueta abre el objeto enlazado (evento `linkClick`). */
|
/** La etiqueta abre el objeto enlazado (evento `linkClick`). */
|
||||||
readonly linkable = input(false, { transform: booleanAttribute });
|
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 picked = output<VhRow>();
|
||||||
readonly linkClick = output<void>();
|
readonly linkClick = output<void>();
|
||||||
|
|
||||||
protected readonly listOpen = signal(false);
|
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');
|
private readonly box = viewChild.required<TextInput>('box');
|
||||||
|
|
||||||
protected openList(): void {
|
protected openList(): void {
|
||||||
@@ -87,8 +104,33 @@ export class LookupField extends FieldBase {
|
|||||||
protected onPicked(rows: VhRow[]): void {
|
protected onPicked(rows: VhRow[]): void {
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
|
this.apply(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private apply(row: VhRow): void {
|
||||||
const key = this.lookup().valueKey ?? this.lookup().columns[0]?.key ?? '';
|
const key = this.lookup().valueKey ?? this.lookup().columns[0]?.key ?? '';
|
||||||
|
this.missing.set(null);
|
||||||
this.value.set(String(row[key] ?? ''));
|
this.value.set(String(row[key] ?? ''));
|
||||||
|
this.resolved = this.value();
|
||||||
this.picked.emit(row);
|
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');
|
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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
input,
|
input,
|
||||||
model,
|
model,
|
||||||
|
output,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { formatNumber, parseDecimal } from '../../core/format';
|
import { formatNumber, parseDecimal } from '../../core/format';
|
||||||
|
import { SlLookupApi } from '../../core/sl/sl-lookup';
|
||||||
import type { ListRow } from '../list/list.types';
|
import type { ListRow } from '../list/list.types';
|
||||||
import { ValueHelp } from '../value-help/value-help';
|
import { ValueHelp } from '../value-help/value-help';
|
||||||
import type { GridColumn, GridLine, GridMultiAdd } from './grid.types';
|
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 recalc = input<((line: GridLine, changedKey: string) => GridLine) | undefined>(undefined);
|
||||||
readonly multiAdd = input<GridMultiAdd>();
|
readonly multiAdd = input<GridMultiAdd>();
|
||||||
readonly addLabel = input('Añadir línea');
|
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 host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
@@ -94,7 +98,36 @@ export class LinesGrid {
|
|||||||
this.update(row, column.key, value);
|
this.update(row, column.key, value);
|
||||||
input.value = formatNumber(value, column.decimals);
|
input.value = formatNumber(value, column.decimals);
|
||||||
} else if (text !== String(line[column.key] ?? '')) {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user