Merge branch 'main' into cliente/tagumedica
This commit is contained in:
commit
6cb5d3fce3
17 files changed
+517
-11
No files matched your search
@@ -4,7 +4,7 @@ import { Router } from '@angular/router';
|
||||
import type { LoginRequest, LoginResponse } from '@basic-erp/sap-types';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { API_URL } from '../api';
|
||||
import { SapDbApi } from '../sl/sapdb-api';
|
||||
import { SessionReset } from '../session-reset';
|
||||
import { MenuService } from '../menu/menu.service';
|
||||
import { SessionStore } from './session.store';
|
||||
|
||||
@@ -13,13 +13,13 @@ export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly session = inject(SessionStore);
|
||||
private readonly menu = inject(MenuService);
|
||||
private readonly sapdb = inject(SapDbApi);
|
||||
private readonly reset = inject(SessionReset);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
async login(credentials: LoginRequest): Promise<void> {
|
||||
const res = await firstValueFrom(this.http.post<LoginResponse>(`${API_URL}/auth/login`, credentials));
|
||||
this.menu.reset();
|
||||
this.sapdb.reset();
|
||||
this.reset.run();
|
||||
this.session.set(res);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,6 @@ export class AuthService {
|
||||
private end(): void {
|
||||
this.session.clear();
|
||||
this.menu.reset();
|
||||
this.sapdb.reset();
|
||||
this.reset.run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Lo que debe olvidarse al iniciar o cerrar sesión (los permisos y lo que depende de ellos). Cada servicio con datos de la
|
||||
* sesión se registra aquí al crearse, así el servicio de sesión no tiene que conocerlos (y no arrastra su código al arranque).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionReset {
|
||||
private readonly callbacks: (() => void)[] = [];
|
||||
|
||||
register(callback: () => void): void {
|
||||
this.callbacks.push(callback);
|
||||
}
|
||||
|
||||
run(): void {
|
||||
for (const cb of this.callbacks) cb();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { API_URL } from '../api';
|
||||
import { SessionReset } from '../session-reset';
|
||||
|
||||
/** Respuesta de una consulta a la base de SAP. */
|
||||
export interface SapDbResult {
|
||||
@@ -18,6 +19,10 @@ export class SapDbApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private names?: Promise<Set<string>>;
|
||||
|
||||
constructor() {
|
||||
inject(SessionReset).register(() => this.reset());
|
||||
}
|
||||
|
||||
/** La consulta existe para este usuario y la base está configurada. Se pregunta una vez por sesión. */
|
||||
available(name: string): Promise<boolean> {
|
||||
this.names ??= firstValueFrom(this.http.get<{ name: string }[]>(`${API_URL}/sapdb/queries`))
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { API_URL } from '../api';
|
||||
import { SessionReset } from '../session-reset';
|
||||
|
||||
/** La definición de un campo de usuario de SAP (`GET /api/sapdb/user-fields/:tabla/:campo`). */
|
||||
export interface UserFieldInfo {
|
||||
table: string;
|
||||
alias: string;
|
||||
description: string;
|
||||
/** `A` alfanumérico, `N` numérico, `D` fecha, `M` memo… */
|
||||
type: string;
|
||||
size: number;
|
||||
validValues: { value: string; description: string }[];
|
||||
linkedTable: string | null;
|
||||
linkedRows: { code: string; name: string }[];
|
||||
linkedTruncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valores válidos y tabla enlazada de los campos de usuario, tal como están definidos en SAP: así un campo de usuario
|
||||
* se muestra como lista desplegable o con su ventana de selección sin escribir sus valores en el código. Se pregunta una
|
||||
* vez por campo y sesión; sin base de datos configurada (o sin el campo) devuelve `null` y el campo se pide como texto.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserFieldsApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private cache = new Map<string, Promise<UserFieldInfo | null>>();
|
||||
|
||||
constructor() {
|
||||
inject(SessionReset).register(() => this.reset());
|
||||
}
|
||||
|
||||
get(table: string, alias: string): Promise<UserFieldInfo | null> {
|
||||
const key = `${table}|${alias}`;
|
||||
let hit = this.cache.get(key);
|
||||
if (!hit) {
|
||||
hit = firstValueFrom(this.http.get<UserFieldInfo>(`${API_URL}/sapdb/user-fields/${encodeURIComponent(table)}/${encodeURIComponent(alias)}`)).catch(() => null);
|
||||
this.cache.set(key, hit);
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
/** Al iniciar o cerrar sesión. */
|
||||
reset(): void {
|
||||
this.cache = new Map();
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,16 @@
|
||||
(picked)="onPartner($event)"
|
||||
/>
|
||||
}
|
||||
@case ('udf') {
|
||||
<sbo-udf-field
|
||||
[label]="f.label" [dbName]="dbNames(f)"
|
||||
[table]="f.table" [alias]="f.alias"
|
||||
[required]="!!f.required"
|
||||
[readonly]="readOnly() || !!f.readonly || locked(f)"
|
||||
[value]="header()[f.key]"
|
||||
(valueChange)="setHeader(f.key, $event)"
|
||||
/>
|
||||
}
|
||||
@case ('checkbox') {
|
||||
<sbo-checkbox-field
|
||||
[label]="f.label" [dbName]="dbNames(f)"
|
||||
|
||||
@@ -10,7 +10,7 @@ import { formatNumber, todayIso } from '../../core/format';
|
||||
import { CompanySettingsService } from '../../core/company/company-settings';
|
||||
import { ControlAccountsService } from '../../core/sl/control-accounts';
|
||||
import { SlDocumentApi } from '../../core/sl/sl-document';
|
||||
import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField, TextareaInput } from '../../shared/form';
|
||||
import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField, TextareaInput, UdfField } from '../../shared/form';
|
||||
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
|
||||
import { LinesGrid } from '../../shared/grid/lines-grid';
|
||||
import type { VhLookup, VhRow } from '../../shared/value-help/value-help.types';
|
||||
@@ -76,7 +76,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
|
||||
@Component({
|
||||
selector: 'sbo-document-form-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [AddressDialog, DocumentPdf, Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, TextareaInput, Toolbar, ValueHelp],
|
||||
imports: [AddressDialog, DocumentPdf, Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, TextareaInput, Toolbar, UdfField, ValueHelp],
|
||||
templateUrl: './document-form.page.html',
|
||||
styles: `
|
||||
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
|
||||
|
||||
@@ -12,6 +12,19 @@ export type GroupCell =
|
||||
| { kind: 'text'; key: string; label: string; width?: string; maxlength?: number }
|
||||
| { kind: 'lookup'; key: string; label: string; lookup: VhLookup; width?: string };
|
||||
|
||||
/**
|
||||
* Un campo de usuario por su definición en SAP: la tabla (`OBTN`, `OWTQ`…) y el alias sin el `U_` (`SIS_ESTLOT`). Sus valores
|
||||
* válidos o su tabla de usuario enlazada se leen de SAP al abrir la pantalla; no se escriben aquí.
|
||||
*/
|
||||
export const udfField = (table: string, alias: string, label: string, extra: { required?: boolean; missing?: string; readonly?: boolean } = {}): HeaderField => ({
|
||||
type: 'udf',
|
||||
key: `U_${alias}`,
|
||||
label,
|
||||
table,
|
||||
alias,
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** Fila con varios campos juntos, para la cabecera de un documento. */
|
||||
export const fieldGroup = (label: string, cells: readonly GroupCell[]): HeaderField => ({ type: 'group', label, cells });
|
||||
|
||||
@@ -40,6 +53,11 @@ export type HeaderField =
|
||||
/** Campo de código con su ventana de selección (grupo, moneda, lista de precios…). */
|
||||
| { type: 'lookup'; key: string; label: string; lookup: VhLookup; required?: boolean; missing?: string; immutable?: boolean; onPick?: (row: Record<string, unknown>) => Record<string, unknown> }
|
||||
| { type: 'textarea'; key: string; label: string }
|
||||
/**
|
||||
* Campo de usuario (`U_*`) que se pide como lo definió SAP: lista desplegable si tiene valores válidos, ventana de
|
||||
* selección si está enlazado a una tabla de usuario y texto en otro caso (`udfField`).
|
||||
*/
|
||||
| { type: 'udf'; key: string; label: string; table: string; alias: string; required?: boolean; missing?: string; readonly?: boolean }
|
||||
/** Lista de valores válidos (típico de un campo de usuario con valores válidos en SAP). */
|
||||
| { type: 'select'; key: string; label: string; options: readonly { value: string | number; label: string }[]; required?: boolean; missing?: string; immutable?: boolean }
|
||||
/** Abre la lista de almacenes. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
import { CheckboxField, DateField, LookupField, NumberField, SelectField, TextField, TextareaField } from '../../shared/form';
|
||||
import { CheckboxField, DateField, LookupField, NumberField, SelectField, TextField, TextareaField, UdfField } from '../../shared/form';
|
||||
import { WAREHOUSE_LOOKUP } from '../documents/document-layouts';
|
||||
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
||||
import type { HeaderField } from '../documents/document-layouts';
|
||||
@@ -13,7 +13,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
|
||||
@Component({
|
||||
selector: 'sbo-master-field',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CheckboxField, DateField, LookupField, NumberField, SelectField, TextField, TextareaField],
|
||||
imports: [CheckboxField, DateField, LookupField, NumberField, SelectField, TextField, TextareaField, UdfField],
|
||||
template: `
|
||||
@switch (field().type) {
|
||||
@case ('checkbox') {
|
||||
@@ -46,6 +46,16 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
|
||||
resolveOnLeave
|
||||
/>
|
||||
}
|
||||
@case ('udf') {
|
||||
<sbo-udf-field
|
||||
[label]="field().label" [dbName]="dbName()"
|
||||
[table]="udf().table" [alias]="udf().alias"
|
||||
[required]="required()"
|
||||
[readonly]="readOnly() || !!ro()"
|
||||
[value]="value()"
|
||||
(valueChange)="changed.emit($any($event))"
|
||||
/>
|
||||
}
|
||||
@case ('select') {
|
||||
<sbo-select-field
|
||||
[label]="field().label" [dbName]="dbName()"
|
||||
@@ -94,6 +104,12 @@ export class MasterField {
|
||||
readonly saved = input(false);
|
||||
readonly changed = output<string | number>();
|
||||
|
||||
protected readonly udf = computed(() => {
|
||||
const f = this.field();
|
||||
if (f.type !== 'udf') throw new Error('campo sin definición de usuario');
|
||||
return f;
|
||||
});
|
||||
|
||||
protected readonly warehouse: VhLookup = { ...WAREHOUSE_LOOKUP, nameKey: 'WarehouseName' };
|
||||
|
||||
/** Un código numérico de SAP (grupo, lista de precios) se guarda como número. */
|
||||
|
||||
@@ -4,3 +4,4 @@ export { SboField } from './sbo-field';
|
||||
// Atajos con etiqueta (un control por fila)
|
||||
export { CheckboxField, DateField, NumberField, SelectField, TextField, TextareaField } from './fields';
|
||||
export { LookupField } from './lookup-field';
|
||||
export { UdfField } from './udf-field';
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { UdfField } from './udf-field';
|
||||
|
||||
@Component({
|
||||
imports: [UdfField],
|
||||
template: '<sbo-udf-field label="Campo" [table]="table" [alias]="alias" [value]="value()" (valueChange)="value.set($event)" />',
|
||||
})
|
||||
class Host {
|
||||
table = 'OBTN';
|
||||
alias = 'SIS_X';
|
||||
value = signal<unknown>('');
|
||||
}
|
||||
|
||||
const base = { table: 'OBTN', alias: 'SIS_X', description: 'Campo X', size: 10, validValues: [], linkedTable: null, linkedRows: [], linkedTruncated: false };
|
||||
|
||||
describe('campo de usuario de SAP', () => {
|
||||
let fixture: ComponentFixture<Host>;
|
||||
let ctrl: HttpTestingController;
|
||||
const el = () => fixture.nativeElement as HTMLElement;
|
||||
const tick = async () => {
|
||||
fixture.detectChanges();
|
||||
await new Promise((r) => setTimeout(r));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
async function open(answer: object, value: unknown = '') {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||
ctrl = TestBed.inject(HttpTestingController);
|
||||
fixture = TestBed.createComponent(Host);
|
||||
fixture.componentInstance.value.set(value);
|
||||
await tick();
|
||||
const req = ctrl.expectOne('/api/sapdb/user-fields/OBTN/SIS_X');
|
||||
if (answer && typeof answer === 'object' && 'fail' in answer) req.flush({ message: 'x' }, { status: 503, statusText: 'Service Unavailable' });
|
||||
else req.flush(answer as Record<string, unknown>);
|
||||
await tick();
|
||||
}
|
||||
|
||||
beforeEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('con valores válidos se pide en una lista desplegable con ellos', async () => {
|
||||
await open({ ...base, validValues: [{ value: '101', description: 'Cuarentena' }, { value: '102', description: 'Aprobado' }] }, '102');
|
||||
const select = el().querySelector('select') as HTMLSelectElement;
|
||||
expect([...select.options].map((o) => o.textContent?.trim())).toEqual(expect.arrayContaining(['101 · Cuarentena', '102 · Aprobado']));
|
||||
expect(select.value).toBe('102');
|
||||
select.value = '101';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
await tick();
|
||||
expect(fixture.componentInstance.value()).toBe('101');
|
||||
});
|
||||
|
||||
it('enlazado a una tabla de usuario, muestra su nombre junto al código y abre su lista', async () => {
|
||||
await open({ ...base, linkedTable: 'SIS_REGSAN', linkedRows: [{ code: '10001', name: 'DM2462E' }, { code: '10002', name: 'OTRO' }] }, '10001');
|
||||
await tick(); // el nombre se resuelve de las filas ya cargadas
|
||||
const inputs = [...el().querySelectorAll<HTMLInputElement>('input')].map((i) => i.value);
|
||||
expect(inputs).toEqual(expect.arrayContaining(['10001', 'DM2462E']));
|
||||
expect(el().querySelector('button.sbo-btn')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('un campo numérico guarda número, no texto', async () => {
|
||||
await open({ ...base, type: 'N', validValues: [{ value: '1', description: 'Uno' }, { value: '2', description: 'Dos' }] }, 1);
|
||||
const select = el().querySelector('select') as HTMLSelectElement;
|
||||
select.value = '2';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
await tick();
|
||||
expect(fixture.componentInstance.value()).toBe(2);
|
||||
});
|
||||
|
||||
it('sin base de datos configurada o sin el campo, se pide como texto', async () => {
|
||||
await open({ fail: true }, 'abc');
|
||||
expect(el().querySelector('select')).toBeNull();
|
||||
const input = el().querySelector('input') as HTMLInputElement;
|
||||
expect(input.value).toBe('abc');
|
||||
input.value = 'xyz';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
await tick();
|
||||
expect(fixture.componentInstance.value()).toBe('xyz');
|
||||
});
|
||||
|
||||
it('un campo sin valores válidos ni tabla enlazada también es texto', async () => {
|
||||
await open({ ...base });
|
||||
expect(el().querySelector('select')).toBeNull();
|
||||
expect(el().querySelector('input')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, model, resource } from '@angular/core';
|
||||
import { UserFieldsApi, type UserFieldInfo } from '../../core/sl/user-fields';
|
||||
import type { VhLookup } from '../value-help/value-help.types';
|
||||
import { inject } from '@angular/core';
|
||||
import { SelectField, TextField } from './fields';
|
||||
import { FieldBase } from './field-base';
|
||||
import { LookupField } from './lookup-field';
|
||||
|
||||
/**
|
||||
* Campo de usuario de SAP (`U_*`) que se pide según como lo definió SAP: lista desplegable si tiene valores válidos,
|
||||
* ventana de selección si está enlazado a una tabla de usuario, texto en otro caso (o mientras carga, o si la base de datos
|
||||
* no está configurada). El tipo numérico se guarda como número.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'sbo-udf-field',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [LookupField, SelectField, TextField],
|
||||
template: `
|
||||
@if (kind() === 'select') {
|
||||
<sbo-select-field [label]="label()" [dbName]="dbName()" [required]="required()" [readonly]="readOnly()" [error]="error()" [options]="options()" placeholder="" [value]="str() || null" (valueChange)="emit($event)" />
|
||||
} @else if (kind() === 'lookup') {
|
||||
<sbo-lookup-field [label]="label()" [dbName]="dbName()" [required]="required()" [readonly]="readOnly()" [error]="error()" [lookup]="lookup()" [value]="str()" (valueChange)="emit($event)" resolveOnLeave />
|
||||
} @else {
|
||||
<sbo-text-field [label]="label()" [dbName]="dbName()" [required]="required()" [readonly]="readOnly()" [error]="error()" [value]="str()" (valueChange)="emit($event)" />
|
||||
}
|
||||
`,
|
||||
styles: ':host { display: block; }',
|
||||
})
|
||||
export class UdfField extends FieldBase {
|
||||
private readonly api = inject(UserFieldsApi);
|
||||
|
||||
/** Tabla del campo (`OBTN`, `OWTQ`, `@MI_TABLA`) y alias sin el `U_` (`SIS_ESTLOT`). */
|
||||
readonly table = input.required<string>();
|
||||
readonly alias = input.required<string>();
|
||||
readonly value = model<unknown>('');
|
||||
|
||||
protected readonly info = resource({
|
||||
params: () => ({ table: this.table(), alias: this.alias() }),
|
||||
loader: ({ params }) => this.api.get(params.table, params.alias),
|
||||
});
|
||||
private readonly def = computed<UserFieldInfo | null>(() => (this.info.hasValue() ? this.info.value() : null));
|
||||
|
||||
protected readonly kind = computed<'select' | 'lookup' | 'text'>(() => {
|
||||
const d = this.def();
|
||||
return d?.validValues.length ? 'select' : d?.linkedRows.length ? 'lookup' : 'text';
|
||||
});
|
||||
protected readonly str = computed(() => {
|
||||
const v = this.value();
|
||||
return v === null || v === undefined ? '' : String(v);
|
||||
});
|
||||
protected readonly options = computed(() => (this.def()?.validValues ?? []).map((v) => ({ value: v.value, label: v.description ? `${v.value} · ${v.description}` : v.value })));
|
||||
protected readonly lookup = computed<VhLookup>(() => {
|
||||
const d = this.def()!;
|
||||
return {
|
||||
title: d.description || d.linkedTable || 'Valores',
|
||||
resource: d.linkedTable ?? 'UserTable',
|
||||
columns: [
|
||||
{ key: 'code', title: 'Código', width: '140px', mono: true, searchable: true },
|
||||
{ key: 'name', title: 'Nombre', searchable: true },
|
||||
],
|
||||
rows: d.linkedRows.map((r) => ({ code: r.code, name: r.name })),
|
||||
valueKey: 'code',
|
||||
nameKey: 'name',
|
||||
codeWidth: '140px',
|
||||
};
|
||||
});
|
||||
|
||||
protected emit(v: string | number | null): void {
|
||||
const s = v === null ? '' : String(v);
|
||||
this.value.set(this.def()?.type === 'N' && s.trim() !== '' && Number.isFinite(Number(s)) ? Number(s) : s);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user