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:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 13:23:27 -05:00
1 parent 08977741de
commit 369be317ab
13 files changed
+304 -10

No files matched your search

@@ -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(', ')}`);
+3 -1
View File
@@ -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();
});
});
+51 -1
View File
@@ -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();
}