feat(web): tablas en los maestros (direcciones, personas de contacto, precios por lista, stock por almacén)
sbo-rows-table reutilizable (también en la ventana de lotes). Direcciones y contactos se envían completos con reemplazo; los precios solo por filas cambiadas. Probado contra SBODemoCL. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e853b1b76c
commit
4e9d6a757d
14 files changed
+679
-96
No files matched your search
@@ -34,8 +34,9 @@ export class SlMasterApi {
|
||||
}
|
||||
|
||||
/** Modifica el registro. Con `etag`, si alguien lo cambió antes SAP responde 412 y no se pisa su cambio. */
|
||||
update(resource: string, key: string, body: Rec, etag: string | undefined): Promise<void> {
|
||||
const headers = etag ? new HttpHeaders().set('If-Match', etag) : new HttpHeaders();
|
||||
update(resource: string, key: string, body: Rec, etag: string | undefined, replaceCollections = false): Promise<void> {
|
||||
let headers = etag ? new HttpHeaders().set('If-Match', etag) : new HttpHeaders();
|
||||
if (replaceCollections) headers = headers.set('B1S-ReplaceCollectionsOnPatch', 'true');
|
||||
return firstValueFrom(this.http.patch<void>(this.url(resource, key), body, { headers }));
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,46 @@ export const ARTICULOS_SCREENS: readonly MasterScreen[] = [
|
||||
],
|
||||
right: [],
|
||||
},
|
||||
{
|
||||
id: 'precios',
|
||||
label: 'Precios',
|
||||
left: [],
|
||||
right: [],
|
||||
collection: {
|
||||
key: 'ItemPrices',
|
||||
// Una fila por lista de precios: solo se envían las que cambian (SAP recalcula las listas derivadas).
|
||||
mode: 'merge',
|
||||
rowKey: 'PriceList',
|
||||
identity: ['PriceList'],
|
||||
columns: [
|
||||
{ key: 'PriceList', title: 'Lista de precios', type: 'text', readonly: true, width: '130px' },
|
||||
{ key: 'Price', title: 'Precio', type: 'number', decimals: 2, width: '130px' },
|
||||
{ key: 'Currency', title: 'Moneda', type: 'text', width: '90px' },
|
||||
{ key: 'Factor', title: 'Factor', type: 'number', readonly: true, width: '90px' },
|
||||
{ key: 'BasePriceList', title: 'Lista base', type: 'text', readonly: true, width: '100px' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'stock',
|
||||
label: 'Stock por almacén',
|
||||
left: [],
|
||||
right: [],
|
||||
collection: {
|
||||
key: 'ItemWarehouseInfoCollection',
|
||||
mode: 'replace',
|
||||
readonly: true,
|
||||
identity: ['WarehouseCode'],
|
||||
columns: [
|
||||
{ key: 'WarehouseCode', title: 'Almacén', type: 'text', width: '110px' },
|
||||
{ key: 'InStock', title: 'En stock', type: 'number', width: '110px' },
|
||||
{ key: 'Committed', title: 'Comprometido', type: 'number', width: '110px' },
|
||||
{ key: 'Ordered', title: 'Pedido', type: 'number', width: '110px' },
|
||||
{ key: 'MinimalStock', title: 'Mínimo', type: 'number', width: '100px' },
|
||||
{ key: 'MaximalStock', title: 'Máximo', type: 'number', width: '100px' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ id: 'observaciones', label: 'Observaciones', left: [field({ type: 'textarea', key: 'User_Text', label: 'Observaciones' })], right: [] },
|
||||
],
|
||||
newHeader: () => ({
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MasterFormPage } from './master-form.page';
|
||||
import { collectionPayload, collectionRowsOf, collectionsCreatePayload, isBlankRow, masterCollectionsOf } from './master-model';
|
||||
import { BASE_MASTER_SCREENS } from './master-screens';
|
||||
|
||||
const screen = (path: string) => BASE_MASTER_SCREENS.find((s) => s.path === path)!;
|
||||
const collection = (path: string, key: string) => masterCollectionsOf(screen(path).layout).find((c) => c.key === key)!;
|
||||
|
||||
const ADDR = { AddressName: 'BillTo', AddressType: 'bo_BillTo', Street: 'Calle 1', City: 'Santiago', Country: 'CL', BPCode: 'C1', RowNum: 0 };
|
||||
const SHIP = { AddressName: 'Bodega', AddressType: 'bo_ShipTo', Street: 'Ruta 5', City: 'Rancagua', Country: 'CL', BPCode: 'C1', RowNum: 1 };
|
||||
|
||||
describe('tablas de los maestros: qué se envía', () => {
|
||||
const addresses = collection('/socios/clientes', 'BPAddresses');
|
||||
const prices = collection('/inventario/articulos', 'ItemPrices');
|
||||
|
||||
it('los clientes y proveedores tienen direcciones y contactos; el artículo, precios y stock por almacén', () => {
|
||||
for (const p of ['/socios/clientes', '/socios/proveedores']) {
|
||||
expect(masterCollectionsOf(screen(p).layout).map((c) => c.key)).toEqual(['BPAddresses', 'ContactEmployees']);
|
||||
}
|
||||
expect(masterCollectionsOf(screen('/inventario/articulos').layout).map((c) => c.key)).toEqual(['ItemPrices', 'ItemWarehouseInfoCollection']);
|
||||
});
|
||||
|
||||
it('direcciones y contactos se envían completos (replace); los precios por filas (merge); el stock no se edita', () => {
|
||||
expect(addresses.mode).toBe('replace');
|
||||
expect(collection('/socios/clientes', 'ContactEmployees').mode).toBe('replace');
|
||||
expect(prices.mode).toBe('merge');
|
||||
expect(prices.rowKey).toBe('PriceList');
|
||||
expect(collection('/inventario/articulos', 'ItemWarehouseInfoCollection').readonly).toBe(true);
|
||||
});
|
||||
|
||||
it('sin cambios no se envía nada', () => {
|
||||
expect(collectionPayload(addresses, [ADDR, SHIP], [{ ...ADDR }, { ...SHIP }])).toBeNull();
|
||||
expect(collectionPayload(addresses, [], [])).toBeNull();
|
||||
});
|
||||
|
||||
it('replace: al cambiar una celda se envía la colección completa, con las filas leídas y sus campos de SAP', () => {
|
||||
const out = collectionPayload(addresses, [ADDR, SHIP], [{ ...ADDR, Street: 'Calle 2' }, { ...SHIP }])!;
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0]).toMatchObject({ AddressName: 'BillTo', Street: 'Calle 2', BPCode: 'C1', RowNum: 0 }); // conserva lo que SAP trae
|
||||
expect(out[1]).toEqual(SHIP);
|
||||
});
|
||||
|
||||
it('replace: añadir, quitar o reordenar una fila cuenta como cambio; una fila vacía no', () => {
|
||||
expect(collectionPayload(addresses, [ADDR], [ADDR, { AddressName: 'Nueva', AddressType: 'bo_ShipTo' }])).toHaveLength(2);
|
||||
expect(collectionPayload(addresses, [ADDR, SHIP], [ADDR])).toEqual([ADDR]);
|
||||
expect(collectionPayload(addresses, [ADDR], [])).toEqual([]);
|
||||
expect(collectionPayload(addresses, [ADDR], [ADDR, { AddressName: '', Street: '' }])).toBeNull();
|
||||
expect(isBlankRow(addresses, { AddressName: ' ', Street: 'x' })).toBe(true);
|
||||
expect(isBlankRow(addresses, { AddressName: 'A' })).toBe(false);
|
||||
});
|
||||
|
||||
it('merge: solo se envían las filas cambiadas, con su clave y los campos que cambiaron', () => {
|
||||
const original = [
|
||||
{ PriceList: 1, Price: 100, Currency: 'CLP', Factor: 1 },
|
||||
{ PriceList: 2, Price: 50, Currency: 'CLP', Factor: 0.5 },
|
||||
];
|
||||
const out = collectionPayload(prices, original, [{ ...original[0], Price: 120 }, { ...original[1] }]);
|
||||
expect(out).toEqual([{ PriceList: 1, Price: 120 }]);
|
||||
expect(collectionPayload(prices, original, [{ ...original[0], Price: '100' }, { ...original[1] }])).toBeNull(); // 100 = '100'
|
||||
});
|
||||
|
||||
it('merge: vaciar un precio lo envía como null; las columnas de solo lectura no cuentan', () => {
|
||||
const original = [{ PriceList: 1, Price: 100, Currency: 'CLP', Factor: 1 }];
|
||||
expect(collectionPayload(prices, original, [{ ...original[0], Price: '' }])).toEqual([{ PriceList: 1, Price: null }]);
|
||||
expect(collectionPayload(prices, original, [{ ...original[0], Factor: 9 }])).toBeNull();
|
||||
});
|
||||
|
||||
it('el stock por almacén, de solo lectura, nunca se envía', () => {
|
||||
const stock = collection('/inventario/articulos', 'ItemWarehouseInfoCollection');
|
||||
expect(collectionPayload(stock, [{ WarehouseCode: '01', InStock: 5 }], [{ WarehouseCode: '01', InStock: 99 }])).toBeNull();
|
||||
});
|
||||
|
||||
it('al crear se envían las filas con datos y sin campos vacíos; las vacías y el stock no', () => {
|
||||
const body = collectionsCreatePayload(screen('/socios/clientes').layout, {
|
||||
BPAddresses: [{ AddressName: 'BillTo', AddressType: 'bo_BillTo', Street: 'Calle 1', City: '' }, { AddressName: '' }],
|
||||
ContactEmployees: [],
|
||||
});
|
||||
expect(body).toEqual({ BPAddresses: [{ AddressName: 'BillTo', AddressType: 'bo_BillTo', Street: 'Calle 1' }] });
|
||||
expect(collectionsCreatePayload(screen('/inventario/articulos').layout, { ItemPrices: [{ PriceList: 1, Price: 10 }], ItemWarehouseInfoCollection: [{ WarehouseCode: '01' }] })).toEqual({
|
||||
ItemPrices: [{ PriceList: 1, Price: 10 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('copia las filas de SAP para editarlas sin tocar el original', () => {
|
||||
const doc = { BPAddresses: [ADDR] };
|
||||
const rows = collectionRowsOf(doc, addresses);
|
||||
rows[0]['Street'] = 'otra';
|
||||
expect(doc.BPAddresses[0].Street).toBe('Calle 1');
|
||||
expect(collectionRowsOf(undefined, addresses)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formulario con tablas', () => {
|
||||
let fixture: ComponentFixture<MasterFormPage>;
|
||||
let ctrl: HttpTestingController;
|
||||
let navigate: ReturnType<typeof vi.spyOn>;
|
||||
const el = () => fixture.nativeElement as HTMLElement;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const page = () => fixture.componentInstance as any;
|
||||
const tick = async () => {
|
||||
fixture.detectChanges();
|
||||
await new Promise((r) => setTimeout(r));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
const save = () => (el().querySelector('.actions .btn.main') as HTMLElement).click();
|
||||
|
||||
async function open(path: string, key: string | null, doc?: Record<string, unknown>) {
|
||||
const s = screen(path);
|
||||
const params = key === null ? {} : { key };
|
||||
const route = { snapshot: { data: { screen: s, title: s.singular }, paramMap: convertToParamMap(params) }, paramMap: of(convertToParamMap(params)) };
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MasterFormPage],
|
||||
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
|
||||
}).compileComponents();
|
||||
ctrl = TestBed.inject(HttpTestingController);
|
||||
navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
||||
fixture = TestBed.createComponent(MasterFormPage);
|
||||
await tick();
|
||||
if (key !== null) ctrl.expectOne(`/api/sl/${s.resource}/${encodeURIComponent(key)}`).flush(doc ?? {});
|
||||
await tick();
|
||||
}
|
||||
|
||||
const customer = () => ({
|
||||
'odata.etag': 'W/"4"', CardCode: 'C1', CardName: 'ACME', CardType: 'cCustomer', Frozen: 'tNO',
|
||||
BPAddresses: [{ ...ADDR }, { ...SHIP }],
|
||||
ContactEmployees: [{ Name: 'Juan Perez', FirstName: 'Juan', E_Mail: 'j@x.cl', InternalCode: 39, CardCode: 'C1' }],
|
||||
});
|
||||
const item = () => ({
|
||||
'odata.etag': 'W/"2"', ItemCode: 'A1', ItemName: 'Pieza', Frozen: 'tNO',
|
||||
ItemPrices: [{ PriceList: 1, Price: 100, Currency: 'CLP', Factor: 1, BasePriceList: 1 }, { PriceList: 2, Price: 50, Currency: 'CLP', Factor: 0.5, BasePriceList: 1 }],
|
||||
ItemWarehouseInfoCollection: [{ WarehouseCode: '01', InStock: 990, Committed: 2, Ordered: 1, MinimalStock: 0, MaximalStock: 0 }],
|
||||
});
|
||||
const cell = (tab: number, row: number, col: number): HTMLInputElement =>
|
||||
el().querySelectorAll('sbo-tab')[tab].querySelectorAll('tbody tr')[row].querySelectorAll<HTMLInputElement>('input, select')[col] as HTMLInputElement;
|
||||
|
||||
beforeEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('muestra las direcciones y los contactos que trae SAP en sus pestañas', async () => {
|
||||
await open('/socios/clientes', 'C1', customer());
|
||||
const tabs = el().querySelectorAll('sbo-tab');
|
||||
const addrRows = [...tabs[3].querySelectorAll('tbody tr')];
|
||||
expect(addrRows).toHaveLength(2);
|
||||
expect(addrRows[0].querySelector<HTMLInputElement>('input')!.value).toBe('BillTo');
|
||||
expect(tabs[4].querySelector<HTMLInputElement>('tbody input')!.value).toBe('Juan Perez');
|
||||
expect(page().dirty()).toBe(false);
|
||||
});
|
||||
|
||||
it('editar una dirección envía la colección completa con el reemplazo de colecciones', async () => {
|
||||
await open('/socios/clientes', 'C1', customer());
|
||||
const street = cell(3, 0, 2);
|
||||
street.value = 'Calle 99';
|
||||
street.dispatchEvent(new Event('change'));
|
||||
await tick();
|
||||
expect(page().dirty()).toBe(true);
|
||||
save();
|
||||
await tick();
|
||||
const req = ctrl.expectOne((r) => r.method === 'PATCH');
|
||||
expect(req.request.headers.get('B1S-ReplaceCollectionsOnPatch')).toBe('true');
|
||||
const body = req.request.body as { BPAddresses: Record<string, unknown>[] };
|
||||
expect(Object.keys(body)).toEqual(['BPAddresses']);
|
||||
expect(body.BPAddresses).toHaveLength(2);
|
||||
expect(body.BPAddresses[0]).toMatchObject({ AddressName: 'BillTo', Street: 'Calle 99', RowNum: 0, BPCode: 'C1' });
|
||||
expect(body.BPAddresses[1]).toEqual(SHIP);
|
||||
req.flush(null);
|
||||
await tick();
|
||||
ctrl.expectOne('/api/sl/BusinessPartners/C1').flush(customer());
|
||||
});
|
||||
|
||||
it('añadir una persona de contacto y quitar una dirección', async () => {
|
||||
await open('/socios/clientes', 'C1', customer());
|
||||
page().addRow(masterCollectionsOf(screen('/socios/clientes').layout)[1]);
|
||||
await tick();
|
||||
expect(el().querySelectorAll('sbo-tab')[4].querySelectorAll('tbody tr')).toHaveLength(2);
|
||||
const name = cell(4, 1, 0);
|
||||
name.value = 'Ana Soto';
|
||||
name.dispatchEvent(new Event('change'));
|
||||
(el().querySelectorAll('sbo-tab')[3].querySelectorAll('td.del button')[1] as HTMLElement).click(); // quita «Bodega»
|
||||
await tick();
|
||||
save();
|
||||
await tick();
|
||||
const req = ctrl.expectOne((r) => r.method === 'PATCH');
|
||||
const body = req.request.body as { BPAddresses: unknown[]; ContactEmployees: Record<string, unknown>[] };
|
||||
expect(body.BPAddresses).toHaveLength(1);
|
||||
expect(body.ContactEmployees.map((c) => c['Name'])).toEqual(['Juan Perez', 'Ana Soto']);
|
||||
expect(body.ContactEmployees[0]).toMatchObject({ InternalCode: 39 }); // la persona existente conserva su código de SAP
|
||||
req.flush(null);
|
||||
await tick();
|
||||
ctrl.expectOne('/api/sl/BusinessPartners/C1').flush(customer());
|
||||
});
|
||||
|
||||
it('una fila añadida y dejada vacía no se envía ni cuenta como cambio', async () => {
|
||||
await open('/socios/clientes', 'C1', customer());
|
||||
page().addRow(masterCollectionsOf(screen('/socios/clientes').layout)[0]);
|
||||
await tick();
|
||||
expect(page().dirty()).toBe(false);
|
||||
save();
|
||||
await tick();
|
||||
ctrl.expectNone((r) => r.method === 'PATCH');
|
||||
expect(el().querySelector('.status')?.textContent).toContain('No hay cambios');
|
||||
});
|
||||
|
||||
it('un cliente nuevo con una dirección la envía en el POST', async () => {
|
||||
await open('/socios/clientes', null);
|
||||
page().setValue('CardCode', 'C9');
|
||||
page().setValue('CardName', 'Nuevo');
|
||||
page().addRow(masterCollectionsOf(screen('/socios/clientes').layout)[0]);
|
||||
await tick();
|
||||
const name = cell(3, 0, 0);
|
||||
name.value = 'BillTo';
|
||||
name.dispatchEvent(new Event('change'));
|
||||
await tick();
|
||||
save();
|
||||
await tick();
|
||||
const req = ctrl.expectOne((r) => r.method === 'POST');
|
||||
expect((req.request.body as Record<string, unknown>)['BPAddresses']).toEqual([{ AddressName: 'BillTo', AddressType: 'bo_ShipTo' }]);
|
||||
req.flush({ CardCode: 'C9' });
|
||||
await tick();
|
||||
expect(navigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('precios: solo se envía la lista que cambió, sin reemplazar la colección', async () => {
|
||||
await open('/inventario/articulos', 'A1', item());
|
||||
const price = cell(4, 1, 1);
|
||||
expect(price.value).toBe('50.00');
|
||||
price.value = '777';
|
||||
price.dispatchEvent(new Event('change'));
|
||||
await tick();
|
||||
save();
|
||||
await tick();
|
||||
const req = ctrl.expectOne((r) => r.method === 'PATCH');
|
||||
expect(req.request.headers.has('B1S-ReplaceCollectionsOnPatch')).toBe(false);
|
||||
expect(req.request.body).toEqual({ ItemPrices: [{ PriceList: 2, Price: 777 }] });
|
||||
req.flush(null);
|
||||
await tick();
|
||||
ctrl.expectOne('/api/sl/Items/A1').flush(item());
|
||||
});
|
||||
|
||||
it('precios: la lista y el factor se ven pero no se editan, y no se pueden añadir ni quitar filas', async () => {
|
||||
await open('/inventario/articulos', 'A1', item());
|
||||
const tab = el().querySelectorAll('sbo-tab')[4];
|
||||
expect((tab.querySelector('tbody tr input') as HTMLInputElement).readOnly).toBe(true); // lista de precios
|
||||
expect(tab.querySelectorAll('td.del button')).toHaveLength(0);
|
||||
expect(tab.querySelector('.addrow')).toBeNull();
|
||||
});
|
||||
|
||||
it('el stock por almacén se muestra completo y de solo lectura', async () => {
|
||||
await open('/inventario/articulos', 'A1', item());
|
||||
const tab = el().querySelectorAll('sbo-tab')[5];
|
||||
const inputs = [...tab.querySelectorAll<HTMLInputElement>('tbody input')];
|
||||
expect(inputs.map((i) => i.value)).toEqual(['01', '990', '2', '1', '0', '0']);
|
||||
expect(inputs.every((i) => i.readOnly)).toBe(true);
|
||||
expect(tab.querySelector('.addrow')).toBeNull();
|
||||
expect(page().dirty()).toBe(false);
|
||||
});
|
||||
|
||||
it('un artículo con cambios en un campo y en los precios envía las dos cosas juntas', async () => {
|
||||
await open('/inventario/articulos', 'A1', item());
|
||||
page().setValue('ItemName', 'Pieza nueva');
|
||||
const price = cell(4, 0, 1);
|
||||
price.value = '120';
|
||||
price.dispatchEvent(new Event('change'));
|
||||
await tick();
|
||||
save();
|
||||
await tick();
|
||||
expect(ctrl.expectOne((r) => r.method === 'PATCH').request.body).toEqual({ ItemName: 'Pieza nueva', ItemPrices: [{ PriceList: 1, Price: 120 }] });
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,19 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (tab.collection; as c) {
|
||||
<sbo-rows-table
|
||||
[columns]="c.columns"
|
||||
[rows]="rowsOf(c)"
|
||||
(rowsChange)="setRows(c, $event)"
|
||||
[readonly]="!!c.readonly"
|
||||
[removable]="growable(c)"
|
||||
[emptyText]="c.readonly ? 'Sin datos.' : 'Sin filas.'"
|
||||
/>
|
||||
@if (growable(c)) {
|
||||
<p class="addrow"><button type="button" class="btn" (click)="addRow(c)">{{ c.addLabel ?? '+ Añadir fila' }}</button></p>
|
||||
}
|
||||
}
|
||||
</sbo-tab>
|
||||
}
|
||||
</sbo-tabs>
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('MasterFormPage', () => {
|
||||
it('muestra el código y el nombre, el estado y las pestañas con sus campos', () => {
|
||||
expect(el().querySelector('.title')?.textContent).toContain('Cliente · C100');
|
||||
expect(el().querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Activo');
|
||||
expect([...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim())).toEqual(['General', 'Condiciones de pago', 'Contabilidad', 'Observaciones']);
|
||||
expect([...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim())).toEqual(['General', 'Condiciones de pago', 'Contabilidad', 'Direcciones', 'Personas de contacto', 'Observaciones']);
|
||||
expect(labels()).toEqual(expect.arrayContaining(['Código', 'Nombre', 'RUT', 'Teléfono 1', 'Correo electrónico', 'Condición de pago', 'Límite de crédito']));
|
||||
const values = [...el().querySelectorAll<HTMLInputElement>('input')].map((i) => i.value);
|
||||
expect(values).toEqual(expect.arrayContaining(['C100', 'ACME Ltda', '76111222-3', 'ventas@acme.cl']));
|
||||
|
||||
@@ -5,19 +5,25 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { map } from 'rxjs';
|
||||
import { API_URL, apiErrorMessage } from '../../core/api';
|
||||
import { SlMasterApi } from '../../core/sl/sl-master';
|
||||
import { RowsTable } from '../../shared/grid/rows-table';
|
||||
import { Icon, SboWindow, Tab, Tabs, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
|
||||
import { updatePayload, type DocRecord } from '../documents/document-model';
|
||||
import type { HeaderField } from '../documents/document-layouts';
|
||||
import { MasterField } from './master-field';
|
||||
import {
|
||||
type CollectionRow,
|
||||
type MasterNavTarget,
|
||||
collectionPayload,
|
||||
collectionRowsOf,
|
||||
collectionsCreatePayload,
|
||||
createMasterPayload,
|
||||
masterCollectionsOf,
|
||||
masterKeysOf,
|
||||
masterRequiredOf,
|
||||
pickMaster,
|
||||
validateMaster,
|
||||
} from './master-model';
|
||||
import type { MasterScreen } from './master-types';
|
||||
import type { MasterCollection, MasterScreen } from './master-types';
|
||||
|
||||
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
|
||||
|
||||
@@ -30,8 +36,9 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
|
||||
@Component({
|
||||
selector: 'sbo-master-form-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [Icon, MasterField, RouterLink, SboWindow, Tab, Tabs, Toolbar],
|
||||
imports: [Icon, MasterField, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
|
||||
templateUrl: './master-form.page.html',
|
||||
styles: '.addrow { margin: 6px 0 0; }',
|
||||
})
|
||||
export class MasterFormPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
@@ -43,6 +50,7 @@ export class MasterFormPage {
|
||||
protected readonly layout = this.screen.layout;
|
||||
private readonly keys = masterKeysOf(this.layout);
|
||||
private readonly required = masterRequiredOf(this.layout);
|
||||
private readonly collections = masterCollectionsOf(this.layout);
|
||||
|
||||
protected readonly key = toSignal(this.route.paramMap.pipe(map((p) => p.get('key'))), {
|
||||
initialValue: this.route.snapshot.paramMap.get('key'),
|
||||
@@ -68,6 +76,22 @@ export class MasterFormPage {
|
||||
return pickMaster(doc, this.keys, this.layout.newHeader());
|
||||
}
|
||||
|
||||
/** Filas de las tablas (direcciones, contactos, precios…); se reinician al cargar o recargar el registro. */
|
||||
protected readonly tables = linkedSignal<Record<string, CollectionRow[]>>(() =>
|
||||
Object.fromEntries(this.collections.map((c) => [c.key, collectionRowsOf(this.doc(), c)])),
|
||||
);
|
||||
|
||||
/** Lo que cambió en cada tabla (colección → filas a enviar). */
|
||||
private tableChanges(): Record<string, CollectionRow[]> {
|
||||
const d = this.doc();
|
||||
return Object.fromEntries(
|
||||
this.collections.flatMap((c) => {
|
||||
const changed = collectionPayload(c, collectionRowsOf(d, c), this.tables()[c.key] ?? []);
|
||||
return changed ? [[c.key, changed] as const] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// --- estado --------------------------------------------------------------------------------
|
||||
|
||||
protected readonly saving = signal(false);
|
||||
@@ -82,7 +106,7 @@ export class MasterFormPage {
|
||||
protected readonly dirty = computed(() => {
|
||||
const d = this.doc();
|
||||
if (!d) return false;
|
||||
return Object.keys(updatePayload(this.pick(d), this.header(), [], []).body).length > 0;
|
||||
return Object.keys(updatePayload(this.pick(d), this.header(), [], []).body).length > 0 || Object.keys(this.tableChanges()).length > 0;
|
||||
});
|
||||
|
||||
protected readonly status = computed<StatusMessage>(() => {
|
||||
@@ -123,6 +147,24 @@ export class MasterFormPage {
|
||||
return this.lockedKeys.has(key);
|
||||
}
|
||||
|
||||
protected rowsOf(c: MasterCollection): CollectionRow[] {
|
||||
return this.tables()[c.key] ?? [];
|
||||
}
|
||||
|
||||
protected setRows(c: MasterCollection, rows: CollectionRow[]): void {
|
||||
this.tables.update((t) => ({ ...t, [c.key]: rows }));
|
||||
this.message.set(null);
|
||||
}
|
||||
|
||||
protected addRow(c: MasterCollection): void {
|
||||
this.setRows(c, [...this.rowsOf(c), { ...(c.newRow?.() ?? {}) }]);
|
||||
}
|
||||
|
||||
/** La tabla permite añadir y quitar filas. */
|
||||
protected growable(c: MasterCollection): boolean {
|
||||
return !c.readonly && c.mode === 'replace' && c.growable !== false;
|
||||
}
|
||||
|
||||
protected value(key: string): unknown {
|
||||
return this.header()[key];
|
||||
}
|
||||
@@ -153,17 +195,22 @@ export class MasterFormPage {
|
||||
try {
|
||||
const d = this.doc();
|
||||
if (!d) {
|
||||
const created = await this.api.create(this.screen.resource, createMasterPayload(this.header(), this.layout.fixed));
|
||||
const body = { ...createMasterPayload(this.header(), this.layout.fixed), ...collectionsCreatePayload(this.layout, this.tables()) };
|
||||
const created = await this.api.create(this.screen.resource, body);
|
||||
const key = text(created[this.screen.keyField]);
|
||||
await this.router.navigate(['..', key], { relativeTo: this.route, state: { created: key } });
|
||||
return;
|
||||
}
|
||||
const { body } = updatePayload(this.pick(d), this.header(), [], []);
|
||||
const changes = this.tableChanges();
|
||||
Object.assign(body, changes);
|
||||
if (!Object.keys(body).length) {
|
||||
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
|
||||
return;
|
||||
}
|
||||
await this.api.update(this.screen.resource, this.key()!, body, text(d['odata.etag']) || undefined);
|
||||
// Reemplazar la colección solo cuando se manda completa (direcciones, contactos), nunca en las que van por filas.
|
||||
const replace = this.collections.some((c) => c.mode === 'replace' && c.key in changes);
|
||||
await this.api.update(this.screen.resource, this.key()!, body, text(d['odata.etag']) || undefined, replace);
|
||||
this.message.set({ kind: 'ok', text: `${this.screen.singular} actualizado.` });
|
||||
this.resource.reload();
|
||||
} catch (e) {
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('más campos de los maestros', () => {
|
||||
it('el artículo suma unidades, proveedor principal, método de valoración y si es sujeto a impuesto', () => {
|
||||
const a = by('/inventario/articulos').layout;
|
||||
expect(masterKeysOf(a)).toEqual(expect.arrayContaining(['SalesUnit', 'PurchaseUnit', 'Mainsupplier', 'CostAccountingMethod', 'GLMethod', 'VatLiable']));
|
||||
expect(a.tabs.map((t) => t.id)).toEqual(['general', 'inventario', 'ventas', 'compras', 'observaciones']);
|
||||
expect(a.tabs.map((t) => t.id)).toEqual(['general', 'inventario', 'ventas', 'compras', 'precios', 'stock', 'observaciones']);
|
||||
expect(a.newHeader()).toMatchObject({ VatLiable: 'tYES', CostAccountingMethod: 'bis_MovingAverage', GLMethod: 'glm_ItemClass' });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { escapeODataString } from '../../core/sl/odata';
|
||||
import { type HeaderField, keysOfFields, requiredOfFields } from '../documents/document-layouts';
|
||||
import { type DocRecord, compact } from '../documents/document-model';
|
||||
import type { MasterLayout } from './master-types';
|
||||
import type { MasterCollection, MasterLayout } from './master-types';
|
||||
|
||||
const isEmpty = (v: unknown): boolean => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
|
||||
|
||||
@@ -58,3 +58,61 @@ export function masterNavigationQuery(
|
||||
return current === null ? { $orderby: asc } : { $filter: `${keyField} gt ${quoted}`, $orderby: asc };
|
||||
}
|
||||
}
|
||||
|
||||
// --- tablas (colecciones) ----------------------------------------------------------------
|
||||
|
||||
export type CollectionRow = Record<string, unknown>;
|
||||
|
||||
/** Las tablas que declara el formulario, de todas sus pestañas. */
|
||||
export const masterCollectionsOf = (layout: MasterLayout): MasterCollection[] =>
|
||||
layout.tabs.flatMap((t) => (t.collection ? [t.collection] : []));
|
||||
|
||||
/** Copia de las filas que trae SAP de una colección (sobre ellas se aplican los cambios). */
|
||||
export const collectionRowsOf = (doc: DocRecord | undefined, c: MasterCollection): CollectionRow[] =>
|
||||
((doc?.[c.key] as CollectionRow[] | undefined) ?? []).map((r) => ({ ...r }));
|
||||
|
||||
const has = (v: unknown): boolean => !(v === null || v === undefined || (typeof v === 'string' && v.trim() === ''));
|
||||
|
||||
/** Una fila vacía: ninguno de los campos que la identifican tiene valor. */
|
||||
export const isBlankRow = (c: MasterCollection, row: CollectionRow): boolean => !c.identity.some((k) => has(row[k]));
|
||||
|
||||
const editableKeys = (c: MasterCollection): string[] => c.columns.filter((col) => !col.readonly).map((col) => col.key);
|
||||
/** Dos valores de una celda son iguales (vacío = vacío; un número escrito como texto = el número). */
|
||||
function same(a: unknown, b: unknown): boolean {
|
||||
if (!has(a) && !has(b)) return true;
|
||||
if (!has(a) || !has(b)) return false;
|
||||
return a === b || (Number.isFinite(Number(a)) && Number(a) === Number(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lo que se envía a SAP de una tabla tras editarla, o `null` si no cambió. En `replace` la colección completa
|
||||
* (las filas leídas con los cambios y las nuevas, sin las vacías); en `merge` solo las filas cambiadas con su
|
||||
* `rowKey` y los campos que cambiaron.
|
||||
*/
|
||||
export function collectionPayload(c: MasterCollection, original: readonly CollectionRow[], current: readonly CollectionRow[]): CollectionRow[] | null {
|
||||
if (c.readonly) return null;
|
||||
const keys = editableKeys(c);
|
||||
const rows = current.filter((r) => !isBlankRow(c, r));
|
||||
if (c.mode === 'merge') {
|
||||
const rk = c.rowKey ?? '';
|
||||
const changed = rows.flatMap((r) => {
|
||||
const before = original.find((o) => o[rk] === r[rk]);
|
||||
const diff = keys.filter((k) => !same(before?.[k], r[k]));
|
||||
return before && diff.length ? [{ [rk]: r[rk], ...Object.fromEntries(diff.map((k) => [k, has(r[k]) ? r[k] : null])) }] : [];
|
||||
});
|
||||
return changed.length ? changed : null;
|
||||
}
|
||||
const unchanged = rows.length === original.length && rows.every((r, i) => keys.every((k) => same(original[i]?.[k], r[k])));
|
||||
return unchanged ? null : rows;
|
||||
}
|
||||
|
||||
/** Tablas con filas para enviar al crear el registro (las vacías no). */
|
||||
export function collectionsCreatePayload(layout: MasterLayout, current: Readonly<Record<string, readonly CollectionRow[]>>): DocRecord {
|
||||
const body: DocRecord = {};
|
||||
for (const c of masterCollectionsOf(layout)) {
|
||||
if (c.readonly) continue;
|
||||
const rows = (current[c.key] ?? []).filter((r) => !isBlankRow(c, r)).map((r) => compact(r));
|
||||
if (rows.length) body[c.key] = rows;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -1,12 +1,41 @@
|
||||
import type { ListFilter } from '../../core/sl/list-filters';
|
||||
import type { DetailColumn } from '../../shared/grid/grid.types';
|
||||
import type { ListColumn } from '../../shared/list/list.types';
|
||||
import type { FieldColumns } from '../documents/document-layouts';
|
||||
import type { DocRecord } from '../documents/document-model';
|
||||
|
||||
/** Una pestaña del maestro (General, Condiciones de pago…): dos columnas de campos como máximo. */
|
||||
/**
|
||||
* Una tabla de filas del maestro (direcciones, personas de contacto, precios, stock por almacén): una
|
||||
* colección del Service Layer que se edita en la pestaña, debajo de los campos.
|
||||
*/
|
||||
export interface MasterCollection {
|
||||
/** Colección de SAP (`BPAddresses`, `ContactEmployees`, `ItemPrices`…). */
|
||||
key: string;
|
||||
columns: readonly DetailColumn[];
|
||||
/**
|
||||
* Cómo se envía al modificar: `replace` manda la colección completa (las filas leídas con los cambios
|
||||
* aplicados y las nuevas; SAP quita las que faltan) y `merge` solo las filas cambiadas, identificadas por
|
||||
* `rowKey` (los precios: SAP recalcula las listas derivadas si no se le reenvían con valores viejos).
|
||||
*/
|
||||
mode: 'replace' | 'merge';
|
||||
/** Campo que identifica una fila en `merge` (`PriceList`). */
|
||||
rowKey?: string;
|
||||
/** Solo se muestra (el stock por almacén). */
|
||||
readonly?: boolean;
|
||||
/** Se pueden añadir y quitar filas (por defecto sí; no en `merge` ni en solo lectura). */
|
||||
growable?: boolean;
|
||||
addLabel?: string;
|
||||
/** Una fila sin ninguno de estos campos con valor se considera vacía y no se guarda. */
|
||||
identity: readonly string[];
|
||||
/** Valores de una fila nueva. */
|
||||
newRow?: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Una pestaña del maestro (General, Condiciones de pago…): dos columnas de campos como máximo y, si la hay, una tabla. */
|
||||
export interface MasterTab extends FieldColumns {
|
||||
id: string;
|
||||
label: string;
|
||||
collection?: MasterCollection;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -108,6 +108,61 @@ const partner = (opts: { path: string; type: 'cCustomer' | 'cSupplier'; singular
|
||||
],
|
||||
right: [],
|
||||
},
|
||||
{
|
||||
id: 'direcciones',
|
||||
label: 'Direcciones',
|
||||
left: [],
|
||||
right: [],
|
||||
collection: {
|
||||
key: 'BPAddresses',
|
||||
mode: 'replace',
|
||||
identity: ['AddressName'],
|
||||
addLabel: '+ Añadir dirección',
|
||||
newRow: () => ({ AddressType: 'bo_ShipTo' }),
|
||||
columns: [
|
||||
{ key: 'AddressName', title: 'Nombre', type: 'text', required: true, width: '130px' },
|
||||
{
|
||||
key: 'AddressType',
|
||||
title: 'Tipo',
|
||||
type: 'select',
|
||||
width: '170px',
|
||||
options: [
|
||||
{ value: 'bo_BillTo', label: 'Facturación' },
|
||||
{ value: 'bo_ShipTo', label: 'Envío' },
|
||||
],
|
||||
},
|
||||
{ key: 'Street', title: 'Calle', type: 'text', width: '200px' },
|
||||
{ key: 'StreetNo', title: 'Número', type: 'text', width: '80px' },
|
||||
{ key: 'Block', title: 'Complemento', type: 'text', width: '140px' },
|
||||
{ key: 'City', title: 'Ciudad', type: 'text', width: '130px' },
|
||||
{ key: 'County', title: 'Comuna', type: 'text', width: '130px' },
|
||||
{ key: 'State', title: 'Región', type: 'text', width: '100px' },
|
||||
{ key: 'ZipCode', title: 'Código postal', type: 'text', width: '110px' },
|
||||
{ key: 'Country', title: 'País', type: 'text', width: '70px' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'contactos',
|
||||
label: 'Personas de contacto',
|
||||
left: [],
|
||||
right: [],
|
||||
collection: {
|
||||
key: 'ContactEmployees',
|
||||
mode: 'replace',
|
||||
identity: ['Name'],
|
||||
addLabel: '+ Añadir persona de contacto',
|
||||
columns: [
|
||||
{ key: 'Name', title: 'Nombre', type: 'text', required: true, width: '160px' },
|
||||
{ key: 'FirstName', title: 'Nombres', type: 'text', width: '130px' },
|
||||
{ key: 'LastName', title: 'Apellidos', type: 'text', width: '130px' },
|
||||
{ key: 'Position', title: 'Cargo', type: 'text', width: '130px' },
|
||||
{ key: 'Phone1', title: 'Teléfono', type: 'text', width: '110px' },
|
||||
{ key: 'MobilePhone', title: 'Celular', type: 'text', width: '110px' },
|
||||
{ key: 'E_Mail', title: 'Correo electrónico', type: 'text', width: '200px' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ id: 'observaciones', label: 'Observaciones', left: [field({ type: 'textarea', key: 'Notes', label: 'Observaciones' })], right: [] },
|
||||
],
|
||||
newHeader: () => ({ Frozen: 'tNO', CreditLimit: 0, DiscountPercent: 0, MaxCommitment: 0 }),
|
||||
|
||||
@@ -48,9 +48,15 @@ export interface GridMultiAdd {
|
||||
export interface DetailColumn {
|
||||
key: string;
|
||||
title: string;
|
||||
type: 'text' | 'number' | 'date';
|
||||
type: 'text' | 'number' | 'date' | 'select';
|
||||
width?: string;
|
||||
required?: boolean;
|
||||
/** Solo se muestra (aunque la tabla sea editable). */
|
||||
readonly?: boolean;
|
||||
/** Opciones de una columna `select`. */
|
||||
options?: readonly { value: string; label: string }[];
|
||||
/** Decimales fijos de una columna numérica; sin él, hasta 3. */
|
||||
decimals?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, linkedSignal, output } from '@angular/core';
|
||||
import { formatNumber, parseDecimal } from '../../core/format';
|
||||
import type { DetailColumn } from './grid.types';
|
||||
import { RowsTable } from './rows-table';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** `2026-01-05T00:00:00Z` → `2026-01-05` (el formato del selector de fecha). */
|
||||
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
|
||||
|
||||
/**
|
||||
* Ventana con las filas de un detalle de línea (lotes, números de serie): tabla editable con «Añadir fila»
|
||||
* y «Quitar». Solo emite `saved` al aceptar; cancelar descarta los cambios.
|
||||
@@ -14,6 +11,7 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
|
||||
@Component({
|
||||
selector: 'sbo-line-detail-dialog',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RowsTable],
|
||||
template: `
|
||||
<div class="overlay">
|
||||
<div class="dlg" role="dialog" aria-modal="true" [attr.aria-label]="title()" (keydown.escape)="closed.emit()">
|
||||
@@ -24,47 +22,7 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
|
||||
}
|
||||
</header>
|
||||
<div class="body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="rn">#</th>
|
||||
@for (c of columns(); track c.key) {
|
||||
<th [style.width]="c.width" [class.req]="c.required">{{ c.title }}</th>
|
||||
}
|
||||
@if (!readonly()) {
|
||||
<th class="del"></th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows(); track $index; let i = $index) {
|
||||
<tr>
|
||||
<td class="rn">{{ i + 1 }}</td>
|
||||
@for (c of columns(); track c.key) {
|
||||
<td>
|
||||
<input
|
||||
[type]="c.type === 'date' ? 'date' : 'text'"
|
||||
autocomplete="off"
|
||||
[class.num]="c.type === 'number'"
|
||||
[readOnly]="readonly()"
|
||||
[attr.data-r]="i"
|
||||
[attr.aria-label]="c.title + ' de la fila ' + (i + 1)"
|
||||
[value]="text(row, c)"
|
||||
(change)="set(i, c, $any($event.target))"
|
||||
/>
|
||||
</td>
|
||||
}
|
||||
@if (!readonly()) {
|
||||
<td class="del"><button type="button" [attr.aria-label]="'Quitar la fila ' + (i + 1)" (click)="remove(i)">×</button></td>
|
||||
}
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr class="none">
|
||||
<td [attr.colspan]="columns().length + 2">{{ readonly() ? 'Sin filas.' : 'Sin filas. Pulse «Añadir fila».' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<sbo-rows-table [columns]="columns()" [(rows)]="rows" [readonly]="readonly()" [emptyText]="readonly() ? 'Sin filas.' : 'Sin filas. Pulse «Añadir fila».'" />
|
||||
</div>
|
||||
@if (hint()) {
|
||||
<p class="hint" [class.bad]="bad()">{{ hint() }}</p>
|
||||
@@ -89,19 +47,6 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
|
||||
h2 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.sub { font-size: 12px; color: var(--quiet); }
|
||||
.body { overflow: auto; padding: 8px 12px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { padding: 4px 6px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; font-weight: 600; text-align: left; white-space: nowrap; }
|
||||
th.req::after { content: ' *'; color: var(--err); }
|
||||
td { padding: 0 2px; height: 27px; border: 1px solid var(--line-soft); }
|
||||
td.rn, th.rn { width: 30px; text-align: center; color: var(--quiet); }
|
||||
td.del, th.del { width: 30px; text-align: center; }
|
||||
input { width: 100%; height: 22px; padding: 1px 4px; background: transparent; border: 1px solid transparent; border-radius: var(--radius); }
|
||||
input.num { text-align: right; }
|
||||
input:hover:not([readonly]) { border-color: var(--field-b); }
|
||||
input:focus { background: var(--field); border-color: var(--focus); outline: none; }
|
||||
td.del button { padding: 2px 6px; background: none; border: none; color: var(--quiet); font-size: 15px; line-height: 1; cursor: pointer; border-radius: var(--radius); }
|
||||
td.del button:hover { color: var(--err); background: var(--err-bg); }
|
||||
tr.none td { padding: 14px; text-align: center; color: var(--quiet); }
|
||||
.hint { margin: 0; padding: 6px 12px; font-size: 12px; color: var(--quiet); border-top: 1px solid var(--line-soft); }
|
||||
.hint.bad { color: var(--err); }
|
||||
footer { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--line); background: var(--panel); }
|
||||
@@ -125,35 +70,10 @@ export class LineDetailDialog {
|
||||
protected readonly hint = computed(() => this.hintState().text);
|
||||
protected readonly bad = computed(() => this.hintState().bad);
|
||||
|
||||
protected text(row: Row, c: DetailColumn): string {
|
||||
const v = row[c.key];
|
||||
if (c.type === 'date') return dateOnly(v);
|
||||
if (c.type === 'number') return v === null || v === undefined || v === '' ? '' : formatNumber(v, undefined);
|
||||
return v === null || v === undefined ? '' : String(v);
|
||||
}
|
||||
|
||||
protected add(): void {
|
||||
this.rows.update((rs) => [...rs, {}]);
|
||||
}
|
||||
|
||||
protected remove(index: number): void {
|
||||
this.rows.update((rs) => rs.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
protected set(index: number, c: DetailColumn, input: HTMLInputElement): void {
|
||||
const raw = input.value;
|
||||
let value: unknown = raw;
|
||||
if (c.type === 'number') {
|
||||
const parsed = raw.trim() === '' ? null : parseDecimal(raw);
|
||||
if (raw.trim() !== '' && parsed === null) {
|
||||
input.value = this.text(this.rows()[index], c); // no es un número: se conserva el anterior
|
||||
return;
|
||||
}
|
||||
value = parsed;
|
||||
}
|
||||
this.rows.update((rs) => rs.map((r, i) => (i === index ? { ...r, [c.key]: value } : r)));
|
||||
}
|
||||
|
||||
/** Las filas sin ningún dato no se guardan. */
|
||||
protected clean(): Row[] {
|
||||
return this.rows().filter((r) => this.columns().some((c) => r[c.key] !== null && r[c.key] !== undefined && r[c.key] !== ''));
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
|
||||
import { formatNumber, parseDecimal } from '../../core/format';
|
||||
import type { DetailColumn } from './grid.types';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** `2026-01-05T00:00:00Z` → `2026-01-05` (el formato del selector de fecha). */
|
||||
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
|
||||
|
||||
/**
|
||||
* Tabla editable de filas sencillas (direcciones, personas de contacto, precios, lotes…): texto, número,
|
||||
* fecha o lista fija por columna, con botón para quitar cada fila. Quien la usa añade las filas
|
||||
* (`rows.update(...)`) y decide qué hacer con ellas; cada cambio actualiza `rows`.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'sbo-rows-table',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="rn">#</th>
|
||||
@for (c of columns(); track c.key) {
|
||||
<th [style.width]="c.width" [class.req]="c.required">{{ c.title }}</th>
|
||||
}
|
||||
@if (canRemove()) {
|
||||
<th class="del"></th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows(); track $index; let i = $index) {
|
||||
<tr>
|
||||
<td class="rn">{{ i + 1 }}</td>
|
||||
@for (c of columns(); track c.key) {
|
||||
<td [class.ro]="locked(c)">
|
||||
@if (c.type === 'select') {
|
||||
<select
|
||||
[disabled]="locked(c)"
|
||||
[attr.data-r]="i"
|
||||
[attr.aria-label]="c.title + ' de la fila ' + (i + 1)"
|
||||
(change)="setSelect(i, c, $any($event.target).value)"
|
||||
>
|
||||
<option value=""></option>
|
||||
@for (o of c.options ?? []; track o.value) {
|
||||
<option [value]="o.value" [selected]="o.value === row[c.key]">{{ o.label }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<input
|
||||
[type]="c.type === 'date' ? 'date' : 'text'"
|
||||
autocomplete="off"
|
||||
[class.num]="c.type === 'number'"
|
||||
[readOnly]="locked(c)"
|
||||
[attr.data-r]="i"
|
||||
[attr.aria-label]="c.title + ' de la fila ' + (i + 1)"
|
||||
[value]="text(row, c)"
|
||||
(change)="set(i, c, $any($event.target))"
|
||||
/>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
@if (canRemove()) {
|
||||
<td class="del"><button type="button" [attr.aria-label]="'Quitar la fila ' + (i + 1)" (click)="remove(i)">×</button></td>
|
||||
}
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr class="none">
|
||||
<td [attr.colspan]="columns().length + 2">{{ emptyText() }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host { display: block; }
|
||||
.wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { padding: 4px 6px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; font-weight: 600; text-align: left; white-space: nowrap; }
|
||||
th.req::after { content: ' *'; color: var(--err); }
|
||||
td { padding: 0 2px; height: 27px; border: 1px solid var(--line-soft); white-space: nowrap; }
|
||||
td.ro { background: var(--field-ro); }
|
||||
td.rn, th.rn { width: 30px; text-align: center; color: var(--quiet); }
|
||||
td.del, th.del { width: 30px; text-align: center; }
|
||||
input, select { width: 100%; height: 22px; padding: 1px 4px; background: transparent; border: 1px solid transparent; border-radius: var(--radius); }
|
||||
input.num { text-align: right; }
|
||||
input:hover:not([readonly]), select:hover:not(:disabled) { border-color: var(--field-b); }
|
||||
input:focus, select:focus { background: var(--field); border-color: var(--focus); outline: none; }
|
||||
td.del button { padding: 2px 6px; background: none; border: none; color: var(--quiet); font-size: 15px; line-height: 1; cursor: pointer; border-radius: var(--radius); }
|
||||
td.del button:hover { color: var(--err); background: var(--err-bg); }
|
||||
tr.none td { padding: 14px; text-align: center; color: var(--quiet); }
|
||||
`,
|
||||
})
|
||||
export class RowsTable {
|
||||
readonly columns = input.required<readonly DetailColumn[]>();
|
||||
readonly rows = model<Row[]>([]);
|
||||
/** Toda la tabla en solo lectura. */
|
||||
readonly readonly = input(false);
|
||||
/** Se pueden quitar filas (por defecto, si la tabla es editable). */
|
||||
readonly removable = input(true);
|
||||
readonly emptyText = input('Sin filas.');
|
||||
|
||||
protected canRemove(): boolean {
|
||||
return !this.readonly() && this.removable();
|
||||
}
|
||||
|
||||
protected locked(c: DetailColumn): boolean {
|
||||
return this.readonly() || !!c.readonly;
|
||||
}
|
||||
|
||||
protected text(row: Row, c: DetailColumn): string {
|
||||
const v = row[c.key];
|
||||
if (c.type === 'date') return dateOnly(v);
|
||||
if (c.type === 'number') return v === null || v === undefined || v === '' ? '' : formatNumber(v, c.decimals);
|
||||
return v === null || v === undefined ? '' : String(v);
|
||||
}
|
||||
|
||||
protected remove(index: number): void {
|
||||
this.rows.update((rs) => rs.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
protected setSelect(index: number, c: DetailColumn, value: string): void {
|
||||
this.rows.update((rs) => rs.map((r, i) => (i === index ? { ...r, [c.key]: value } : r)));
|
||||
}
|
||||
|
||||
protected set(index: number, c: DetailColumn, input: HTMLInputElement): void {
|
||||
const raw = input.value;
|
||||
let value: unknown = raw;
|
||||
if (c.type === 'number') {
|
||||
const parsed = raw.trim() === '' ? null : parseDecimal(raw);
|
||||
if (raw.trim() !== '' && parsed === null) {
|
||||
input.value = this.text(this.rows()[index], c); // no es un número: se conserva el anterior
|
||||
return;
|
||||
}
|
||||
value = parsed;
|
||||
input.value = parsed === null ? '' : formatNumber(parsed, c.decimals);
|
||||
}
|
||||
this.rows.update((rs) => rs.map((r, i) => (i === index ? { ...r, [c.key]: value } : r)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user