feat: base de bancos (recursos de pagos recibidos y efectuados, acción Cancel, menú Bancos, tabla con ventana de selección)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
08977741de
commit
369be317ab
13 files changed
+304
-10
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<SlResource> {
|
||||
const resource = SL_RESOURCE_MAP.get(name);
|
||||
if (!resource) throw new NotFoundException(`Recurso no disponible: ${name}`);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ export const AUDIT_ACTIONS: Readonly<Record<string, string>> = {
|
||||
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<AuditRow, 'detail'>): 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(', ')}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: `<sbo-rows-table [columns]="columns" [(rows)]="rows" [readonly]="readonly()" [removable]="removable()" emptyText="Nada" />`,
|
||||
})
|
||||
class Host {
|
||||
columns = COLUMNS;
|
||||
rows = signal<Record<string, unknown>[]>([{ 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<Host>;
|
||||
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<HTMLInputElement | HTMLSelectElement>('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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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: `
|
||||
<div class="wrap">
|
||||
<table>
|
||||
@@ -47,6 +50,21 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
|
||||
<option [value]="o.value" [selected]="o.value === row[c.key]">{{ o.label }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else if (c.type === 'lookup') {
|
||||
<div class="pick">
|
||||
<input
|
||||
autocomplete="off"
|
||||
[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))"
|
||||
(keydown.f4)="$event.preventDefault(); openLookup(i, c)"
|
||||
/>
|
||||
@if (!locked(c)) {
|
||||
<button type="button" tabindex="-1" [attr.aria-label]="'Abrir la lista de ' + c.title" title="Lista de valores (F4)" (click)="openLookup(i, c)">…</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<input
|
||||
[type]="c.type === 'date' ? 'date' : 'text'"
|
||||
@@ -73,9 +91,26 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (target(); as t) {
|
||||
<sbo-value-help
|
||||
[open]="true"
|
||||
[title]="t.lookup.title"
|
||||
[resource]="t.lookup.resource"
|
||||
[columns]="t.lookup.columns"
|
||||
[baseFilter]="t.lookup.baseFilter"
|
||||
[orderby]="t.lookup.orderby"
|
||||
[rowKey]="t.lookup.valueKey"
|
||||
[extraFields]="t.lookup.extraFields ?? []"
|
||||
(selected)="picked($event)"
|
||||
(closed)="target.set(null)"
|
||||
/>
|
||||
}
|
||||
`,
|
||||
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<DetailColumn['lookup']> } | 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();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AuditAction =
|
||||
| 'logout'
|
||||
| 'sl_create'
|
||||
| 'sl_update'
|
||||
| 'sl_action'
|
||||
| 'sl_denied'
|
||||
| 'permissions_set'
|
||||
| 'permissions_remove';
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user