feat(web): la «Cuenta / socio» del asiento se elige de cuentas, clientes o proveedores y completa la cuenta de control del socio

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 11:17:44 -05:00
1 parent 5dfe096c5d
commit c567bc6836
3 files changed
+129 -16

No files matched your search

@@ -1,9 +1,9 @@
import { HttpErrorResponse, httpResource } from '@angular/common/http';
import { HttpClient, HttpErrorResponse, HttpParams, httpResource } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import type { SlListResponse } from '@basic-erp/sap-types';
import { map } from 'rxjs';
import { firstValueFrom, map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber } from '../../core/format';
import { SlDocumentApi } from '../../core/sl/sl-document';
@@ -11,12 +11,29 @@ import { DateField, LookupField, SboField, SelectField, TextField, TextInput } f
import type { DetailColumn } from '../../shared/grid/grid.types';
import { RowsTable } from '../../shared/grid/rows-table';
import { Icon, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
import { ACCOUNT_CODES } from '../../core/sl/account-codes';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import { ACCOUNT_LOOKUP, INDICATOR_LOOKUP, PROJECT_LOOKUP, dimensionLookup } from '../documents/dimension-lookups';
import type { NavTarget } from '../documents/document-model';
import { isManualJournal, journalOrigin, journalPayload, journalTotals, validateJournal } from './journal-model';
type Row = Record<string, unknown>;
/** Clientes o proveedores para la «Cuenta / socio» de una línea; piden su cuenta de control para completar la «Cuenta». */
const partnerLookup = (type: 'cCustomer' | 'cSupplier', title: string): VhLookup => ({
title,
resource: 'BusinessPartners',
baseFilter: `CardType eq '${type}'`,
orderby: 'CardCode',
columns: [
{ key: 'CardCode', title: 'Código', width: '130px', mono: true },
{ key: 'CardName', title: 'Nombre' },
],
});
/** Una línea de socio: el código del socio (su cuenta de control se pide aparte: SAP falla al listarla junto con los socios). */
const partnerLine = (partner: Record<string, unknown>): Record<string, unknown> => ({ ShortName: partner['CardCode'] });
/** Tipo de objeto de SAP del asiento contable (para pedir sus series de numeración). */
const OBJECT_TYPE = 30;
@@ -40,6 +57,7 @@ export class JournalFormPage {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly api = inject(SlDocumentApi);
private readonly http = inject(HttpClient);
protected readonly title = this.route.snapshot.data['title'] as string;
protected readonly jdtNum = toSignal(this.route.paramMap.pipe(map((p) => (p.has('jdtNum') ? Number(p.get('jdtNum')) : null))), {
@@ -105,9 +123,29 @@ export class JournalFormPage {
// --- vista -----------------------------------------------------------------------------------
/** La cuenta de control del socio, para una línea que todavía no tiene cuenta. */
private async controlAccount(partner: Record<string, unknown>, row: Record<string, unknown>): Promise<Record<string, unknown>> {
if (row['AccountCode']) return {};
const res = await firstValueFrom(
this.http.get<{ DebitorAccount?: string }>(`${API_URL}/sl/BusinessPartners/${encodeURIComponent(String(partner['CardCode']))}`, { params: new HttpParams().set('$select', 'DebitorAccount') }),
);
return res.DebitorAccount ? { AccountCode: res.DebitorAccount } : {};
}
protected readonly columns: readonly DetailColumn[] = [
{ key: 'AccountCode', title: 'Cuenta', type: 'lookup', required: true, lookup: ACCOUNT_LOOKUP, width: '170px' },
{ key: 'ShortName', title: 'Cuenta / socio', type: 'lookup', lookup: ACCOUNT_LOOKUP, width: '170px' },
{
key: 'ShortName',
title: 'Cuenta / socio',
type: 'lookup',
width: '170px',
codes: ACCOUNT_CODES,
lookups: [
{ label: 'Cuentas', lookup: ACCOUNT_LOOKUP, apply: (a) => ({ ShortName: a['Code'], AccountCode: a['Code'] }) },
{ label: 'Clientes', lookup: partnerLookup('cCustomer', 'Clientes'), apply: partnerLine, complete: (p, row) => this.controlAccount(p, row) },
{ label: 'Proveedores', lookup: partnerLookup('cSupplier', 'Proveedores'), apply: partnerLine, complete: (p, row) => this.controlAccount(p, row) },
],
},
{ key: 'Debit', title: 'Debe', type: 'number', decimals: 2, width: '120px' },
{ key: 'Credit', title: 'Haber', type: 'number', decimals: 2, width: '120px' },
{ key: 'DueDate', title: 'Vencimiento', type: 'date', width: '120px' },
+17 -1
View File
@@ -1,5 +1,5 @@
import type { ListRow } from '../list/list.types';
import type { VhLookup } from '../value-help/value-help.types';
import type { CodeDisplay, VhLookup } from '../value-help/value-help.types';
/** Una línea del documento (los campos de `DocumentLines` del Service Layer). */
export type GridLine = Record<string, unknown>;
@@ -59,6 +59,22 @@ export interface DetailColumn {
lookup?: VhLookup;
/** Decimales fijos de una columna numérica; sin él, hasta 3. */
decimals?: number;
/**
* Una celda que puede tomar su valor de varias listas (la «Cuenta / socio» de un asiento: una cuenta, un cliente o
* un proveedor). El botón «…» pregunta cuál abrir; un código escrito se busca en ellas en orden.
*/
lookups?: readonly DetailLookupOption[];
/** Texto que se ve en vez del código guardado, para una columna con varias listas (en una con `lookup`, va en la lista). */
codes?: CodeDisplay;
}
/** Una de las listas de una celda con varias: al elegir, `apply` devuelve los campos de la fila que cambian. */
export interface DetailLookupOption {
label: string;
lookup: VhLookup;
apply: (picked: ListRow, row: Record<string, unknown>) => Record<string, unknown>;
/** Lo que se completa después, con otra consulta (la cuenta de control del socio); se suma a lo de `apply` cuando llega. */
complete?: (picked: ListRow, row: Record<string, unknown>) => Promise<Record<string, unknown>>;
}
/**
+71 -12
View File
@@ -3,7 +3,7 @@ import { formatNumber, parseDecimal } from '../../core/format';
import { SlLookupApi } from '../../core/sl/sl-lookup';
import type { ListRow } from '../list/list.types';
import { ValueHelp } from '../value-help/value-help';
import type { DetailColumn } from './grid.types';
import type { DetailColumn, DetailLookupOption } from './grid.types';
type Row = Record<string, unknown>;
@@ -60,10 +60,10 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
[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)"
(keydown.f4)="$event.preventDefault(); openLookup(i, c, $event)"
/>
@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>
<button type="button" tabindex="-1" [attr.aria-label]="'Abrir la lista de ' + c.title" title="Lista de valores (F4)" (click)="openLookup(i, c, $event)">…</button>
}
</div>
} @else {
@@ -92,6 +92,14 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
</tbody>
</table>
</div>
@if (menu(); as m) {
<div class="menu-back" (click)="menu.set(null)"></div>
<div class="menu" role="menu" [style.left.px]="m.x" [style.top.px]="m.y" (keydown.escape)="menu.set(null)">
@for (o of m.col.lookups ?? []; track o.label) {
<button type="button" role="menuitem" (click)="openOption(o)">{{ o.label }}</button>
}
</div>
}
@if (target(); as t) {
<sbo-value-help
[open]="true"
@@ -113,6 +121,10 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
.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; }
.menu-back { position: fixed; inset: 0; z-index: 40; }
.menu { position: fixed; z-index: 41; min-width: 120px; padding: 2px; background: var(--win); border: 1px solid var(--line); border-radius: var(--radius-panel); box-shadow: var(--shadow-pop); }
.menu button { display: block; width: 100%; padding: 4px 10px; background: none; border: none; text-align: left; cursor: pointer; }
.menu button:hover { background: var(--btn-h); }
table { width: 100%; min-width: max-content; 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); }
@@ -145,25 +157,44 @@ export class RowsTable {
effect(() => {
const rows = this.rows();
for (const c of this.columns()) {
const codes = c.lookup?.codes;
const codes = c.lookup?.codes ?? c.codes;
if (codes) codes.ensure(rows.map((r) => String(r[c.key] ?? '')).filter((v) => v !== ''));
}
});
}
/** 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 readonly target = signal<{ row: number; col: DetailColumn; lookup: NonNullable<DetailColumn['lookup']>; option?: DetailLookupOption } | null>(null);
/** El menú de listas de una celda con varias (cuentas, clientes, proveedores), con su posición en pantalla. */
protected readonly menu = signal<{ row: number; col: DetailColumn; x: number; y: number } | null>(null);
protected openLookup(row: number, col: DetailColumn): void {
if (col.lookup && !this.locked(col)) this.target.set({ row, col, lookup: col.lookup });
protected openLookup(row: number, col: DetailColumn, event?: Event): void {
if (this.locked(col)) return;
if (col.lookups?.length) {
const box = (event?.target as HTMLElement | undefined)?.getBoundingClientRect?.();
this.menu.set({ row, col, x: box?.left ?? 0, y: box?.bottom ?? 0 });
} else if (col.lookup) {
this.target.set({ row, col, lookup: col.lookup });
}
}
protected openOption(option: DetailLookupOption): void {
const m = this.menu();
this.menu.set(null);
if (m) this.target.set({ row: m.row, col: m.col, lookup: option.lookup, option });
}
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 ?? '';
t.lookup.codes?.remember(picked);
if (t.option) {
const option = t.option;
this.applyOption(t.row, option, 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)));
}
@@ -179,7 +210,8 @@ export class RowsTable {
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);
if (c.type === 'lookup' && c.lookup?.codes && typeof v === 'string' && v !== '') return c.lookup.codes.display(v);
const codes = c.lookup?.codes ?? c.codes;
if (c.type === 'lookup' && codes && typeof v === 'string' && v !== '') return codes.display(v);
return v === null || v === undefined ? '' : String(v);
}
@@ -204,7 +236,7 @@ export class RowsTable {
input.value = parsed === null ? '' : formatNumber(parsed, c.decimals);
}
this.rows.update((rs) => rs.map((r, i) => (i === index ? { ...r, [c.key]: value } : r)));
if (c.type === 'lookup' && c.lookup?.codes && raw.trim() !== '') void this.resolveTyped(index, c, raw.trim());
if (c.type === 'lookup' && (c.lookup?.codes || c.lookups?.length) && raw.trim() !== '') void this.resolveTyped(index, c, raw.trim());
}
/**
@@ -212,9 +244,36 @@ export class RowsTable {
* guarda el `Code` de SAP. Si no se encuentra se deja lo escrito: la celda también admite códigos de otro tipo
* (el `ShortName` de un asiento puede ser un socio) y SAP valida al guardar.
*/
private async resolveTyped(index: number, c: DetailColumn, code: string): Promise<void> {
const lookup = c.lookup!;
private applyOption(index: number, option: DetailLookupOption, picked: ListRow): void {
this.rows.update((rs) => rs.map((r, i) => (i === index ? { ...r, ...option.apply(picked, r) } : r)));
void this.completeOption(index, option, picked);
}
/** Lo que `complete` pide aparte (la cuenta de control) se suma a la fila cuando llega, si la fila sigue ahí. */
private async completeOption(index: number, option: DetailLookupOption, picked: ListRow): Promise<void> {
if (!option.complete) return;
try {
const patch = await option.complete(picked, this.rows()[index] ?? {});
if (Object.keys(patch).length) this.rows.update((rs) => rs.map((r, i) => (i === index ? { ...r, ...patch } : r)));
} catch {
/* sin la cuenta de control: queda lo elegido y SAP la completa al guardar */
}
}
private async resolveTyped(index: number, c: DetailColumn, code: string): Promise<void> {
try {
if (c.lookups?.length) {
for (const option of c.lookups) {
const found = await this.lookupApi.find(option.lookup, code);
if (!found) continue;
option.lookup.codes?.remember(found);
this.rows.update((rs) => rs.map((r, i) => (i === index && r[c.key] === code ? { ...r, ...option.apply(found, r) } : r)));
void this.completeOption(index, option, found);
return;
}
return;
}
const lookup = c.lookup!;
const found = await this.lookupApi.find(lookup, code);
if (!found) return;
lookup.codes?.remember(found);