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:
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' },
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user