fix(web): la grilla de líneas no aprieta las columnas (ancho mínimo = suma de anchos), el total va tras el impuesto y el proyecto de la cabecera abre su ventana
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f66ef108cf
commit
f077a61d08
11 files changed
+90
-13
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:
|
||||
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 antes del total y se envían a SAP) y `listColumns`. Solo suma, no quita; una ruta inexistente o ajena al módulo lanza un error al arrancar. El test «el proyecto base no usa campos de usuario» revisa `BASE_SCREENS`, no las extensiones. Verificado: el proxy `/api/sl` deja pasar nombres `U_*` en `$select`/`$orderby` y en el cuerpo (el error de un campo inexistente lo da SAP). Límite: una columna de línea de usuario con lista de valores válidos aún no tiene editor desplegable (solo texto/número).
|
||||
**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).
|
||||
|
||||
**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 `--`).
|
||||
|
||||
|
||||
@@ -210,3 +210,38 @@ describe('copiar conserva dimensiones y proyecto', () => {
|
||||
expect(objectLabel(-1)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('el proyecto de la cabecera se elige de su ventana modal', () => {
|
||||
it('el campo tiene su botón «…» y abre la lista de proyectos', async () => {
|
||||
TestBed.resetTestingModule();
|
||||
const screen = DOCUMENT_SCREENS.find((s) => s.path === '/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();
|
||||
const ctrl = TestBed.inject(HttpTestingController);
|
||||
const fixture = TestBed.createComponent(DocumentFormPage);
|
||||
fixture.detectChanges();
|
||||
await new Promise((r) => setTimeout(r));
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
const row = [...el.querySelectorAll('sbo-lookup-field')].find((f) => f.querySelector('label')?.textContent?.trim() === 'Proyecto')!;
|
||||
expect(row, 'campo Proyecto con lista').toBeDefined();
|
||||
(row.querySelector('button.sbo-btn') as HTMLElement).click();
|
||||
fixture.detectChanges();
|
||||
await new Promise((r) => setTimeout(r));
|
||||
const req = ctrl.expectOne((r) => r.url === '/api/sl/Projects');
|
||||
expect(req.request.params.get('$filter')).toContain(`Active eq 'tYES'`);
|
||||
req.flush({ value: [{ Code: 'PE001', Name: 'Proyecto uno' }], 'odata.count': '1' });
|
||||
fixture.detectChanges();
|
||||
await new Promise((r) => setTimeout(r));
|
||||
fixture.detectChanges();
|
||||
(el.querySelector('.dlg tbody tr') as HTMLElement).click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance['header']()['Project']).toBe('PE001');
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,17 @@
|
||||
(picked)="onPartner($event)"
|
||||
/>
|
||||
}
|
||||
@case ('lookup') {
|
||||
<sbo-lookup-field
|
||||
[label]="f.label"
|
||||
[required]="!!f.required"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()[f.key])"
|
||||
(valueChange)="setHeader(f.key, $event)"
|
||||
[lookup]="f.lookup"
|
||||
resolveOnLeave
|
||||
/>
|
||||
}
|
||||
@case ('warehouse') {
|
||||
<sbo-lookup-field
|
||||
[label]="f.label"
|
||||
|
||||
@@ -206,9 +206,9 @@ describe('columnas de línea por documento', () => {
|
||||
it('sin elegir, el documento lleva todas las columnas y envía los mismos campos de siempre', () => {
|
||||
const l = marketingLayout(base);
|
||||
expect(l.columns.map((c) => c.key)).toEqual([
|
||||
'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode',
|
||||
'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode', 'LineTotal',
|
||||
'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode', 'AccountCode',
|
||||
'BaseType', 'BaseEntry', 'BaseLine', 'LineTotal',
|
||||
'BaseType', 'BaseEntry', 'BaseLine',
|
||||
]);
|
||||
// lo que se envía por línea: las columnas editables (sin el total ni las de solo lectura) y la unidad de medida
|
||||
expect(l.lines.fields).toEqual([
|
||||
|
||||
@@ -25,9 +25,9 @@ describe('columnas estándar de línea', () => {
|
||||
|
||||
it('son las de una línea de documento de SAP (con TaxCode)', () => {
|
||||
expect(keys).toEqual([
|
||||
'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode',
|
||||
'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode', 'LineTotal',
|
||||
'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode', 'AccountCode',
|
||||
'BaseType', 'BaseEntry', 'BaseLine', 'LineTotal',
|
||||
'BaseType', 'BaseEntry', 'BaseLine',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [
|
||||
apply: (tax, line) => ({ ...line, TaxCode: tax['Code'] }),
|
||||
},
|
||||
},
|
||||
{ key: 'LineTotal', title: 'Total', type: 'number', decimals: 2, width: '110px', readonly: true },
|
||||
dimension(1),
|
||||
dimension(2),
|
||||
dimension(3),
|
||||
@@ -175,7 +176,6 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [
|
||||
{ key: 'BaseType', title: 'Doc. base', width: '150px', readonly: true, format: (l) => (hasBase(l) ? objectLabel(l['BaseType']) : '') },
|
||||
{ key: 'BaseEntry', title: 'Nº base', width: '80px', mono: true, readonly: true, format: (l) => (hasBase(l) ? String(l['BaseEntry']) : '') },
|
||||
{ key: 'BaseLine', title: 'Línea base', width: '90px', mono: true, readonly: true, format: (l) => (hasBase(l) ? String(l['BaseLine'] ?? '') : '') },
|
||||
{ key: 'LineTotal', title: 'Total', type: 'number', decimals: 2, width: '110px', readonly: true },
|
||||
];
|
||||
|
||||
const columnOf = (key: string): GridColumn => DOCUMENT_LINE_COLUMNS.find((c) => c.key === key)!;
|
||||
|
||||
@@ -35,10 +35,11 @@ describe('extensiones por cliente', () => {
|
||||
expect(headerKeysOf(s.layout)).toEqual(expect.arrayContaining(['U_Obra', 'U_Nota']));
|
||||
});
|
||||
|
||||
it('agregan columnas de línea antes del total y las envían; y columnas de lista al final', () => {
|
||||
it('agregan columnas de línea al final (después de las base) y las envían; y columnas de lista al final', () => {
|
||||
const s = by(applyExtensions(BASE_SCREENS, EXT), '/ventas/facturas');
|
||||
const keys = s.layout.columns.map((c) => c.key);
|
||||
expect(keys.indexOf('U_Lote')).toBe(keys.indexOf('LineTotal') - 1);
|
||||
expect(keys.at(-1)).toBe('U_Lote');
|
||||
expect(keys.indexOf('LineTotal')).toBeLessThan(keys.indexOf('U_Lote'));
|
||||
expect(s.layout.lines.fields).toContain('U_Lote');
|
||||
expect(s.columns.at(-1)).toMatchObject({ key: 'U_Obra' });
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface ScreenExtension {
|
||||
headerRight?: readonly HeaderField[];
|
||||
footerLeft?: readonly HeaderField[];
|
||||
footerRight?: readonly HeaderField[];
|
||||
/** Columnas de línea nuevas (antes del total, si el documento lo tiene); se envían a SAP. */
|
||||
/** Columnas de línea nuevas, al final; se envían a SAP. */
|
||||
lineColumns?: readonly GridColumn[];
|
||||
/** Columnas nuevas de la lista, al final. */
|
||||
listColumns?: readonly ListColumn[];
|
||||
@@ -32,9 +32,7 @@ const withFields = (base: FieldColumns, left?: readonly HeaderField[], right?: r
|
||||
|
||||
function extendLayout(layout: DocumentLayout, ext: ScreenExtension): DocumentLayout {
|
||||
const extra = ext.lineColumns ?? [];
|
||||
const at = layout.columns.findIndex((c) => c.key === 'LineTotal');
|
||||
const columns =
|
||||
at < 0 ? [...layout.columns, ...extra] : [...layout.columns.slice(0, at), ...extra, ...layout.columns.slice(at)];
|
||||
const columns = [...layout.columns, ...extra];
|
||||
const fields = [...new Set([...layout.lines.fields, ...lineFieldsOf(extra)])];
|
||||
const extended: DocumentLayout = {
|
||||
...layout,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<div class="gridwrap">
|
||||
<table class="grid">
|
||||
<table class="grid" [style.min-width.px]="tableMinWidth()">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="rn">#</th>
|
||||
|
||||
@@ -422,3 +422,22 @@ describe('LinesGrid', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LinesGrid: ancho de la tabla', () => {
|
||||
it('el ancho mínimo es la suma de los anchos de sus columnas, para no apretarlas', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Host],
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(Host);
|
||||
fixture.componentInstance.columns = [
|
||||
{ key: 'A', title: 'A', width: '100px' },
|
||||
{ key: 'B', title: 'B', width: '60px' },
|
||||
{ key: 'C', title: 'C' }, // sin ancho: reserva 220
|
||||
];
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const table = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('table.grid')!;
|
||||
expect(parseFloat(table.style.minWidth)).toBe(100 + 60 + 220 + 34 + 30);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,19 @@ export class LinesGrid {
|
||||
protected readonly lookupTarget = signal<LookupTarget | null>(null);
|
||||
protected readonly multiOpen = signal(false);
|
||||
protected readonly lookup = computed(() => this.lookupTarget()?.column.lookup?.config);
|
||||
|
||||
/**
|
||||
* Ancho mínimo de la tabla: la suma de los anchos de sus columnas (las que no declaran uno, como la
|
||||
* descripción, reservan 220 px), más el número de fila y el botón de quitar. Si no cabe en pantalla, la
|
||||
* grilla se desplaza en horizontal en lugar de apretar las columnas hasta cortar su contenido.
|
||||
*/
|
||||
protected readonly tableMinWidth = computed(() => {
|
||||
const px = (c: GridColumn): number => {
|
||||
const w = parseFloat(c.width ?? '');
|
||||
return Number.isFinite(w) ? w : 220;
|
||||
};
|
||||
return this.columns().reduce((sum, c) => sum + px(c), 34 + (this.readonly() ? 0 : 30));
|
||||
});
|
||||
protected readonly lookupMultiple = computed(() => !!this.lookupTarget()?.column.lookup?.multiple);
|
||||
|
||||
protected cellText(line: GridLine, column: GridColumn): string {
|
||||
|
||||
Reference in new issue
Block a user