From c567bc68362199199123a87d0b7f881c0423f02a Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 11:17:44 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20la=20=C2=ABCuenta=20/=20socio?= =?UTF-8?q?=C2=BB=20del=20asiento=20se=20elige=20de=20cuentas,=20clientes?= =?UTF-8?q?=20o=20proveedores=20y=20completa=20la=20cuenta=20de=20control?= =?UTF-8?q?=20del=20socio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../app/features/finance/journal-form.page.ts | 44 +++++++++- apps/web/src/app/shared/grid/grid.types.ts | 18 +++- apps/web/src/app/shared/grid/rows-table.ts | 83 ++++++++++++++++--- 3 files changed, 129 insertions(+), 16 deletions(-) diff --git a/apps/web/src/app/features/finance/journal-form.page.ts b/apps/web/src/app/features/finance/journal-form.page.ts index fd843f6..fcb9e7a 100644 --- a/apps/web/src/app/features/finance/journal-form.page.ts +++ b/apps/web/src/app/features/finance/journal-form.page.ts @@ -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; +/** 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): Record => ({ 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, row: Record): Promise> { + 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' }, diff --git a/apps/web/src/app/shared/grid/grid.types.ts b/apps/web/src/app/shared/grid/grid.types.ts index 0866d60..5b90f00 100644 --- a/apps/web/src/app/shared/grid/grid.types.ts +++ b/apps/web/src/app/shared/grid/grid.types.ts @@ -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; @@ -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) => Record; + /** 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) => Promise>; } /** diff --git a/apps/web/src/app/shared/grid/rows-table.ts b/apps/web/src/app/shared/grid/rows-table.ts index c68386d..4f194b7 100644 --- a/apps/web/src/app/shared/grid/rows-table.ts +++ b/apps/web/src/app/shared/grid/rows-table.ts @@ -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; @@ -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)) { - + } } @else { @@ -92,6 +92,14 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) + @if (menu(); as m) { + + + } @if (target(); as t) { (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 } | null>(null); + protected readonly target = signal<{ row: number; col: DetailColumn; lookup: NonNullable; 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 { - 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 { + 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 { + 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);