From 369be317abe1ff4d266e42790aa1d59168860cff Mon Sep 17 00:00:00 2001 From: George Date: Fri, 16 Oct 2026 13:23:27 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20base=20de=20bancos=20(recursos=20de=20p?= =?UTF-8?q?agos=20recibidos=20y=20efectuados,=20acci=C3=B3n=20Cancel,=20me?= =?UTF-8?q?n=C3=BA=20Bancos,=20tabla=20con=20ventana=20de=20selecci=C3=B3n?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/sl/sl-proxy.controller.ts | 12 ++ apps/api/src/sl/sl-proxy.service.spec.ts | 33 +++++ apps/api/src/sl/sl-proxy.service.ts | 16 +++ apps/api/src/sl/sl-resources.spec.ts | 31 +++++ apps/api/src/sl/sl-resources.ts | 23 +++- .../src/app/core/menu/menu.service.spec.ts | 2 +- .../src/app/features/admin/audit.page.spec.ts | 2 +- apps/web/src/app/features/admin/audit.page.ts | 2 + apps/web/src/app/shared/grid/grid.types.ts | 4 +- .../src/app/shared/grid/rows-table.spec.ts | 128 ++++++++++++++++++ apps/web/src/app/shared/grid/rows-table.ts | 52 ++++++- libs/sap-types/src/audit.ts | 1 + libs/sap-types/src/menu.ts | 8 ++ 13 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/app/shared/grid/rows-table.spec.ts diff --git a/apps/api/src/sl/sl-proxy.controller.ts b/apps/api/src/sl/sl-proxy.controller.ts index f9a0daa..0061922 100644 --- a/apps/api/src/sl/sl-proxy.controller.ts +++ b/apps/api/src/sl/sl-proxy.controller.ts @@ -49,6 +49,18 @@ export class SlProxyController { return this.proxy.create(req.user, resource, body, pickSlHeaders(headers, 'POST'), req.ip ?? 'desconocida'); } + /** Acción sobre un registro (`/sl/IncomingPayments/512/Cancel`): solo las de la lista blanca del recurso. */ + @Post(':resource/:key/:action') + @HttpCode(200) + action( + @Req() req: AuthenticatedRequest, + @Param('resource') resource: string, + @Param('key') key: string, + @Param('action') action: string, + ) { + return this.proxy.action(req.user, resource, key, action, req.ip ?? 'desconocida'); + } + @Patch(':resource/:key') @HttpCode(204) async update( diff --git a/apps/api/src/sl/sl-proxy.service.spec.ts b/apps/api/src/sl/sl-proxy.service.spec.ts index 427b38d..cb1798c 100644 --- a/apps/api/src/sl/sl-proxy.service.spec.ts +++ b/apps/api/src/sl/sl-proxy.service.spec.ts @@ -80,4 +80,37 @@ describe('SlProxyService: auditoría', () => { await service.get(user, 'Orders', '5', {}); expect(record).not.toHaveBeenCalled(); }); + + describe('acciones sobre un registro (anular un pago)', () => { + it('anular un pago llama a Entidad(clave)/Cancel y queda registrado', async () => { + request.mockResolvedValueOnce({ data: undefined }); + await service.action(user, 'IncomingPayments', '512', 'Cancel', '10.0.0.5'); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST', path: 'IncomingPayments(512)/Cancel' })); + expect(record).toHaveBeenCalledWith({ action: 'sl_action', webUser: 'ana', ip: '10.0.0.5', resource: 'IncomingPayments', key: '512', detail: { action: 'Cancel' } }); + }); + + it('una acción que no está en la lista blanca del recurso no llega a SAP', async () => { + await expect(service.action(user, 'IncomingPayments', '512', 'Delete', '10.0.0.5')).rejects.toThrow(/no disponible/i); + await expect(service.action(user, 'Orders', '5', 'Cancel', '10.0.0.5')).rejects.toThrow(/no disponible/i); + expect(request).not.toHaveBeenCalled(); + }); + + it('sin permiso de escritura queda denegado y no llega a SAP', async () => { + paths = ['/bancos/pagos-efectuados']; + await expect(service.action(user, 'IncomingPayments', '512', 'Cancel', '10.0.0.5')).rejects.toBeInstanceOf(ForbiddenException); + expect(request).not.toHaveBeenCalled(); + expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'sl_denied', resource: 'IncomingPayments', outcome: 'denied' })); + }); + + it('si SAP rechaza la anulación queda registrada como error y el error sigue su camino', async () => { + request.mockRejectedValueOnce(new Error('ya está anulado')); + await expect(service.action(user, 'VendorPayments', '9', 'Cancel', '10.0.0.5')).rejects.toThrow('ya está anulado'); + expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'sl_action', resource: 'VendorPayments', key: '9', outcome: 'error', detail: { action: 'Cancel', error: 'ya está anulado' } })); + }); + + it('la clave del registro se valida (solo números en los pagos)', async () => { + await expect(service.action(user, 'IncomingPayments', "1);DROP", 'Cancel', '10.0.0.5')).rejects.toThrow(); + expect(request).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/api/src/sl/sl-proxy.service.ts b/apps/api/src/sl/sl-proxy.service.ts index 46f61cb..d9fedda 100644 --- a/apps/api/src/sl/sl-proxy.service.ts +++ b/apps/api/src/sl/sl-proxy.service.ts @@ -94,6 +94,22 @@ export class SlProxyService { } } + /** Invoca una acción de un registro (`Entidad(clave)/Cancel`) de la lista blanca del recurso. */ + async action(user: AuthUser, name: string, key: string, action: string, ip: string) { + const resource = await this.access(user, name, 'POST', ip); + if (!resource.actions?.includes(action)) throw new NotFoundException(`Acción no disponible: ${name}/${action}`); + const path = `${entityPath(resource, key)}/${action}`; + this.logger.log(`AUDIT ${user.webUser} POST ${path}`); + try { + const res = await this.sap.request({ method: 'POST', path, headers: {} }); + await this.audit.record({ action: 'sl_action', webUser: user.webUser, ip, resource: resource.name, key, detail: { action } }); + return res.data ?? null; + } catch (e) { + await this.audit.record({ action: 'sl_action', webUser: user.webUser, ip, resource: resource.name, key, outcome: 'error', detail: { action, error: errorMessage(e) } }); + throw e; + } + } + private async access(user: AuthUser, name: string, method: SlMethod, ip?: string): Promise { const resource = SL_RESOURCE_MAP.get(name); if (!resource) throw new NotFoundException(`Recurso no disponible: ${name}`); diff --git a/apps/api/src/sl/sl-resources.spec.ts b/apps/api/src/sl/sl-resources.spec.ts index 4ee6cc1..98cfb74 100644 --- a/apps/api/src/sl/sl-resources.spec.ts +++ b/apps/api/src/sl/sl-resources.spec.ts @@ -32,4 +32,35 @@ describe('recursos del Service Layer', () => { expect(r.write as string[], p).toContain(p); } }); + + it('los pagos recibidos y efectuados se crean y se anulan (Cancel), pero no se modifican', () => { + for (const [name, path] of [['IncomingPayments', '/bancos/pagos-recibidos'], ['VendorPayments', '/bancos/pagos-efectuados']] as const) { + const r = SL_RESOURCE_MAP.get(name)!; + expect(r.methods, name).toEqual(['GET', 'POST']); + expect(r.actions, name).toEqual(['Cancel']); + expect(r.write, name).toBe(path); + expect(r.read, name).toEqual([path]); + } + }); + + it('solo los pagos tienen acciones sobre un registro', () => { + expect(SL_RESOURCES.filter((r) => r.actions?.length).map((r) => r.name)).toEqual(['IncomingPayments', 'VendorPayments']); + }); + + it('las facturas y notas de crédito se pueden consultar desde la pantalla de pagos, sin poder modificarlas', () => { + for (const [name, pay] of [['Invoices', '/bancos/pagos-recibidos'], ['CreditNotes', '/bancos/pagos-recibidos'], ['PurchaseInvoices', '/bancos/pagos-efectuados'], ['PurchaseCreditNotes', '/bancos/pagos-efectuados']] as const) { + const r = SL_RESOURCE_MAP.get(name)!; + expect(r.read as string[], name).toContain(pay); + expect(r.write as string | string[], name).not.toContain(pay); + } + }); + + it('el pago de clientes no lee las facturas de proveedores ni al revés', () => { + expect(SL_RESOURCE_MAP.get('PurchaseInvoices')!.read as string[]).not.toContain('/bancos/pagos-recibidos'); + expect(SL_RESOURCE_MAP.get('Invoices')!.read as string[]).not.toContain('/bancos/pagos-efectuados'); + }); + + it('los bancos son un catálogo de solo lectura', () => { + expect(SL_RESOURCE_MAP.get('Banks')!.methods).toEqual(['GET']); + }); }); diff --git a/apps/api/src/sl/sl-resources.ts b/apps/api/src/sl/sl-resources.ts index 34b9863..cb092c9 100644 --- a/apps/api/src/sl/sl-resources.ts +++ b/apps/api/src/sl/sl-resources.ts @@ -15,13 +15,19 @@ export interface SlResource { write: RequiredPath | null; /** Colecciones permitidas en `$expand` (por defecto ninguna). */ expand?: string[]; + /** Acciones sobre un registro (`POST Entidad(clave)/Accion`) que se pueden invocar, p. ej. `Cancel`. Requieren el permiso de escritura. */ + actions?: readonly string[]; } -const document = (name: string, path: RequiredPath): SlResource => ({ +/** + * Documento de marketing o de pagos. `alsoRead` suma pantallas que solo pueden consultarlo (los pagos leen las + * facturas pendientes del socio sin poder modificarlas). + */ +const document = (name: string, path: RequiredPath, alsoRead: RequiredPath = []): SlResource => ({ name, key: 'number', methods: ['GET', 'POST', 'PATCH'], - read: path, + read: [path, alsoRead].flat(), write: path, }); @@ -51,18 +57,18 @@ export const SL_RESOURCES: readonly SlResource[] = [ document('Orders', '/ventas/ordenes'), document('DeliveryNotes', '/ventas/entrega'), // Boletas (`DocumentSubType = bod_Bill`) y facturas de reserva (`ReserveInvoice = tYES`) son facturas: mismo recurso, cuatro pantallas. - document('Invoices', ['/ventas/facturas', '/ventas/boletas', '/ventas/facturas-reserva', '/ventas/boletas-reserva']), + document('Invoices', ['/ventas/facturas', '/ventas/boletas', '/ventas/facturas-reserva', '/ventas/boletas-reserva'], '/bancos/pagos-recibidos'), document('Returns', '/ventas/devoluciones'), - document('CreditNotes', '/ventas/notas-credito'), + document('CreditNotes', '/ventas/notas-credito', '/bancos/pagos-recibidos'), // Compras document('PurchaseRequests', '/compras/solicitudes'), document('PurchaseQuotations', '/compras/ofertas'), document('PurchaseOrders', '/compras/ordenes'), document('PurchaseDeliveryNotes', '/compras/recepcion'), - document('PurchaseInvoices', '/compras/facturas'), + document('PurchaseInvoices', '/compras/facturas', '/bancos/pagos-efectuados'), document('PurchaseReturns', '/compras/devoluciones'), - document('PurchaseCreditNotes', '/compras/notas-credito'), + document('PurchaseCreditNotes', '/compras/notas-credito', '/bancos/pagos-efectuados'), // Inventario document('StockTransfers', '/inventario/transferencias'), @@ -70,6 +76,10 @@ export const SL_RESOURCES: readonly SlResource[] = [ document('InventoryGenEntries', '/inventario/entradas'), document('InventoryGenExits', '/inventario/salidas'), + // Bancos: pagos recibidos y efectuados. Se pueden anular (`Cancel`) pero no modificar. + { ...document('IncomingPayments', '/bancos/pagos-recibidos'), methods: ['GET', 'POST'], actions: ['Cancel'] }, + { ...document('VendorPayments', '/bancos/pagos-efectuados'), methods: ['GET', 'POST'], actions: ['Cancel'] }, + // Maestros (consulta para cualquier sesión; escritura según el módulo) catalog('BusinessPartners', 'string', ['/socios/clientes', '/socios/proveedores']), catalog('Items', 'string', '/inventario/articulos'), @@ -84,6 +94,7 @@ export const SL_RESOURCES: readonly SlResource[] = [ // Grupos de los maestros (para elegirlos y filtrar las listas de clientes, proveedores y artículos). catalog('BusinessPartnerGroups', 'number'), catalog('ItemGroups', 'number'), + catalog('Banks', 'string'), // Dimensiones, proyectos y cuentas de los documentos (para elegirlos en cabecera y líneas). // Indicadores de los documentos (tabla OIDC; en la localización chilena, el tipo de documento: 33 factura, 39 boleta). catalog('FactoringIndicators', 'string'), diff --git a/apps/web/src/app/core/menu/menu.service.spec.ts b/apps/web/src/app/core/menu/menu.service.spec.ts index 96c50d8..e18f85b 100644 --- a/apps/web/src/app/core/menu/menu.service.spec.ts +++ b/apps/web/src/app/core/menu/menu.service.spec.ts @@ -36,7 +36,7 @@ describe('MenuService', () => { const p = menu.ensureLoaded(); ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] }); await p; - expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'socios', 'admin']); + expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'bancos', 'socios', 'admin']); }); it('si falla marca el error y permite reintentar', async () => { diff --git a/apps/web/src/app/features/admin/audit.page.spec.ts b/apps/web/src/app/features/admin/audit.page.spec.ts index b61397c..7bdb6cf 100644 --- a/apps/web/src/app/features/admin/audit.page.spec.ts +++ b/apps/web/src/app/features/admin/audit.page.spec.ts @@ -46,7 +46,7 @@ describe('descripción del detalle', () => { }); it('cada acción del servidor tiene su nombre en español', () => { - for (const a of ['login', 'login_failed', 'logout', 'sl_create', 'sl_update', 'sl_denied', 'permissions_set', 'permissions_remove']) { + for (const a of ['login', 'login_failed', 'logout', 'sl_create', 'sl_update', 'sl_action', 'sl_denied', 'permissions_set', 'permissions_remove']) { expect(AUDIT_ACTIONS[a], a).toBeTruthy(); } }); diff --git a/apps/web/src/app/features/admin/audit.page.ts b/apps/web/src/app/features/admin/audit.page.ts index ba0542d..b1e099f 100644 --- a/apps/web/src/app/features/admin/audit.page.ts +++ b/apps/web/src/app/features/admin/audit.page.ts @@ -13,6 +13,7 @@ export const AUDIT_ACTIONS: Readonly> = { logout: 'Cierre de sesión', sl_create: 'Creación', sl_update: 'Modificación', + sl_action: 'Acción sobre un registro', sl_denied: 'Operación denegada', permissions_set: 'Permisos asignados', permissions_remove: 'Permisos quitados', @@ -35,6 +36,7 @@ export function describeDetail(row: Pick): string { const d = row.detail; if (!d) return ''; const parts: string[] = []; + if (typeof d['action'] === 'string') parts.push(d['action'] === 'Cancel' ? 'Anulación' : d['action']); if (typeof d['reason'] === 'string') parts.push(d['reason']); if (typeof d['error'] === 'string') parts.push(d['error']); if (Array.isArray(d['fields']) && d['fields'].length) parts.push(`Campos: ${(d['fields'] as string[]).join(', ')}`); diff --git a/apps/web/src/app/shared/grid/grid.types.ts b/apps/web/src/app/shared/grid/grid.types.ts index 2159251..0866d60 100644 --- a/apps/web/src/app/shared/grid/grid.types.ts +++ b/apps/web/src/app/shared/grid/grid.types.ts @@ -48,13 +48,15 @@ export interface GridMultiAdd { export interface DetailColumn { key: string; title: string; - type: 'text' | 'number' | 'date' | 'select'; + type: 'text' | 'number' | 'date' | 'select' | 'lookup'; 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 }[]; + /** Ventana de selección de una columna `lookup` (botón «…»): se escribe el código o se elige de la lista. */ + lookup?: VhLookup; /** Decimales fijos de una columna numérica; sin él, hasta 3. */ decimals?: number; } diff --git a/apps/web/src/app/shared/grid/rows-table.spec.ts b/apps/web/src/app/shared/grid/rows-table.spec.ts new file mode 100644 index 0000000..baeb65e --- /dev/null +++ b/apps/web/src/app/shared/grid/rows-table.spec.ts @@ -0,0 +1,128 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { Component, signal } from '@angular/core'; +import { type ComponentFixture, TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it } from 'vitest'; +import type { DetailColumn } from './grid.types'; +import { RowsTable } from './rows-table'; + +const COLUMNS: DetailColumn[] = [ + { key: 'Name', title: 'Nombre', type: 'text', required: true }, + { key: 'Amount', title: 'Importe', type: 'number', decimals: 2 }, + { key: 'Due', title: 'Vence', type: 'date' }, + { key: 'Kind', title: 'Tipo', type: 'select', options: [{ value: 'a', label: 'Uno' }, { value: 'b', label: 'Dos' }] }, + { key: 'Total', title: 'Total', type: 'number', readonly: true }, + { key: 'Bank', title: 'Banco', type: 'lookup', lookup: { title: 'Bancos', resource: 'Banks', columns: [{ key: 'BankCode', title: 'Código' }, { key: 'BankName', title: 'Nombre' }] } }, +]; + +@Component({ + imports: [RowsTable], + template: ``, +}) +class Host { + columns = COLUMNS; + rows = signal[]>([{ Name: 'A', Amount: 1500, Due: '2026-10-16T00:00:00Z', Kind: 'b', Total: 9, Bank: '001' }]); + readonly = signal(false); + removable = signal(true); +} + +describe('RowsTable', () => { + let fixture: ComponentFixture; + let ctrl: HttpTestingController; + const el = () => fixture.nativeElement as HTMLElement; + const tick = async () => { + fixture.detectChanges(); + await new Promise((r) => setTimeout(r)); + fixture.detectChanges(); + }; + const cells = (row = 0) => [...el().querySelectorAll('tbody tr')[row].querySelectorAll('input, select')]; + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ imports: [Host], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents(); + ctrl = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(Host); + await tick(); + }); + + it('muestra cada valor con su formato: número con decimales, fecha AAAA-MM-DD, lista y código', () => { + const [name, amount, due, kind, total, bank] = cells(); + expect(name.value).toBe('A'); + expect(amount.value).toBe('1,500.00'); + expect(due.value).toBe('2026-10-16'); + expect(kind.value).toBe('b'); + expect(total.value).toBe('9'); + expect(bank.value).toBe('001'); + }); + + it('editar una celda de texto, número o fecha actualiza la fila', async () => { + const [name, amount, due] = cells() as HTMLInputElement[]; + name.value = 'Nuevo'; + name.dispatchEvent(new Event('change')); + amount.value = '2.250,5'; + amount.dispatchEvent(new Event('change')); + due.value = '2026-12-31'; + due.dispatchEvent(new Event('change')); + await tick(); + expect(fixture.componentInstance.rows()[0]).toMatchObject({ Name: 'Nuevo', Due: '2026-12-31' }); + expect(typeof fixture.componentInstance.rows()[0]['Amount']).toBe('number'); + }); + + it('un valor que no es número conserva el anterior', async () => { + const amount = cells()[1] as HTMLInputElement; + amount.value = 'abc'; + amount.dispatchEvent(new Event('change')); + await tick(); + expect(fixture.componentInstance.rows()[0]['Amount']).toBe(1500); + expect(amount.value).toBe('1,500.00'); + }); + + it('una celda de lista elige entre sus opciones', async () => { + const kind = cells()[3] as HTMLSelectElement; + kind.value = 'a'; + kind.dispatchEvent(new Event('change')); + await tick(); + expect(fixture.componentInstance.rows()[0]['Kind']).toBe('a'); + }); + + it('las columnas de solo lectura no se editan', () => { + expect((cells()[4] as HTMLInputElement).readOnly).toBe(true); + expect((cells()[0] as HTMLInputElement).readOnly).toBe(false); + }); + + it('el botón × quita la fila y la tabla vacía muestra su texto', async () => { + (el().querySelector('td.del button') as HTMLElement).click(); + await tick(); + expect(fixture.componentInstance.rows()).toEqual([]); + expect(el().querySelector('tr.none')?.textContent).toContain('Nada'); + }); + + it('sin permiso para quitar o en solo lectura no hay botón ×', async () => { + fixture.componentInstance.removable.set(false); + await tick(); + expect(el().querySelector('td.del')).toBeNull(); + fixture.componentInstance.removable.set(true); + fixture.componentInstance.readonly.set(true); + await tick(); + expect(el().querySelector('td.del')).toBeNull(); + expect(cells().every((c) => (c as HTMLInputElement).readOnly || (c as HTMLSelectElement).disabled)).toBe(true); + }); + + it('una columna con lista de valores abre su ventana y escribe el código elegido', async () => { + (el().querySelector('.pick button') as HTMLElement).click(); + await tick(); + const req = ctrl.expectOne((r) => r.url === '/api/sl/Banks'); + req.flush({ value: [{ BankCode: '001', BankName: 'Banco de Chile' }, { BankCode: '009', BankName: 'Banco Internacional' }], 'odata.count': '2' }); + await tick(); + (el().querySelectorAll('.dlg tbody tr')[1] as HTMLElement).click(); + await tick(); + expect(fixture.componentInstance.rows()[0]['Bank']).toBe('009'); + expect(el().querySelector('.dlg')).toBeNull(); + }); + + it('en solo lectura una columna con lista no ofrece el botón «…»', async () => { + fixture.componentInstance.readonly.set(true); + await tick(); + expect(el().querySelector('.pick button')).toBeNull(); + }); +}); diff --git a/apps/web/src/app/shared/grid/rows-table.ts b/apps/web/src/app/shared/grid/rows-table.ts index 7e46cf5..2467e05 100644 --- a/apps/web/src/app/shared/grid/rows-table.ts +++ b/apps/web/src/app/shared/grid/rows-table.ts @@ -1,5 +1,7 @@ -import { ChangeDetectionStrategy, Component, input, model } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input, model, signal } from '@angular/core'; import { formatNumber, parseDecimal } from '../../core/format'; +import type { ListRow } from '../list/list.types'; +import { ValueHelp } from '../value-help/value-help'; import type { DetailColumn } from './grid.types'; type Row = Record; @@ -15,6 +17,7 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) @Component({ selector: 'sbo-rows-table', changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ValueHelp], template: `
@@ -47,6 +50,21 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) } + } @else if (c.type === 'lookup') { +
+ + @if (!locked(c)) { + + } +
} @else { (typeof v === 'string' ? v.slice(0, 10)
+ @if (target(); as t) { + + } `, styles: ` :host { display: block; } + .pick { display: grid; grid-template-columns: minmax(0, 1fr) 22px; } + .pick button { padding: 0; background: var(--btn); border: 1px solid var(--field-b); border-radius: var(--radius); cursor: pointer; } + .pick button:hover { background: var(--btn-h); } .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; } @@ -102,6 +137,21 @@ export class RowsTable { readonly removable = input(true); readonly emptyText = input('Sin filas.'); + /** La ventana de selección abierta: qué fila y de qué columna. */ + protected readonly target = signal<{ row: number; col: DetailColumn; lookup: NonNullable } | null>(null); + + protected openLookup(row: number, col: DetailColumn): void { + if (col.lookup && !this.locked(col)) this.target.set({ row, col, lookup: col.lookup }); + } + + protected picked(rows: ListRow[]): void { + const t = this.target(); + const picked = rows[0]; + if (!t || !picked) return; + const key = t.lookup.valueKey ?? t.lookup.columns[0]?.key ?? ''; + this.rows.update((rs) => rs.map((r, i) => (i === t.row ? { ...r, [t.col.key]: picked[key] ?? '' } : r))); + } + protected canRemove(): boolean { return !this.readonly() && this.removable(); } diff --git a/libs/sap-types/src/audit.ts b/libs/sap-types/src/audit.ts index edd58f6..fc0ba3f 100644 --- a/libs/sap-types/src/audit.ts +++ b/libs/sap-types/src/audit.ts @@ -5,6 +5,7 @@ export type AuditAction = | 'logout' | 'sl_create' | 'sl_update' + | 'sl_action' | 'sl_denied' | 'permissions_set' | 'permissions_remove'; diff --git a/libs/sap-types/src/menu.ts b/libs/sap-types/src/menu.ts index 9f89708..2f6cc08 100644 --- a/libs/sap-types/src/menu.ts +++ b/libs/sap-types/src/menu.ts @@ -56,6 +56,14 @@ export const MENU: readonly MenuModule[] = [ { path: '/inventario/solicitudes-traslado', label: 'Solicitudes de traslado' }, ], }, + { + id: 'bancos', + label: 'Bancos', + items: [ + { path: '/bancos/pagos-recibidos', label: 'Pagos recibidos' }, + { path: '/bancos/pagos-efectuados', label: 'Pagos efectuados' }, + ], + }, { id: 'socios', label: 'Socios de negocio',