feat(web): dirección del documento desde AddressExtension (texto y corrección con «…») y campos de lista que muestran el código con formato (cuenta asociada)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c1f2cea1e1
commit
896fa25f2f
9 files changed
+342
-14
No files matched your search
@@ -0,0 +1,52 @@
|
||||
import { ChangeDetectionStrategy, Component, input, linkedSignal, output } from '@angular/core';
|
||||
import { SboField, TextInput } from '../../shared/form';
|
||||
import { ADDRESS_PARTS, type AddressValues } from './address-fields';
|
||||
|
||||
/** Ventana para corregir las partes de la dirección de un documento (calle, número, ciudad…). Solo emite `saved` al aceptar. */
|
||||
@Component({
|
||||
selector: 'sbo-address-dialog',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [SboField, TextInput],
|
||||
template: `
|
||||
<div class="overlay">
|
||||
<div class="dlg" role="dialog" aria-modal="true" [attr.aria-label]="title()" (keydown.escape)="closed.emit()">
|
||||
<header><h2>{{ title() }}</h2></header>
|
||||
<div class="body">
|
||||
@for (p of parts; track p.part) {
|
||||
<sbo-field [label]="p.label">
|
||||
<sbo-input [ariaLabel]="p.label" [value]="values()[p.part] ?? ''" (valueChange)="set(p.part, $event)" />
|
||||
</sbo-field>
|
||||
}
|
||||
</div>
|
||||
<footer>
|
||||
<span class="sp"></span>
|
||||
<button class="btn" type="button" (click)="closed.emit()">Cancelar</button>
|
||||
<button class="btn main" type="button" (click)="saved.emit(values())">Aceptar</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.overlay { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; padding: 16px; background: var(--overlay); }
|
||||
.dlg { display: flex; flex-direction: column; width: min(520px, 100%); max-height: 85vh; background: var(--win); border: 1px solid var(--line); border-radius: var(--radius-panel); overflow: hidden; box-shadow: var(--shadow-pop); }
|
||||
header { padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--line); }
|
||||
h2 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.body { display: grid; gap: 4px; overflow: auto; padding: 10px 12px; }
|
||||
footer { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--line); background: var(--panel); }
|
||||
.sp { flex: 1; }
|
||||
`,
|
||||
})
|
||||
export class AddressDialog {
|
||||
readonly title = input.required<string>();
|
||||
readonly initial = input.required<AddressValues>();
|
||||
|
||||
readonly saved = output<AddressValues>();
|
||||
readonly closed = output<void>();
|
||||
|
||||
protected readonly parts = ADDRESS_PARTS;
|
||||
protected readonly values = linkedSignal<AddressValues>(() => ({ ...this.initial() }));
|
||||
|
||||
protected set(part: string, value: string): void {
|
||||
this.values.update((v) => ({ ...v, [part]: value }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { addressFromExtension, addressFromPartner, composeAddress, toExtension } from './address-fields';
|
||||
|
||||
describe('dirección del documento (AddressExtension)', () => {
|
||||
const ext = { ShipToStreet: 'AV. PASO 923', ShipToZipCode: 'PUEBLO LIBRE', ShipToCounty: 'LIMA', ShipToState: '15', ShipToCountry: 'PE', BillToStreet: 'OTRA 1', ShipToCity: null, ShipToBuilding: '' };
|
||||
|
||||
it('toma las partes de un lado y arma el texto sin vacíos', () => {
|
||||
const ship = addressFromExtension(ext, 'ShipTo');
|
||||
expect(ship['Street']).toBe('AV. PASO 923');
|
||||
expect(ship['City']).toBe('');
|
||||
expect(composeAddress(ship)).toBe('AV. PASO 923, PUEBLO LIBRE, LIMA, 15, PE');
|
||||
expect(composeAddress(addressFromExtension(ext, 'BillTo'))).toBe('OTRA 1');
|
||||
});
|
||||
|
||||
it('une la calle y su número', () => {
|
||||
expect(composeAddress({ Street: 'Av. Sur', StreetNo: '100', City: 'Lima' })).toBe('Av. Sur 100, Lima');
|
||||
});
|
||||
|
||||
it('la dirección del socio usa los mismos nombres (el edificio viene de BuildingFloorRoom)', () => {
|
||||
expect(addressFromPartner({ Street: 'Calle 1', BuildingFloorRoom: 'Piso 2', ZipCode: '15001', Country: 'PE' })).toMatchObject({ Street: 'Calle 1', Building: 'Piso 2', ZipCode: '15001', Country: 'PE', City: '' });
|
||||
});
|
||||
|
||||
it('para guardar lleva el prefijo del lado', () => {
|
||||
expect(toExtension({ Street: 'X', City: 'Y' }, 'BillTo')).toEqual({ BillToStreet: 'X', BillToCity: 'Y' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* La dirección de un documento (`AddressExtension` en el Service Layer): la de envío (`ShipTo…`) y la de facturación
|
||||
* (`BillTo…`), cada una con estas partes. Al elegir la dirección del socio SAP las completa; el usuario puede
|
||||
* corregirlas para este documento (el botón «…» de la pestaña Logística).
|
||||
*/
|
||||
export const ADDRESS_PARTS = [
|
||||
{ part: 'Street', label: 'Calle' },
|
||||
{ part: 'StreetNo', label: 'Número' },
|
||||
{ part: 'Block', label: 'Bloque' },
|
||||
{ part: 'Building', label: 'Edificio / piso' },
|
||||
{ part: 'City', label: 'Ciudad' },
|
||||
{ part: 'ZipCode', label: 'Código postal' },
|
||||
{ part: 'County', label: 'Provincia / distrito' },
|
||||
{ part: 'State', label: 'Región / estado' },
|
||||
{ part: 'Country', label: 'País' },
|
||||
] as const;
|
||||
|
||||
export type AddressSide = 'ShipTo' | 'BillTo';
|
||||
export type AddressValues = Record<string, string>;
|
||||
|
||||
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v).trim());
|
||||
|
||||
/** Las partes de una dirección de un lado, de un `AddressExtension` (campos `ShipToStreet`, `ShipToCity`…). */
|
||||
export function addressFromExtension(ext: Record<string, unknown> | undefined, side: AddressSide): AddressValues {
|
||||
return Object.fromEntries(ADDRESS_PARTS.map(({ part }) => [part, text(ext?.[`${side}${part}`])]));
|
||||
}
|
||||
|
||||
/** Las partes de una dirección del socio de negocio (`BPAddresses`), con los nombres de `AddressExtension`. */
|
||||
export function addressFromPartner(row: Record<string, unknown> | undefined): AddressValues {
|
||||
return {
|
||||
Street: text(row?.['Street']),
|
||||
StreetNo: text(row?.['StreetNo']),
|
||||
Block: text(row?.['Block']),
|
||||
Building: text(row?.['BuildingFloorRoom']),
|
||||
City: text(row?.['City']),
|
||||
ZipCode: text(row?.['ZipCode']),
|
||||
County: text(row?.['County']),
|
||||
State: text(row?.['State']),
|
||||
Country: text(row?.['Country']),
|
||||
};
|
||||
}
|
||||
|
||||
/** Las partes con el prefijo del lado, listas para `AddressExtension` (`Street` → `ShipToStreet`). */
|
||||
export const toExtension = (values: AddressValues, side: AddressSide): AddressValues => Object.fromEntries(Object.entries(values).map(([part, v]) => [`${side}${part}`, v]));
|
||||
|
||||
/** El texto de una dirección: calle y número, bloque, edificio, código postal, ciudad, provincia, región y país, separados por comas. */
|
||||
export function composeAddress(a: AddressValues): string {
|
||||
const street = [a['Street'], a['StreetNo']].filter((p) => p).join(' ');
|
||||
return [street, a['Block'], a['Building'], a['ZipCode'], a['City'], a['County'], a['State'], a['Country']].filter((p) => p).join(', ');
|
||||
}
|
||||
@@ -90,6 +90,10 @@
|
||||
}
|
||||
</sbo-window>
|
||||
|
||||
@if (addressDialog(); as f) {
|
||||
<sbo-address-dialog [title]="f.label" [initial]="addressValues(f)" (saved)="saveAddress(f, $event)" (closed)="addressDialog.set(null)" />
|
||||
}
|
||||
|
||||
@if (groupCell(); as cell) {
|
||||
@if (cell.kind === 'lookup') {
|
||||
<sbo-value-help
|
||||
@@ -286,7 +290,12 @@
|
||||
[value]="str(header()[f.key]) || null"
|
||||
(valueChange)="setHeader(f.key, $event ?? '')"
|
||||
/>
|
||||
<sbo-textarea-field label="" [rows]="3" readonly [value]="addressText(f)" />
|
||||
<sbo-field label="">
|
||||
<sbo-textarea [rows]="3" readonly ariaLabel="Texto de la dirección" [value]="addressText(f)" />
|
||||
@if (!readOnly() && !locked(f)) {
|
||||
<button type="button" class="sbo-btn" [attr.aria-label]="'Corregir la ' + f.label.toLowerCase()" title="Corregir la dirección de este documento" (click)="addressDialog.set(f)">…</button>
|
||||
}
|
||||
</sbo-field>
|
||||
}
|
||||
@case ('currency') {
|
||||
<sbo-field [label]="f.label">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { API_URL, apiErrorMessage } from '../../core/api';
|
||||
import { formatNumber, todayIso } from '../../core/format';
|
||||
import { CompanySettingsService } from '../../core/company/company-settings';
|
||||
import { SlDocumentApi } from '../../core/sl/sl-document';
|
||||
import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } from '../../shared/form';
|
||||
import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField, TextareaInput } from '../../shared/form';
|
||||
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
|
||||
import { LinesGrid } from '../../shared/grid/lines-grid';
|
||||
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
} from './document-model';
|
||||
import { SERVICE_DOC_TYPE } from './document-layouts';
|
||||
import { type ComputedSource, type GroupCell, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasCurrency, hasExpenses, hasFolio, hasPartnerData, hasSeries, headerKeysOf, keysOfFields, requiredHeaderOf } from './document-layouts';
|
||||
import { type AddressSide, type AddressValues, addressFromExtension, addressFromPartner, composeAddress, toExtension } from './address-fields';
|
||||
import { AddressDialog } from './address-dialog';
|
||||
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
|
||||
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
|
||||
import { OBJECT_TYPES } from './document-objects';
|
||||
@@ -72,7 +74,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
|
||||
@Component({
|
||||
selector: 'sbo-document-form-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
|
||||
imports: [AddressDialog, Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, TextareaInput, Toolbar, ValueHelp],
|
||||
templateUrl: './document-form.page.html',
|
||||
styles: `
|
||||
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
|
||||
@@ -296,9 +298,9 @@ export class DocumentFormPage {
|
||||
|
||||
protected readonly dirty = computed(() => {
|
||||
const d = this.doc();
|
||||
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l, this.serviceMode())) || expensesPayload(this.expenses()).length > 0;
|
||||
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l, this.serviceMode())) || expensesPayload(this.expenses()).length > 0 || Object.keys(this.addressEdits()).length > 0;
|
||||
const { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec());
|
||||
return Object.keys(body).length > 0 || (this.hasExpenses && expensesChanged(d, this.expenses()));
|
||||
return Object.keys(body).length > 0 || (this.hasExpenses && expensesChanged(d, this.expenses())) || Object.keys(this.addressEdits()).length > 0;
|
||||
});
|
||||
|
||||
// --- totales -------------------------------------------------------------------------------
|
||||
@@ -501,6 +503,11 @@ export class DocumentFormPage {
|
||||
if (key === 'DocType' && this.layout.service) return this.onDocType(String(value));
|
||||
this.header.update((h) => ({ ...h, [key]: value }));
|
||||
this.message.set(null);
|
||||
// otra dirección elegida: las correcciones hechas a la anterior ya no valen
|
||||
if (key === 'ShipToCode' || key === 'PayToCode') {
|
||||
const side = key === 'ShipToCode' ? 'ShipTo' : 'BillTo';
|
||||
this.addressEdits.update((e) => Object.fromEntries(Object.entries(e).filter(([k]) => !k.startsWith(side))));
|
||||
}
|
||||
// otra fecha del documento, otro tipo de cambio
|
||||
if (key === 'DocDate' && this.isNew() && !this.isLocalCurrency()) void this.fetchRate(this.currencyValue());
|
||||
}
|
||||
@@ -538,13 +545,49 @@ export class DocumentFormPage {
|
||||
protected readonly shipOptions = computed(() => this.addressOptions('ship'));
|
||||
protected readonly billOptions = computed(() => this.addressOptions('bill'));
|
||||
|
||||
/** El texto de la dirección elegida: el que SAP guardó en el documento o, al elegir otra, el armado con los datos de la dirección del socio. */
|
||||
protected addressText(f: { key: string; side: 'ship' | 'bill' }): string {
|
||||
/** Correcciones del usuario a la dirección de este documento (`AddressExtension`, campos con prefijo `ShipTo`/`BillTo`). */
|
||||
protected readonly addressEdits = linkedSignal<Record<string, string>>(() => (this.doc(), {}));
|
||||
|
||||
private sideOf(f: { side: 'ship' | 'bill' }): AddressSide {
|
||||
return f.side === 'ship' ? 'ShipTo' : 'BillTo';
|
||||
}
|
||||
|
||||
/** Las partes de la dirección sin correcciones: las del documento guardado o, si se eligió otra, las de la dirección del socio. */
|
||||
private baseAddress(f: { key: string; side: 'ship' | 'bill' }): AddressValues {
|
||||
const code = text(this.header()[f.key]);
|
||||
const found = this.addresses().find((a) => text(a['AddressName']) === code && a['AddressType'] === (f.side === 'ship' ? 'bo_ShipTo' : 'bo_BillTo'));
|
||||
if (found) return [found['Street'], found['Block'], found['ZipCode'], found['City'], found['County'], found['State']].map(text).filter((p) => p !== '').join(', ');
|
||||
const saved = this.doc();
|
||||
if (saved && code === text(saved[f.key])) return addressFromExtension(saved['AddressExtension'] as Record<string, unknown> | undefined, this.sideOf(f));
|
||||
const type = f.side === 'ship' ? 'bo_ShipTo' : 'bo_BillTo';
|
||||
return addressFromPartner(this.addresses().find((a) => text(a['AddressName']) === code && a['AddressType'] === type));
|
||||
}
|
||||
|
||||
/** Las partes de la dirección con las correcciones del usuario encima. */
|
||||
protected addressValues(f: { key: string; side: 'ship' | 'bill' }): AddressValues {
|
||||
const side = this.sideOf(f);
|
||||
const values = this.baseAddress(f);
|
||||
for (const [key, value] of Object.entries(this.addressEdits())) if (key.startsWith(side)) values[key.slice(side.length)] = value;
|
||||
return values;
|
||||
}
|
||||
|
||||
/** El texto de la dirección elegida; si no hay datos de ella, el que SAP guardó en el documento. */
|
||||
protected addressText(f: { key: string; side: 'ship' | 'bill' }): string {
|
||||
const composed = composeAddress(this.addressValues(f));
|
||||
if (composed) return composed;
|
||||
const saved = this.doc()?.[f.side === 'ship' ? 'Address' : 'Address2'];
|
||||
return code === text(this.doc()?.[f.key]) ? text(saved).replace(/\r/g, '\n') : '';
|
||||
return text(this.header()[f.key]) === text(this.doc()?.[f.key]) ? text(saved).replace(/\r/g, '\n') : '';
|
||||
}
|
||||
|
||||
/** La ventana de corrección de la dirección abierta. */
|
||||
protected readonly addressDialog = signal<{ key: string; side: 'ship' | 'bill'; label: string } | null>(null);
|
||||
|
||||
/** Guarda lo corregido (solo lo que cambió respecto de la dirección elegida). */
|
||||
protected saveAddress(f: { key: string; side: 'ship' | 'bill' }, values: AddressValues): void {
|
||||
const base = this.baseAddress(f);
|
||||
const side = this.sideOf(f);
|
||||
const changed = Object.fromEntries(Object.entries(toExtension(values, side)).filter(([key, v]) => v !== (base[key.slice(side.length)] ?? '')));
|
||||
this.addressEdits.update((e) => ({ ...Object.fromEntries(Object.entries(e).filter(([k]) => !k.startsWith(side))), ...changed }));
|
||||
this.addressDialog.set(null);
|
||||
this.message.set(null);
|
||||
}
|
||||
|
||||
/** Un código numérico de SAP (lista de precios, empleado) que se escribe o elige como texto se guarda como número. */
|
||||
@@ -659,6 +702,7 @@ export class DocumentFormPage {
|
||||
}
|
||||
const body = createPayload(header, this.lines(), this.spec());
|
||||
if (this.hasExpenses && expensesPayload(this.expenses()).length) body[EXPENSES_COLLECTION] = expensesPayload(this.expenses());
|
||||
if (Object.keys(this.addressEdits()).length) body['AddressExtension'] = this.addressEdits();
|
||||
const created = await this.api.create(this.screen.resource, body);
|
||||
await this.router.navigate(['..', created['DocEntry']], {
|
||||
relativeTo: this.route,
|
||||
@@ -669,6 +713,7 @@ export class DocumentFormPage {
|
||||
const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec());
|
||||
const body = folioAsNumber(update.body);
|
||||
let replaceLines = update.replaceLines;
|
||||
if (Object.keys(this.addressEdits()).length) body['AddressExtension'] = this.addressEdits();
|
||||
if (this.hasExpenses && expensesChanged(d, this.expenses())) {
|
||||
body[EXPENSES_COLLECTION] = expensesPayload(this.expenses());
|
||||
replaceLines = true; // SAP reemplaza la colección entera
|
||||
|
||||
@@ -98,6 +98,42 @@ describe('pestañas Contenido, Logística y Finanzas', () => {
|
||||
expect(page().addressText(ship)).toBe('Jr. Norte 5, Callao');
|
||||
});
|
||||
|
||||
it('la dirección se corrige desde el botón «…» y viaja en AddressExtension, solo lo que cambió', async () => {
|
||||
await open('Orders');
|
||||
page().onPartner({ CardCode: 'C1', CardName: 'ACME' });
|
||||
await tick();
|
||||
ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners/C1').flush(PARTNER);
|
||||
await tick();
|
||||
const ship = page().layout.tabs.logistics.left.find((f: { type: string }) => f.type === 'address');
|
||||
expect(page().addressValues(ship)).toMatchObject({ Street: 'Av. Sur 100', City: 'Lima' });
|
||||
page().saveAddress(ship, { ...page().addressValues(ship), Street: 'Nueva 1' });
|
||||
expect(page().addressEdits()).toEqual({ ShipToStreet: 'Nueva 1' }); // solo cambió la calle
|
||||
expect(page().addressText(ship)).toBe('Nueva 1, Lima');
|
||||
expect(page().dirty()).toBe(true);
|
||||
page().setHeader('CardCode', 'C1');
|
||||
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 10, LineTotal: 10 }]);
|
||||
await tick();
|
||||
(el().querySelector('.actions .btn.main') as HTMLElement).click();
|
||||
await tick();
|
||||
const req = ctrl.expectOne((r) => r.method === 'POST' && r.url === '/api/sl/Orders');
|
||||
expect((req.request.body as Record<string, unknown>)['AddressExtension']).toEqual({ ShipToStreet: 'Nueva 1' });
|
||||
req.flush({ DocEntry: 1, DocNum: 1 });
|
||||
await tick();
|
||||
});
|
||||
|
||||
it('elegir otra dirección descarta lo corregido a la anterior', async () => {
|
||||
await open('Orders');
|
||||
page().onPartner({ CardCode: 'C1', CardName: 'ACME' });
|
||||
await tick();
|
||||
ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners/C1').flush(PARTNER);
|
||||
await tick();
|
||||
const ship = page().layout.tabs.logistics.left.find((f: { type: string }) => f.type === 'address');
|
||||
page().saveAddress(ship, { ...page().addressValues(ship), Street: 'Nueva 1' });
|
||||
page().setHeader('ShipToCode', 'ALMACEN');
|
||||
expect(page().addressEdits()).toEqual({});
|
||||
expect(page().addressText(ship)).toBe('Jr. Norte 5, Callao');
|
||||
});
|
||||
|
||||
it('un valor por defecto no pisa lo que el usuario ya eligió', async () => {
|
||||
await open('Orders');
|
||||
page().setHeader('PaymentGroupCode', 9);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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 { ACCOUNT_CODES, AccountCodesService } from '../../core/sl/account-codes';
|
||||
import { ACCOUNT_LOOKUP } from '../../features/documents/dimension-lookups';
|
||||
import { LookupField } from './lookup-field';
|
||||
|
||||
@Component({
|
||||
imports: [LookupField],
|
||||
template: `<sbo-lookup-field label="Cuenta" resolveOnLeave [lookup]="lookup" [(value)]="value" />`,
|
||||
})
|
||||
class Host {
|
||||
lookup = ACCOUNT_LOOKUP;
|
||||
value = signal('');
|
||||
}
|
||||
|
||||
describe('LookupField: lista con texto propio para el código (la cuenta)', () => {
|
||||
let fixture: ComponentFixture<Host>;
|
||||
let ctrl: HttpTestingController;
|
||||
const input = () => (fixture.nativeElement as HTMLElement).querySelector('input') as HTMLInputElement;
|
||||
const tick = async () => {
|
||||
fixture.detectChanges();
|
||||
await new Promise((r) => setTimeout(r));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({ imports: [Host], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
|
||||
ctrl = TestBed.inject(HttpTestingController);
|
||||
TestBed.inject(AccountCodesService);
|
||||
fixture = TestBed.createComponent(Host);
|
||||
await tick();
|
||||
});
|
||||
|
||||
it('muestra el código con formato en lugar del interno que está guardado', async () => {
|
||||
ACCOUNT_CODES.remember({ Code: '_SYS77', FormatCode: '1212111010', Name: 'Cuentas por cobrar' });
|
||||
fixture.componentInstance.value.set('_SYS77');
|
||||
await tick();
|
||||
expect(input().value).toBe('1212111010');
|
||||
expect(fixture.componentInstance.value()).toBe('_SYS77'); // lo guardado sigue siendo el interno
|
||||
});
|
||||
|
||||
it('pide el código con formato de un valor que llega sin conocerse', async () => {
|
||||
fixture.componentInstance.value.set('_SYS88');
|
||||
await tick();
|
||||
const req = ctrl.expectOne((r) => r.url === '/api/sl/ChartOfAccounts' && r.params.get('$filter') === "Code eq '_SYS88'");
|
||||
req.flush({ value: [{ Code: '_SYS88', FormatCode: '4012111010', Name: 'Proveedores' }] });
|
||||
await tick();
|
||||
expect(input().value).toBe('4012111010');
|
||||
});
|
||||
|
||||
it('escribir el código con formato guarda el interno al salir del campo', async () => {
|
||||
input().value = '1071111010';
|
||||
input().dispatchEvent(new Event('input'));
|
||||
await tick();
|
||||
input().dispatchEvent(new Event('focusout', { bubbles: true }));
|
||||
await tick();
|
||||
const req = ctrl.expectOne((r) => r.url === '/api/sl/ChartOfAccounts' && (r.params.get('$filter') ?? '').includes("FormatCode eq '1071111010'"));
|
||||
req.flush({ value: [{ Code: '_SYS99', FormatCode: '1071111010', Name: 'Banco' }] });
|
||||
await tick();
|
||||
expect(fixture.componentInstance.value()).toBe('_SYS99');
|
||||
expect(input().value).toBe('1071111010');
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
booleanAttribute,
|
||||
computed,
|
||||
Injector,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
model,
|
||||
@@ -37,7 +38,8 @@ import { SboField } from './sbo-field';
|
||||
>
|
||||
<sbo-input
|
||||
#box
|
||||
[(value)]="value"
|
||||
[value]="shown()"
|
||||
(valueChange)="typing($event)"
|
||||
[readonly]="readOnly()"
|
||||
[disabled]="disabled()"
|
||||
[required]="required()"
|
||||
@@ -93,6 +95,36 @@ export class LookupField extends FieldBase {
|
||||
private resolved = '';
|
||||
private readonly box = viewChild.required<TextInput>('box');
|
||||
|
||||
/** El usuario está escribiendo: se muestra lo que escribe y no el texto con que la lista presenta el código guardado. */
|
||||
private readonly editing = signal(false);
|
||||
/**
|
||||
* Lo que se ve en el campo: el código guardado o, en una lista que tiene texto propio para él (la cuenta con su
|
||||
* código con formato en vez del interno `_SYS…`), ese texto.
|
||||
*/
|
||||
protected readonly shown = computed(() => {
|
||||
const v = this.value();
|
||||
const codes = this.lookup().codes;
|
||||
return !this.editing() && codes && v !== '' ? codes.display(v) : v;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
effect(() => {
|
||||
const v = this.value();
|
||||
const codes = this.lookup().codes;
|
||||
// un valor que llega de fuera (documento cargado, valor por defecto) ya está resuelto y se pide su texto; lo que se escribe, no
|
||||
if (!this.editing()) {
|
||||
if (codes && v !== '') codes.ensure([v]);
|
||||
this.resolved = v.trim();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected typing(text: string): void {
|
||||
this.editing.set(true);
|
||||
this.value.set(text);
|
||||
}
|
||||
|
||||
protected openList(): void {
|
||||
if (!this.readOnly() && !this.disabled()) this.listOpen.set(true);
|
||||
}
|
||||
@@ -111,6 +143,8 @@ export class LookupField extends FieldBase {
|
||||
private apply(row: VhRow): void {
|
||||
const key = this.lookup().valueKey ?? this.lookup().columns[0]?.key ?? '';
|
||||
this.missing.set(null);
|
||||
this.lookup().codes?.remember(row);
|
||||
this.editing.set(false);
|
||||
this.value.set(String(row[key] ?? ''));
|
||||
this.resolved = this.value();
|
||||
this.picked.emit(row);
|
||||
@@ -118,13 +152,21 @@ export class LookupField extends FieldBase {
|
||||
|
||||
protected async resolveTyped(): Promise<void> {
|
||||
const code = this.value().trim();
|
||||
if (!this.resolveOnLeave() || this.readOnly() || this.disabled() || this.listOpen()) return;
|
||||
if (!this.editing()) return; // no se escribió nada: lo guardado ya está resuelto
|
||||
if (!this.resolveOnLeave() || this.readOnly() || this.disabled() || this.listOpen()) {
|
||||
this.editing.set(false);
|
||||
return;
|
||||
}
|
||||
if (!code) {
|
||||
this.missing.set(null);
|
||||
this.resolved = '';
|
||||
this.editing.set(false);
|
||||
return;
|
||||
}
|
||||
if (code === this.resolved) {
|
||||
this.editing.set(false);
|
||||
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
|
||||
@@ -132,6 +174,8 @@ export class LookupField extends FieldBase {
|
||||
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.' });
|
||||
} finally {
|
||||
if (this.value().trim() === code) this.editing.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user