feat(web): las extensiones de cliente pueden ocultar campos y columnas y dejarlos de solo lectura (documentos y maestros)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f077a61d08
commit
8dc4a8eaab
20 files changed
+374
-38
No files matched your search
@@ -58,7 +58,7 @@ Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compart
|
|||||||
Pendiente, en orden:
|
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.
|
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/<nombre>` declara ahí, por ruta de pantalla, qué suma: `headerLeft`/`headerRight`/`footerLeft`/`footerRight` (campos; tipos `text`, `date`, `number`, `textarea`, `select` con `options`, `warehouse`…), `lineColumns` (se agregan 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/<nombre>` declara ahí, por ruta de pantalla, qué suma: `headerLeft`/`headerRight`/`footerLeft`/`footerRight` (campos; tipos `text`, `date`, `number`, `textarea`, `select` con `options`, `warehouse`…), `lineColumns` (se agregan 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 `--`).
|
**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 `--`).
|
||||||
|
|
||||||
|
|||||||
@@ -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/<nombre>` lo completa
|
* vacío: el proyecto base solo lleva campos estándar de SAP. La rama `cliente/<nombre>` lo completa
|
||||||
* con campos estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo.
|
* 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):
|
* Ejemplo (los campos son de ejemplo):
|
||||||
*
|
*
|
||||||
* '/compras/facturas': {
|
* '/compras/facturas': {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
<sbo-lookup-field
|
<sbo-lookup-field
|
||||||
[label]="f.label"
|
[label]="f.label"
|
||||||
[required]="!!f.required"
|
[required]="!!f.required"
|
||||||
[readonly]="readOnly() || !isNew()"
|
[readonly]="readOnly() || !isNew() || locked(f)"
|
||||||
[value]="str(header()[f.key])"
|
[value]="str(header()[f.key])"
|
||||||
(valueChange)="onPartnerText($event)"
|
(valueChange)="onPartnerText($event)"
|
||||||
[lookup]="partnerLookup"
|
[lookup]="partnerLookup"
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
<sbo-lookup-field
|
<sbo-lookup-field
|
||||||
[label]="f.label"
|
[label]="f.label"
|
||||||
[required]="!!f.required"
|
[required]="!!f.required"
|
||||||
[readonly]="readOnly()"
|
[readonly]="readOnly() || locked(f)"
|
||||||
[value]="str(header()[f.key])"
|
[value]="str(header()[f.key])"
|
||||||
(valueChange)="setHeader(f.key, $event)"
|
(valueChange)="setHeader(f.key, $event)"
|
||||||
[lookup]="f.lookup"
|
[lookup]="f.lookup"
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
<sbo-lookup-field
|
<sbo-lookup-field
|
||||||
[label]="f.label"
|
[label]="f.label"
|
||||||
[required]="!!f.required"
|
[required]="!!f.required"
|
||||||
[readonly]="readOnly()"
|
[readonly]="readOnly() || locked(f)"
|
||||||
[value]="str(header()[f.key])"
|
[value]="str(header()[f.key])"
|
||||||
(valueChange)="setHeader(f.key, $event)"
|
(valueChange)="setHeader(f.key, $event)"
|
||||||
[lookup]="warehouseLookup"
|
[lookup]="warehouseLookup"
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
<sbo-date-field
|
<sbo-date-field
|
||||||
[label]="f.label"
|
[label]="f.label"
|
||||||
[required]="!!f.required"
|
[required]="!!f.required"
|
||||||
[readonly]="readOnly()"
|
[readonly]="readOnly() || locked(f)"
|
||||||
[value]="str(header()[f.key])"
|
[value]="str(header()[f.key])"
|
||||||
(valueChange)="setHeader(f.key, $event)"
|
(valueChange)="setHeader(f.key, $event)"
|
||||||
/>
|
/>
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
<sbo-select-field
|
<sbo-select-field
|
||||||
[label]="f.label"
|
[label]="f.label"
|
||||||
[required]="!!f.required"
|
[required]="!!f.required"
|
||||||
[readonly]="readOnly()"
|
[readonly]="readOnly() || locked(f)"
|
||||||
[options]="f.options"
|
[options]="f.options"
|
||||||
placeholder=""
|
placeholder=""
|
||||||
[value]="str(header()[f.key]) || null"
|
[value]="str(header()[f.key]) || null"
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
<sbo-number-field
|
<sbo-number-field
|
||||||
[label]="f.label"
|
[label]="f.label"
|
||||||
[decimals]="f.decimals"
|
[decimals]="f.decimals"
|
||||||
[readonly]="readOnly()"
|
[readonly]="readOnly() || locked(f)"
|
||||||
[value]="+str(header()[f.key])"
|
[value]="+str(header()[f.key])"
|
||||||
(valueChange)="setHeader(f.key, $event ?? 0)"
|
(valueChange)="setHeader(f.key, $event ?? 0)"
|
||||||
/>
|
/>
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
@case ('textarea') {
|
@case ('textarea') {
|
||||||
<sbo-textarea-field
|
<sbo-textarea-field
|
||||||
[label]="f.label"
|
[label]="f.label"
|
||||||
[readonly]="readOnly()"
|
[readonly]="readOnly() || locked(f)"
|
||||||
[value]="str(header()[f.key])"
|
[value]="str(header()[f.key])"
|
||||||
(valueChange)="setHeader(f.key, $event)"
|
(valueChange)="setHeader(f.key, $event)"
|
||||||
/>
|
/>
|
||||||
@@ -155,13 +155,13 @@
|
|||||||
<sbo-input
|
<sbo-input
|
||||||
width="90px"
|
width="90px"
|
||||||
[maxlength]="10"
|
[maxlength]="10"
|
||||||
[readonly]="readOnly()"
|
[readonly]="readOnly() || locked(f)"
|
||||||
[value]="str(header()[f.prefixKey])"
|
[value]="str(header()[f.prefixKey])"
|
||||||
(valueChange)="setHeader(f.prefixKey, $event)"
|
(valueChange)="setHeader(f.prefixKey, $event)"
|
||||||
/>
|
/>
|
||||||
<sbo-input
|
<sbo-input
|
||||||
ariaLabel="Número de folio"
|
ariaLabel="Número de folio"
|
||||||
[readonly]="readOnly()"
|
[readonly]="readOnly() || locked(f)"
|
||||||
[value]="str(header()[f.numberKey])"
|
[value]="str(header()[f.numberKey])"
|
||||||
(valueChange)="setHeader(f.numberKey, $event)"
|
(valueChange)="setHeader(f.numberKey, $event)"
|
||||||
/>
|
/>
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
ariaLabel="Serie de numeración"
|
ariaLabel="Serie de numeración"
|
||||||
placeholder="(por defecto)"
|
placeholder="(por defecto)"
|
||||||
[options]="seriesOptions()"
|
[options]="seriesOptions()"
|
||||||
[readonly]="readOnly() || !isNew()"
|
[readonly]="readOnly() || !isNew() || locked(f)"
|
||||||
[value]="seriesValue()"
|
[value]="seriesValue()"
|
||||||
(valueChange)="setHeader(f.key, $event ?? '')"
|
(valueChange)="setHeader(f.key, $event ?? '')"
|
||||||
/>
|
/>
|
||||||
@@ -188,14 +188,14 @@
|
|||||||
ariaLabel="Gasto adicional del flete"
|
ariaLabel="Gasto adicional del flete"
|
||||||
placeholder=""
|
placeholder=""
|
||||||
[options]="expenseOptions()"
|
[options]="expenseOptions()"
|
||||||
[readonly]="readOnly() || !freightEditable()"
|
[readonly]="readOnly() || !freightEditable() || locked(f)"
|
||||||
[value]="str(header()['FreightExpense']) || defaultExpense() || null"
|
[value]="str(header()['FreightExpense']) || defaultExpense() || null"
|
||||||
(valueChange)="setHeader('FreightExpense', $event ?? '')"
|
(valueChange)="setHeader('FreightExpense', $event ?? '')"
|
||||||
/>
|
/>
|
||||||
<sbo-number
|
<sbo-number
|
||||||
ariaLabel="Importe del flete"
|
ariaLabel="Importe del flete"
|
||||||
[decimals]="2"
|
[decimals]="2"
|
||||||
[readonly]="readOnly() || !freightEditable()"
|
[readonly]="readOnly() || !freightEditable() || locked(f)"
|
||||||
[value]="+str(header()['FreightAmount'])"
|
[value]="+str(header()['FreightAmount'])"
|
||||||
(valueChange)="setHeader('FreightAmount', $event ?? 0)"
|
(valueChange)="setHeader('FreightAmount', $event ?? 0)"
|
||||||
/>
|
/>
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
@default {
|
@default {
|
||||||
<sbo-text-field
|
<sbo-text-field
|
||||||
[label]="f.label"
|
[label]="f.label"
|
||||||
[readonly]="readOnly() || !!f.readonly"
|
[readonly]="readOnly() || !!f.readonly || locked(f)"
|
||||||
[value]="str(header()[f.key])"
|
[value]="str(header()[f.key])"
|
||||||
(valueChange)="setHeader(f.key, $event)"
|
(valueChange)="setHeader(f.key, $event)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
updatePayload,
|
updatePayload,
|
||||||
validateLayout,
|
validateLayout,
|
||||||
} from './document-model';
|
} 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 { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
|
||||||
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
|
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
|
||||||
import { OBJECT_TYPES } from './document-objects';
|
import { OBJECT_TYPES } from './document-objects';
|
||||||
@@ -330,6 +330,12 @@ export class DocumentFormPage {
|
|||||||
|
|
||||||
protected str = text;
|
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). */
|
/** Valor de una fila de solo lectura (número del documento, datos del socio, totales). */
|
||||||
protected computedValue(source: ComputedSource): string {
|
protected computedValue(source: ComputedSource): string {
|
||||||
switch (source) {
|
switch (source) {
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export interface DocumentLayout {
|
|||||||
newLine: (header: DocRecord) => GridLine;
|
newLine: (header: DocRecord) => GridLine;
|
||||||
/** Recalcula una línea tras un cambio (total = cantidad × precio…); recibe la tasa de cada código de impuesto. */
|
/** 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<string, number>) => GridLine;
|
recalc?: (line: GridLine, changedKey: string, rates?: ReadonlyMap<string, number>) => 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…). */
|
/** Subtipo con que SAP etiqueta las series de este documento (`--` = ninguno, `IB` = boleta…). */
|
||||||
seriesSubType?: string;
|
seriesSubType?: string;
|
||||||
/** Columnas de las listas de valores de este documento; las que falten usan las del base. */
|
/** Columnas de las listas de valores de este documento; las que falten usan las del base. */
|
||||||
|
|||||||
@@ -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/<nombre>` lo completa
|
* vacío: el proyecto base solo lleva campos estándar de SAP. La rama `cliente/<nombre>` lo completa
|
||||||
* con campos estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo.
|
* 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):
|
* Ejemplo (los campos son de ejemplo):
|
||||||
*
|
*
|
||||||
* '/inventario/transferencias': {
|
* '/inventario/transferencias': {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { of } from 'rxjs';
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { CLIENT_EXTENSIONS } from './client-extensions';
|
import { CLIENT_EXTENSIONS } from './client-extensions';
|
||||||
import { headerKeysOf } from './document-layouts';
|
import { headerKeysOf } from './document-layouts';
|
||||||
|
import { lineFieldsOf } from './line-columns';
|
||||||
import { DocumentFormPage } from './document-form.page';
|
import { DocumentFormPage } from './document-form.page';
|
||||||
import { BASE_SCREENS, DOCUMENT_SCREENS } from './document-screens';
|
import { BASE_SCREENS, DOCUMENT_SCREENS } from './document-screens';
|
||||||
import { type ScreenExtensions, applyExtensions } from './screen-extensions';
|
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');
|
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<DocumentFormPage>;
|
||||||
|
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<HTMLInputElement>('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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import type { GridColumn } from '../../shared/grid/grid.types';
|
import type { GridColumn } from '../../shared/grid/grid.types';
|
||||||
import type { ListColumn } from '../../shared/list/list.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 type { DocumentScreen } from './screen-kit';
|
||||||
import { lineFieldsOf } from './line-columns';
|
import { lineFieldsOf } from './line-columns';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lo que una rama de cliente agrega a una pantalla (campos estándar adicionales o de usuario `U_*`)
|
* Lo que una rama de cliente cambia en una pantalla sin tocar las pantallas base: agrega campos estándar
|
||||||
* sin tocar las pantallas base. Solo suma: no quita ni cambia lo que ya trae el base.
|
* 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 {
|
export interface ScreenExtension {
|
||||||
/** Campos al final de cada columna de la cabecera o del pie. */
|
/** Campos al final de cada columna de la cabecera o del pie. */
|
||||||
@@ -20,6 +23,16 @@ export interface ScreenExtension {
|
|||||||
listColumns?: readonly ListColumn[];
|
listColumns?: readonly ListColumn[];
|
||||||
/** Columnas que se suman al final de las listas de valores (ventanas de selección) de este documento. */
|
/** Columnas que se suman al final de las listas de valores (ventanas de selección) de este documento. */
|
||||||
lookupColumns?: LookupColumns;
|
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`). */
|
/** 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 ?? [])],
|
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 extra = ext.lineColumns ?? [];
|
||||||
const columns = [...layout.columns, ...extra];
|
const lockedKeys = fields.filter((f) => namesOf(f).some((n) => (ext.readOnlyHeaderFields ?? []).includes(n))).flatMap((f) => keysOfFields([f]));
|
||||||
const fields = [...new Set([...layout.lines.fields, ...lineFieldsOf(extra)])];
|
|
||||||
const extended: DocumentLayout = {
|
const extended: DocumentLayout = {
|
||||||
...layout,
|
...layout,
|
||||||
header: withFields(layout.header, ext.headerLeft, ext.headerRight),
|
header: withFields(visible(layout.header), ext.headerLeft, ext.headerRight),
|
||||||
footer: withFields(layout.footer, ext.footerLeft, ext.footerRight),
|
footer: withFields(visible(layout.footer), ext.footerLeft, ext.footerRight),
|
||||||
columns,
|
columns: [...base, ...extra],
|
||||||
lines: { ...layout.lines, fields },
|
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;
|
return ext.lookupColumns ? withLookupColumns(extended, ext.lookupColumns, 'add') : extended;
|
||||||
}
|
}
|
||||||
@@ -55,10 +102,11 @@ export function applyExtensions(screens: readonly DocumentScreen[], extensions:
|
|||||||
return screens.map((s) => {
|
return screens.map((s) => {
|
||||||
const ext = extensions[s.path];
|
const ext = extensions[s.path];
|
||||||
if (!ext) return s;
|
if (!ext) return s;
|
||||||
|
checkNames(s.path, 'la columna de lista', ext.hideListColumns, s.columns.map((c) => c.key));
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
layout: extendLayout(s.layout, ext),
|
layout: extendLayout(s.path, s.layout, ext),
|
||||||
columns: [...s.columns, ...(ext.listColumns ?? [])],
|
columns: [...s.columns.filter((c) => !(ext.hideListColumns ?? []).includes(c.key)), ...(ext.listColumns ?? [])],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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/<nombre>` lo completa
|
* vacío: el proyecto base solo lleva campos estándar de SAP. La rama `cliente/<nombre>` lo completa
|
||||||
* con campos estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo.
|
* 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):
|
* Ejemplo (los campos son de ejemplo):
|
||||||
*
|
*
|
||||||
* '/ventas/facturas': {
|
* '/ventas/facturas': {
|
||||||
@@ -12,6 +16,11 @@ import type { ScreenExtensions } from '../screen-extensions';
|
|||||||
* lineColumns: [{ key: 'U_Lote', title: 'Lote', width: '90px' }],
|
* lineColumns: [{ key: 'U_Lote', title: 'Lote', width: '90px' }],
|
||||||
* listColumns: [{ key: 'U_Obra', title: 'Obra', searchable: true }],
|
* 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': {
|
* '/ventas/ordenes': {
|
||||||
* headerLeft: [
|
* headerLeft: [
|
||||||
* { type: 'select', key: 'U_Canal', label: 'Canal', options: [{ value: 'W', label: 'Web' }, { value: 'T', label: 'Tienda' }] },
|
* { type: 'select', key: 'U_Canal', label: 'Canal', options: [{ value: 'W', label: 'Web' }, { value: 'T', label: 'Tienda' }] },
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import type { MasterExtensions } from '../master-extensions';
|
|||||||
* lleva campos estándar de SAP. La rama `cliente/<nombre>` lo completa, por ruta de pantalla, con campos
|
* lleva campos estándar de SAP. La rama `cliente/<nombre>` lo completa, por ruta de pantalla, con campos
|
||||||
* estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo.
|
* 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):
|
* Ejemplo (los campos son de ejemplo):
|
||||||
*
|
*
|
||||||
* '/inventario/articulos': {
|
* '/inventario/articulos': {
|
||||||
|
|||||||
@@ -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/);
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ListColumn } from '../../shared/list/list.types';
|
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';
|
import type { MasterScreen, MasterTab } from './master-types';
|
||||||
|
|
||||||
/** Campos que se agregan al final de las dos columnas de una cabecera o pestaña. */
|
/** 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
|
* Lo que una rama de cliente cambia en la pantalla de un maestro sin tocar las pantallas base: agrega campos
|
||||||
* usuario `U_*`) sin tocar las pantallas base. Solo suma: no quita ni cambia lo que trae el base.
|
* 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 {
|
export interface MasterExtension {
|
||||||
/** Campos al final de la cabecera (código y nombre). */
|
/** Campos al final de la cabecera (código y nombre). */
|
||||||
@@ -21,6 +22,12 @@ export interface MasterExtension {
|
|||||||
newTabs?: readonly MasterTab[];
|
newTabs?: readonly MasterTab[];
|
||||||
/** Columnas nuevas de la lista, al final. */
|
/** Columnas nuevas de la lista, al final. */
|
||||||
listColumns?: readonly ListColumn[];
|
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`). */
|
/** Extensiones por ruta de la pantalla (`/socios/clientes`). */
|
||||||
@@ -31,6 +38,16 @@ const add = (base: FieldColumns, extra?: FieldAdditions): FieldColumns => ({
|
|||||||
right: [...base.right, ...(extra?.right ?? [])],
|
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,
|
* 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.
|
* 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 ?? {})) {
|
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}»`);
|
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 {
|
return {
|
||||||
...s,
|
...s,
|
||||||
columns: [...s.columns, ...(ext.listColumns ?? [])],
|
columns: [...s.columns.filter((c) => !(ext.hideListColumns ?? []).includes(c.key)), ...(ext.listColumns ?? [])],
|
||||||
layout: { ...s.layout, header: add(s.layout.header, ext.header), tabs: [...tabs, ...(ext.newTabs ?? [])] },
|
layout: {
|
||||||
|
...s.layout,
|
||||||
|
header: add(visible(s.layout.header), ext.header),
|
||||||
|
tabs: [...tabs, ...(ext.newTabs ?? [])],
|
||||||
|
...(lockedKeys.length ? { lockedKeys } : {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -12,12 +12,12 @@
|
|||||||
<div class="sbo-hdr">
|
<div class="sbo-hdr">
|
||||||
<div class="sbo-col">
|
<div class="sbo-col">
|
||||||
@for (f of layout.header.left; track $index) {
|
@for (f of layout.header.left; track $index) {
|
||||||
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" (changed)="setValue(keyOf(f), $event)" />
|
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" [readOnly]="locked(keyOf(f))" (changed)="setValue(keyOf(f), $event)" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="sbo-col">
|
<div class="sbo-col">
|
||||||
@for (f of layout.header.right; track $index) {
|
@for (f of layout.header.right; track $index) {
|
||||||
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" (changed)="setValue(keyOf(f), $event)" />
|
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" [readOnly]="locked(keyOf(f))" (changed)="setValue(keyOf(f), $event)" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,12 +28,12 @@
|
|||||||
<div class="sbo-hdr">
|
<div class="sbo-hdr">
|
||||||
<div class="sbo-col">
|
<div class="sbo-col">
|
||||||
@for (f of tab.left; track $index) {
|
@for (f of tab.left; track $index) {
|
||||||
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" (changed)="setValue(keyOf(f), $event)" />
|
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" [readOnly]="locked(keyOf(f))" (changed)="setValue(keyOf(f), $event)" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="sbo-col">
|
<div class="sbo-col">
|
||||||
@for (f of tab.right; track $index) {
|
@for (f of tab.right; track $index) {
|
||||||
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" (changed)="setValue(keyOf(f), $event)" />
|
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" [readOnly]="locked(keyOf(f))" (changed)="setValue(keyOf(f), $event)" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { of } from 'rxjs';
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { MasterFormPage } from './master-form.page';
|
import { MasterFormPage } from './master-form.page';
|
||||||
import { BASE_MASTER_SCREENS } from './master-screens';
|
import { BASE_MASTER_SCREENS } from './master-screens';
|
||||||
|
import { applyMasterExtensions } from './master-extensions';
|
||||||
|
|
||||||
const customer = (over: Record<string, unknown> = {}) => ({
|
const customer = (over: Record<string, unknown> = {}) => ({
|
||||||
'odata.etag': 'W/"3"',
|
'odata.etag': 'W/"3"',
|
||||||
@@ -257,4 +258,25 @@ describe('MasterFormPage', () => {
|
|||||||
await tick();
|
await tick();
|
||||||
expect(el().querySelector('.status')?.textContent).toContain('El registro no existe');
|
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<HTMLInputElement>('input')].find((i) => i.value === 'ventas@acme.cl')!;
|
||||||
|
expect(email.readOnly).toBe(true);
|
||||||
|
const phone = [...el().querySelectorAll<HTMLInputElement>('input')].find((i) => i.value === '2 2345 6789')!;
|
||||||
|
expect(phone.readOnly).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -116,6 +116,13 @@ export class MasterFormPage {
|
|||||||
return 'key' in f ? f.key : '';
|
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 {
|
protected value(key: string): unknown {
|
||||||
return this.header()[key];
|
return this.header()[key];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface MasterLayout {
|
|||||||
newHeader: () => DocRecord;
|
newHeader: () => DocRecord;
|
||||||
/** Valores que se envían siempre al crear (ej. `CardType: 'cCustomer'`). */
|
/** Valores que se envían siempre al crear (ej. `CardType: 'cCustomer'`). */
|
||||||
fixed?: DocRecord;
|
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. */
|
/** Pantalla de un maestro: lista + formulario (nuevo / ver-editar) de un recurso de clave de texto. */
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import type { MasterExtensions } from '../master-extensions';
|
|||||||
* lleva campos estándar de SAP. La rama `cliente/<nombre>` lo completa, por ruta de pantalla, con campos
|
* lleva campos estándar de SAP. La rama `cliente/<nombre>` lo completa, por ruta de pantalla, con campos
|
||||||
* estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo.
|
* 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):
|
* Ejemplo (los campos son de ejemplo):
|
||||||
*
|
*
|
||||||
* '/socios/clientes': {
|
* '/socios/clientes': {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface GridColumn {
|
|||||||
decimals?: number;
|
decimals?: number;
|
||||||
/** No editable (siempre, o según la línea). */
|
/** No editable (siempre, o según la línea). */
|
||||||
readonly?: boolean | ((line: GridLine) => boolean);
|
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;
|
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. */
|
/** 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;
|
propagate?: boolean;
|
||||||
|
|||||||
@@ -441,3 +441,26 @@ describe('LinesGrid: ancho de la tabla', () => {
|
|||||||
expect(parseFloat(table.style.minWidth)).toBe(100 + 60 + 220 + 34 + 30);
|
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<HTMLInputElement>('input[data-r="0"][data-c="1"]')!;
|
||||||
|
expect(locked.value).toBe('CC1');
|
||||||
|
expect(locked.readOnly).toBe(true);
|
||||||
|
expect(el.querySelector<HTMLInputElement>('input[data-r="0"][data-c="0"]')!.readOnly).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,7 +78,7 @@ export class LinesGrid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected isReadonly(line: GridLine, column: GridColumn): boolean {
|
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;
|
return typeof column.readonly === 'function' ? column.readonly(line) : !!column.readonly;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user