From 9f003ee4bfc588fcd47fb1d6fbf4ab0daf3e9d0a Mon Sep 17 00:00:00 2001 From: George Date: Fri, 16 Oct 2026 10:24:39 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20maestros=20de=20clientes,=20provee?= =?UTF-8?q?dores=20y=20art=C3=ADculos=20(lista,=20formulario=20con=20pesta?= =?UTF-8?q?=C3=B1as,=20extensiones=20por=20cliente)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 +- apps/api/src/sl/sl-resources.ts | 3 + apps/web/src/app/app.routes.spec.ts | 18 +- apps/web/src/app/app.routes.ts | 24 +- apps/web/src/app/core/sl/sl-lookup.spec.ts | 54 ++++ apps/web/src/app/core/sl/sl-lookup.ts | 5 +- apps/web/src/app/core/sl/sl-master.ts | 41 +++ .../features/documents/document-layouts.ts | 46 +++- .../masters/articulos/articulos.extensions.ts | 14 + .../masters/articulos/articulos.screens.ts | 97 +++++++ .../masters/master-extensions.spec.ts | 43 +++ .../app/features/masters/master-extensions.ts | 55 ++++ .../src/app/features/masters/master-field.ts | 127 +++++++++ .../features/masters/master-form.page.html | 51 ++++ .../features/masters/master-form.page.spec.ts | 260 ++++++++++++++++++ .../app/features/masters/master-form.page.ts | 179 ++++++++++++ .../features/masters/master-list.page.spec.ts | 61 ++++ .../app/features/masters/master-list.page.ts | 43 +++ .../app/features/masters/master-lookups.ts | 44 +++ .../app/features/masters/master-model.spec.ts | 113 ++++++++ .../src/app/features/masters/master-model.ts | 60 ++++ .../src/app/features/masters/master-paths.ts | 6 + .../app/features/masters/master-screens.ts | 26 ++ .../src/app/features/masters/master-types.ts | 44 +++ .../socios-negocio/socios.extensions.ts | 15 + .../masters/socios-negocio/socios.screens.ts | 102 +++++++ apps/web/src/app/shared/form/controls.ts | 20 ++ apps/web/src/app/shared/form/fields.ts | 17 +- apps/web/src/app/shared/form/index.ts | 4 +- .../app/shared/value-help/value-help.types.ts | 2 + 30 files changed, 1553 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/app/core/sl/sl-lookup.spec.ts create mode 100644 apps/web/src/app/core/sl/sl-master.ts create mode 100644 apps/web/src/app/features/masters/articulos/articulos.extensions.ts create mode 100644 apps/web/src/app/features/masters/articulos/articulos.screens.ts create mode 100644 apps/web/src/app/features/masters/master-extensions.spec.ts create mode 100644 apps/web/src/app/features/masters/master-extensions.ts create mode 100644 apps/web/src/app/features/masters/master-field.ts create mode 100644 apps/web/src/app/features/masters/master-form.page.html create mode 100644 apps/web/src/app/features/masters/master-form.page.spec.ts create mode 100644 apps/web/src/app/features/masters/master-form.page.ts create mode 100644 apps/web/src/app/features/masters/master-list.page.spec.ts create mode 100644 apps/web/src/app/features/masters/master-list.page.ts create mode 100644 apps/web/src/app/features/masters/master-lookups.ts create mode 100644 apps/web/src/app/features/masters/master-model.spec.ts create mode 100644 apps/web/src/app/features/masters/master-model.ts create mode 100644 apps/web/src/app/features/masters/master-paths.ts create mode 100644 apps/web/src/app/features/masters/master-screens.ts create mode 100644 apps/web/src/app/features/masters/master-types.ts create mode 100644 apps/web/src/app/features/masters/socios-negocio/socios.extensions.ts create mode 100644 apps/web/src/app/features/masters/socios-negocio/socios.screens.ts diff --git a/CLAUDE.md b/CLAUDE.md index a6af196..1da9486 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` 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 (``). 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). diff --git a/apps/api/src/sl/sl-resources.ts b/apps/api/src/sl/sl-resources.ts index c71629e..c280e33 100644 --- a/apps/api/src/sl/sl-resources.ts +++ b/apps/api/src/sl/sl-resources.ts @@ -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'), ]; diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 4e63ecd..c32600d 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -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ú', () => { diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index a892fda..1fc2147 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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, ], diff --git a/apps/web/src/app/core/sl/sl-lookup.spec.ts b/apps/web/src/app/core/sl/sl-lookup.spec.ts new file mode 100644 index 0000000..8e071c8 --- /dev/null +++ b/apps/web/src/app/core/sl/sl-lookup.spec.ts @@ -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; + }); +}); diff --git a/apps/web/src/app/core/sl/sl-lookup.ts b/apps/web/src/app/core/sl/sl-lookup.ts index ceed50e..4843e1c 100644 --- a/apps/web/src/app/core/sl/sl-lookup.ts +++ b/apps/web/src/app/core/sl/sl-lookup.ts @@ -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 { 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 ?? '') diff --git a/apps/web/src/app/core/sl/sl-master.ts b/apps/web/src/app/core/sl/sl-master.ts new file mode 100644 index 0000000..c5bfc27 --- /dev/null +++ b/apps/web/src/app/core/sl/sl-master.ts @@ -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; + +/** 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 { + 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>(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 { + return firstValueFrom(this.http.post(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 { + const headers = etag ? new HttpHeaders().set('If-Match', etag) : new HttpHeaders(); + return firstValueFrom(this.http.patch(this.url(resource, key), body, { headers })); + } +} diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index f99960b..82fe484 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -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'); diff --git a/apps/web/src/app/features/masters/articulos/articulos.extensions.ts b/apps/web/src/app/features/masters/articulos/articulos.extensions.ts new file mode 100644 index 0000000..f3a85bc --- /dev/null +++ b/apps/web/src/app/features/masters/articulos/articulos.extensions.ts @@ -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/` 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 = {}; diff --git a/apps/web/src/app/features/masters/articulos/articulos.screens.ts b/apps/web/src/app/features/masters/articulos/articulos.screens.ts new file mode 100644 index 0000000..6026ba5 --- /dev/null +++ b/apps/web/src/app/features/masters/articulos/articulos.screens.ts @@ -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, + }), + }, + }, +]; diff --git a/apps/web/src/app/features/masters/master-extensions.spec.ts b/apps/web/src/app/features/masters/master-extensions.spec.ts new file mode 100644 index 0000000..39272be --- /dev/null +++ b/apps/web/src/app/features/masters/master-extensions.spec.ts @@ -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/); + }); +}); diff --git a/apps/web/src/app/features/masters/master-extensions.ts b/apps/web/src/app/features/masters/master-extensions.ts new file mode 100644 index 0000000..aacca33 --- /dev/null +++ b/apps/web/src/app/features/masters/master-extensions.ts @@ -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>; + /** 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>; + +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 ?? [])] }, + }; + }); +} diff --git a/apps/web/src/app/features/masters/master-field.ts b/apps/web/src/app/features/masters/master-field.ts new file mode 100644 index 0000000..4af6da9 --- /dev/null +++ b/apps/web/src/app/features/masters/master-field.ts @@ -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') { + + } + @case ('lookup') { + + } + @case ('warehouse') { + + } + @case ('select') { + + } + @case ('number') { + + } + @case ('date') { + + } + @case ('textarea') { + + } + @default { + + } + } + `, + styles: ':host { display: block; }', +}) +export class MasterField { + readonly field = input.required(); + readonly value = input(''); + /** 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(); + + 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; + }); +} diff --git a/apps/web/src/app/features/masters/master-form.page.html b/apps/web/src/app/features/masters/master-form.page.html new file mode 100644 index 0000000..e14e9c6 --- /dev/null +++ b/apps/web/src/app/features/masters/master-form.page.html @@ -0,0 +1,51 @@ + + Lista + Nuevo + + + + + + + + +
+
+ @for (f of layout.header.left; track $index) { + + } +
+
+ @for (f of layout.header.right; track $index) { + + } +
+
+ + + @for (tab of layout.tabs; track tab.id) { + +
+
+ @for (f of tab.left; track $index) { + + } +
+
+ @for (f of tab.right; track $index) { + + } +
+
+
+ } +
+ + + @if (conflict()) { + + } + Cancelar +
diff --git a/apps/web/src/app/features/masters/master-form.page.spec.ts b/apps/web/src/app/features/masters/master-form.page.spec.ts new file mode 100644 index 0000000..8a090ad --- /dev/null +++ b/apps/web/src/app/features/masters/master-form.page.spec.ts @@ -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 = {}) => ({ + '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; + let ctrl: HttpTestingController; + let navigate: ReturnType; + 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) { + 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('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('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('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('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)['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'); + }); +}); diff --git a/apps/web/src/app/features/masters/master-form.page.ts b/apps/web/src/app/features/masters/master-form.page.ts new file mode 100644 index 0000000..9edd7ff --- /dev/null +++ b/apps/web/src/app/features/masters/master-form.page.ts @@ -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(() => { + 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(() => 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 => { + 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(() => { + 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(() => { + 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 { + 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 { + 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(); + } +} diff --git a/apps/web/src/app/features/masters/master-list.page.spec.ts b/apps/web/src/app/features/masters/master-list.page.spec.ts new file mode 100644 index 0000000..bfd1dd4 --- /dev/null +++ b/apps/web/src/app/features/masters/master-list.page.spec.ts @@ -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; + let ctrl: HttpTestingController; + let navigate: ReturnType; + 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()); + }); +}); diff --git a/apps/web/src/app/features/masters/master-list.page.ts b/apps/web/src/app/features/masters/master-list.page.ts new file mode 100644 index 0000000..a1cbc67 --- /dev/null +++ b/apps/web/src/app/features/masters/master-list.page.ts @@ -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: ` + + `, + 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 }); + } +} diff --git a/apps/web/src/app/features/masters/master-lookups.ts b/apps/web/src/app/features/masters/master-lookups.ts new file mode 100644 index 0000000..b78c48d --- /dev/null +++ b/apps/web/src/app/features/masters/master-lookups.ts @@ -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 => ({ 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; diff --git a/apps/web/src/app/features/masters/master-model.spec.ts b/apps/web/src/app/features/masters/master-model.spec.ts new file mode 100644 index 0000000..2ae0b0f --- /dev/null +++ b/apps/web/src/app/features/masters/master-model.spec.ts @@ -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'); + } + }); +}); diff --git a/apps/web/src/app/features/masters/master-model.ts b/apps/web/src/app/features/masters/master-model.ts new file mode 100644 index 0000000..76a0fdb --- /dev/null +++ b/apps/web/src/app/features/masters/master-model.ts @@ -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 => + 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 }; + } +} diff --git a/apps/web/src/app/features/masters/master-paths.ts b/apps/web/src/app/features/masters/master-paths.ts new file mode 100644 index 0000000..ca00520 --- /dev/null +++ b/apps/web/src/app/features/masters/master-paths.ts @@ -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']; diff --git a/apps/web/src/app/features/masters/master-screens.ts b/apps/web/src/app/features/masters/master-screens.ts new file mode 100644 index 0000000..891a557 --- /dev/null +++ b/apps/web/src/app/features/masters/master-screens.ts @@ -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); diff --git a/apps/web/src/app/features/masters/master-types.ts b/apps/web/src/app/features/masters/master-types.ts new file mode 100644 index 0000000..7d75e86 --- /dev/null +++ b/apps/web/src/app/features/masters/master-types.ts @@ -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; +} diff --git a/apps/web/src/app/features/masters/socios-negocio/socios.extensions.ts b/apps/web/src/app/features/masters/socios-negocio/socios.extensions.ts new file mode 100644 index 0000000..0008b32 --- /dev/null +++ b/apps/web/src/app/features/masters/socios-negocio/socios.extensions.ts @@ -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/` 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 = {}; diff --git a/apps/web/src/app/features/masters/socios-negocio/socios.screens.ts b/apps/web/src/app/features/masters/socios-negocio/socios.screens.ts new file mode 100644 index 0000000..6a1ee8e --- /dev/null +++ b/apps/web/src/app/features/masters/socios-negocio/socios.screens.ts @@ -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' }), +]; diff --git a/apps/web/src/app/shared/form/controls.ts b/apps/web/src/app/shared/form/controls.ts index 192c856..2e1d7f9 100644 --- a/apps/web/src/app/shared/form/controls.ts +++ b/apps/web/src/app/shared/form/controls.ts @@ -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: ` + + `, + 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', diff --git a/apps/web/src/app/shared/form/fields.ts b/apps/web/src/app/shared/form/fields.ts index 76c2a28..26bd9c0 100644 --- a/apps/web/src/app/shared/form/fields.ts +++ b/apps/web/src/app/shared/form/fields.ts @@ -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: ` + + + + `, + styles: ':host { display: block; }', +}) +export class CheckboxField extends FieldBase { + readonly value = model(false); +} + @Component({ selector: 'sbo-textarea-field', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/apps/web/src/app/shared/form/index.ts b/apps/web/src/app/shared/form/index.ts index 6ba4387..dfd85a9 100644 --- a/apps/web/src/app/shared/form/index.ts +++ b/apps/web/src/app/shared/form/index.ts @@ -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'; diff --git a/apps/web/src/app/shared/value-help/value-help.types.ts b/apps/web/src/app/shared/value-help/value-help.types.ts index b54596a..9320522 100644 --- a/apps/web/src/app/shared/value-help/value-help.types.ts +++ b/apps/web/src/app/shared/value-help/value-help.types.ts @@ -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'; }