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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user