diff --git a/CLAUDE.md b/CLAUDE.md index 15110d3..3f6e829 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compart Pendiente, en orden: 1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Formularios sin socio ni totales **hechos** (`document-layouts.ts`, configuración declarativa: cabecera de 2 columnas, columnas de línea, colección `DocumentLines` o `StockTransferLines`): entradas y salidas de inventario, transferencias de stock, solicitudes de traslado y solicitudes de compra. Entradas, salidas y transferencias contabilizadas son de solo lectura (SAP no las modifica); solicitudes de traslado y de compra se crean y modifican. Probado contra `SBODemoCL`: solicitud de compra 2 (la 1 es una prueba por API), solicitud de traslado 472 (cantidad modificada) y entrada de inventario 155 (1 unidad de `3MG30080714 M 10.5` en el almacén 8803). Limitaciones conocidas: los artículos por **lote o serie** no se pueden contabilizar (SAP pide `BatchNumbers`/`SerialNumbers`); las líneas de compra/inventario llevan `UoMEntry` solo si el artículo tiene grupo de unidades (`uomEntryOf`; sin él SAP responde "specify a UoM code"); los formularios de ventas y compras también lo envían (la unidad de venta o de compra del artículo, o la de inventario; `itemColumnWithUom`), y no se envía la unidad manual (-1). Probado: orden de compra 2884 con `SU01`, que no tiene unidad de compra por defecto. **Cómo se define un formulario:** el formulario base (`document-form.page`) no sabe de qué documento se trata; recibe de la pantalla su `layout` (`DocumentLayout` en `document-layouts.ts`): `header` y `footer` (cada uno con `left`/`right`, máximo 2 columnas) son arreglos de campos tomados del catálogo `FIELDS` (texto, fecha, número, almacén, socio, folio, calculado), `columns` son las columnas de línea y `lines` la colección que se guarda. Cada pantalla (en la carpeta de su módulo) declara los suyos (la orden de venta no lleva folio; las facturas, notas de crédito, entregas y recepciones sí, con `TAX_DOCUMENT`); agregar o quitar un campo es editar ese arreglo, sin tocar el HTML. En ventas y compras la pantalla puede agregar campos (`headerLeft`/`headerRight`), reemplazar la cabecera (`header`) o elegir sus columnas de línea del catálogo `LINE_COLUMNS` (`lineColumns: [LINE_COLUMNS.item, LINE_COLUMNS.quantity, …]`; lo que se envía por línea sale de ellas); sin elegir, el documento lleva la cabecera y todas las columnas base. -**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 al final 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). +**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 al final y se envían a SAP) y `listColumns`. Además de agregar, la extensión puede **ocultar** (`hideHeaderFields`, `hideLineColumns`, `hideListColumns`; en maestros `hideFields`, `hideListColumns`) y dejar de **solo lectura** (`readOnlyHeaderFields`, `readOnlyLineColumns`; en maestros `readOnlyFields`): lo oculto ni se muestra ni se envía; lo de solo lectura se ve pero no se edita y su valor sigue enviándose (columna `locked`, a diferencia de `readonly`, que no se envía). Los nombres son los de SAP (`CostingCode2`, `Indicator`…); un nombre inexistente, ocultar un campo/columna obligatorio, o una ruta inexistente o ajena al módulo lanzan 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. **Campos de contabilidad analítica y numeración (estándar):** cabecera de todos los documentos: `Series` (la fila «Número» lleva la lista de series del documento, pedida con la acción `SeriesService_GetDocumentSeries` del Service Layer —registrada en el proxy como acción de solo consulta—, filtrada por el subtipo de la pantalla; no se puede cambiar una vez creado; sin elegir, SAP usa la por defecto), `Indicator` y `Project` (lista de proyectos). Detalle de ventas y compras: `PriceAfterVAT` (el precio con IVA; solo se envía a SAP el que el usuario escribió, `Price` o `PriceAfterVAT`, nunca los dos), `CostingCode` a `CostingCode5` y `ProjectCode` (cada uno con su **ventana modal** de selección: reglas de distribución por dimensión y proyectos), `AccountCode` (plan de cuentas) y `BaseType`/`BaseEntry`/`BaseLine` (solo lectura: el documento de origen de una línea copiada). Inventario y solicitudes de compra llevan en el detalle solo las cinco dimensiones y el proyecto. `OBJECT_TYPES` (`document-objects.ts`) da el tipo de objeto de SAP de cada pantalla. El subtipo de las series de boleta (`IB`) y el resto de `SERIES_SUBTYPE` no está verificado contra SBODemoCL (solo hay series `--`). diff --git a/apps/web/src/app/features/documents/compras/compras.extensions.ts b/apps/web/src/app/features/documents/compras/compras.extensions.ts index 72258a8..421182c 100644 --- a/apps/web/src/app/features/documents/compras/compras.extensions.ts +++ b/apps/web/src/app/features/documents/compras/compras.extensions.ts @@ -5,6 +5,10 @@ import type { ScreenExtensions } from '../screen-extensions'; * vacío: el proyecto base solo lleva campos estándar de SAP. La rama `cliente/` lo completa * con campos estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo. * + * Además de agregar, puede ocultar (`hideHeaderFields`, `hideLineColumns`, `hideListColumns`) y dejar de solo + * lectura (`readOnlyHeaderFields`, `readOnlyLineColumns`: se ven pero no se editan; su valor sigue enviándose). + * Un nombre que no exista, o ocultar lo obligatorio, lanza un error al arrancar. + * * Ejemplo (los campos son de ejemplo): * * '/compras/facturas': { diff --git a/apps/web/src/app/features/documents/document-form.page.html b/apps/web/src/app/features/documents/document-form.page.html index 72cf026..4915457 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -83,7 +83,7 @@ @@ -126,7 +126,7 @@ @@ -145,7 +145,7 @@ @case ('textarea') { @@ -155,13 +155,13 @@ @@ -174,7 +174,7 @@ ariaLabel="Serie de numeración" placeholder="(por defecto)" [options]="seriesOptions()" - [readonly]="readOnly() || !isNew()" + [readonly]="readOnly() || !isNew() || locked(f)" [value]="seriesValue()" (valueChange)="setHeader(f.key, $event ?? '')" /> @@ -188,14 +188,14 @@ ariaLabel="Gasto adicional del flete" placeholder="" [options]="expenseOptions()" - [readonly]="readOnly() || !freightEditable()" + [readonly]="readOnly() || !freightEditable() || locked(f)" [value]="str(header()['FreightExpense']) || defaultExpense() || null" (valueChange)="setHeader('FreightExpense', $event ?? '')" /> @@ -207,7 +207,7 @@ @default { diff --git a/apps/web/src/app/features/documents/document-form.page.ts b/apps/web/src/app/features/documents/document-form.page.ts index e448be2..ba32ad4 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -28,7 +28,7 @@ import { updatePayload, validateLayout, } from './document-model'; -import { type ComputedSource, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, hasFreight, hasSeries, headerKeysOf, requiredHeaderOf } from './document-layouts'; +import { type ComputedSource, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, hasFreight, hasSeries, headerKeysOf, keysOfFields, requiredHeaderOf } from './document-layouts'; import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy'; import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens'; import { OBJECT_TYPES } from './document-objects'; @@ -330,6 +330,12 @@ export class DocumentFormPage { protected str = text; + private readonly lockedKeys = new Set(this.layout.lockedKeys ?? []); + /** El campo se ve pero no se edita (una extensión de cliente lo dejó de solo lectura). */ + protected locked(f: HeaderField): boolean { + return this.lockedKeys.size > 0 && (keysOfFields([f]).some((k) => this.lockedKeys.has(k)) || (f.type === 'computed' && this.lockedKeys.has(f.source))); + } + /** Valor de una fila de solo lectura (número del documento, datos del socio, totales). */ protected computedValue(source: ComputedSource): string { switch (source) { diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index 64cc6cc..32eccdb 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -73,6 +73,8 @@ export interface DocumentLayout { newLine: (header: DocRecord) => GridLine; /** Recalcula una línea tras un cambio (total = cantidad × precio…); recibe la tasa de cada código de impuesto. */ recalc?: (line: GridLine, changedKey: string, rates?: ReadonlyMap) => GridLine; + /** Campos de cabecera que se ven pero no se editan (un cliente puede querer verlos sin cambiarlos). */ + lockedKeys?: readonly string[]; /** Subtipo con que SAP etiqueta las series de este documento (`--` = ninguno, `IB` = boleta…). */ seriesSubType?: string; /** Columnas de las listas de valores de este documento; las que falten usan las del base. */ diff --git a/apps/web/src/app/features/documents/inventario/inventario.extensions.ts b/apps/web/src/app/features/documents/inventario/inventario.extensions.ts index 1b81391..4c2ed8c 100644 --- a/apps/web/src/app/features/documents/inventario/inventario.extensions.ts +++ b/apps/web/src/app/features/documents/inventario/inventario.extensions.ts @@ -5,6 +5,10 @@ import type { ScreenExtensions } from '../screen-extensions'; * vacío: el proyecto base solo lleva campos estándar de SAP. La rama `cliente/` lo completa * con campos estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo. * + * Además de agregar, puede ocultar (`hideHeaderFields`, `hideLineColumns`, `hideListColumns`) y dejar de solo + * lectura (`readOnlyHeaderFields`, `readOnlyLineColumns`: se ven pero no se editan; su valor sigue enviándose). + * Un nombre que no exista, o ocultar lo obligatorio, lanza un error al arrancar. + * * Ejemplo (los campos son de ejemplo): * * '/inventario/transferencias': { diff --git a/apps/web/src/app/features/documents/screen-extensions.spec.ts b/apps/web/src/app/features/documents/screen-extensions.spec.ts index d771769..4eafeb6 100644 --- a/apps/web/src/app/features/documents/screen-extensions.spec.ts +++ b/apps/web/src/app/features/documents/screen-extensions.spec.ts @@ -6,6 +6,7 @@ import { of } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CLIENT_EXTENSIONS } from './client-extensions'; import { headerKeysOf } from './document-layouts'; +import { lineFieldsOf } from './line-columns'; import { DocumentFormPage } from './document-form.page'; import { BASE_SCREENS, DOCUMENT_SCREENS } from './document-screens'; import { type ScreenExtensions, applyExtensions } from './screen-extensions'; @@ -224,3 +225,122 @@ describe('columnas de las listas de valores por documento', () => { for (const c of cols) expect(c.lookup!.config.columns.at(-1)!.key).toBe('U_Sucursal'); }); }); + +describe('ocultar y dejar de solo lectura (extensiones de cliente)', () => { + const layoutOf = (out: readonly { path: string }[], path: string) => by(out, path).layout; + const apply = (e: ScreenExtensions) => applyExtensions(BASE_SCREENS, e); + const DIMS_2_5 = ['CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5']; + + it('un cliente que solo quiere CostingCode oculta las otras dimensiones: no se muestran ni se envían', () => { + const l = layoutOf(apply({ '/ventas/facturas': { hideLineColumns: DIMS_2_5 } }), '/ventas/facturas'); + const keys = l.columns.map((c) => c.key); + expect(keys).toContain('CostingCode'); + for (const k of DIMS_2_5) { + expect(keys).not.toContain(k); + expect(l.lines.fields).not.toContain(k); + } + expect(l.lines.fields).toContain('CostingCode'); + }); + + it('oculta también las columnas de lectura y de lista, sin tocar las demás pantallas', () => { + const out = apply({ + '/ventas/facturas': { hideLineColumns: ['BaseType', 'BaseEntry', 'BaseLine', 'AccountCode'], hideListColumns: ['FolioPrefixString'] }, + }); + expect(layoutOf(out, '/ventas/facturas').columns.map((c) => c.key)).not.toEqual(expect.arrayContaining(['BaseType'])); + expect(by(out, '/ventas/facturas').columns.map((c) => c.key)).not.toContain('FolioPrefixString'); + expect(by(out, '/ventas/facturas').columns.map((c) => c.key)).toContain('FolioNumber'); + expect(layoutOf(out, '/ventas/ordenes').columns.map((c) => c.key)).toEqual(layoutOf(BASE_SCREENS, '/ventas/ordenes').columns.map((c) => c.key)); + }); + + it('oculta campos de cabecera: dejan de mostrarse y de enviarse', () => { + const l = layoutOf(apply({ '/ventas/ordenes': { hideHeaderFields: ['Indicator', 'Project', 'partnerTaxId'] } }), '/ventas/ordenes'); + const keys = headerKeysOf(l); + expect(keys).not.toEqual(expect.arrayContaining(['Indicator'])); + expect(keys).not.toContain('Project'); + expect(l.header.left.map((f) => ('label' in f ? f.label : ''))).not.toContain('RUT'); + expect(keys).toContain('NumAtCard'); + }); + + it('el cliente puede ocultar una columna base y agregar la suya', () => { + const l = layoutOf(apply({ '/ventas/ordenes': { hideLineColumns: ['AccountCode'], lineColumns: [{ key: 'U_Lote', title: 'Lote' }] } }), '/ventas/ordenes'); + const keys = l.columns.map((c) => c.key); + expect(keys).not.toContain('AccountCode'); + expect(keys.at(-1)).toBe('U_Lote'); + }); + + it('una columna de solo lectura se ve y no se edita, pero su valor sigue enviándose', () => { + const l = layoutOf(apply({ '/ventas/facturas': { readOnlyLineColumns: ['CostingCode', 'ProjectCode'] } }), '/ventas/facturas'); + const col = (k: string) => l.columns.find((c) => c.key === k)!; + expect(col('CostingCode').locked).toBe(true); + expect(col('ProjectCode').locked).toBe(true); + expect(col('CostingCode2').locked).toBeUndefined(); + expect(l.lines.fields).toEqual(expect.arrayContaining(['CostingCode', 'ProjectCode'])); + expect(lineFieldsOf(l.columns)).toEqual(expect.arrayContaining(['CostingCode', 'ProjectCode'])); + }); + + it('un campo de cabecera de solo lectura queda marcado y se sigue enviando', () => { + const l = layoutOf(apply({ '/ventas/ordenes': { readOnlyHeaderFields: ['Indicator', 'NumAtCard'] } }), '/ventas/ordenes'); + expect(l.lockedKeys).toEqual(expect.arrayContaining(['Indicator', 'NumAtCard'])); + expect(headerKeysOf(l)).toEqual(expect.arrayContaining(['Indicator', 'NumAtCard'])); + }); + + it('no se puede ocultar lo obligatorio, y un nombre que no existe es un error', () => { + expect(() => apply({ '/ventas/ordenes': { hideLineColumns: ['ItemCode'] } })).toThrow(/obligatoria/); + expect(() => apply({ '/ventas/ordenes': { hideLineColumns: ['Quantity'] } })).toThrow(/obligatoria/); + expect(() => apply({ '/ventas/ordenes': { hideHeaderFields: ['CardCode'] } })).toThrow(/obligatorio/); + expect(() => apply({ '/inventario/entradas': { hideLineColumns: ['WarehouseCode'] } })).toThrow(/obligatoria/); + expect(() => apply({ '/ventas/ordenes': { hideLineColumns: ['CostingCode9'] } })).toThrow(/no tiene la columna de línea/); + expect(() => apply({ '/ventas/ordenes': { hideHeaderFields: ['Nada'] } })).toThrow(/no tiene el campo de cabecera/); + expect(() => apply({ '/ventas/ordenes': { readOnlyLineColumns: ['Nada'] } })).toThrow(/no tiene la columna de línea/); + expect(() => apply({ '/ventas/ordenes': { hideListColumns: ['Nada'] } })).toThrow(/no tiene la columna de lista/); + }); +}); + +describe('formulario con campos ocultos o de solo lectura', () => { + let fixture: ComponentFixture; + const el = () => fixture.nativeElement as HTMLElement; + const tick = async () => { + fixture.detectChanges(); + await new Promise((r) => setTimeout(r)); + fixture.detectChanges(); + }; + + async function openWith(ext: ScreenExtensions) { + TestBed.resetTestingModule(); + const screen = by(applyExtensions(BASE_SCREENS, ext), '/ventas/ordenes'); + const route = { + snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap({}) }, + paramMap: of(convertToParamMap({})), + }; + await TestBed.configureTestingModule({ + imports: [DocumentFormPage], + providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], + }).compileComponents(); + fixture = TestBed.createComponent(DocumentFormPage); + await tick(); + } + const input = (label: string) => { + const f = [...el().querySelectorAll('sbo-field, sbo-text-field, sbo-lookup-field')].find((x) => x.querySelector('label')?.textContent?.trim() === label); + return f?.querySelector('input') ?? null; + }; + + it('un campo oculto no aparece en el formulario', async () => { + await openWith({ '/ventas/ordenes': { hideHeaderFields: ['Indicator'] } }); + expect([...el().querySelectorAll('label')].map((l) => l.textContent?.trim())).not.toContain('Indicador'); + expect([...el().querySelectorAll('label')].map((l) => l.textContent?.trim())).toContain('Proyecto'); + }); + + it('un campo de solo lectura se ve pero no se puede escribir', async () => { + await openWith({ '/ventas/ordenes': { readOnlyHeaderFields: ['Indicator', 'Project'] } }); + expect(input('Indicador')!.readOnly).toBe(true); + expect(input('Proyecto')!.readOnly).toBe(true); + expect(input('Nº referencia')!.readOnly).toBe(false); + const buttons = [...el().querySelectorAll('sbo-lookup-field')].find((f) => f.querySelector('label')?.textContent?.trim() === 'Proyecto')!.querySelector('button.sbo-btn') as HTMLButtonElement; + expect(buttons.disabled).toBe(true); + }); + + it('sin extensiones los campos se editan como siempre', async () => { + await openWith({}); + expect(input('Indicador')!.readOnly).toBe(false); + }); +}); diff --git a/apps/web/src/app/features/documents/screen-extensions.ts b/apps/web/src/app/features/documents/screen-extensions.ts index 70a6b01..2110b47 100644 --- a/apps/web/src/app/features/documents/screen-extensions.ts +++ b/apps/web/src/app/features/documents/screen-extensions.ts @@ -1,12 +1,15 @@ import type { GridColumn } from '../../shared/grid/grid.types'; import type { ListColumn } from '../../shared/list/list.types'; -import { type DocumentLayout, type FieldColumns, type HeaderField, type LookupColumns, withLookupColumns } from './document-layouts'; +import { type DocumentLayout, type FieldColumns, type HeaderField, type LookupColumns, isRequired, keysOfFields, withLookupColumns } from './document-layouts'; import type { DocumentScreen } from './screen-kit'; import { lineFieldsOf } from './line-columns'; /** - * Lo que una rama de cliente agrega a una pantalla (campos estándar adicionales o de usuario `U_*`) - * sin tocar las pantallas base. Solo suma: no quita ni cambia lo que ya trae el base. + * Lo que una rama de cliente cambia en una pantalla sin tocar las pantallas base: agrega campos estándar + * adicionales o de usuario (`U_*`), oculta lo que no usa o lo deja de solo lectura. + * + * Los nombres de `hide…` y `readOnly…` son los de SAP (`CostingCode2`, `Indicator`…); uno que no exista, o + * que sea obligatorio (no se puede ocultar un campo sin el que no se guarda el documento), es un error. */ export interface ScreenExtension { /** Campos al final de cada columna de la cabecera o del pie. */ @@ -20,6 +23,16 @@ export interface ScreenExtension { listColumns?: readonly ListColumn[]; /** Columnas que se suman al final de las listas de valores (ventanas de selección) de este documento. */ lookupColumns?: LookupColumns; + /** Campos de cabecera o de pie que no se muestran (ni se envían). */ + hideHeaderFields?: readonly string[]; + /** Campos de cabecera o de pie que se ven pero no se editan. */ + readOnlyHeaderFields?: readonly string[]; + /** Columnas de línea que no se muestran (ni se envían a SAP). */ + hideLineColumns?: readonly string[]; + /** Columnas de línea que se ven pero no se editan (su valor sigue enviándose, ej. el copiado del origen). */ + readOnlyLineColumns?: readonly string[]; + /** Columnas de la lista que no se muestran. */ + hideListColumns?: readonly string[]; } /** Extensiones por ruta de la pantalla (`/ventas/facturas`). */ @@ -30,16 +43,50 @@ const withFields = (base: FieldColumns, left?: readonly HeaderField[], right?: r right: [...base.right, ...(right ?? [])], }); -function extendLayout(layout: DocumentLayout, ext: ScreenExtension): DocumentLayout { +/** Nombres con que se puede referir a un campo del formulario: sus claves de SAP y, si es calculado, su origen. */ +const namesOf = (f: HeaderField): string[] => [...keysOfFields([f]), ...(f.type === 'computed' ? [f.source] : [])]; + +const allFieldsOf = (layout: DocumentLayout): HeaderField[] => [ + ...layout.header.left, + ...layout.header.right, + ...layout.footer.left, + ...layout.footer.right, +]; + +function checkNames(path: string, what: string, wanted: readonly string[] | undefined, known: readonly string[]): void { + for (const n of wanted ?? []) if (!known.includes(n)) throw new Error(`client-extensions: «${path}» no tiene ${what} «${n}»`); +} + +/** Aplica a un layout lo que oculta, deja de solo lectura y agrega la extensión de `path`. */ +function extendLayout(path: string, layout: DocumentLayout, ext: ScreenExtension): DocumentLayout { + // --- validar los nombres contra lo que trae el base + const fields = allFieldsOf(layout); + checkNames(path, 'el campo de cabecera', [...(ext.hideHeaderFields ?? []), ...(ext.readOnlyHeaderFields ?? [])], fields.flatMap(namesOf)); + checkNames(path, 'la columna de línea', [...(ext.hideLineColumns ?? []), ...(ext.readOnlyLineColumns ?? [])], layout.columns.map((c) => c.key)); + const hideHeader = new Set(ext.hideHeaderFields ?? []); + for (const f of fields) { + if (isRequired(f) && namesOf(f).some((n) => hideHeader.has(n))) throw new Error(`client-extensions: «${path}» no puede ocultar el campo obligatorio «${f.label}»`); + } + const hideLine = new Set(ext.hideLineColumns ?? []); + const mustKeep = new Set([...layout.columns.filter((c) => c.required).map((c) => c.key), ...layout.requiredLine.map((r) => r.key)]); + for (const k of hideLine) if (mustKeep.has(k)) throw new Error(`client-extensions: «${path}» no puede ocultar la columna obligatoria «${k}»`); + + // --- ocultar, bloquear y agregar + const visible = (cols: FieldColumns): FieldColumns => ({ + left: cols.left.filter((f) => !namesOf(f).some((n) => hideHeader.has(n))), + right: cols.right.filter((f) => !namesOf(f).some((n) => hideHeader.has(n))), + }); + const lockedLine = new Set(ext.readOnlyLineColumns ?? []); + const base = layout.columns.filter((c) => !hideLine.has(c.key)).map((c) => (lockedLine.has(c.key) ? { ...c, locked: true } : c)); const extra = ext.lineColumns ?? []; - const columns = [...layout.columns, ...extra]; - const fields = [...new Set([...layout.lines.fields, ...lineFieldsOf(extra)])]; + const lockedKeys = fields.filter((f) => namesOf(f).some((n) => (ext.readOnlyHeaderFields ?? []).includes(n))).flatMap((f) => keysOfFields([f])); const extended: DocumentLayout = { ...layout, - header: withFields(layout.header, ext.headerLeft, ext.headerRight), - footer: withFields(layout.footer, ext.footerLeft, ext.footerRight), - columns, - lines: { ...layout.lines, fields }, + header: withFields(visible(layout.header), ext.headerLeft, ext.headerRight), + footer: withFields(visible(layout.footer), ext.footerLeft, ext.footerRight), + columns: [...base, ...extra], + lines: { ...layout.lines, fields: [...new Set([...layout.lines.fields.filter((f) => !hideLine.has(f)), ...lineFieldsOf(extra)])] }, + ...(lockedKeys.length ? { lockedKeys: [...new Set([...(layout.lockedKeys ?? []), ...lockedKeys])] } : {}), }; return ext.lookupColumns ? withLookupColumns(extended, ext.lookupColumns, 'add') : extended; } @@ -55,10 +102,11 @@ export function applyExtensions(screens: readonly DocumentScreen[], extensions: return screens.map((s) => { const ext = extensions[s.path]; if (!ext) return s; + checkNames(s.path, 'la columna de lista', ext.hideListColumns, s.columns.map((c) => c.key)); return { ...s, - layout: extendLayout(s.layout, ext), - columns: [...s.columns, ...(ext.listColumns ?? [])], + layout: extendLayout(s.path, s.layout, ext), + columns: [...s.columns.filter((c) => !(ext.hideListColumns ?? []).includes(c.key)), ...(ext.listColumns ?? [])], }; }); } diff --git a/apps/web/src/app/features/documents/ventas/ventas.extensions.ts b/apps/web/src/app/features/documents/ventas/ventas.extensions.ts index 11ccddc..a75f4c8 100644 --- a/apps/web/src/app/features/documents/ventas/ventas.extensions.ts +++ b/apps/web/src/app/features/documents/ventas/ventas.extensions.ts @@ -5,6 +5,10 @@ import type { ScreenExtensions } from '../screen-extensions'; * vacío: el proyecto base solo lleva campos estándar de SAP. La rama `cliente/` lo completa * con campos estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo. * + * Además de agregar, puede ocultar (`hideHeaderFields`, `hideLineColumns`, `hideListColumns`) y dejar de solo + * lectura (`readOnlyHeaderFields`, `readOnlyLineColumns`: se ven pero no se editan; su valor sigue enviándose). + * Un nombre que no exista, o ocultar lo obligatorio, lanza un error al arrancar. + * * Ejemplo (los campos son de ejemplo): * * '/ventas/facturas': { @@ -12,6 +16,11 @@ import type { ScreenExtensions } from '../screen-extensions'; * lineColumns: [{ key: 'U_Lote', title: 'Lote', width: '90px' }], * listColumns: [{ key: 'U_Obra', title: 'Obra', searchable: true }], * }, + * '/ventas/facturas': { + * hideLineColumns: ['CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5'], // solo quiere la dimensión 1 + * readOnlyLineColumns: ['ProjectCode'], // el proyecto se ve pero no se edita + * hideHeaderFields: ['Indicator'], + * }, * '/ventas/ordenes': { * headerLeft: [ * { type: 'select', key: 'U_Canal', label: 'Canal', options: [{ value: 'W', label: 'Web' }, { value: 'T', label: 'Tienda' }] }, diff --git a/apps/web/src/app/features/masters/articulos/articulos.extensions.ts b/apps/web/src/app/features/masters/articulos/articulos.extensions.ts index f3a85bc..5e97c7a 100644 --- a/apps/web/src/app/features/masters/articulos/articulos.extensions.ts +++ b/apps/web/src/app/features/masters/articulos/articulos.extensions.ts @@ -5,6 +5,10 @@ import type { MasterExtensions } from '../master-extensions'; * 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. * + * Además de agregar, puede ocultar campos (`hideFields`, `hideListColumns`) y dejarlos de solo lectura + * (`readOnlyFields`: se ven pero no se editan). Un nombre que no exista, o ocultar un campo obligatorio, lanza + * un error al arrancar. + * * Ejemplo (los campos son de ejemplo): * * '/inventario/articulos': { diff --git a/apps/web/src/app/features/masters/master-extensions.spec.ts b/apps/web/src/app/features/masters/master-extensions.spec.ts index 39272be..504e48a 100644 --- a/apps/web/src/app/features/masters/master-extensions.spec.ts +++ b/apps/web/src/app/features/masters/master-extensions.spec.ts @@ -41,3 +41,43 @@ describe('extensiones por cliente de los maestros', () => { expect(() => applyMasterExtensions(BASE_MASTER_SCREENS, { '/socios/clientes': { tabs: { nada: { left: [] } } } })).toThrow(/no tiene la pestaña/); }); }); + +describe('ocultar y dejar de solo lectura en los maestros', () => { + const apply = (e: MasterExtensions) => applyMasterExtensions(BASE_MASTER_SCREENS, e); + const keys = (out: readonly { path: string }[], path: string) => masterKeysOf(by(out, path).layout); + + it('oculta campos de cualquier pestaña y de la cabecera: no se muestran ni se envían', () => { + const out = apply({ '/socios/clientes': { hideFields: ['Website', 'Frozen'] } }); + expect(keys(out, '/socios/clientes')).not.toContain('Website'); + expect(keys(out, '/socios/clientes')).not.toContain('Frozen'); + expect(keys(out, '/socios/clientes')).toContain('EmailAddress'); + expect(keys(out, '/socios/proveedores')).toContain('Website'); + }); + + it('oculta columnas de la lista', () => { + const out = apply({ '/socios/clientes': { hideListColumns: ['EmailAddress'] } }); + expect(by(out, '/socios/clientes').columns.map((c) => c.key)).not.toContain('EmailAddress'); + expect(by(out, '/socios/clientes').columns.map((c) => c.key)).toContain('Phone1'); + }); + + it('un campo de solo lectura queda marcado y sigue siendo parte del registro', () => { + const out = apply({ '/inventario/articulos': { readOnlyFields: ['BarCode', 'MinInventory'] } }); + const l = by(out, '/inventario/articulos').layout; + expect(l.lockedKeys).toEqual(['BarCode', 'MinInventory']); + expect(masterKeysOf(l)).toEqual(expect.arrayContaining(['BarCode', 'MinInventory'])); + }); + + it('puede ocultar un campo del base y agregar el suyo', () => { + const out = apply({ '/socios/clientes': { hideFields: ['Website'], tabs: { general: { right: [{ type: 'text', key: 'U_Zona', label: 'Zona' }] } } } }); + expect(keys(out, '/socios/clientes')).toEqual(expect.arrayContaining(['U_Zona'])); + expect(keys(out, '/socios/clientes')).not.toContain('Website'); + }); + + it('no se puede ocultar lo obligatorio y un nombre que no existe es un error', () => { + expect(() => apply({ '/socios/clientes': { hideFields: ['CardCode'] } })).toThrow(/obligatorio/); + expect(() => apply({ '/inventario/articulos': { hideFields: ['ItemName'] } })).toThrow(/obligatorio/); + expect(() => apply({ '/socios/clientes': { hideFields: ['Nada'] } })).toThrow(/no tiene el campo/); + expect(() => apply({ '/socios/clientes': { readOnlyFields: ['Nada'] } })).toThrow(/no tiene el campo/); + expect(() => apply({ '/socios/clientes': { hideListColumns: ['Nada'] } })).toThrow(/no tiene la columna de lista/); + }); +}); diff --git a/apps/web/src/app/features/masters/master-extensions.ts b/apps/web/src/app/features/masters/master-extensions.ts index aacca33..037221d 100644 --- a/apps/web/src/app/features/masters/master-extensions.ts +++ b/apps/web/src/app/features/masters/master-extensions.ts @@ -1,5 +1,5 @@ import type { ListColumn } from '../../shared/list/list.types'; -import type { FieldColumns, HeaderField } from '../documents/document-layouts'; +import { type FieldColumns, type HeaderField, isRequired, keysOfFields } 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. */ @@ -9,8 +9,9 @@ export interface FieldAdditions { } /** - * 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. + * Lo que una rama de cliente cambia en la pantalla de un maestro sin tocar las pantallas base: agrega campos + * estándar adicionales o de usuario (`U_*`), oculta lo que no usa o lo deja de solo lectura. Un nombre que no + * exista, o ocultar un campo obligatorio, es un error. */ export interface MasterExtension { /** Campos al final de la cabecera (código y nombre). */ @@ -21,6 +22,12 @@ export interface MasterExtension { newTabs?: readonly MasterTab[]; /** Columnas nuevas de la lista, al final. */ listColumns?: readonly ListColumn[]; + /** Campos (de la cabecera o de cualquier pestaña) que no se muestran ni se envían, por su clave de SAP. */ + hideFields?: readonly string[]; + /** Campos que se ven pero no se editan. */ + readOnlyFields?: readonly string[]; + /** Columnas de la lista que no se muestran. */ + hideListColumns?: readonly ListColumn['key'][]; } /** Extensiones por ruta de la pantalla (`/socios/clientes`). */ @@ -31,6 +38,16 @@ const add = (base: FieldColumns, extra?: FieldAdditions): FieldColumns => ({ right: [...base.right, ...(extra?.right ?? [])], }); +const allFields = (s: MasterScreen): HeaderField[] => [ + ...s.layout.header.left, + ...s.layout.header.right, + ...s.layout.tabs.flatMap((t) => [...t.left, ...t.right]), +]; + +function checkNames(path: string, what: string, wanted: readonly string[] | undefined, known: readonly string[]): void { + for (const n of wanted ?? []) if (!known.includes(n)) throw new Error(`master-extensions: «${path}» no tiene ${what} «${n}»`); +} + /** * 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. @@ -45,11 +62,33 @@ export function applyMasterExtensions(screens: readonly MasterScreen[], extensio 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)); + // --- validar lo que se oculta o se bloquea contra lo que trae el base + const fields = allFields(s); + const names = fields.flatMap((f) => keysOfFields([f])); + checkNames(s.path, 'el campo', [...(ext.hideFields ?? []), ...(ext.readOnlyFields ?? [])], names); + checkNames(s.path, 'la columna de lista', ext.hideListColumns, s.columns.map((c) => c.key)); + const hide = new Set(ext.hideFields ?? []); + for (const f of fields) { + if (isRequired(f) && keysOfFields([f]).some((k) => hide.has(k))) throw new Error(`master-extensions: «${s.path}» no puede ocultar el campo obligatorio «${f.label}»`); + } + const visible = (cols: FieldColumns): FieldColumns => ({ + left: cols.left.filter((f) => !keysOfFields([f]).some((k) => hide.has(k))), + right: cols.right.filter((f) => !keysOfFields([f]).some((k) => hide.has(k))), + }); + const tabs = s.layout.tabs.map((t) => { + const base = { ...t, ...visible(t) }; + return ext.tabs?.[t.id] ? { ...base, ...add(base, ext.tabs[t.id]) } : base; + }); + const lockedKeys = [...new Set([...(s.layout.lockedKeys ?? []), ...(ext.readOnlyFields ?? [])])]; return { ...s, - columns: [...s.columns, ...(ext.listColumns ?? [])], - layout: { ...s.layout, header: add(s.layout.header, ext.header), tabs: [...tabs, ...(ext.newTabs ?? [])] }, + columns: [...s.columns.filter((c) => !(ext.hideListColumns ?? []).includes(c.key)), ...(ext.listColumns ?? [])], + layout: { + ...s.layout, + header: add(visible(s.layout.header), ext.header), + tabs: [...tabs, ...(ext.newTabs ?? [])], + ...(lockedKeys.length ? { lockedKeys } : {}), + }, }; }); } diff --git a/apps/web/src/app/features/masters/master-form.page.html b/apps/web/src/app/features/masters/master-form.page.html index e14e9c6..7488345 100644 --- a/apps/web/src/app/features/masters/master-form.page.html +++ b/apps/web/src/app/features/masters/master-form.page.html @@ -12,12 +12,12 @@
@for (f of layout.header.left; track $index) { - + }
@for (f of layout.header.right; track $index) { - + }
@@ -28,12 +28,12 @@
@for (f of tab.left; track $index) { - + }
@for (f of tab.right; track $index) { - + }
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 index 7c25386..9b40da4 100644 --- a/apps/web/src/app/features/masters/master-form.page.spec.ts +++ b/apps/web/src/app/features/masters/master-form.page.spec.ts @@ -6,6 +6,7 @@ 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'; +import { applyMasterExtensions } from './master-extensions'; const customer = (over: Record = {}) => ({ 'odata.etag': 'W/"3"', @@ -257,4 +258,25 @@ describe('MasterFormPage', () => { await tick(); expect(el().querySelector('.status')?.textContent).toContain('El registro no existe'); }); + + it('un campo oculto no aparece y uno de solo lectura se ve pero no se escribe', async () => { + const screen = applyMasterExtensions(BASE_MASTER_SCREENS, { + '/socios/clientes': { hideFields: ['Website'], readOnlyFields: ['EmailAddress'] }, + }).find((s) => s.path === '/socios/clientes')!; + const route = { snapshot: { data: { screen, title: 'Cliente' }, paramMap: convertToParamMap({ key: 'C100' }) }, paramMap: of(convertToParamMap({ key: 'C100' })) }; + 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/C100').flush(customer()); + await tick(); + expect(labels()).not.toContain('Sitio web'); + const email = [...el().querySelectorAll('input')].find((i) => i.value === 'ventas@acme.cl')!; + expect(email.readOnly).toBe(true); + const phone = [...el().querySelectorAll('input')].find((i) => i.value === '2 2345 6789')!; + expect(phone.readOnly).toBe(false); + }); }); diff --git a/apps/web/src/app/features/masters/master-form.page.ts b/apps/web/src/app/features/masters/master-form.page.ts index 9edd7ff..d20929d 100644 --- a/apps/web/src/app/features/masters/master-form.page.ts +++ b/apps/web/src/app/features/masters/master-form.page.ts @@ -116,6 +116,13 @@ export class MasterFormPage { return 'key' in f ? f.key : ''; } + private readonly lockedKeys = new Set(this.layout.lockedKeys ?? []); + + /** El campo se ve pero no se edita (una extensión de cliente lo dejó de solo lectura). */ + protected locked(key: string): boolean { + return this.lockedKeys.has(key); + } + protected value(key: string): unknown { return this.header()[key]; } diff --git a/apps/web/src/app/features/masters/master-types.ts b/apps/web/src/app/features/masters/master-types.ts index 7d75e86..3eb6bae 100644 --- a/apps/web/src/app/features/masters/master-types.ts +++ b/apps/web/src/app/features/masters/master-types.ts @@ -21,6 +21,8 @@ export interface MasterLayout { newHeader: () => DocRecord; /** Valores que se envían siempre al crear (ej. `CardType: 'cCustomer'`). */ fixed?: DocRecord; + /** Campos que se ven pero no se editan (una extensión de cliente los dejó de solo lectura). */ + lockedKeys?: readonly string[]; } /** Pantalla de un maestro: lista + formulario (nuevo / ver-editar) de un recurso de clave de texto. */ 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 index 0008b32..8e74ade 100644 --- a/apps/web/src/app/features/masters/socios-negocio/socios.extensions.ts +++ b/apps/web/src/app/features/masters/socios-negocio/socios.extensions.ts @@ -5,6 +5,10 @@ import type { MasterExtensions } from '../master-extensions'; * 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. * + * Además de agregar, puede ocultar campos (`hideFields`, `hideListColumns`) y dejarlos de solo lectura + * (`readOnlyFields`: se ven pero no se editan). Un nombre que no exista, o ocultar un campo obligatorio, lanza + * un error al arrancar. + * * Ejemplo (los campos son de ejemplo): * * '/socios/clientes': { diff --git a/apps/web/src/app/shared/grid/grid.types.ts b/apps/web/src/app/shared/grid/grid.types.ts index 165e883..4c1ebe1 100644 --- a/apps/web/src/app/shared/grid/grid.types.ts +++ b/apps/web/src/app/shared/grid/grid.types.ts @@ -23,6 +23,8 @@ export interface GridColumn { decimals?: number; /** No editable (siempre, o según la línea). */ readonly?: boolean | ((line: GridLine) => boolean); + /** Se ve pero el usuario no la edita; a diferencia de `readonly`, su valor sí se envía a SAP (ej. una dimensión copiada del origen). */ + locked?: boolean; required?: boolean; /** Lo elegido en la primera fila se copia a las filas de abajo (almacén, impuesto); desde la 2.ª fila no se replica. */ propagate?: boolean; diff --git a/apps/web/src/app/shared/grid/lines-grid.spec.ts b/apps/web/src/app/shared/grid/lines-grid.spec.ts index 8d4de98..908e66a 100644 --- a/apps/web/src/app/shared/grid/lines-grid.spec.ts +++ b/apps/web/src/app/shared/grid/lines-grid.spec.ts @@ -441,3 +441,26 @@ describe('LinesGrid: ancho de la tabla', () => { expect(parseFloat(table.style.minWidth)).toBe(100 + 60 + 220 + 34 + 30); }); }); + +describe('LinesGrid: columnas bloqueadas', () => { + it('una columna bloqueada se ve con su valor pero no se puede editar', async () => { + await TestBed.configureTestingModule({ + imports: [Host], + providers: [provideHttpClient(), provideHttpClientTesting()], + }).compileComponents(); + const fixture = TestBed.createComponent(Host); + fixture.componentInstance.columns = [ + { key: 'ItemCode', title: 'Artículo' }, + { key: 'CostingCode', title: 'Dim. 1', locked: true }, + ]; + fixture.componentInstance.lines.set([{ ItemCode: 'A', CostingCode: 'CC1' }]); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const el = fixture.nativeElement as HTMLElement; + const locked = el.querySelector('input[data-r="0"][data-c="1"]')!; + expect(locked.value).toBe('CC1'); + expect(locked.readOnly).toBe(true); + expect(el.querySelector('input[data-r="0"][data-c="0"]')!.readOnly).toBe(false); + }); +}); diff --git a/apps/web/src/app/shared/grid/lines-grid.ts b/apps/web/src/app/shared/grid/lines-grid.ts index 5f131fc..448a7ea 100644 --- a/apps/web/src/app/shared/grid/lines-grid.ts +++ b/apps/web/src/app/shared/grid/lines-grid.ts @@ -78,7 +78,7 @@ export class LinesGrid { } protected isReadonly(line: GridLine, column: GridColumn): boolean { - if (this.readonly() || column.format) return true; + if (this.readonly() || column.format || column.locked) return true; return typeof column.readonly === 'function' ? column.readonly(line) : !!column.readonly; }