feat(web): maestros de clientes, proveedores y artículos (lista, formulario con pestañas, extensiones por cliente)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 10:24:39 -05:00
1 parent b759c7bce7
commit 9f003ee4bf
30 files changed
+1553 -23

No files matched your search

+1 -1
View File
@@ -61,7 +61,7 @@ Pendiente, en orden:
**Campos por cliente (estándar adicionales o `U_*`):** no se editan las pantallas base. Cada módulo tiene su archivo de extensiones, vacío en `main`: `ventas/ventas.extensions.ts`, `compras/compras.extensions.ts` e `inventario/inventario.extensions.ts` (reunidos en `client-extensions.ts`). La rama `cliente/<nombre>` declara ahí, por ruta de pantalla, qué suma: `headerLeft`/`headerRight`/`footerLeft`/`footerRight` (campos; tipos `text`, `date`, `number`, `textarea`, `select` con `options`, `warehouse`…), `lineColumns` (se agregan antes del total y se envían a SAP) y `listColumns`. Solo suma, no quita; una ruta inexistente o ajena al módulo lanza un error al arrancar. El test «el proyecto base no usa campos de usuario» revisa `BASE_SCREENS`, no las extensiones. Verificado: el proxy `/api/sl` deja pasar nombres `U_*` en `$select`/`$orderby` y en el cuerpo (el error de un campo inexistente lo da SAP). Límite: una columna de línea de usuario con lista de valores válidos aún no tiene editor desplegable (solo texto/número).
**Columnas de las ventanas de selección (cliente, artículo, almacén, impuesto):** son configurables por documento. En una pantalla del base (ventas/compras): `lookups: { item: [...], partner: [...], warehouse: [...], tax: [...] }` en el último parámetro de `marketing(...)` *sustituye* las columnas de esa lista (la primera columna es el código que se escribe en el campo). En la rama del cliente: `lookupColumns` en su archivo de extensiones *suma* columnas al final (por ejemplo `U_Marca` en artículos). El recurso, el filtro y el orden de cada lista no cambian; para los documentos de inventario o solicitudes se usa `withLookupColumns(layout, cols, 'replace' | 'add')`. Las columnas de almacén aplican también al almacén de la cabecera y al de origen de una transferencia. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. Flete hecho: fila «Flete» en el pie de ventas y compras (gasto adicional + importe; `freightPayload` lo guarda como `DocumentAdditionalExpenses`, reemplazando la colección; el catálogo `AdditionalExpenses` está registrado en el proxy; con varios gastos adicionales cargados en SAP solo se muestra). `freight: false` en `marketing(...)` lo quita de un documento. El toolbar del formulario ocupa todo el ancho del contenido (`<sbo-toolbar bleed>`). Falta: botón PDF. En ventas el flete falla en SBODemoCL ("Define the freight revenue account…"): el gasto adicional «Flete» solo tiene cuenta de gasto, no de ingresos (configuración de SAP, no del código); en compras funciona (orden de compra 2885).
2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible".
2. ~~Pantallas de maestros~~ — hechas (clientes, proveedores y artículos): `features/masters/`, con la misma idea que los documentos pero con clave de texto (`CardCode`, `ItemCode`). Cada pantalla declara su `MasterScreen` (`socios-negocio/socios.screens.ts`, `articulos/articulos.screens.ts`): columnas y filtros de la lista, `baseFilter` (clientes = `CardType eq 'cCustomer'`, proveedores = `cSupplier`; el tipo también se envía al crear con `fixed`), y un `MasterLayout` con código/nombre/estado arriba y los demás campos en **pestañas** (dos columnas, tipos de `HeaderField`: texto, número, fecha, área de texto, casilla `tYES/tNO`, lista de valores numérica o de texto, almacén). El código no se cambia una vez creado (`immutable`); «Inactivo» es `Frozen`. Rutas con `master-paths.ts` + `resolve` diferido; navegación primero/anterior/siguiente/último por clave de texto (`SlMasterApi`). Los campos por cliente van en `socios-negocio/socios.extensions.ts` y `articulos/articulos.extensions.ts` (vacíos en `main`; `header`, `tabs` por `id`, `newTabs`, `listColumns`; solo suma; pestaña o ruta inexistente = error). Pendiente en maestros: colecciones (direcciones, personas de contacto, precios por lista, almacenes por artículo), lotes/series, y cambiar el tipo de socio. Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy.
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.
5. Migrar reportes/PDF vía API Gateway (módulo y cola aparte; no registrar credenciales de los `tools/` del mini-erp, traen contraseñas en claro).
+3
View File
@@ -75,6 +75,9 @@ export const SL_RESOURCES: readonly SlResource[] = [
catalog('SalesPersons', 'number'),
catalog('PriceLists', 'number'),
catalog('UnitOfMeasurements', 'number'),
// Grupos de los maestros (para elegirlos y filtrar las listas de clientes, proveedores y artículos).
catalog('BusinessPartnerGroups', 'number'),
catalog('ItemGroups', 'number'),
// Gastos adicionales de un documento (el flete): se leen para elegir el código de gasto.
catalog('AdditionalExpenses', 'number'),
];
+14 -4
View File
@@ -4,6 +4,8 @@ import { routes } from './app.routes';
import { permissionGuard } from './core/auth/guards';
import { DOCUMENT_PATHS } from './features/documents/document-paths';
import { DOCUMENT_SCREENS } from './features/documents/document-screens';
import { MASTER_PATHS } from './features/masters/master-paths';
import { MASTER_SCREENS } from './features/masters/master-screens';
const shell = routes.find((r) => r.path === '')!;
const children = shell.children!;
@@ -38,10 +40,18 @@ describe('rutas', () => {
}
});
it('las pantallas que no son documentos (maestros) siguen como "no disponible"', () => {
const route = children.find((r) => r.path === 'socios/clientes')!;
expect(route.children).toBeUndefined();
expect(route.loadComponent).toBeDefined();
it('cada maestro ofrece lista, "nuevo" y "ver/editar" por su clave de texto', async () => {
for (const path of MASTER_PATHS) {
const route = children.find((r) => r.path === path.slice(1))!;
expect(route.children?.map((c) => c.path), path).toEqual(['', 'nuevo', ':key']);
expect(route.canActivate, path).toContain(permissionGuard);
const screen = await (route.resolve!['screen'] as () => Promise<{ path: string }>)();
expect(screen.path).toBe(path);
}
});
it('la lista de rutas de maestros del enrutador coincide con las pantallas', () => {
expect([...MASTER_PATHS].sort()).toEqual(MASTER_SCREENS.map((s) => s.path).sort());
});
it('la página de componentes está restringida a desarrollo y no está en el menú', () => {
+23 -1
View File
@@ -3,6 +3,7 @@ import type { Route, Routes } from '@angular/router';
import { MENU } from '@basic-erp/sap-types';
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
import { DOCUMENT_PATHS } from './features/documents/document-paths';
import { MASTER_PATHS } from './features/masters/master-paths';
const menuLabel = (path: string): string => MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path;
@@ -37,6 +38,26 @@ const documentRoutes: Routes = DOCUMENT_PATHS.map(
}),
);
/**
* Pantallas de maestros (clientes, proveedores, artículos): la ruta del menú muestra la lista; `nuevo`
* abre el formulario vacío y `:key` el registro. Su configuración también se carga en diferido.
*/
const masterRoutes: Routes = MASTER_PATHS.map(
(path): Route => ({
path: path.slice(1),
canActivate: [permissionGuard],
data: { path, title: menuLabel(path) },
resolve: {
screen: () => import('./features/masters/master-screens').then((m) => m.MASTER_SCREENS.find((s) => s.path === path)!),
},
children: [
{ path: '', loadComponent: () => import('./features/masters/master-list.page').then((m) => m.MasterListPage) },
{ path: 'nuevo', loadComponent: () => import('./features/masters/master-form.page').then((m) => m.MasterFormPage) },
{ path: ':key', loadComponent: () => import('./features/masters/master-form.page').then((m) => m.MasterFormPage) },
],
}),
);
/**
* Página de componentes: solo coincide al ejecutar `npm run dev:web` (isDevMode se evalúa al navegar);
* en producción cae en la ruta comodín. No aparece en el menú.
@@ -60,7 +81,7 @@ const adminRoutes: Routes = [
];
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
const builtPaths = new Set([...DOCUMENT_PATHS, '/admin/permisos']);
const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, '/admin/permisos']);
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
.filter((item) => !builtPaths.has(item.path))
.map(
@@ -90,6 +111,7 @@ export const routes: Routes = [
},
...devRoutes,
...documentRoutes,
...masterRoutes,
...adminRoutes,
...pendingRoutes,
],
@@ -0,0 +1,54 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import { SlLookupApi } from './sl-lookup';
describe('SlLookupApi.find', () => {
let api: SlLookupApi;
let ctrl: HttpTestingController;
const text: VhLookup = { title: 'Monedas', resource: 'Currencies', columns: [{ key: 'Code', title: 'Código' }, { key: 'Name', title: 'Nombre' }] };
const numeric: VhLookup = {
title: 'Grupos',
resource: 'BusinessPartnerGroups',
valueType: 'number',
baseFilter: `Type eq 'bbpgt_CustomerGroup'`,
columns: [{ key: 'Code', title: 'Código' }, { key: 'Name', title: 'Nombre' }],
};
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
api = TestBed.inject(SlLookupApi);
ctrl = TestBed.inject(HttpTestingController);
});
it('un código de texto va entre comillas, escapadas', async () => {
const p = api.find(text, "O'N");
const req = ctrl.expectOne((r) => r.url === '/api/sl/Currencies');
expect(req.request.params.get('$filter')).toBe(`Code eq 'O''N'`);
req.flush({ value: [] });
expect(await p).toBeNull();
});
it('un código numérico va sin comillas y se combina con el filtro de la lista', async () => {
const p = api.find(numeric, ' 100 ');
const req = ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartnerGroups');
expect(req.request.params.get('$filter')).toBe(`(Type eq 'bbpgt_CustomerGroup') and (Code eq 100)`);
req.flush({ value: [{ Code: 100, Name: 'Clientes' }] });
expect(await p).toEqual({ Code: 100, Name: 'Clientes' });
});
it('un código numérico que no es un número no existe: no consulta a SAP', async () => {
expect(await api.find(numeric, 'abc')).toBeNull();
ctrl.expectNone((r) => r.url === '/api/sl/BusinessPartnerGroups');
});
it('pide también los campos extra de la lista', async () => {
const p = api.find({ ...text, extraFields: ['U_Zona'] }, 'CLP');
const req = ctrl.expectOne((r) => r.url === '/api/sl/Currencies');
expect(req.request.params.get('$select')).toBe('Code,Name,U_Zona');
req.flush({ value: [] });
await p;
});
});
+4 -1
View File
@@ -18,7 +18,10 @@ export class SlLookupApi {
/** 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 numeric = lookup.valueType === 'number';
if (numeric && !/^-?\d+$/.test(code.trim())) return null; // un código numérico que no es número no existe
const match = numeric ? `${keyField} eq ${code.trim()}` : `${keyField} eq '${escapeODataString(code.trim())}'`;
const filter = combineFilters(lookup.baseFilter, match);
const params = new HttpParams()
.set('$select', [...new Set([keyField, ...lookup.columns.map((c) => c.key), ...(lookup.extraFields ?? [])])].join(','))
.set('$filter', filter ?? '')
+41
View File
@@ -0,0 +1,41 @@
import { HttpClient, HttpHeaders, 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 MasterNavTarget, masterNavigationQuery } from '../../features/masters/master-model';
import { API_URL } from '../api';
import { combineFilters } from './odata';
type Rec = Record<string, unknown>;
/** Lectura y escritura de un maestro de clave de texto (cliente, proveedor, artículo) por el proxy `/api/sl`. */
@Injectable({ providedIn: 'root' })
export class SlMasterApi {
private readonly http = inject(HttpClient);
private url(resource: string, key?: string): string {
return `${API_URL}/sl/${resource}${key === undefined ? '' : `/${encodeURIComponent(key)}`}`;
}
/** Clave del registro vecino (primero / anterior / siguiente / último), o `null` si no hay. */
async neighbour(resource: string, keyField: string, target: MasterNavTarget, from: string | null, baseFilter?: string): Promise<string | null> {
const q = masterNavigationQuery(target, keyField, from);
let params = new HttpParams().set('$select', keyField).set('$top', 1).set('$orderby', q.$orderby);
const filter = combineFilters(baseFilter, q.$filter);
if (filter) params = params.set('$filter', filter);
const res = await firstValueFrom(this.http.get<SlListResponse<Rec>>(this.url(resource), { params }));
const key = res.value[0]?.[keyField];
return typeof key === 'string' ? key : null;
}
/** Crea el registro; SAP devuelve el creado. */
create(resource: string, body: Rec): Promise<Rec> {
return firstValueFrom(this.http.post<Rec>(this.url(resource), body));
}
/** Modifica el registro. Con `etag`, si alguien lo cambió antes SAP responde 412 y no se pisa su cambio. */
update(resource: string, key: string, body: Rec, etag: string | undefined): Promise<void> {
const headers = etag ? new HttpHeaders().set('If-Match', etag) : new HttpHeaders();
return firstValueFrom(this.http.patch<void>(this.url(resource, key), body, { headers }));
}
}
@@ -12,9 +12,22 @@ export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtot
* de campos y el formulario base los dibuja: no hay HTML por documento.
*/
export type HeaderField =
| { type: 'text'; key: string; label: string; readonly?: boolean }
| {
type: 'text';
key: string;
label: string;
readonly?: boolean;
/** El código de un maestro no se cambia una vez creado. */
immutable?: boolean;
required?: boolean;
missing?: string;
}
| { type: 'date'; key: string; label: string; required?: boolean; missing?: string }
| { type: 'number'; key: string; label: string; decimals?: number }
| { type: 'number'; key: string; label: string; decimals?: number; readonly?: boolean }
/** Casilla: en SAP un campo `tYES` / `tNO`. */
| { type: 'checkbox'; key: string; label: string; readonly?: boolean }
/** 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 }
| { type: 'textarea'; key: string; label: string }
/** 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 }
@@ -221,28 +234,35 @@ const allFields = (layout: DocumentLayout): HeaderField[] => [
...layout.footer.right,
];
/** Campos de cabecera que se editan (y se envían a SAP). */
export const headerKeysOf = (layout: DocumentLayout): readonly string[] =>
allFields(layout).flatMap((f) => {
/** Campos de SAP que se editan (y se envían) de una lista de campos. */
export const keysOfFields = (fields: readonly HeaderField[]): string[] =>
fields.flatMap((f) => {
switch (f.type) {
case 'computed':
return [];
case 'freight':
return ['FreightExpense', 'FreightAmount'];
case 'folio':
return [f.prefixKey, f.numberKey];
case 'freight':
return ['FreightExpense', 'FreightAmount'];
default:
return [f.key];
}
});
/** Campos obligatorios de una lista de campos, con el texto para el aviso ("Indique el cliente."). */
export const requiredOfFields = (fields: readonly HeaderField[]): { key: string; label: string }[] =>
fields.flatMap((f) => (isRequired(f) ? [{ key: f.key, label: f.missing ?? f.label }] : []));
/** Campos de cabecera que se editan (y se envían a SAP). */
export const headerKeysOf = (layout: DocumentLayout): readonly string[] => keysOfFields(allFields(layout));
/** Campos obligatorios de la cabecera, con el texto para el aviso ("Indique el cliente."). */
export const requiredHeaderOf = (layout: DocumentLayout): { key: string; label: string }[] =>
allFields(layout).flatMap((f) =>
(f.type === 'date' || f.type === 'warehouse' || f.type === 'partner' || f.type === 'select') && f.required
? [{ key: f.key, label: f.missing ?? f.label }]
: [],
);
export const requiredHeaderOf = (layout: DocumentLayout): { key: string; label: string }[] => requiredOfFields(allFields(layout));
/** Campos con clave y marca de obligatorio. */
export function isRequired(f: HeaderField): f is HeaderField & { key: string; required: true; missing?: string } {
return 'required' in f && f.required === true && 'key' in f;
}
export const hasFreight = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'freight');
@@ -0,0 +1,14 @@
import type { MasterExtensions } from '../master-extensions';
/**
* Campos que este cliente agrega a las pantallas de artículos. En `main` va vacío: el proyecto base solo
* lleva campos estándar de SAP. La rama `cliente/<nombre>` lo completa, por ruta de pantalla, con campos
* estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo.
*
* Ejemplo (los campos son de ejemplo):
*
* '/inventario/articulos': {
* tabs: { inventario: { left: [{ type: 'text', key: 'U_Ubicacion', label: 'Ubicación' }] } },
* },
*/
export const ARTICULOS_EXTENSIONS: MasterExtensions = {};
@@ -0,0 +1,97 @@
import type { SelectFilter } from '../../../core/sl/list-filters';
import type { HeaderField } from '../../documents/document-layouts';
import { MASTER_LOOKUPS } from '../master-lookups';
import type { MasterScreen } from '../master-types';
const field = (f: HeaderField): HeaderField => f;
const frozenFilter: SelectFilter = {
type: 'select',
key: 'Frozen',
label: 'Estado',
defaultValue: 'tNO',
options: [
{ value: 'tNO', label: 'Activo' },
{ value: 'tYES', label: 'Inactivo' },
],
};
/** Artículos (`Items`): solo campos estándar de SAP. */
export const ARTICULOS_SCREENS: readonly MasterScreen[] = [
{
path: '/inventario/articulos',
resource: 'Items',
keyField: 'ItemCode',
singular: 'Artículo',
createLabel: 'Nuevo artículo',
orderby: 'ItemCode',
extraFields: ['Frozen'],
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: 'QuantityOnStock', title: 'En stock', type: 'number', width: '110px' },
{
key: 'Frozen',
title: 'Estado',
width: '100px',
format: (r) => (r['Frozen'] === 'tYES' ? 'Inactivo' : 'Activo'),
tone: (r) => (r['Frozen'] === 'tYES' ? 'muted' : 'ok'),
},
],
filters: [
frozenFilter,
{ type: 'multi', key: 'ItemsGroupCode', label: 'Grupo', resource: 'ItemGroups', valueField: 'Number', labelField: 'GroupName', valueType: 'number' },
],
layout: {
header: {
left: [
field({ type: 'text', key: 'ItemCode', label: 'Nº de artículo', immutable: true, required: true, missing: 'el número de artículo' }),
field({ type: 'text', key: 'ItemName', label: 'Descripción', required: true, missing: 'la descripción' }),
],
right: [field({ type: 'checkbox', key: 'Frozen', label: 'Inactivo' })],
},
tabs: [
{
id: 'general',
label: 'General',
left: [
field({ type: 'text', key: 'ForeignName', label: 'Nombre extranjero' }),
field({ type: 'lookup', key: 'ItemsGroupCode', label: 'Grupo de artículos', lookup: MASTER_LOOKUPS.itemGroup }),
field({ type: 'text', key: 'BarCode', label: 'Código de barras' }),
],
right: [
field({ type: 'checkbox', key: 'InventoryItem', label: 'Artículo de inventario' }),
field({ type: 'checkbox', key: 'SalesItem', label: 'Artículo de venta' }),
field({ type: 'checkbox', key: 'PurchaseItem', label: 'Artículo de compra' }),
],
},
{
id: 'inventario',
label: 'Inventario',
left: [
field({ type: 'warehouse', key: 'DefaultWarehouse', label: 'Almacén por defecto' }),
field({ type: 'checkbox', key: 'ManageBatchNumbers', label: 'Gestionar por lotes' }),
field({ type: 'checkbox', key: 'ManageSerialNumbers', label: 'Gestionar por series' }),
],
right: [
field({ type: 'number', key: 'QuantityOnStock', label: 'En stock', readonly: true }),
field({ type: 'number', key: 'MinInventory', label: 'Stock mínimo' }),
field({ type: 'number', key: 'MaxInventory', label: 'Stock máximo' }),
],
},
{ id: 'observaciones', label: 'Observaciones', left: [field({ type: 'textarea', key: 'User_Text', label: 'Observaciones' })], right: [] },
],
newHeader: () => ({
Frozen: 'tNO',
InventoryItem: 'tYES',
SalesItem: 'tYES',
PurchaseItem: 'tYES',
ManageBatchNumbers: 'tNO',
ManageSerialNumbers: 'tNO',
MinInventory: 0,
MaxInventory: 0,
}),
},
},
];
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { MASTER_EXTENSIONS, BASE_MASTER_SCREENS, MASTER_SCREENS } from './master-screens';
import { type MasterExtensions, applyMasterExtensions } from './master-extensions';
import { masterKeysOf } from './master-model';
const by = (screens: readonly { path: string }[], path: string) => screens.find((s) => s.path === path)! as (typeof BASE_MASTER_SCREENS)[number];
const EXT: MasterExtensions = {
'/socios/clientes': {
header: { right: [{ type: 'text', key: 'U_Ref', label: 'Ref.' }] },
tabs: { general: { right: [{ type: 'text', key: 'U_Zona', label: 'Zona' }] }, pago: { left: [{ type: 'number', key: 'U_Cupo', label: 'Cupo' }] } },
newTabs: [{ id: 'extra', label: 'Extra', left: [{ type: 'checkbox', key: 'U_Vip', label: 'VIP' }], right: [] }],
listColumns: [{ key: 'U_Zona', title: 'Zona', searchable: true }],
},
};
describe('extensiones por cliente de los maestros', () => {
it('en main no agregan nada', () => {
expect(MASTER_EXTENSIONS).toEqual({});
expect(MASTER_SCREENS).toEqual(BASE_MASTER_SCREENS);
});
it('agregan campos al final de la cabecera y de las pestañas, una pestaña nueva y columnas de lista', () => {
const s = by(applyMasterExtensions(BASE_MASTER_SCREENS, EXT), '/socios/clientes');
expect(s.layout.header.right.at(-1)).toMatchObject({ key: 'U_Ref' });
expect(s.layout.tabs.find((t) => t.id === 'general')!.right.at(-1)).toMatchObject({ key: 'U_Zona' });
expect(s.layout.tabs.find((t) => t.id === 'pago')!.left.at(-1)).toMatchObject({ key: 'U_Cupo' });
expect(s.layout.tabs.at(-1)).toMatchObject({ id: 'extra' });
expect(s.columns.at(-1)).toMatchObject({ key: 'U_Zona' });
expect(masterKeysOf(s.layout)).toEqual(expect.arrayContaining(['U_Ref', 'U_Zona', 'U_Cupo', 'U_Vip']));
});
it('solo cambia la pantalla extendida', () => {
const out = applyMasterExtensions(BASE_MASTER_SCREENS, EXT);
expect(by(out, '/socios/proveedores')).toEqual(by(BASE_MASTER_SCREENS, '/socios/proveedores'));
expect(masterKeysOf(by(BASE_MASTER_SCREENS, '/socios/clientes').layout)).not.toContain('U_Zona');
});
it('una ruta o una pestaña que no existen son un error', () => {
expect(() => applyMasterExtensions(BASE_MASTER_SCREENS, { '/socios/clientez': { listColumns: [] } })).toThrow(/no existe la pantalla/);
expect(() => applyMasterExtensions(BASE_MASTER_SCREENS, { '/socios/clientes': { tabs: { nada: { left: [] } } } })).toThrow(/no tiene la pestaña/);
});
});
@@ -0,0 +1,55 @@
import type { ListColumn } from '../../shared/list/list.types';
import type { FieldColumns, HeaderField } from '../documents/document-layouts';
import type { MasterScreen, MasterTab } from './master-types';
/** Campos que se agregan al final de las dos columnas de una cabecera o pestaña. */
export interface FieldAdditions {
left?: readonly HeaderField[];
right?: readonly HeaderField[];
}
/**
* Lo que una rama de cliente agrega a la pantalla de un maestro (campos estándar adicionales o de
* usuario `U_*`) sin tocar las pantallas base. Solo suma: no quita ni cambia lo que trae el base.
*/
export interface MasterExtension {
/** Campos al final de la cabecera (código y nombre). */
header?: FieldAdditions;
/** Campos al final de una pestaña existente, por su `id` (`general`, `pago`, `inventario`…). */
tabs?: Readonly<Record<string, FieldAdditions>>;
/** Pestañas nuevas, después de las del base. */
newTabs?: readonly MasterTab[];
/** Columnas nuevas de la lista, al final. */
listColumns?: readonly ListColumn[];
}
/** Extensiones por ruta de la pantalla (`/socios/clientes`). */
export type MasterExtensions = Readonly<Record<string, MasterExtension>>;
const add = (base: FieldColumns, extra?: FieldAdditions): FieldColumns => ({
left: [...base.left, ...(extra?.left ?? [])],
right: [...base.right, ...(extra?.right ?? [])],
});
/**
* Aplica las extensiones a las pantallas base. Una ruta que no existe, o una pestaña que no existe,
* es un error (un typo no debe pasar en silencio); las pantallas sin extensión quedan idénticas.
*/
export function applyMasterExtensions(screens: readonly MasterScreen[], extensions: MasterExtensions): readonly MasterScreen[] {
for (const path of Object.keys(extensions)) {
if (!screens.some((s) => s.path === path)) throw new Error(`master-extensions: no existe la pantalla «${path}»`);
}
return screens.map((s) => {
const ext = extensions[s.path];
if (!ext) return s;
for (const id of Object.keys(ext.tabs ?? {})) {
if (!s.layout.tabs.some((t) => t.id === id)) throw new Error(`master-extensions: «${s.path}» no tiene la pestaña «${id}»`);
}
const tabs = s.layout.tabs.map((t) => (ext.tabs?.[t.id] ? { ...t, ...add(t, ext.tabs[t.id]) } : t));
return {
...s,
columns: [...s.columns, ...(ext.listColumns ?? [])],
layout: { ...s.layout, header: add(s.layout.header, ext.header), tabs: [...tabs, ...(ext.newTabs ?? [])] },
};
});
}
@@ -0,0 +1,127 @@
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 { HeaderField } from '../documents/document-layouts';
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
/**
* Dibuja un campo de un formulario de maestro según su tipo. Recibe el valor tal como está en SAP
* (las casillas son `tYES` / `tNO`) y emite el nuevo valor en esa misma forma.
*/
@Component({
selector: 'sbo-master-field',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CheckboxField, DateField, LookupField, NumberField, SelectField, TextField, TextareaField],
template: `
@switch (field().type) {
@case ('checkbox') {
<sbo-checkbox-field
[label]="field().label"
[readonly]="readOnly() || !!ro()"
[value]="value() === 'tYES'"
(valueChange)="changed.emit($event ? 'tYES' : 'tNO')"
/>
}
@case ('lookup') {
<sbo-lookup-field
[label]="field().label"
[required]="required()"
[readonly]="readOnly()"
[value]="str()"
(valueChange)="changed.emit(typed($event))"
[lookup]="lookup()"
resolveOnLeave
/>
}
@case ('warehouse') {
<sbo-lookup-field
[label]="field().label"
[required]="required()"
[readonly]="readOnly()"
[value]="str()"
(valueChange)="changed.emit($event)"
[lookup]="warehouse"
resolveOnLeave
/>
}
@case ('select') {
<sbo-select-field
[label]="field().label"
[required]="required()"
[readonly]="readOnly()"
[options]="options()"
placeholder=""
[value]="str() || null"
(valueChange)="changed.emit($event ?? '')"
/>
}
@case ('number') {
<sbo-number-field
[label]="field().label"
[decimals]="decimals()"
[readonly]="readOnly() || !!ro()"
[value]="num()"
(valueChange)="changed.emit($event ?? 0)"
/>
}
@case ('date') {
<sbo-date-field [label]="field().label" [required]="required()" [readonly]="readOnly()" [value]="str()" (valueChange)="changed.emit($event)" />
}
@case ('textarea') {
<sbo-textarea-field [label]="field().label" [readonly]="readOnly()" [value]="str()" (valueChange)="changed.emit($event)" />
}
@default {
<sbo-text-field
[label]="field().label"
[required]="required()"
[readonly]="readOnly() || !!ro()"
[value]="str()"
(valueChange)="changed.emit($event)"
/>
}
}
`,
styles: ':host { display: block; }',
})
export class MasterField {
readonly field = input.required<HeaderField>();
readonly value = input<unknown>('');
/** Todo el formulario en solo lectura. */
readonly readOnly = input(false);
/** El registro ya existe: los campos `immutable` (el código) no se pueden cambiar. */
readonly saved = input(false);
readonly changed = output<string | number>();
protected readonly warehouse = WAREHOUSE_LOOKUP;
/** Un código numérico de SAP (grupo, lista de precios) se guarda como número. */
protected typed(code: string): string | number {
const f = this.field();
return f.type === 'lookup' && f.lookup.valueType === 'number' && /^\d+$/.test(code.trim()) ? Number(code) : code;
}
protected readonly str = computed(() => text(this.value()));
protected readonly num = computed(() => (this.value() === '' || this.value() === null || this.value() === undefined ? null : Number(this.value())));
protected readonly required = computed(() => {
const f = this.field();
return 'required' in f && f.required === true;
});
protected readonly ro = computed(() => {
const f = this.field();
if (f.type === 'text') return f.readonly || (f.immutable && this.saved());
return 'readonly' in f ? f.readonly : false;
});
protected readonly lookup = computed(() => {
const f = this.field();
if (f.type !== 'lookup') throw new Error('campo sin lista de valores');
return f.lookup;
});
protected readonly options = computed(() => {
const f = this.field();
return f.type === 'select' ? f.options : [];
});
protected readonly decimals = computed(() => {
const f = this.field();
return f.type === 'number' ? f.decimals : undefined;
});
}
@@ -0,0 +1,51 @@
<sbo-toolbar bleed aria-label="Acciones del registro">
<a class="tb" routerLink=".." title="Volver a la lista"><sbo-icon name="find" />Lista</a>
<a class="tb" [routerLink]="isNew() ? '.' : '../nuevo'" title="Registro nuevo"><sbo-icon name="new" />Nuevo</a>
<span class="tb-sep"></span>
<button class="tb" type="button" aria-label="Primero" (click)="go('first')"><sbo-icon name="first" /></button>
<button class="tb" type="button" aria-label="Anterior" (click)="go('prev')"><sbo-icon name="prev" /></button>
<button class="tb" type="button" aria-label="Siguiente" (click)="go('next')"><sbo-icon name="next" /></button>
<button class="tb" type="button" aria-label="Último" (click)="go('last')"><sbo-icon name="last" /></button>
</sbo-toolbar>
<sbo-window [title]="windowTitle()" [badge]="badge()" [status]="status()">
<div class="sbo-hdr">
<div class="sbo-col">
@for (f of layout.header.left; track $index) {
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" (changed)="setValue(keyOf(f), $event)" />
}
</div>
<div class="sbo-col">
@for (f of layout.header.right; track $index) {
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" (changed)="setValue(keyOf(f), $event)" />
}
</div>
</div>
<sbo-tabs ariaLabel="Datos del registro">
@for (tab of layout.tabs; track tab.id) {
<sbo-tab [label]="tab.label">
<div class="sbo-hdr">
<div class="sbo-col">
@for (f of tab.left; track $index) {
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" (changed)="setValue(keyOf(f), $event)" />
}
</div>
<div class="sbo-col">
@for (f of tab.right; track $index) {
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" (changed)="setValue(keyOf(f), $event)" />
}
</div>
</div>
</sbo-tab>
}
</sbo-tabs>
<button sboActions class="btn main" type="button" [disabled]="saving() || loading()" (click)="save()">
{{ isNew() ? 'Crear' : 'Actualizar' }}
</button>
@if (conflict()) {
<button sboActions class="btn" type="button" (click)="reload()">Recargar</button>
}
<a sboActions class="btn" routerLink="..">Cancelar</a>
</sbo-window>
@@ -0,0 +1,260 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MasterFormPage } from './master-form.page';
import { BASE_MASTER_SCREENS } from './master-screens';
const customer = (over: Record<string, unknown> = {}) => ({
'odata.etag': 'W/"3"',
CardCode: 'C100',
CardName: 'ACME Ltda',
CardType: 'cCustomer',
FederalTaxID: '76111222-3',
CardForeignName: null,
GroupCode: 100,
Currency: 'CLP',
Phone1: '2 2345 6789',
Phone2: null,
Cellular: null,
EmailAddress: 'ventas@acme.cl',
Website: null,
ContactPerson: null,
PayTermsGrpCode: 1,
PriceListNum: 1,
CreditLimit: 5000,
DiscountPercent: 0,
Frozen: 'tNO',
Notes: null,
...over,
});
describe('MasterFormPage', () => {
let fixture: ComponentFixture<MasterFormPage>;
let ctrl: HttpTestingController;
let navigate: ReturnType<typeof vi.spyOn>;
const el = () => fixture.nativeElement as HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = () => fixture.componentInstance as any;
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
const labels = () => [...el().querySelectorAll('label')].map((l) => l.textContent?.trim());
const buttons = () => [...el().querySelectorAll('.actions .btn')].map((b) => b.textContent?.trim());
async function open(path: string, key: string | null, doc?: Record<string, unknown>) {
const screen = BASE_MASTER_SCREENS.find((s) => s.path === path)!;
const params = key === null ? {} : { key };
const route = {
snapshot: { data: { screen, title: screen.singular }, paramMap: convertToParamMap(params) },
paramMap: of(convertToParamMap(params)),
};
await TestBed.configureTestingModule({
imports: [MasterFormPage],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(MasterFormPage);
await tick();
if (key !== null) ctrl.expectOne(`/api/sl/${screen.resource}/${encodeURIComponent(key)}`).flush(doc ?? {});
await tick();
}
beforeEach(() => TestBed.resetTestingModule());
describe('un cliente existente', () => {
beforeEach(() => open('/socios/clientes', 'C100', customer()));
it('muestra el código y el nombre, el estado y las pestañas con sus campos', () => {
expect(el().querySelector('.title')?.textContent).toContain('Cliente · C100');
expect(el().querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Activo');
expect([...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim())).toEqual(['General', 'Condiciones de pago', 'Observaciones']);
expect(labels()).toEqual(expect.arrayContaining(['Código', 'Nombre', 'RUT', 'Teléfono 1', 'Correo electrónico', 'Condición de pago', 'Límite de crédito']));
const values = [...el().querySelectorAll<HTMLInputElement>('input')].map((i) => i.value);
expect(values).toEqual(expect.arrayContaining(['C100', 'ACME Ltda', '76111222-3', 'ventas@acme.cl']));
});
it('el código no se puede cambiar', () => {
const code = [...el().querySelectorAll<HTMLInputElement>('input')].find((i) => i.value === 'C100')!;
expect(code.readOnly).toBe(true);
});
it('sin cambios, Actualizar no envía nada', async () => {
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectNone((r) => r.method === 'PATCH');
expect(el().querySelector('.status')?.textContent).toContain('No hay cambios');
});
it('envía solo lo que cambió, con If-Match, y recarga', async () => {
page().setValue('Phone1', '9 8765 4321');
page().setValue('CreditLimit', 8000);
await tick();
expect(page().dirty()).toBe(true);
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect(req.request.url).toBe('/api/sl/BusinessPartners/C100');
expect(req.request.headers.get('If-Match')).toBe('W/"3"');
expect(req.request.body).toEqual({ Phone1: '9 8765 4321', CreditLimit: 8000 });
req.flush(null);
await tick();
ctrl.expectOne('/api/sl/BusinessPartners/C100').flush(customer({ Phone1: '9 8765 4321', CreditLimit: 8000 }));
await tick();
expect(el().querySelector('.status')?.textContent).toContain('Cliente actualizado');
});
it('un campo vaciado se envía como null para que SAP lo limpie', async () => {
page().setValue('EmailAddress', '');
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
expect(ctrl.expectOne((r) => r.method === 'PATCH').request.body).toEqual({ EmailAddress: null });
});
it('un 412 avisa del conflicto, no guarda y ofrece Recargar', async () => {
page().setValue('CardName', 'Otro nombre');
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectOne((r) => r.method === 'PATCH').flush({ message: 'x' }, { status: 412, statusText: 'Precondition Failed' });
await tick();
expect(el().querySelector('.status')?.textContent).toContain('Otro usuario modificó');
expect(buttons()).toContain('Recargar');
});
it('la navegación pide el vecino por clave de texto y abre su registro', async () => {
const done = page().go('next');
const req = ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners' && r.params.has('$orderby'));
expect(req.request.params.get('$filter')).toBe(`(CardType eq 'cCustomer') and (CardCode gt 'C100')`);
expect(req.request.params.get('$orderby')).toBe('CardCode asc');
req.flush({ value: [{ CardCode: 'C200' }] });
await done;
expect(navigate).toHaveBeenCalledWith(['..', 'C200'], expect.anything());
});
it('al llegar al extremo avisa y no navega', async () => {
const done = page().go('next');
ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners').flush({ value: [] });
await done;
await tick();
expect(navigate).not.toHaveBeenCalled();
expect(el().querySelector('.status')?.textContent).toContain('No hay más registros');
});
});
it('un cliente inactivo se muestra como tal', async () => {
await open('/socios/clientes', 'C100', customer({ Frozen: 'tYES' }));
expect(el().querySelector('.badge.tone-muted')?.textContent?.trim()).toBe('Inactivo');
});
it('una clave con espacios y puntos se codifica en la URL (artículos)', async () => {
await open('/inventario/articulos', '3MG30080714 M 10.5', {
ItemCode: '3MG30080714 M 10.5',
ItemName: 'Cloud X 5',
Frozen: 'tNO',
InventoryItem: 'tYES',
QuantityOnStock: 12,
});
expect(el().querySelector('.title')?.textContent).toContain('Artículo · 3MG30080714 M 10.5');
});
it('las casillas de un artículo muestran y cambian tYES / tNO', async () => {
await open('/inventario/articulos', 'A1', { 'odata.etag': 'W/"1"', ItemCode: 'A1', ItemName: 'Pieza', Frozen: 'tNO', InventoryItem: 'tYES', SalesItem: 'tNO', PurchaseItem: 'tYES' });
const boxes = [...el().querySelectorAll<HTMLInputElement>('input[type=checkbox]')];
expect(boxes.map((b) => b.checked)).toEqual(expect.arrayContaining([true, false]));
page().setValue('SalesItem', 'tYES');
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
expect(ctrl.expectOne((r) => r.method === 'PATCH').request.body).toEqual({ SalesItem: 'tYES' });
});
describe('un cliente nuevo', () => {
beforeEach(() => open('/socios/clientes', null));
it('arranca vacío, con el código editable y el botón Crear', () => {
expect(el().querySelector('.badge.tone-info')?.textContent?.trim()).toBe('Nuevo');
expect(buttons()).toContain('Crear');
const code = el().querySelector<HTMLInputElement>('input')!;
expect(code.readOnly).toBe(false);
});
it('no envía nada sin código o sin nombre', async () => {
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectNone((r) => r.method === 'POST');
expect(el().querySelector('.status')?.textContent).toContain('Indique el código');
page().setValue('CardCode', 'C300');
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
expect(el().querySelector('.status')?.textContent).toContain('Indique el nombre');
});
it('crea el cliente con su tipo, sin campos vacíos, y abre el creado', async () => {
page().setValue('CardCode', 'C300');
page().setValue('CardName', 'Nuevo SpA');
page().setValue('GroupCode', 100);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'POST');
expect(req.request.url).toBe('/api/sl/BusinessPartners');
expect(req.request.body).toEqual({
CardCode: 'C300',
CardName: 'Nuevo SpA',
GroupCode: 100,
Frozen: 'tNO',
CreditLimit: 0,
DiscountPercent: 0,
CardType: 'cCustomer',
});
req.flush({ CardCode: 'C300' });
await tick();
expect(navigate).toHaveBeenCalledWith(['..', 'C300'], expect.objectContaining({ state: { created: 'C300' } }));
});
it('un proveedor se crea con el tipo de proveedor', async () => {
TestBed.resetTestingModule();
await open('/socios/proveedores', null);
page().setValue('CardCode', 'P1');
page().setValue('CardName', 'Proveedor Uno');
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
expect((ctrl.expectOne((r) => r.method === 'POST').request.body as Record<string, unknown>)['CardType']).toBe('cSupplier');
});
it('muestra el error de SAP si rechaza el registro', async () => {
page().setValue('CardCode', 'C300');
page().setValue('CardName', 'Nuevo SpA');
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectOne((r) => r.method === 'POST').flush({ message: 'Código duplicado' }, { status: 400, statusText: 'Bad Request' });
await tick();
expect(el().querySelector('.status')?.textContent).toContain('Código duplicado');
expect(navigate).not.toHaveBeenCalled();
});
});
it('un registro que no existe lo avisa', async () => {
const screen = BASE_MASTER_SCREENS.find((s) => s.path === '/socios/clientes')!;
const route = { snapshot: { data: { screen, title: 'Cliente' }, paramMap: convertToParamMap({ key: 'NOPE' }) }, paramMap: of(convertToParamMap({ key: 'NOPE' })) };
await TestBed.configureTestingModule({
imports: [MasterFormPage],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(MasterFormPage);
await tick();
ctrl.expectOne('/api/sl/BusinessPartners/NOPE').flush({}, { status: 404, statusText: 'Not Found' });
await tick();
expect(el().querySelector('.status')?.textContent).toContain('El registro no existe');
});
});
@@ -0,0 +1,179 @@
import { HttpErrorResponse, httpResource } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { SlMasterApi } from '../../core/sl/sl-master';
import { Icon, SboWindow, Tab, Tabs, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
import { updatePayload, type DocRecord } from '../documents/document-model';
import type { HeaderField } from '../documents/document-layouts';
import { MasterField } from './master-field';
import {
type MasterNavTarget,
createMasterPayload,
masterKeysOf,
masterRequiredOf,
pickMaster,
validateMaster,
} from './master-model';
import type { MasterScreen } from './master-types';
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
/**
* Formulario de un maestro (cliente, proveedor, artículo): `nuevo` (vacío) o `:clave` (ver / editar).
* Los campos y pestañas los declara la pantalla (`MasterLayout`); aquí solo se cargan, se editan y se
* guardan. Al modificar se envía el `odata.etag` leído (`If-Match`): si otro usuario cambió el registro,
* SAP responde 412 y se avisa sin pisarlo.
*/
@Component({
selector: 'sbo-master-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Icon, MasterField, RouterLink, SboWindow, Tab, Tabs, Toolbar],
templateUrl: './master-form.page.html',
})
export class MasterFormPage {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly api = inject(SlMasterApi);
protected readonly title = this.route.snapshot.data['title'] as string;
protected readonly screen = this.route.snapshot.data['screen'] as MasterScreen;
protected readonly layout = this.screen.layout;
private readonly keys = masterKeysOf(this.layout);
private readonly required = masterRequiredOf(this.layout);
protected readonly key = toSignal(this.route.paramMap.pipe(map((p) => p.get('key'))), {
initialValue: this.route.snapshot.paramMap.get('key'),
});
protected readonly isNew = computed(() => this.key() === null);
// --- datos ---------------------------------------------------------------------------------
private readonly resource = httpResource<DocRecord>(() => {
const key = this.key();
return key === null ? undefined : `${API_URL}/sl/${this.screen.resource}/${encodeURIComponent(key)}`;
});
protected readonly doc = computed(() => (this.resource.hasValue() ? this.resource.value() : undefined));
protected readonly loading = computed(() => this.resource.isLoading());
protected readonly loadError = computed(() => {
const err = this.resource.error();
return err ? (err instanceof HttpErrorResponse && err.status === 404 ? 'El registro no existe.' : apiErrorMessage(err)) : '';
});
/** Valores editables; se reinician al cargar o recargar el registro. */
protected readonly header = linkedSignal<DocRecord>(() => this.pick(this.doc()));
private pick(doc: DocRecord | undefined): DocRecord {
return pickMaster(doc, this.keys, this.layout.newHeader());
}
// --- estado --------------------------------------------------------------------------------
protected readonly saving = signal(false);
protected readonly conflict = signal(false);
private readonly message = signal<StatusMessage | null>(
((): StatusMessage | null => {
const created = (this.router.currentNavigation()?.extras.state as { created?: string } | undefined)?.created;
return created ? { kind: 'ok', text: `${this.screen.singular} ${created} creado.` } : null;
})(),
);
protected readonly dirty = computed(() => {
const d = this.doc();
if (!d) return false;
return Object.keys(updatePayload(this.pick(d), this.header(), [], []).body).length > 0;
});
protected readonly status = computed<StatusMessage>(() => {
if (this.loading()) return { kind: 'info', text: 'Cargando…' };
if (this.loadError()) return { kind: 'err', text: this.loadError() };
const message = this.message();
if (message) return message;
if (this.isNew()) return { kind: 'info', text: 'Indique el código y el nombre.' };
return { kind: 'info', text: this.dirty() ? 'Hay cambios sin guardar.' : '' };
});
protected readonly badge = computed<WindowBadge | undefined>(() => {
if (this.isNew()) return { label: 'Nuevo', tone: 'info' };
const d = this.doc();
if (!d) return undefined;
return d['Frozen'] === 'tYES' ? { label: 'Inactivo', tone: 'muted' } : { label: 'Activo', tone: 'ok' };
});
protected readonly windowTitle = computed(() => {
const code = this.doc()?.[this.screen.keyField];
return code === undefined ? this.screen.singular : `${this.screen.singular} · ${text(code)}`;
});
// --- edición -------------------------------------------------------------------------------
protected setValue(key: string, value: string | number): void {
this.header.update((h) => ({ ...h, [key]: value }));
this.message.set(null);
}
/** Clave de SAP de un campo del formulario (los de un maestro siempre la tienen). */
protected keyOf(f: HeaderField): string {
return 'key' in f ? f.key : '';
}
protected value(key: string): unknown {
return this.header()[key];
}
protected async go(target: MasterNavTarget): Promise<void> {
try {
const next = await this.api.neighbour(this.screen.resource, this.screen.keyField, target, this.key(), this.screen.baseFilter);
if (next === null) {
this.message.set({ kind: 'warn', text: 'No hay más registros en esa dirección.' });
return;
}
this.message.set(null);
await this.router.navigate(['..', next], { relativeTo: this.route });
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
}
}
protected async save(): Promise<void> {
if (this.saving()) return;
const errors = validateMaster(this.header(), this.required);
if (errors.length) {
this.message.set({ kind: 'err', text: errors[0] });
return;
}
this.saving.set(true);
this.conflict.set(false);
try {
const d = this.doc();
if (!d) {
const created = await this.api.create(this.screen.resource, createMasterPayload(this.header(), this.layout.fixed));
const key = text(created[this.screen.keyField]);
await this.router.navigate(['..', key], { relativeTo: this.route, state: { created: key } });
return;
}
const { body } = updatePayload(this.pick(d), this.header(), [], []);
if (!Object.keys(body).length) {
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
return;
}
await this.api.update(this.screen.resource, this.key()!, body, text(d['odata.etag']) || undefined);
this.message.set({ kind: 'ok', text: `${this.screen.singular} actualizado.` });
this.resource.reload();
} catch (e) {
if (e instanceof HttpErrorResponse && e.status === 412) {
this.conflict.set(true);
this.message.set({ kind: 'warn', text: 'Otro usuario modificó este registro. Recárguelo para ver sus cambios; los suyos no se guardaron.' });
} else {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
}
} finally {
this.saving.set(false);
}
}
protected reload(): void {
this.conflict.set(false);
this.message.set(null);
this.resource.reload();
}
}
@@ -0,0 +1,61 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MasterListPage } from './master-list.page';
import { BASE_MASTER_SCREENS } from './master-screens';
describe('MasterListPage', () => {
let fixture: ComponentFixture<MasterListPage>;
let ctrl: HttpTestingController;
let navigate: ReturnType<typeof vi.spyOn>;
const el = () => fixture.nativeElement as HTMLElement;
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
async function open(path: string) {
const screen = BASE_MASTER_SCREENS.find((s) => s.path === path)!;
await TestBed.configureTestingModule({
imports: [MasterListPage],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: ActivatedRoute, useValue: { snapshot: { data: { screen, title: screen.singular + 's' } } } },
],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(MasterListPage);
await tick();
}
beforeEach(() => TestBed.resetTestingModule());
it('lista solo los clientes activos por defecto, por su código, y ofrece "Nuevo cliente"', async () => {
await open('/socios/clientes');
const req = ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners');
expect(req.request.params.get('$filter')).toBe(`(CardType eq 'cCustomer') and (Frozen eq 'tNO')`);
expect(req.request.params.get('$orderby')).toBe('CardCode');
expect(req.request.params.get('$select')).toContain('Frozen');
req.flush({ value: [{ CardCode: 'C1', CardName: 'ACME', FederalTaxID: '76-1', Frozen: 'tNO' }], 'odata.count': '1' });
await tick();
expect(el().querySelector('.btn.main')?.textContent?.trim()).toBe('Nuevo cliente');
expect(el().querySelector('tbody tr')?.textContent).toContain('ACME');
expect(el().querySelector('tbody .badge')?.textContent?.trim()).toBe('Activo');
});
it('un clic en una fila abre el registro por su clave, y el botón abre uno nuevo', async () => {
await open('/inventario/articulos');
ctrl.expectOne((r) => r.url === '/api/sl/Items').flush({ value: [{ ItemCode: 'A 1.5', ItemName: 'Pieza', QuantityOnStock: 3, Frozen: 'tNO' }], 'odata.count': '1' });
await tick();
(el().querySelector('tbody tr') as HTMLElement).click();
expect(navigate).toHaveBeenCalledWith(['A 1.5'], expect.anything());
(el().querySelector('.btn.main') as HTMLElement).click();
expect(navigate).toHaveBeenCalledWith(['nuevo'], expect.anything());
});
});
@@ -0,0 +1,43 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ListView } from '../../shared/list/list-view';
import type { 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. */
@Component({
selector: 'sbo-master-list-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ListView],
template: `
<sbo-list-view
[title]="title"
[resource]="screen.resource"
[columns]="screen.columns"
[extraFields]="screen.extraFields ?? []"
[filters]="screen.filters"
[orderby]="screen.orderby"
[baseFilter]="screen.baseFilter"
[rowKey]="screen.keyField"
[createLabel]="screen.createLabel"
(open)="open($event)"
(create)="create()"
/>
`,
styles: ':host { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }',
})
export class MasterListPage {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
protected readonly screen = this.route.snapshot.data['screen'] as MasterScreen;
protected readonly title = this.route.snapshot.data['title'] as string;
protected open(row: ListRow): void {
void this.router.navigate([String(row[this.screen.keyField])], { relativeTo: this.route });
}
protected create(): void {
void this.router.navigate(['nuevo'], { relativeTo: this.route });
}
}
@@ -0,0 +1,44 @@
import type { ListColumn } from '../../shared/list/list.types';
import type { VhLookup } from '../../shared/value-help/value-help.types';
const col = (key: string, title: string, extra: Partial<ListColumn> = {}): ListColumn => ({ key, title, ...extra });
/** Ventanas de selección de los campos de código de los maestros. */
export const MASTER_LOOKUPS = {
/** Grupo de clientes o de proveedores. */
partnerGroup: (type: 'cCustomer' | 'cSupplier'): VhLookup => ({
title: type === 'cSupplier' ? 'Grupos de proveedores' : 'Grupos de clientes',
resource: 'BusinessPartnerGroups',
orderby: 'Code',
baseFilter: `Type eq '${type === 'cSupplier' ? 'bbpgt_VendorGroup' : 'bbpgt_CustomerGroup'}'`,
valueType: 'number',
columns: [col('Code', 'Código', { width: '90px', mono: true }), col('Name', 'Nombre')],
}),
currency: {
title: 'Monedas',
resource: 'Currencies',
orderby: 'Code',
columns: [col('Code', 'Código', { width: '90px', mono: true }), col('Name', 'Nombre')],
} satisfies VhLookup,
paymentTerms: {
title: 'Condiciones de pago',
resource: 'PaymentTermsTypes',
orderby: 'GroupNumber',
valueType: 'number',
columns: [col('GroupNumber', 'Código', { width: '90px', mono: true }), col('PaymentTermsGroupName', 'Nombre')],
} satisfies VhLookup,
priceList: {
title: 'Listas de precios',
resource: 'PriceLists',
orderby: 'PriceListNo',
valueType: 'number',
columns: [col('PriceListNo', 'Código', { width: '90px', mono: true }), col('PriceListName', 'Nombre')],
} satisfies VhLookup,
itemGroup: {
title: 'Grupos de artículos',
resource: 'ItemGroups',
orderby: 'Number',
valueType: 'number',
columns: [col('Number', 'Código', { width: '90px', mono: true }), col('GroupName', 'Nombre')],
} satisfies VhLookup,
} as const;
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import {
createMasterPayload,
masterImmutableOf,
masterKeysOf,
masterNavigationQuery,
masterRequiredOf,
pickMaster,
validateMaster,
} from './master-model';
import { BASE_MASTER_SCREENS } from './master-screens';
const by = (path: string) => BASE_MASTER_SCREENS.find((s) => s.path === path)!;
describe('modelo de los maestros', () => {
const clientes = by('/socios/clientes').layout;
it('los campos que se leen y envían salen de las pestañas y la cabecera, sin repetir ni inventar', () => {
const keys = masterKeysOf(clientes);
expect(keys).toEqual(expect.arrayContaining(['CardCode', 'CardName', 'Frozen', 'FederalTaxID', 'PayTermsGrpCode', 'CreditLimit', 'Notes']));
expect(new Set(keys).size).toBe(keys.length);
});
it('el código y el nombre son obligatorios y el código no se cambia una vez creado', () => {
expect(masterRequiredOf(clientes)).toEqual([
{ key: 'CardCode', label: 'el código' },
{ key: 'CardName', label: 'el nombre' },
]);
expect([...masterImmutableOf(clientes)]).toEqual(['CardCode']);
});
it('valida los obligatorios', () => {
const req = masterRequiredOf(clientes);
expect(validateMaster({ CardCode: '', CardName: ' ' }, req)).toEqual(['Indique el código.', 'Indique el nombre.']);
expect(validateMaster({ CardCode: 'C1', CardName: 'ACME' }, req)).toEqual([]);
});
it('al crear envía los campos con valor más los fijos de la pantalla', () => {
const body = createMasterPayload({ CardCode: 'C1', CardName: 'ACME', Phone1: '', Frozen: 'tNO', CreditLimit: 0 }, clientes.fixed);
expect(body).toEqual({ CardCode: 'C1', CardName: 'ACME', Frozen: 'tNO', CreditLimit: 0, CardType: 'cCustomer' });
});
it('un registro nuevo arranca con sus valores por defecto; uno de SAP, con sus valores y vacíos como texto', () => {
expect(pickMaster(undefined, ['CardCode'], clientes.newHeader())).toMatchObject({ Frozen: 'tNO' });
expect(pickMaster({ CardCode: 'C1', Phone1: null, CreditLimit: 5 }, ['CardCode', 'Phone1', 'CreditLimit'], {})).toEqual({
CardCode: 'C1',
Phone1: '',
CreditLimit: 5,
});
});
it('navega por clave de texto, escapando las comillas', () => {
expect(masterNavigationQuery('first', 'CardCode', null)).toEqual({ $orderby: 'CardCode asc' });
expect(masterNavigationQuery('last', 'CardCode', 'C1')).toEqual({ $orderby: 'CardCode desc' });
expect(masterNavigationQuery('prev', 'CardCode', "O'BRIEN")).toEqual({ $filter: "CardCode lt 'O''BRIEN'", $orderby: 'CardCode desc' });
expect(masterNavigationQuery('next', 'ItemCode', 'A 1')).toEqual({ $filter: "ItemCode gt 'A 1'", $orderby: 'ItemCode asc' });
expect(masterNavigationQuery('next', 'ItemCode', null)).toEqual({ $orderby: 'ItemCode asc' });
});
});
describe('pantallas de maestros', () => {
it('clientes y proveedores comparten BusinessPartners y se separan por tipo, al listar y al crear', () => {
const c = by('/socios/clientes');
const p = by('/socios/proveedores');
expect([c.resource, p.resource]).toEqual(['BusinessPartners', 'BusinessPartners']);
expect(c.baseFilter).toBe(`CardType eq 'cCustomer'`);
expect(p.baseFilter).toBe(`CardType eq 'cSupplier'`);
expect(c.layout.fixed).toEqual({ CardType: 'cCustomer' });
expect(p.layout.fixed).toEqual({ CardType: 'cSupplier' });
});
it('el grupo de cada tipo es de su clase', () => {
const group = (path: string) => {
const f = by(path).layout.tabs[0].left.find((x) => x.type === 'lookup' && x.key === 'GroupCode');
return f && f.type === 'lookup' ? f.lookup : undefined;
};
expect(group('/socios/clientes')?.baseFilter).toContain('bbpgt_CustomerGroup');
expect(group('/socios/proveedores')?.baseFilter).toContain('bbpgt_VendorGroup');
});
it('el artículo tiene su código inmutable y sin filtro fijo', () => {
const a = by('/inventario/articulos');
expect(a.keyField).toBe('ItemCode');
expect(a.baseFilter).toBeUndefined();
expect([...masterImmutableOf(a.layout)]).toEqual(['ItemCode']);
});
it('el proyecto base solo usa campos estándar (sin U_*), y las listas ordenan por su clave', () => {
for (const s of BASE_MASTER_SCREENS) {
for (const k of masterKeysOf(s.layout)) expect(k.startsWith('U_'), `${s.path} ${k}`).toBe(false);
for (const c of s.columns) expect(c.key.startsWith('U_')).toBe(false);
expect(s.orderby).toBe(s.keyField);
expect(s.columns[0].key).toBe(s.keyField);
}
});
it('cada pestaña tiene como máximo dos columnas de campos y ids únicos', () => {
for (const s of BASE_MASTER_SCREENS) {
const ids = s.layout.tabs.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
for (const t of s.layout.tabs) expect(Object.keys(t).filter((k) => k === 'left' || k === 'right')).toEqual(['left', 'right']);
}
});
it('los códigos numéricos de SAP (grupo, condición de pago, lista de precios) se declaran numéricos', () => {
const c = by('/socios/clientes').layout;
const lookups = c.tabs.flatMap((t) => [...t.left, ...t.right]).filter((f) => f.type === 'lookup');
for (const key of ['GroupCode', 'PayTermsGrpCode', 'PriceListNum']) {
const f = lookups.find((x) => x.type === 'lookup' && x.key === key);
expect(f && f.type === 'lookup' && f.lookup.valueType, key).toBe('number');
}
});
});
@@ -0,0 +1,60 @@
import { escapeODataString } from '../../core/sl/odata';
import { type HeaderField, keysOfFields, requiredOfFields } from '../documents/document-layouts';
import { type DocRecord, compact } from '../documents/document-model';
import type { MasterLayout } from './master-types';
const isEmpty = (v: unknown): boolean => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
/** Todos los campos del formulario: los de arriba y los de cada pestaña. */
export const masterFieldsOf = (layout: MasterLayout): HeaderField[] => [
...layout.header.left,
...layout.header.right,
...layout.tabs.flatMap((t) => [...t.left, ...t.right]),
];
/** Campos de SAP que se leen, se editan y se envían. */
export const masterKeysOf = (layout: MasterLayout): readonly string[] => keysOfFields(masterFieldsOf(layout));
/** Campos obligatorios con el texto del aviso ("Indique el código."). */
export const masterRequiredOf = (layout: MasterLayout): { key: string; label: string }[] => requiredOfFields(masterFieldsOf(layout));
/** Campos que no se pueden cambiar una vez creado el registro (el código). */
export const masterImmutableOf = (layout: MasterLayout): ReadonlySet<string> =>
new Set(masterFieldsOf(layout).flatMap((f) => (f.type === 'text' && f.immutable ? [f.key] : [])));
/** Valores editables de un registro de SAP (los vacíos quedan como texto vacío). */
export function pickMaster(doc: DocRecord | undefined, keys: readonly string[], blank: DocRecord): DocRecord {
if (!doc) return blank;
return Object.fromEntries(keys.map((k) => [k, doc[k] ?? '']));
}
/** Errores que se pueden detectar sin preguntarle a SAP. */
export function validateMaster(header: DocRecord, required: readonly { key: string; label: string }[]): string[] {
return required.filter((f) => isEmpty(header[f.key])).map((f) => `Indique ${f.label.toLowerCase()}.`);
}
/** Cuerpo para crear: cabecera sin campos vacíos más los valores fijos de la pantalla. */
export const createMasterPayload = (header: DocRecord, fixed?: DocRecord): DocRecord => ({ ...compact(header), ...fixed });
export type MasterNavTarget = 'first' | 'prev' | 'next' | 'last';
/** Consulta de un solo registro para ir al primero / anterior / siguiente / último por su clave de texto. */
export function masterNavigationQuery(
target: MasterNavTarget,
keyField: string,
current: string | null,
): { $filter?: string; $orderby: string } {
const asc = `${keyField} asc`;
const desc = `${keyField} desc`;
const quoted = current === null ? '' : `'${escapeODataString(current)}'`;
switch (target) {
case 'first':
return { $orderby: asc };
case 'last':
return { $orderby: desc };
case 'prev':
return current === null ? { $orderby: desc } : { $filter: `${keyField} lt ${quoted}`, $orderby: desc };
case 'next':
return current === null ? { $orderby: asc } : { $filter: `${keyField} gt ${quoted}`, $orderby: asc };
}
}
@@ -0,0 +1,6 @@
/**
* Rutas de menú que son pantallas de maestros. Es lo único que el enrutador necesita al arrancar; la
* configuración completa (`master-screens.ts`) se carga en diferido al abrir una. Un test comprueba que
* coincide con `MASTER_SCREENS`.
*/
export const MASTER_PATHS: readonly string[] = ['/socios/clientes', '/socios/proveedores', '/inventario/articulos'];
@@ -0,0 +1,26 @@
import { ARTICULOS_SCREENS } from './articulos/articulos.screens';
import { ARTICULOS_EXTENSIONS } from './articulos/articulos.extensions';
import { type MasterExtensions, applyMasterExtensions } from './master-extensions';
import { SOCIOS_EXTENSIONS } from './socios-negocio/socios.extensions';
import { SOCIOS_SCREENS } from './socios-negocio/socios.screens';
import type { MasterScreen } from './master-types';
/** Las pantallas de maestros del proyecto base, agrupadas por módulo (una carpeta cada uno). */
export const BASE_MASTER_SCREENS: readonly MasterScreen[] = [...SOCIOS_SCREENS, ...ARTICULOS_SCREENS];
/** Cada módulo solo puede extender sus propias pantallas: una ruta ajena es un error, no un descuido silencioso. */
function ownedBy(screens: readonly MasterScreen[], extensions: MasterExtensions): MasterExtensions {
for (const path of Object.keys(extensions)) {
if (!screens.some((s) => s.path === path)) throw new Error(`Extensión con la ruta «${path}», que es de otro módulo`);
}
return extensions;
}
/** Lo que el cliente agrega, reunido de los archivos de extensiones de cada módulo. */
export const MASTER_EXTENSIONS: MasterExtensions = {
...ownedBy(SOCIOS_SCREENS, SOCIOS_EXTENSIONS),
...ownedBy(ARTICULOS_SCREENS, ARTICULOS_EXTENSIONS),
};
/** Las pantallas con lo que cada cliente agrega (en `main`, igual al base). */
export const MASTER_SCREENS: readonly MasterScreen[] = applyMasterExtensions(BASE_MASTER_SCREENS, MASTER_EXTENSIONS);
@@ -0,0 +1,44 @@
import type { ListFilter } from '../../core/sl/list-filters';
import type { ListColumn } from '../../shared/list/list.types';
import type { FieldColumns } from '../documents/document-layouts';
import type { DocRecord } from '../documents/document-model';
/** Una pestaña del maestro (General, Condiciones de pago…): dos columnas de campos como máximo. */
export interface MasterTab extends FieldColumns {
id: string;
label: string;
}
/**
* Formulario de un maestro (cliente, proveedor, artículo): código y nombre encima y los demás campos
* en pestañas. Cada pantalla declara los suyos y el formulario base los dibuja.
*/
export interface MasterLayout {
/** Código, nombre y estado: siempre visibles, encima de las pestañas. */
header: FieldColumns;
tabs: readonly MasterTab[];
/** Valores iniciales de un registro nuevo (las casillas, por ejemplo). */
newHeader: () => DocRecord;
/** Valores que se envían siempre al crear (ej. `CardType: 'cCustomer'`). */
fixed?: DocRecord;
}
/** Pantalla de un maestro: lista + formulario (nuevo / ver-editar) de un recurso de clave de texto. */
export interface MasterScreen {
/** Ruta de menú (`/socios/clientes`); de ella cuelgan `nuevo` y `:clave`. */
path: string;
/** Recurso del proxy `/api/sl`. */
resource: string;
/** Campo clave del recurso (`CardCode`, `ItemCode`). */
keyField: string;
/** Nombre en singular, para el título del formulario (`Cliente`). */
singular: string;
createLabel: string;
columns: readonly ListColumn[];
extraFields?: readonly string[];
filters: readonly ListFilter[];
orderby: string;
/** Filtro fijo de la lista y de la navegación (ej. solo los clientes). */
baseFilter?: string;
layout: MasterLayout;
}
@@ -0,0 +1,15 @@
import type { MasterExtensions } from '../master-extensions';
/**
* Campos que este cliente agrega a las pantallas de clientes y proveedores. En `main` va vacío: el proyecto base solo
* lleva campos estándar de SAP. La rama `cliente/<nombre>` lo completa, por ruta de pantalla, con campos
* estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo.
*
* Ejemplo (los campos son de ejemplo):
*
* '/socios/clientes': {
* tabs: { general: { right: [{ type: 'text', key: 'U_Zona', label: 'Zona' }] } },
* listColumns: [{ key: 'U_Zona', title: 'Zona', searchable: true }],
* },
*/
export const SOCIOS_EXTENSIONS: MasterExtensions = {};
@@ -0,0 +1,102 @@
import type { SelectFilter, ListFilter } from '../../../core/sl/list-filters';
import type { ListColumn } from '../../../shared/list/list.types';
import type { HeaderField } from '../../documents/document-layouts';
import { MASTER_LOOKUPS } from '../master-lookups';
import type { MasterScreen } from '../master-types';
/** Estado del registro: activo (`Frozen = tNO`) o inactivo. */
const statusColumn: ListColumn = {
key: 'Frozen',
title: 'Estado',
width: '100px',
format: (r) => (r['Frozen'] === 'tYES' ? 'Inactivo' : 'Activo'),
tone: (r) => (r['Frozen'] === 'tYES' ? 'muted' : 'ok'),
};
const frozenFilter: SelectFilter = {
type: 'select',
key: 'Frozen',
label: 'Estado',
defaultValue: 'tNO',
options: [
{ value: 'tNO', label: 'Activo' },
{ value: 'tYES', label: 'Inactivo' },
],
};
/** Clientes y proveedores: el mismo recurso `BusinessPartners`, separados por `CardType`. */
const partner = (opts: { path: string; type: 'cCustomer' | 'cSupplier'; singular: string; plural: string }): MasterScreen => {
const field = (f: HeaderField): HeaderField => f;
const filters: ListFilter[] = [
frozenFilter,
{ type: 'multi', key: 'GroupCode', label: 'Grupo', resource: 'BusinessPartnerGroups', valueField: 'Code', labelField: 'Name', valueType: 'number' },
];
return {
path: opts.path,
resource: 'BusinessPartners',
keyField: 'CardCode',
singular: opts.singular,
createLabel: `Nuevo ${opts.singular.toLowerCase()}`,
orderby: 'CardCode',
baseFilter: `CardType eq '${opts.type}'`,
extraFields: ['Frozen'],
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: 'Phone1', title: 'Teléfono', width: '130px', searchable: true },
{ key: 'EmailAddress', title: 'Correo electrónico', searchable: true },
statusColumn,
],
filters,
layout: {
header: {
left: [
field({ type: 'text', key: 'CardCode', label: 'Código', immutable: true, required: true, missing: 'el código' }),
field({ type: 'text', key: 'CardName', label: 'Nombre', required: true, missing: 'el nombre' }),
],
right: [field({ type: 'checkbox', key: 'Frozen', label: 'Inactivo' })],
},
tabs: [
{
id: 'general',
label: 'General',
left: [
field({ type: 'text', key: 'FederalTaxID', label: 'RUT' }),
field({ type: 'text', key: 'CardForeignName', label: 'Nombre extranjero' }),
field({ type: 'lookup', key: 'GroupCode', label: 'Grupo', lookup: MASTER_LOOKUPS.partnerGroup(opts.type) }),
field({ type: 'lookup', key: 'Currency', label: 'Moneda', lookup: MASTER_LOOKUPS.currency }),
],
right: [
field({ type: 'text', key: 'Phone1', label: 'Teléfono 1' }),
field({ type: 'text', key: 'Phone2', label: 'Teléfono 2' }),
field({ type: 'text', key: 'Cellular', label: 'Celular' }),
field({ type: 'text', key: 'EmailAddress', label: 'Correo electrónico' }),
field({ type: 'text', key: 'Website', label: 'Sitio web' }),
field({ type: 'text', key: 'ContactPerson', label: 'Persona de contacto' }),
],
},
{
id: 'pago',
label: 'Condiciones de pago',
left: [
field({ type: 'lookup', key: 'PayTermsGrpCode', label: 'Condición de pago', lookup: MASTER_LOOKUPS.paymentTerms }),
field({ type: 'lookup', key: 'PriceListNum', label: 'Lista de precios', lookup: MASTER_LOOKUPS.priceList }),
field({ type: 'number', key: 'CreditLimit', label: 'Límite de crédito', decimals: 2 }),
field({ type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 }),
],
right: [],
},
{ id: 'observaciones', label: 'Observaciones', left: [field({ type: 'textarea', key: 'Notes', label: 'Observaciones' })], right: [] },
],
newHeader: () => ({ Frozen: 'tNO', CreditLimit: 0, DiscountPercent: 0 }),
fixed: { CardType: opts.type },
},
};
};
/** Socios de negocio: clientes y proveedores. */
export const SOCIOS_SCREENS: readonly MasterScreen[] = [
partner({ path: '/socios/clientes', type: 'cCustomer', singular: 'Cliente', plural: 'Clientes' }),
partner({ path: '/socios/proveedores', type: 'cSupplier', singular: 'Proveedor', plural: 'Proveedores' }),
];
+20
View File
@@ -124,6 +124,26 @@ export class NumberInput extends ControlBase {
}
}
/** Casilla de verificación (en SAP, un campo `tYES`/`tNO`; quien la usa convierte). */
@Component({
selector: 'sbo-checkbox',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input
type="checkbox"
[id]="id"
[checked]="value()"
[disabled]="disabled() || readOnly()"
[attr.aria-label]="ariaLabel() ?? null"
(change)="value.set($any($event.target).checked)"
/>
`,
styles: ':host { display: block; } input { margin: 4px 0; }',
})
export class CheckboxInput extends ControlBase {
readonly value = model(false);
}
/** Fecha como texto ISO `yyyy-MM-dd` (el formato del Service Layer). Selector nativo: sin librerías. */
@Component({
selector: 'sbo-date',
+16 -1
View File
@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
import { FieldBase } from './field-base';
import { DateInput, NumberInput, SelectInput, type SelectOption, TextInput, TextareaInput } from './controls';
import { CheckboxInput, DateInput, NumberInput, SelectInput, type SelectOption, TextInput, TextareaInput } from './controls';
import { SboField } from './sbo-field';
/*
@@ -32,6 +32,21 @@ export class TextField extends FieldBase {
readonly placeholder = input('');
}
@Component({
selector: 'sbo-checkbox-field',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, CheckboxInput],
template: `
<sbo-field [label]="label()" [error]="error()">
<sbo-checkbox [(value)]="value" [readonly]="readOnly()" [disabled]="disabled()" />
</sbo-field>
`,
styles: ':host { display: block; }',
})
export class CheckboxField extends FieldBase {
readonly value = model(false);
}
@Component({
selector: 'sbo-textarea-field',
changeDetection: ChangeDetectionStrategy.OnPush,
+2 -2
View File
@@ -1,6 +1,6 @@
// Fila y controles combinables
export { DateInput, NumberInput, type SelectOption, SelectInput, TextInput, TextareaInput } from './controls';
export { CheckboxInput, DateInput, NumberInput, type SelectOption, SelectInput, TextInput, TextareaInput } from './controls';
export { SboField } from './sbo-field';
// Atajos con etiqueta (un control por fila)
export { DateField, NumberField, SelectField, TextField, TextareaField } from './fields';
export { CheckboxField, DateField, NumberField, SelectField, TextField, TextareaField } from './fields';
export { LookupField } from './lookup-field';
@@ -15,4 +15,6 @@ export interface VhLookup {
valueKey?: string;
/** Campos que se piden al Service Layer sin mostrarlos como columna (los usa quien recibe la fila elegida). */
extraFields?: readonly string[];
/** Tipo de la clave de la lista: `number` para códigos numéricos (grupos, listas de precios), que no llevan comillas. */
valueType?: 'string' | 'number';
}