Compare commits
10
Commits
0f43e6cac7
...
31f55c42a3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31f55c42a3 | ||
|
|
aa520d574f | ||
|
|
a659276d9a | ||
|
|
d05b497cb2 | ||
|
|
8af3b11ac2 | ||
|
|
5253ef4cb7 | ||
|
|
0fdf10d7b2 | ||
|
|
9e98160d01 | ||
|
|
f985667b90 | ||
|
|
8ed44aa55b |
No files matched your search
@@ -48,7 +48,7 @@ Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de
|
||||
- **Proxy `/api/sl/:recurso`**: el frontend arma `$select/$filter/...`; el backend valida y ejecuta solo recursos registrados (`apps/api/src/sl/sl-resources.ts`), con permiso de menú y cabeceras `B1S-*` de lista blanca. Nunca registrar `EmployeesInfo` (tiene `U_WEB_PASS`). `InventoryRequests` no existe: es `InventoryTransferRequests`.
|
||||
- **Permisos de menú** en SQLite (`SQLITE_PATH`), patrones `*`, `/ventas/*`, `/ventas/ordenes`; `ADMIN_WEB_USERS` garantiza el acceso total; no se puede quitar el último admin.
|
||||
- **Listas de documentos:** más recientes primero (`DocEntry desc`), 50 por página, **Estado = Abierto por defecto** (como el mini-erp; cambiarlo es un `defaultValue`), filtro de fecha **siempre rango [desde – hasta]**, multi-selección con **chips**, búsqueda numérica por igualdad. **Sin filtro base en las demás listas; las de `Invoices` filtran por subtipo exacto:** cuatro pantallas comparten el recurso y se separan por `DocumentSubType` (enum `BoDocumentSubType`: `bod_None` 0, `bod_InvoiceExempt` 1, `bod_DebitMemo` 2, `bod_Bill` 3, `bod_ExemptBill` 4, `bod_PurchaseDebitMemo` 5, `bod_ExportInvoice` 6…) y por `ReserveInvoice` (`tYES` = de reserva): Facturas (`tNO` y `eq bod_None`), Boletas (`tNO` y `eq bod_Bill`), Facturas de reserva (`tYES` y `eq bod_None`) y Boletas de reserva (`tYES` y `eq bod_Bill`); `baseFilter` para la lista y `fixed` (subtipo, y `ReserveInvoice` en las reservas) al crear. Nunca `ne`: los demás subtipos (factura exenta, nota de débito, boleta exenta, exportación) no se mezclan y tendrán su propia pantalla si se necesitan. `baseFilter` de la pantalla se aplica también a la navegación primero/anterior/siguiente/último. El PDF y los reportes se migran después vía API Gateway (otro servicio, con su propia cola y sesión).
|
||||
- **Reportes Gateway** (Crystal Reports por el API Gateway de SAP B1, `apps/api/src/gateway/` y `apps/web/.../features/reports/`): menú «Reportes Gateway» con «Reportes» (lista agrupada por categoría y formulario de parámetros con el PDF en pantalla) y «Matrícula de reportes» (solo administradores: de los reportes que el gateway autoriza se eligen los que se usan, con título, categoría y cómo se pide cada parámetro). Servicio aparte del Service Layer: su sesión y su cola propias (`APIGATEWAY_*` en el `.env`; los datos salieron del `.env` del mini-erp y el certificado autofirmado va en `certs/apigateway.pem`). La matrícula vive en SQLite (`gateway_reports`) y arma el cuerpo de `ExportPDFData` (`[{name, type, value:[[…]]}]`: un valor `[[v]]`, varios `[[a],[b]]`, rango `[[desde,hasta]]`); los parámetros se piden con los mismos controles y ventanas de selección de los formularios (texto, número, fecha, lista fija, ventana de selección con un catálogo de listas: socios, artículos, almacenes, documentos por `DocEntry`…). Verificado en la base HANA: los reportes a medida (`DLN20015`, `RCRI0031`) generan PDF con valores discretos y fechas `yyyy-MM-dd`; los estándar de SAP (`RCRI0001`, `RCRI0002`, con títulos y rangos) devolvieron `(---)` con los formatos probados, el formato de rango/varios valores sigue sin confirmar. Los títulos y separadores de un reporte (`…@Title`, `…@separator`) se marcan ocultos. **Botón PDF de los documentos** (`reports/document-pdf.ts`, en la barra del formulario): cada reporte matriculado puede asociarse a pantallas de documento (`documents`, rutas de menú) con el parámetro que recibe el `DocEntry` (`documentParam`; los demás parámetros toman su valor por defecto) y marcarse «por defecto» (`defaultFor`, uno por pantalla, como en SAP: marcarlo quita la marca al anterior). «PDF» imprime el formato por defecto (o el primero) en una ventana con descarga; si hay más, el menú «Otros formatos» los lista. Sin formatos, sin permiso o sin API Gateway el botón no aparece. API: `GET /api/gateway/documents?screen=` y `POST /api/gateway/documents/export {screen, code, docEntry}`; basta el permiso de menú del documento (no el de «Reportes»). Aún no hay botón en pagos, asientos ni maestros.
|
||||
- **Reportes Gateway** (Crystal Reports por el API Gateway de SAP B1, `apps/api/src/gateway/` y `apps/web/.../features/reports/`): menú «Reportes Gateway» con «Reportes» (lista agrupada por categoría y formulario de parámetros con el PDF en pantalla) y «Matrícula de reportes» (solo administradores: de los reportes que el gateway autoriza se eligen los que se usan, con título, categoría y cómo se pide cada parámetro). Servicio aparte del Service Layer: su sesión y su cola propias (`APIGATEWAY_*` en el `.env`; los datos salieron del `.env` del mini-erp y el certificado autofirmado va en `certs/apigateway.pem`). La matrícula vive en SQLite (`gateway_reports`) y arma el cuerpo de `ExportPDFData` (`[{name, type, value:[[…]]}]`: un valor `[[v]]`, varios `[[a],[b]]`, rango `[[desde,hasta]]`); los parámetros se piden con los mismos controles y ventanas de selección de los formularios (texto, número, fecha, lista fija, ventana de selección con un catálogo de listas: socios, artículos, almacenes, documentos por `DocEntry`…). Verificado en la base HANA: los reportes a medida (`DLN20015`, `RCRI0031`) generan PDF con valores discretos y fechas `yyyy-MM-dd`; los estándar de SAP (`RCRI0001`, `RCRI0002`, con títulos y rangos) devolvieron `(---)` con los formatos probados, el formato de rango/varios valores sigue sin confirmar. Los títulos y separadores de un reporte (`…@Title`, `…@separator`) se marcan ocultos. **Botón PDF de los documentos** (`reports/document-pdf.ts`, en la barra del formulario): cada reporte matriculado puede asociarse a pantallas de documento (`documents`, rutas de menú) con el parámetro que recibe el `DocEntry` (`documentParam`; los demás parámetros toman su valor por defecto) y marcarse «por defecto» (`defaultFor`, uno por pantalla, como en SAP: marcarlo quita la marca al anterior). «PDF» imprime el formato por defecto (o el primero) en una ventana con descarga; si hay más, el menú «Otros formatos» los lista. Sin formatos, sin permiso o sin API Gateway el botón no aparece. API: `GET /api/gateway/documents?screen=` y `POST /api/gateway/documents/export {screen, code, docEntry}`; basta el permiso de menú del documento (no el de «Reportes»). Aún no hay botón en pagos, asientos ni maestros. Un reporte con el parámetro `Dockey@` es un **layout de documento** de SAP: la matrícula lo propone como `documentParam`. En `SBO_TAGUMEDICA_21` el Gateway autoriza 179 reportes `RCRI…` (ninguno `DLN…`; `DLN20015` era de otra base): ahí candidatos como `RCRI0083` (GuiaRemision PT) hay que revisarlos por sus parámetros.
|
||||
|
||||
## Flujo de las pantallas de documento
|
||||
|
||||
@@ -91,3 +91,7 @@ Pendiente, en orden:
|
||||
2. Cada cliente tiene su branch (`cliente/<nombre-cliente>`); ahí viven sus personalizaciones y fixes.
|
||||
3. Los cambios de una branch de cliente no se mezclan con `main` ni con otro cliente, salvo backport genérico decidido explícitamente.
|
||||
4. Antes de crear branches, mergear a `main`, o hacer merge/backport entre clientes, confirmar con el usuario. No hacer `git push` sin confirmación. Cada paso termina en un commit propio.
|
||||
5. **Una mejora genérica descubierta en la rama de un cliente** va a `main`, no se queda en el cliente. Mantener el commit del cliente y el genérico **separados** (un solo propósito por commit). Flujo: (a) commit de lo del cliente; (b) `git checkout main` y desarrollar ahí la mejora en su propio commit (o, si ya está hecha en la rama del cliente como commit propio, `git cherry-pick <hash>` desde `main`); (c) `git checkout cliente/<nombre>` y `git merge main`. Desarrollarla dos veces (en el cliente y en `main`) provoca conflictos o código repetido. Como siempre, confirmar con el usuario antes de mergear a `main` o de traer cambios a otra rama.
|
||||
6. **Traer lo nuevo de `main` a la rama de un cliente:** `git merge main` no descarga nada de internet: une la `main` que ya está en el equipo. Si hay remoto y otras personas suben a `main`, primero actualizarla: `git checkout main && git pull`, luego `git checkout cliente/<nombre> && git merge main` (o, sin cambiar de rama: `git fetch && git merge origin/main`). El merge añade los commits que faltan y no toca `main`; los conflictos solo aparecen si ambas ramas editaron las mismas líneas. Tras el merge, la rama del cliente solo tiene lo nuevo en el equipo hasta hacer `git push` desde ella (con confirmación del usuario).
|
||||
7. **Para que los merges salgan limpios:** lo específico del cliente vive en sus archivos de extensiones (`*.extensions.ts`, vacíos en `main`), su `.env` y su matrícula de reportes (SQLite), nunca en las pantallas base. Un cambio que obligue a editar una pantalla base es genérico y va a `main`. El `.env`, `apps/api/certs/*.pem` y `apps/api/data/` están ignorados por git y no se suben; cada cliente tiene los suyos.
|
||||
8. **Estado al preparar las ramas (2026-10-19):** `main` limpio (120 commits), compila y pasa todo (libs 19, API 115, web 682), sin secretos versionados; **aún no hay remoto configurado** (hace falta la URL para `git remote add origin …` y el primer push) ni ramas de cliente creadas.
|
||||
@@ -26,10 +26,11 @@ export const ARTICULOS_SCREENS: readonly MasterScreen[] = [
|
||||
createLabel: 'Nuevo artículo',
|
||||
orderby: 'ItemCode',
|
||||
extraFields: ['Frozen'],
|
||||
listNames: [{ key: 'ItemsGroupCode', resource: 'ItemGroups', valueField: 'Number', labelField: 'GroupName' }],
|
||||
columns: [
|
||||
{ key: 'ItemCode', title: 'Nº de artículo', width: '170px', mono: true, searchable: true },
|
||||
{ key: 'ItemName', title: 'Descripción', searchable: true },
|
||||
{ key: 'ItemsGroupCode', title: 'Grupo', type: 'number', align: 'left', width: '90px', format: (r) => String(r['ItemsGroupCode'] ?? '') },
|
||||
{ key: 'ItemsGroupCode', title: 'Grupo', type: 'number', align: 'left', width: '200px', format: (r) => String(r['ItemsGroupCode'] ?? '') },
|
||||
{ key: 'QuantityOnStock', title: 'En stock', type: 'number', width: '110px' },
|
||||
{
|
||||
key: 'Frozen',
|
||||
@@ -129,11 +130,12 @@ export const ARTICULOS_SCREENS: readonly MasterScreen[] = [
|
||||
rowKey: 'PriceList',
|
||||
identity: ['PriceList'],
|
||||
columns: [
|
||||
{ key: 'PriceList', title: 'Lista de precios', type: 'text', readonly: true, width: '130px' },
|
||||
{ key: 'PriceList', title: 'Lista de precios', type: 'text', readonly: true, width: '90px' },
|
||||
{ key: 'PriceListName', title: 'Nombre', type: 'text', width: '260px', nameOf: { codeKey: 'PriceList', resource: 'PriceLists', valueField: 'PriceListNo', labelField: 'PriceListName' } },
|
||||
{ key: 'Price', title: 'Precio', type: 'number', decimals: 2, width: '130px' },
|
||||
{ key: 'Currency', title: 'Moneda', type: 'text', width: '90px' },
|
||||
{ key: 'Factor', title: 'Factor', type: 'number', readonly: true, width: '90px' },
|
||||
{ key: 'BasePriceList', title: 'Lista base', type: 'text', readonly: true, width: '100px' },
|
||||
{ key: 'BasePriceList', title: 'Lista base', type: 'text', readonly: true, width: '240px', nameOf: { codeKey: 'BasePriceList', resource: 'PriceLists', valueField: 'PriceListNo', labelField: 'PriceListName', withCode: true } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -146,12 +148,15 @@ export const ARTICULOS_SCREENS: readonly MasterScreen[] = [
|
||||
key: 'ItemWarehouseInfoCollection',
|
||||
mode: 'replace',
|
||||
readonly: true,
|
||||
hideEmptyBy: ['InStock', 'Committed', 'Ordered'],
|
||||
hideEmptyLabel: 'Solo almacenes con stock, comprometido o pedido',
|
||||
identity: ['WarehouseCode'],
|
||||
columns: [
|
||||
{ key: 'WarehouseCode', title: 'Almacén', type: 'text', width: '110px' },
|
||||
{ key: 'InStock', title: 'En stock', type: 'number', width: '110px' },
|
||||
{ key: 'Committed', title: 'Comprometido', type: 'number', width: '110px' },
|
||||
{ key: 'Ordered', title: 'Pedido', type: 'number', width: '110px' },
|
||||
{ key: 'WarehouseName', title: 'Nombre', type: 'text', width: '260px', nameOf: { codeKey: 'WarehouseCode', resource: 'Warehouses', valueField: 'WarehouseCode', labelField: 'WarehouseName' } },
|
||||
{ key: 'InStock', title: 'En stock', type: 'number', width: '110px', total: true },
|
||||
{ key: 'Committed', title: 'Comprometido', type: 'number', width: '110px', total: true },
|
||||
{ key: 'Ordered', title: 'Pedido', type: 'number', width: '110px', total: true },
|
||||
{ key: 'MinimalStock', title: 'Mínimo', type: 'number', width: '100px' },
|
||||
{ key: 'MaximalStock', title: 'Máximo', type: 'number', width: '100px' },
|
||||
],
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('formulario con tablas', () => {
|
||||
|
||||
it('precios: solo se envía la lista que cambió, sin reemplazar la colección', async () => {
|
||||
await open('/inventario/articulos', 'A1', item());
|
||||
const price = cell(4, 1, 1);
|
||||
const price = cell(4, 1, 2);
|
||||
expect(price.value).toBe('50.00');
|
||||
price.value = '777';
|
||||
price.dispatchEvent(new Event('change'));
|
||||
@@ -252,7 +252,7 @@ describe('formulario con tablas', () => {
|
||||
await open('/inventario/articulos', 'A1', item());
|
||||
const tab = el().querySelectorAll('sbo-tab')[5];
|
||||
const inputs = [...tab.querySelectorAll<HTMLInputElement>('tbody input')];
|
||||
expect(inputs.map((i) => i.value)).toEqual(['01', '990', '2', '1', '0', '0']);
|
||||
expect(inputs.map((i) => i.value)).toEqual(['01', '', '990', '2', '1', '0', '0']); // la segunda es el nombre del almacén (llega del catálogo)
|
||||
expect(inputs.every((i) => i.readOnly)).toBe(true);
|
||||
expect(tab.querySelector('.addrow')).toBeNull();
|
||||
expect(page().dirty()).toBe(false);
|
||||
@@ -261,7 +261,7 @@ describe('formulario con tablas', () => {
|
||||
it('un artículo con cambios en un campo y en los precios envía las dos cosas juntas', async () => {
|
||||
await open('/inventario/articulos', 'A1', item());
|
||||
page().setValue('ItemName', 'Pieza nueva');
|
||||
const price = cell(4, 0, 1);
|
||||
const price = cell(4, 0, 2);
|
||||
price.value = '120';
|
||||
price.dispatchEvent(new Event('change'));
|
||||
await tick();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
import { CheckboxField, DateField, LookupField, NumberField, SelectField, TextField, TextareaField } 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';
|
||||
|
||||
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
|
||||
@@ -93,7 +94,7 @@ export class MasterField {
|
||||
readonly saved = input(false);
|
||||
readonly changed = output<string | number>();
|
||||
|
||||
protected readonly warehouse = WAREHOUSE_LOOKUP;
|
||||
protected readonly warehouse: VhLookup = { ...WAREHOUSE_LOOKUP, nameKey: 'WarehouseName' };
|
||||
|
||||
/** Un código numérico de SAP (grupo, lista de precios) se guarda como número. */
|
||||
protected typed(code: string): string | number {
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
[rows]="rowsOf(c)"
|
||||
(rowsChange)="setRows(c, $event)"
|
||||
[readonly]="!!c.readonly"
|
||||
[hideEmptyBy]="c.readonly ? (c.hideEmptyBy ?? null) : null"
|
||||
[hideEmptyLabel]="c.hideEmptyLabel ?? 'Ocultar las filas en 0'"
|
||||
[removable]="growable(c)"
|
||||
[emptyText]="c.readonly ? 'Sin datos.' : 'Sin filas.'"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { API_URL } from '../../core/api';
|
||||
import { ListView } from '../../shared/list/list-view';
|
||||
import type { ListRow } from '../../shared/list/list.types';
|
||||
import type { ListColumn, ListRow } from '../../shared/list/list.types';
|
||||
import type { MasterScreen } from './master-types';
|
||||
|
||||
/** Lista de un maestro (clientes, proveedores, artículos); abre uno o crea uno nuevo. */
|
||||
@@ -13,7 +15,7 @@ import type { MasterScreen } from './master-types';
|
||||
<sbo-list-view
|
||||
[title]="title"
|
||||
[resource]="screen.resource"
|
||||
[columns]="screen.columns"
|
||||
[columns]="columns"
|
||||
[extraFields]="screen.extraFields ?? []"
|
||||
[filters]="screen.filters"
|
||||
[orderby]="screen.orderby"
|
||||
@@ -32,6 +34,32 @@ export class MasterListPage {
|
||||
|
||||
protected readonly screen = this.route.snapshot.data['screen'] as MasterScreen;
|
||||
protected readonly title = this.route.snapshot.data['title'] as string;
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly names = signal<Record<string, Map<string, string>>>({});
|
||||
|
||||
/** Las columnas de la pantalla; las de un catálogo muestran `código · nombre` apenas llega el catálogo (una sola consulta). */
|
||||
protected readonly columns: readonly ListColumn[] = this.screen.columns.map((c) => {
|
||||
if (!this.screen.listNames?.some((n) => n.key === c.key)) return c;
|
||||
return {
|
||||
...c,
|
||||
format: (r: ListRow) => {
|
||||
const code = r[c.key];
|
||||
if (code === null || code === undefined || code === '') return '';
|
||||
const name = this.names()[c.key]?.get(String(code));
|
||||
return name ? `${code} · ${name}` : String(code);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
constructor() {
|
||||
for (const n of this.screen.listNames ?? []) {
|
||||
const params = new HttpParams().set('$select', `${n.valueField},${n.labelField}`).set('$top', 500);
|
||||
this.http.get<{ value?: Record<string, unknown>[] }>(`${API_URL}/sl/${n.resource}`, { params }).subscribe({
|
||||
next: (res) => this.names.update((m) => ({ ...m, [n.key]: new Map((res.value ?? []).map((x) => [String(x[n.valueField]), String(x[n.labelField] ?? '')])) })),
|
||||
error: () => undefined, // sin nombres se ve el código
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected open(row: ListRow): void {
|
||||
void this.router.navigate([String(row[this.screen.keyField])], { relativeTo: this.route });
|
||||
|
||||
@@ -13,18 +13,21 @@ export const MASTER_LOOKUPS = {
|
||||
orderby: 'Code',
|
||||
baseFilter: `Type eq '${type === 'cSupplier' ? 'bbpgt_VendorGroup' : 'bbpgt_CustomerGroup'}'`,
|
||||
valueType: 'number',
|
||||
nameKey: 'Name',
|
||||
columns: [col('Code', 'Código', { width: '90px', mono: true }), col('Name', 'Nombre')],
|
||||
}),
|
||||
currency: {
|
||||
title: 'Monedas',
|
||||
resource: 'Currencies',
|
||||
orderby: 'Code',
|
||||
nameKey: 'Name',
|
||||
columns: [col('Code', 'Código', { width: '90px', mono: true }), col('Name', 'Nombre')],
|
||||
} satisfies VhLookup,
|
||||
paymentTerms: {
|
||||
title: 'Condiciones de pago',
|
||||
resource: 'PaymentTermsTypes',
|
||||
orderby: 'GroupNumber',
|
||||
nameKey: 'PaymentTermsGroupName',
|
||||
valueType: 'number',
|
||||
columns: [col('GroupNumber', 'Código', { width: '90px', mono: true }), col('PaymentTermsGroupName', 'Nombre')],
|
||||
} satisfies VhLookup,
|
||||
@@ -32,6 +35,7 @@ export const MASTER_LOOKUPS = {
|
||||
title: 'Listas de precios',
|
||||
resource: 'PriceLists',
|
||||
orderby: 'PriceListNo',
|
||||
nameKey: 'PriceListName',
|
||||
valueType: 'number',
|
||||
columns: [col('PriceListNo', 'Código', { width: '90px', mono: true }), col('PriceListName', 'Nombre')],
|
||||
} satisfies VhLookup,
|
||||
@@ -39,6 +43,7 @@ export const MASTER_LOOKUPS = {
|
||||
title: 'Empleados de ventas',
|
||||
resource: 'SalesPersons',
|
||||
orderby: 'SalesEmployeeCode',
|
||||
nameKey: 'SalesEmployeeName',
|
||||
valueType: 'number',
|
||||
baseFilter: `Active eq 'tYES'`,
|
||||
columns: [col('SalesEmployeeCode', 'Código', { width: '90px', mono: true }), col('SalesEmployeeName', 'Nombre')],
|
||||
@@ -51,12 +56,15 @@ export const MASTER_LOOKUPS = {
|
||||
resource: 'BusinessPartners',
|
||||
orderby: 'CardCode',
|
||||
baseFilter: `CardType eq 'cSupplier'`,
|
||||
nameKey: 'CardName',
|
||||
codeWidth: '150px',
|
||||
columns: [col('CardCode', 'Código', { width: '130px', mono: true }), col('CardName', 'Nombre')],
|
||||
} satisfies VhLookup,
|
||||
itemGroup: {
|
||||
title: 'Grupos de artículos',
|
||||
resource: 'ItemGroups',
|
||||
orderby: 'Number',
|
||||
nameKey: 'GroupName',
|
||||
valueType: 'number',
|
||||
columns: [col('Number', 'Código', { width: '90px', mono: true }), col('GroupName', 'Nombre')],
|
||||
} satisfies VhLookup,
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface MasterCollection {
|
||||
mode: 'replace' | 'merge';
|
||||
/** Campo que identifica una fila en `merge` (`PriceList`). */
|
||||
rowKey?: string;
|
||||
/** Columnas numéricas por las que se ocultan las filas en 0, con una casilla marcada por defecto (solo con `readonly`). */
|
||||
hideEmptyBy?: readonly string[];
|
||||
hideEmptyLabel?: string;
|
||||
/** Solo se muestra (el stock por almacén). */
|
||||
readonly?: boolean;
|
||||
/** Se pueden añadir y quitar filas (por defecto sí; no en `merge` ni en solo lectura). */
|
||||
@@ -71,6 +74,8 @@ export interface MasterScreen {
|
||||
createLabel: string;
|
||||
columns: readonly ListColumn[];
|
||||
extraFields?: readonly string[];
|
||||
/** Columnas de la lista cuyo valor es un código de otro catálogo: se muestra `código · nombre` (`GroupCode` → `BusinessPartnerGroups`). */
|
||||
listNames?: readonly { key: string; resource: string; valueField: string; labelField: string }[];
|
||||
filters: readonly ListFilter[];
|
||||
orderby: string;
|
||||
/** Filtro fijo de la lista y de la navegación (ej. solo los clientes). */
|
||||
|
||||
@@ -40,10 +40,12 @@ const partner = (opts: { path: string; type: 'cCustomer' | 'cSupplier'; singular
|
||||
orderby: 'CardCode',
|
||||
baseFilter: `CardType eq '${opts.type}'`,
|
||||
extraFields: ['Frozen'],
|
||||
listNames: [{ key: 'GroupCode', resource: 'BusinessPartnerGroups', valueField: 'Code', labelField: 'Name' }],
|
||||
columns: [
|
||||
{ key: 'CardCode', title: 'Código', width: '130px', mono: true, searchable: true },
|
||||
{ key: 'CardName', title: 'Nombre', searchable: true },
|
||||
{ key: 'FederalTaxID', title: 'RUT', width: '120px', mono: true, searchable: true },
|
||||
{ key: 'GroupCode', title: 'Grupo', type: 'number', align: 'left', width: '170px' },
|
||||
{ key: 'Phone1', title: 'Teléfono', width: '130px', searchable: true },
|
||||
{ key: 'EmailAddress', title: 'Correo electrónico', searchable: true },
|
||||
statusColumn,
|
||||
|
||||
@@ -38,6 +38,9 @@ const KINDS: { value: GatewayParamKind; label: string }[] = [
|
||||
];
|
||||
const yesNo = [{ value: YES, label: YES }, { value: NO, label: NO }];
|
||||
|
||||
/** El parámetro `Dockey@` es el de los layouts de documento de SAP: recibe el número del documento. */
|
||||
export const dockeyParam = (params: readonly { name: string }[]): string => params.find((p) => /^dockey@/i.test(p.name))?.name ?? '';
|
||||
|
||||
/** Una definición de parámetro como fila editable de la tabla. */
|
||||
const toRow = (p: GatewayParam): Row => ({
|
||||
name: p.name,
|
||||
@@ -106,7 +109,7 @@ export function toParam(r: Row): GatewayParam {
|
||||
<p class="hint">«Tipo de control» decide cómo se pide al usuario; con «Ventana de selección» se elige la lista. «Oculto» es para títulos y separadores del reporte: no se piden ni se envían. «Opciones» de una lista fija: <code>valor=etiqueta; valor=etiqueta</code>.</p>
|
||||
<sbo-rows-table [columns]="columns" [rows]="d.params" (rowsChange)="patch({ params: $event })" [removable]="false" emptyText="El reporte no tiene parámetros." />
|
||||
<h2>Botón PDF de los documentos</h2>
|
||||
<p class="hint">Marque los documentos que ofrecen este formato. El que se marca «Por defecto» es el que abre el botón «PDF» de ese documento (uno solo por documento, como en SAP); los demás salen en «Otros formatos». El número del documento (<code>DocEntry</code>) se envía en el parámetro elegido abajo; los demás parámetros toman su valor por defecto.</p>
|
||||
<p class="hint">Un reporte con el parámetro <code>Dockey@</code> es un layout de documento de SAP y se propone solo aquí. Marque los documentos que ofrecen este formato. El que se marca «Por defecto» es el que abre el botón «PDF» de ese documento (uno solo por documento, como en SAP); los demás salen en «Otros formatos». El número del documento (<code>DocEntry</code>) se envía en el parámetro elegido abajo; los demás parámetros toman su valor por defecto.</p>
|
||||
<sbo-select-field label="Parámetro del documento" [options]="documentParamOptions(d)" [value]="d.documentParam" (valueChange)="patch({ documentParam: '' + ($event ?? '') })" />
|
||||
<table class="grid docs">
|
||||
<thead><tr><th>Documento</th><th class="chk">Ofrecer</th><th class="chk">Por defecto</th></tr></thead>
|
||||
@@ -126,7 +129,7 @@ export function toParam(r: Row): GatewayParam {
|
||||
</div>
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr><th>Código</th><th>Título</th><th>Categoría</th><th class="num">Parámetros</th><th>Estado</th><th></th></tr>
|
||||
<tr><th>Código</th><th>Título</th><th>Categoría</th><th class="num">Parámetros</th><th>Documentos</th><th>Estado</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of rows(); track r.code) {
|
||||
@@ -135,6 +138,7 @@ export function toParam(r: Row): GatewayParam {
|
||||
<td>{{ r.title }}</td>
|
||||
<td>{{ r.category }}</td>
|
||||
<td class="num">{{ r.params.length }}</td>
|
||||
<td>{{ r.documents?.length ? r.documents!.length + ' documento(s)' : '' }}</td>
|
||||
<td>{{ r.enabled ? 'Habilitado' : 'Deshabilitado' }}</td>
|
||||
<td class="acts">
|
||||
<button type="button" class="btn" (click)="edit(r)">Editar</button>
|
||||
@@ -147,7 +151,7 @@ export function toParam(r: Row): GatewayParam {
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr class="none"><td colspan="6">{{ registry.isLoading() ? 'Cargando…' : 'Aún no hay reportes matriculados. Pulse «Matricular reporte».' }}</td></tr>
|
||||
<tr class="none"><td colspan="7">{{ registry.isLoading() ? 'Cargando…' : 'Aún no hay reportes matriculados. Pulse «Matricular reporte».' }}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -271,7 +275,7 @@ export class RegistryPage {
|
||||
this.message.set({ kind: 'info', text: `Cargando los parámetros de ${chosen.code}…` });
|
||||
try {
|
||||
const params = await this.api.parameters(chosen.code);
|
||||
this.draft.set({ code: chosen.code, isNew: true, title: chosen.name, category: 'Otros', description: '', enabled: true, params: params.map(toRow), documents: [], documentParam: '', defaultFor: [] });
|
||||
this.draft.set({ code: chosen.code, isNew: true, title: chosen.name, category: 'Otros', description: '', enabled: true, params: params.map(toRow), documents: [], documentParam: dockeyParam(params), defaultFor: [] });
|
||||
this.message.set(null);
|
||||
} catch (e) {
|
||||
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||
@@ -281,7 +285,7 @@ export class RegistryPage {
|
||||
// --- editar y guardar ----------------------------------------------------------------------------
|
||||
|
||||
protected edit(r: GatewayReport): void {
|
||||
this.draft.set({ code: r.code, isNew: false, title: r.title, category: r.category, description: r.description ?? '', enabled: r.enabled, params: r.params.map(toRow), documents: r.documents ?? [], documentParam: r.documentParam ?? '', defaultFor: r.defaultFor ?? [] });
|
||||
this.draft.set({ code: r.code, isNew: false, title: r.title, category: r.category, description: r.description ?? '', enabled: r.enabled, params: r.params.map(toRow), documents: r.documents ?? [], documentParam: r.documentParam ?? dockeyParam(r.params), defaultFor: r.defaultFor ?? [] });
|
||||
this.message.set(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -201,3 +201,11 @@ describe('toParam', () => {
|
||||
expect(toParam({ name: 'Q', kind: 'lookup', lookup: 'articulos' })).not.toHaveProperty('options');
|
||||
});
|
||||
});
|
||||
|
||||
describe('layouts de documento', () => {
|
||||
it('el parámetro Dockey@ se propone como el que recibe el número del documento', async () => {
|
||||
const { dockeyParam } = await import('./registry.page');
|
||||
expect(dockeyParam([{ name: 'Title@Title' }, { name: 'Dockey@' }])).toBe('Dockey@');
|
||||
expect(dockeyParam([{ name: 'Item@Select * from OITM' }])).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,9 @@ import { TextInput } from './controls';
|
||||
import { FieldBase } from './field-base';
|
||||
import { SboField } from './sbo-field';
|
||||
|
||||
/** Nombres ya consultados de los códigos de las listas con `nameKey`. */
|
||||
const NAMES = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Campo con botón "…" que abre una lista de valores (flecha dorada de SAP).
|
||||
* Escribir el código a mano también es válido; elegir de la lista lo rellena y emite `picked`.
|
||||
@@ -38,6 +41,7 @@ import { SboField } from './sbo-field';
|
||||
>
|
||||
<sbo-input
|
||||
#box
|
||||
[width]="lookup().nameKey ? (lookup().codeWidth ?? '96px') : undefined"
|
||||
[value]="shown()"
|
||||
(valueChange)="typing($event)"
|
||||
[readonly]="readOnly()"
|
||||
@@ -56,6 +60,9 @@ import { SboField } from './sbo-field';
|
||||
>
|
||||
…
|
||||
</button>
|
||||
@if (lookup().nameKey) {
|
||||
<sbo-input readonly [attr.title]="name()" [ariaLabel]="'Nombre de ' + label()" [value]="name()" />
|
||||
}
|
||||
</sbo-field>
|
||||
<sbo-value-help
|
||||
[open]="listOpen()"
|
||||
@@ -70,7 +77,9 @@ import { SboField } from './sbo-field';
|
||||
(closed)="closeList()"
|
||||
/>
|
||||
`,
|
||||
styles: ':host { display: block; }',
|
||||
styles: `
|
||||
:host { display: block; }
|
||||
`,
|
||||
})
|
||||
export class LookupField extends FieldBase {
|
||||
readonly value = model('');
|
||||
@@ -96,6 +105,10 @@ export class LookupField extends FieldBase {
|
||||
private resolved = '';
|
||||
private readonly box = viewChild.required<TextInput>('box');
|
||||
|
||||
/** Nombre de lo elegido, junto al código (`lookup.nameKey`). */
|
||||
protected readonly name = signal('');
|
||||
private nameFor = '';
|
||||
|
||||
/** 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);
|
||||
/**
|
||||
@@ -117,10 +130,30 @@ export class LookupField extends FieldBase {
|
||||
if (!this.editing()) {
|
||||
if (codes && v !== '') codes.ensure([v]);
|
||||
this.resolved = v.trim();
|
||||
void this.loadName(v.trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** El nombre del código guardado; se pregunta una vez por código (el resultado queda en `NAMES`). */
|
||||
private async loadName(code: string): Promise<void> {
|
||||
const lookup = this.lookup();
|
||||
const nameKey = lookup.nameKey;
|
||||
this.nameFor = code;
|
||||
if (!nameKey || !code) return void this.name.set('');
|
||||
const cacheKey = `${lookup.resource}|${lookup.baseFilter ?? ''}|${code}`;
|
||||
const cached = NAMES.get(cacheKey);
|
||||
if (cached !== undefined) return void this.name.set(cached);
|
||||
try {
|
||||
const row = await this.injector.get(SlLookupApi).find(lookup, code);
|
||||
const text = row ? String(row[nameKey] ?? '') : '';
|
||||
NAMES.set(cacheKey, text);
|
||||
if (this.nameFor === code) this.name.set(text);
|
||||
} catch {
|
||||
if (this.nameFor === code) this.name.set('');
|
||||
}
|
||||
}
|
||||
|
||||
protected typing(text: string): void {
|
||||
this.editing.set(true);
|
||||
this.value.set(text);
|
||||
@@ -145,6 +178,11 @@ export class LookupField extends FieldBase {
|
||||
const key = this.lookup().valueKey ?? this.lookup().columns[0]?.key ?? '';
|
||||
this.missing.set(null);
|
||||
this.lookup().codes?.remember(row);
|
||||
const nameKey = this.lookup().nameKey;
|
||||
if (nameKey) {
|
||||
this.nameFor = String(row[this.lookup().valueKey ?? this.lookup().columns[0]?.key ?? ''] ?? '');
|
||||
this.name.set(String(row[nameKey] ?? ''));
|
||||
}
|
||||
this.editing.set(false);
|
||||
this.value.set(String(row[key] ?? ''));
|
||||
this.resolved = this.value();
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface DetailColumn {
|
||||
lookup?: VhLookup;
|
||||
/** Decimales fijos de una columna numérica; sin él, hasta 3. */
|
||||
decimals?: number;
|
||||
/** Se suma la columna en una fila «Total» al pie de la tabla (stock por almacén). */
|
||||
total?: boolean;
|
||||
/**
|
||||
* Una celda que puede tomar su valor de varias listas (la «Cuenta / socio» de un asiento: una cuenta, un cliente o
|
||||
* un proveedor). El botón «…» pregunta cuál abrir; un código escrito se busca en ellas en orden.
|
||||
@@ -70,6 +72,11 @@ export interface DetailColumn {
|
||||
lookupFor?: (row: Record<string, unknown>) => VhLookup | undefined;
|
||||
/** La celda no se edita en esta fila. */
|
||||
readonlyFor?: (row: Record<string, unknown>) => boolean;
|
||||
/**
|
||||
* La columna muestra el nombre de lo que identifica el código de otra columna (`WarehouseCode` → nombre del almacén);
|
||||
* el catálogo se consulta una sola vez. No es un campo que se guarde.
|
||||
*/
|
||||
nameOf?: { codeKey: string; resource: string; valueField: string; labelField: string; /** Se ve `código · nombre` en vez de solo el nombre. */ withCode?: boolean };
|
||||
/** Texto calculado de la fila (solo se muestra; no es un campo que se guarde). */
|
||||
computed?: (row: Record<string, unknown>) => string;
|
||||
/** Campos de la fila que cambian además del propio al elegir o escribir un valor de la lista (la cuenta elegida primero completa el «Cuenta / socio»). */
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, model, signal } from '@angular/core';
|
||||
import { API_URL } from '../../core/api';
|
||||
import { formatNumber, parseDecimal } from '../../core/format';
|
||||
import { SlLookupApi } from '../../core/sl/sl-lookup';
|
||||
import type { ListRow } from '../list/list.types';
|
||||
@@ -20,6 +22,9 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ValueHelp],
|
||||
template: `
|
||||
@if (hideEmptyBy()) {
|
||||
<label class="only"><input type="checkbox" [checked]="onlyFilled()" (change)="onlyFilled.set($any($event.target).checked)" /> {{ hideEmptyLabel() }}</label>
|
||||
}
|
||||
<div class="wrap">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -32,9 +37,20 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
|
||||
<th class="del"></th>
|
||||
}
|
||||
</tr>
|
||||
@if (hasTotals() && rows().length) {
|
||||
<tr>
|
||||
<td class="rn"></td>
|
||||
@for (c of columns(); track c.key; let first = $first) {
|
||||
<td class="tot" [class.num]="c.total">{{ c.total ? totalOf(c) : first ? 'Total' : '' }}</td>
|
||||
}
|
||||
@if (canRemove()) {
|
||||
<td class="del"></td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows(); track $index; let i = $index) {
|
||||
@for (row of shown(); track $index; let i = $index) {
|
||||
<tr>
|
||||
<td class="rn">{{ i + 1 }}</td>
|
||||
@for (c of columns(); track c.key) {
|
||||
@@ -86,7 +102,7 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr class="none">
|
||||
<td [attr.colspan]="columns().length + 2">{{ emptyText() }}</td>
|
||||
<td [attr.colspan]="columns().length + 2">{{ rows().length ? 'Ninguna fila cumple el filtro.' : emptyText() }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -117,6 +133,8 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
|
||||
`,
|
||||
styles: `
|
||||
:host { display: block; }
|
||||
.only { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 6px; cursor: pointer; }
|
||||
.only input { width: auto; height: auto; margin: 0; }
|
||||
.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); }
|
||||
@@ -130,6 +148,8 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
|
||||
th.req::after { content: ' *'; color: var(--err); }
|
||||
td { padding: 0 2px; height: 27px; border: 1px solid var(--line-soft); white-space: nowrap; }
|
||||
td.ro { background: var(--field-ro); }
|
||||
td.tot { padding: 0 8px; font-weight: 600; background: var(--grid-head); }
|
||||
td.tot.num { text-align: right; }
|
||||
td.rn, th.rn { width: 30px; text-align: center; color: var(--quiet); }
|
||||
td.del, th.del { width: 30px; text-align: center; }
|
||||
input, select { width: 100%; height: 22px; padding: 1px 4px; background: transparent; border: 1px solid transparent; border-radius: var(--radius); }
|
||||
@@ -149,10 +169,37 @@ export class RowsTable {
|
||||
/** Se pueden quitar filas (por defecto, si la tabla es editable). */
|
||||
readonly removable = input(true);
|
||||
readonly emptyText = input('Sin filas.');
|
||||
/** Columnas numéricas por las que se puede ocultar las filas en 0 (solo lectura): aparece una casilla encima de la tabla, marcada. */
|
||||
readonly hideEmptyBy = input<readonly string[] | null>(null);
|
||||
readonly hideEmptyLabel = input('Ocultar las filas en 0');
|
||||
protected readonly onlyFilled = signal(true);
|
||||
/** Las filas que se ven; los totales siguen sumando todas. */
|
||||
protected readonly shown = computed(() => {
|
||||
const keys = this.hideEmptyBy();
|
||||
const rows = this.rows();
|
||||
if (!keys || !this.onlyFilled()) return rows;
|
||||
return rows.filter((r) => keys.some((k) => Number(r[k]) !== 0 && Number.isFinite(Number(r[k]))));
|
||||
});
|
||||
|
||||
private readonly lookupApi = inject(SlLookupApi);
|
||||
private readonly http = inject(HttpClient);
|
||||
/** Nombres de los catálogos de las columnas `nameOf`, por recurso. */
|
||||
private readonly names = signal<Record<string, Map<string, string>>>({});
|
||||
private readonly requested = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
for (const c of this.columns()) {
|
||||
const n = c.nameOf;
|
||||
if (!n || this.requested.has(n.resource)) continue;
|
||||
this.requested.add(n.resource);
|
||||
const params = new HttpParams().set('$select', `${n.valueField},${n.labelField}`).set('$top', 500);
|
||||
this.http.get<{ value?: Record<string, unknown>[] }>(`${API_URL}/sl/${n.resource}`, { params }).subscribe({
|
||||
next: (res) => this.names.update((m) => ({ ...m, [n.resource]: new Map((res.value ?? []).map((x) => [String(x[n.valueField]), String(x[n.labelField] ?? '')])) })),
|
||||
error: () => undefined, // sin nombres, la columna queda vacía
|
||||
});
|
||||
}
|
||||
});
|
||||
// las celdas de código con texto propio (la cuenta con su formato) piden los textos de las filas que se cargan
|
||||
effect(() => {
|
||||
const rows = this.rows();
|
||||
@@ -209,12 +256,29 @@ export class RowsTable {
|
||||
return !this.readonly() && this.removable();
|
||||
}
|
||||
|
||||
protected readonly hasTotals = computed(() => this.columns().some((c) => c.total));
|
||||
|
||||
/** La suma de una columna numérica en todas las filas. */
|
||||
protected totalOf(c: DetailColumn): string {
|
||||
let sum = 0;
|
||||
for (const r of this.rows()) {
|
||||
const v = Number(r[c.key]);
|
||||
if (Number.isFinite(v)) sum += v;
|
||||
}
|
||||
return formatNumber(sum, c.decimals);
|
||||
}
|
||||
|
||||
protected locked(c: DetailColumn, row?: Row): boolean {
|
||||
return this.readonly() || !!c.readonly || !!c.computed || (!!row && !!c.readonlyFor?.(row));
|
||||
return this.readonly() || !!c.readonly || !!c.computed || !!c.nameOf || (!!row && !!c.readonlyFor?.(row));
|
||||
}
|
||||
|
||||
protected text(row: Row, c: DetailColumn): string {
|
||||
if (c.computed) return c.computed(row);
|
||||
if (c.nameOf) {
|
||||
const code = String(row[c.nameOf.codeKey] ?? '');
|
||||
const name = this.names()[c.nameOf.resource]?.get(code);
|
||||
return c.nameOf.withCode ? (name ? `${code} · ${name}` : code) : (name ?? '');
|
||||
}
|
||||
const v = row[c.key];
|
||||
if (c.type === 'date') return dateOnly(v);
|
||||
if (c.type === 'number') return v === null || v === undefined || v === '' ? '' : formatNumber(v, c.decimals);
|
||||
|
||||
@@ -24,6 +24,10 @@ export interface VhLookup {
|
||||
rows?: readonly VhRow[];
|
||||
/** Otros campos por los que se reconoce un código escrito a mano (`FormatCode` de una cuenta: el usuario no conoce el `_SYS…` interno). */
|
||||
alsoMatch?: readonly string[];
|
||||
/** Columna con el nombre de lo elegido: el campo lo muestra junto al código (`Name` de un grupo, `SalesEmployeeName`). */
|
||||
nameKey?: string;
|
||||
/** Ancho del casillero del código cuando hay `nameKey` (por defecto 96 px; un código largo como el de un proveedor necesita más). */
|
||||
codeWidth?: string;
|
||||
/** Cómo se muestra el código guardado en una celda (la cuenta con su formato) y cómo se aprenden los de las filas elegidas. */
|
||||
codes?: CodeDisplay;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user