From 456b0f4ac03c2526e477e3194d7c1b97c7e22281 Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 09:46:46 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20=C3=A1rbol=20del=20plan=20de=20cue?= =?UTF-8?q?ntas=20por=20ramas=20y=20con=20c=C3=B3digo=20con=20formato=20(p?= =?UTF-8?q?lanes=20segmentados)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../features/documents/dimension-lookups.ts | 2 + .../masters/finanzas/chart-tree.model.spec.ts | 74 ++++---- .../masters/finanzas/chart-tree.model.ts | 69 +++----- .../masters/finanzas/chart-tree.panel.html | 72 ++++---- .../masters/finanzas/chart-tree.panel.ts | 166 +++++++++++++----- .../masters/finanzas/finanzas.screens.ts | 3 +- .../app/features/masters/master-form.page.ts | 3 +- .../src/app/features/masters/master-types.ts | 2 + 8 files changed, 231 insertions(+), 160 deletions(-) diff --git a/apps/web/src/app/features/documents/dimension-lookups.ts b/apps/web/src/app/features/documents/dimension-lookups.ts index d6c2e37..a20d365 100644 --- a/apps/web/src/app/features/documents/dimension-lookups.ts +++ b/apps/web/src/app/features/documents/dimension-lookups.ts @@ -44,5 +44,7 @@ export const ACCOUNT_LOOKUP: VhLookup = { columns: [ { key: 'Code', title: 'Código', width: '160px', mono: true }, { key: 'Name', title: 'Nombre' }, + // en un plan de cuentas segmentado, `Code` es interno (`_SYS…`): se muestra también el código con formato + { key: 'FormatCode', title: 'Cuenta', width: '140px', mono: true }, ], }; diff --git a/apps/web/src/app/features/masters/finanzas/chart-tree.model.spec.ts b/apps/web/src/app/features/masters/finanzas/chart-tree.model.spec.ts index e6c302c..db7e7a2 100644 --- a/apps/web/src/app/features/masters/finanzas/chart-tree.model.spec.ts +++ b/apps/web/src/app/features/masters/finanzas/chart-tree.model.spec.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { type TreeAccount, parentsOf, visibleRows } from './chart-tree.model'; +import { type TreeAccount, displayCode, flattenBranch, searchFilter } from './chart-tree.model'; -const acc = (Code: string, Name: string, level: number, father: string | null, postable = false): TreeAccount => ({ +const acc = (Code: string, Name: string, level: number, father: string | null, postable = false, FormatCode: string | null = null): TreeAccount => ({ Code, + FormatCode, Name, AccountLevel: level, FatherAccountKey: father, @@ -11,54 +12,45 @@ const acc = (Code: string, Name: string, level: number, father: string | null, p Balance: 0, }); -const accounts = [ - acc('2', 'Pasivos', 1, null), - acc('1', 'Activos', 1, null), - acc('1-1', 'Circulante', 2, '1'), - acc('1-1-001', 'Caja', 3, '1-1', true), - acc('1-1-002', 'Banco', 3, '1-1', true), - acc('1-2', 'Fijo', 2, '1'), -]; -const open = { collapsed: new Set(), query: '', root: null }; +const root = acc('1', 'Activos', 1, null); +const circ = acc('1-1', 'Circulante', 2, '1'); +const caja = acc('_SYS1', 'Caja', 3, '1-1', true, '1110'); +const fijo = acc('1-2', 'Fijo', 2, '1'); +const children = new Map([ + ['1', [circ, fijo]], + ['1-1', [caja]], +]); describe('árbol del plan de cuentas', () => { - it('ordena por código, con cada cuenta seguida de sus hijas', () => { - expect(visibleRows(accounts, open).map((r) => [r.account.Code, r.depth])).toEqual([ - ['1', 0], - ['1-1', 1], - ['1-1-001', 2], - ['1-1-002', 2], - ['1-2', 1], - ['2', 0], + it('sin desplegar solo muestra la raíz', () => { + expect(flattenBranch(root, children, new Set()).map((r) => r.account.Code)).toEqual(['1']); + }); + + it('muestra las hijas cargadas de lo desplegado, en orden y con su profundidad', () => { + const rows = flattenBranch(root, children, new Set(['1', '1-1'])); + expect(rows.map((r) => [r.account.Code, r.depth, r.expandable, r.expanded])).toEqual([ + ['1', 0, true, true], + ['1-1', 1, true, true], + ['_SYS1', 2, false, false], + ['1-2', 1, true, false], ]); }); - it('marca las que tienen hijas y si están desplegadas', () => { - const rows = visibleRows(accounts, { ...open, collapsed: new Set(['1-1']) }); - expect(rows.map((r) => r.account.Code)).toEqual(['1', '1-1', '1-2', '2']); - expect(rows.find((r) => r.account.Code === '1-1')).toMatchObject({ hasChildren: true, expanded: false }); - expect(rows.find((r) => r.account.Code === '1-2')).toMatchObject({ hasChildren: false, expanded: false }); - }); - - it('buscar muestra las coincidencias con sus cuentas padre, desplegadas', () => { - const rows = visibleRows(accounts, { ...open, collapsed: new Set(['1', '1-1']), query: 'banco' }); - expect(rows.map((r) => r.account.Code)).toEqual(['1', '1-1', '1-1-002']); - }); - - it('puede mostrar solo una rama', () => { - expect(visibleRows(accounts, { ...open, root: '2' }).map((r) => r.account.Code)).toEqual(['2']); - }); - - it('una cuenta con el padre ausente se trata como de primer nivel', () => { - expect(visibleRows([acc('9-9', 'Suelta', 2, 'no-existe')], open).map((r) => [r.account.Code, r.depth])).toEqual([['9-9', 0]]); + it('una cuenta desplegada cuyas hijas aún no se cargaron se muestra sin desplegar', () => { + expect(flattenBranch(root, new Map(), new Set(['1']))[0]).toMatchObject({ expandable: true, expanded: false }); }); it('el nivel máximo oculta las cuentas más profundas', () => { - expect(visibleRows(accounts, { ...open, maxLevel: 2 }).map((r) => r.account.Code)).toEqual(['1', '1-1', '1-2', '2']); - expect(visibleRows(accounts, { ...open, maxLevel: 1 }).map((r) => r.account.Code)).toEqual(['1', '2']); + expect(flattenBranch(root, children, new Set(['1', '1-1']), 2).map((r) => r.account.Code)).toEqual(['1', '1-1', '1-2']); }); - it('lista los códigos con hijas', () => { - expect(parentsOf(accounts).sort()).toEqual(['1', '1-1']); + it('el código visible es el FormatCode y, si falta, el Code', () => { + expect(displayCode(caja)).toBe('1110'); + expect(displayCode(circ)).toBe('1-1'); + expect(displayCode({ Code: 'X', FormatCode: ' ' })).toBe('X'); + }); + + it('la búsqueda mira nombre, código interno y código con formato, y escapa la comilla', () => { + expect(searchFilter(" O'Hara ")).toBe("contains(Name,'O''Hara') or contains(Code,'O''Hara') or contains(FormatCode,'O''Hara')"); }); }); diff --git a/apps/web/src/app/features/masters/finanzas/chart-tree.model.ts b/apps/web/src/app/features/masters/finanzas/chart-tree.model.ts index 54f2ce2..b3f8a66 100644 --- a/apps/web/src/app/features/masters/finanzas/chart-tree.model.ts +++ b/apps/web/src/app/features/masters/finanzas/chart-tree.model.ts @@ -1,8 +1,10 @@ /** Campos de cada cuenta que pide el árbol. */ -export const TREE_FIELDS = 'Code,Name,AccountLevel,FatherAccountKey,ActiveAccount,AccountType,Balance,PrimaryAccount'; +export const TREE_FIELDS = 'Code,FormatCode,Name,AccountLevel,FatherAccountKey,ActiveAccount,AccountType,Balance,PrimaryAccount'; export interface TreeAccount { Code: string; + /** Código con formato: en un plan segmentado es el que ve el usuario (`Code` es interno, `_SYS…`); en uno sin segmentar es igual a `Code`. */ + FormatCode?: string | null; Name: string; AccountLevel: number; FatherAccountKey: string | null; @@ -13,59 +15,40 @@ export interface TreeAccount { PrimaryAccount?: string; } +/** + * Código que ve el usuario: el `FormatCode` y, si la cuenta no lo tiene (las de título de un plan segmentado, + * o cualquiera en uno sin segmentar), su `Code`. La clave que se envía a SAP es siempre `Code`. + */ +export const displayCode = (a: { Code: string; FormatCode?: string | null }): string => (a.FormatCode && a.FormatCode.trim() !== '' ? a.FormatCode : a.Code); + export interface TreeRow { account: TreeAccount; - /** 0 en las cuentas de primer nivel. */ + /** 0 en la cuenta de primer nivel. */ depth: number; - hasChildren: boolean; + /** Puede tener hijas (una cuenta de título); se cargan al desplegarla. */ + expandable: boolean; expanded: boolean; } -export interface TreeOptions { - /** Códigos de las cuentas contraídas (sus hijas no se muestran). */ - collapsed: ReadonlySet; - /** Texto buscado en el código o el nombre: muestra las que coinciden y sus cuentas padre, todo desplegado. */ - query: string; - /** Solo la rama de esta cuenta de primer nivel (Activos, Pasivos…); `null` = todas. */ - root: string | null; - /** Solo hasta este nivel de cuenta (el selector «Nivel» de SAP); sin él, todos. */ - maxLevel?: number; -} - /** - * Las cuentas en el orden del árbol (cada una seguida de sus hijas, por código), con su profundidad. Una cuenta - * cuyo padre no está en la lista se trata como de primer nivel, para no perderla. + * Las filas de una rama del árbol, en orden: cada cuenta seguida de sus hijas ya cargadas si está desplegada. + * `maxLevel` oculta las cuentas más profundas (el selector «Nivel» de SAP). */ -export function visibleRows(accounts: readonly TreeAccount[], opts: TreeOptions): TreeRow[] { - const byCode = new Map(accounts.map((a) => [a.Code, a])); - const children = new Map(); - for (const a of [...accounts].sort((x, y) => (x.Code < y.Code ? -1 : x.Code > y.Code ? 1 : 0))) { - const parent = a.FatherAccountKey && byCode.has(a.FatherAccountKey) && a.FatherAccountKey !== a.Code ? a.FatherAccountKey : null; - children.set(parent, [...(children.get(parent) ?? []), a]); - } - - const query = opts.query.trim().toLowerCase(); - let shown: Set | null = null; - if (query) { - shown = new Set(); - for (const a of accounts) { - if (!a.Code.toLowerCase().includes(query) && !a.Name.toLowerCase().includes(query)) continue; - for (let c: TreeAccount | undefined = a; c && !shown.has(c.Code); c = c.FatherAccountKey ? byCode.get(c.FatherAccountKey) : undefined) shown.add(c.Code); - } - } - +export function flattenBranch(root: TreeAccount, children: ReadonlyMap, expanded: ReadonlySet, maxLevel = Infinity): TreeRow[] { const out: TreeRow[] = []; const walk = (a: TreeAccount, depth: number): void => { - if (shown && !shown.has(a.Code)) return; - const within = (k: TreeAccount): boolean => k.AccountLevel <= (opts.maxLevel ?? Infinity); - const kids = (children.get(a.Code) ?? []).filter((k) => within(k) && (!shown || shown.has(k.Code))); - const expanded = kids.length > 0 && (shown !== null || !opts.collapsed.has(a.Code)); - out.push({ account: a, depth, hasChildren: kids.length > 0, expanded }); - if (expanded) for (const k of kids) walk(k, depth + 1); + const expandable = a.ActiveAccount !== 'tYES'; + const open = expandable && expanded.has(a.Code) && children.has(a.Code); + out.push({ account: a, depth, expandable, expanded: open }); + if (!open) return; + for (const k of children.get(a.Code)!) if (k.AccountLevel <= maxLevel) walk(k, depth + 1); }; - for (const r of children.get(null) ?? []) if (opts.root === null || r.Code === opts.root) walk(r, 0); + walk(root, 0); return out; } -/** Códigos de todas las cuentas que tienen hijas (para contraer todo el árbol). */ -export const parentsOf = (accounts: readonly TreeAccount[]): string[] => [...new Set(accounts.flatMap((a) => (a.FatherAccountKey ? [a.FatherAccountKey] : [])))]; +/** Filtro OData de la búsqueda por código (interno o con formato) o por nombre; la comilla simple se duplica. */ +export const searchFilter = (text: string): string => { + const t = text.trim().replace(/'/g, "''"); + return `contains(Name,'${t}') or contains(Code,'${t}') or contains(FormatCode,'${t}')`; +}; diff --git a/apps/web/src/app/features/masters/finanzas/chart-tree.panel.html b/apps/web/src/app/features/masters/finanzas/chart-tree.panel.html index 2acf3b6..3cc2829 100644 --- a/apps/web/src/app/features/masters/finanzas/chart-tree.panel.html +++ b/apps/web/src/app/features/masters/finanzas/chart-tree.panel.html @@ -1,8 +1,7 @@
- - + @@ -15,38 +14,51 @@
-
-
{{ groupName() }}
- @for (r of rows(); track r.account.Code) { -
- @if (r.hasChildren) { - - } @else { - - } - {{ r.account.Code }} - {{ r.account.Name }} -
- } @empty { -
{{ error() || (accounts.isLoading() ? 'Cargando el plan de cuentas…' : 'Sin cuentas.') }}
- } -
-
{{ count() }}
+ @if (query().trim() !== '') { +
+
Resultados
+ @for (a of results(); track a.Code) { +
+ {{ display(a) }} - {{ a.Name }} +
+ } @empty { +
{{ searching() ? 'Buscando…' : 'Sin resultados.' }}
+ } +
+
{{ results().length === 50 ? 'Primeros 50 resultados: afine la búsqueda.' : results().length + ' resultados' }}
+ } @else { +
+
{{ group()?.Name }}
+ @for (r of rows(); track r.account.Code) { +
+ @if (r.expandable) { + + } @else { + + } + {{ display(r.account) }} - {{ r.account.Name }} +
+ } @empty { +
{{ error() || (loading() ? 'Cargando el plan de cuentas…' : 'Sin cuentas.') }}
+ } +
+ }
@for (g of groups(); track g.Code) { - + }
diff --git a/apps/web/src/app/features/masters/finanzas/chart-tree.panel.ts b/apps/web/src/app/features/masters/finanzas/chart-tree.panel.ts index 50a4e03..0b1e487 100644 --- a/apps/web/src/app/features/masters/finanzas/chart-tree.panel.ts +++ b/apps/web/src/app/features/masters/finanzas/chart-tree.panel.ts @@ -1,19 +1,20 @@ import { HttpClient, HttpParams } from '@angular/common/http'; -import { ChangeDetectionStrategy, Component, computed, inject, input, resource, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; import { Router } from '@angular/router'; import type { SlListResponse } from '@basic-erp/sap-types'; import { firstValueFrom } from 'rxjs'; import { API_URL, apiErrorMessage } from '../../../core/api'; -import { TREE_FIELDS, type TreeAccount, parentsOf, visibleRows } from './chart-tree.model'; +import { TREE_FIELDS, type TreeAccount, displayCode, flattenBranch, searchFilter } from './chart-tree.model'; -const PAGE = 200; const LEVELS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +const MAX_RESULTS = 50; +const DEBOUNCE_MS = 300; /** - * El árbol del plan de cuentas como lo muestra SAP: a la derecha una pestaña por grupo (Activos, Pasivos, - * Patrimonio…, las cuentas de primer nivel), arriba el selector «Nivel» y, en el centro, las cuentas de título - * (azul), las asociadas (verde) y las de movimiento. Se cargan todas las cuentas una vez (de a 200) y el árbol - * se arma en el navegador. Elegir una cuenta abre su formulario. + * El árbol del plan de cuentas como lo muestra SAP: a la derecha una pestaña por grupo (las cuentas de primer + * nivel), arriba el selector «Nivel» y, en el centro, las cuentas de título (azul), las asociadas (verde) y las de + * movimiento. Las hijas de una cuenta se piden al desplegarla (un plan segmentado puede tener miles de cuentas) y + * se guardan; buscar consulta al servidor. Cada cuenta se muestra con su código con formato. Elegir una abre su formulario. */ @Component({ selector: 'sbo-chart-tree-panel', @@ -25,67 +26,144 @@ export class ChartTreePanel { private readonly http = inject(HttpClient); private readonly router = inject(Router); - /** Código de la cuenta abierta en el formulario (se resalta y fija el grupo que se ve). */ + /** Código de la cuenta abierta en el formulario (se resalta, se despliega hasta ella y fija el grupo que se ve). */ readonly selected = input(''); /** Ruta de la pantalla; la cuenta elegida se abre en `/`. */ readonly basePath = input('/finanzas/plan-de-cuentas'); protected readonly levels = LEVELS; - protected readonly accounts = resource({ - loader: async (): Promise => { - const all: TreeAccount[] = []; - for (let skip = 0; ; skip += PAGE) { - const params = new HttpParams().set('$select', TREE_FIELDS).set('$orderby', 'Code').set('$top', PAGE).set('$skip', skip); - const page = await firstValueFrom(this.http.get>(`${API_URL}/sl/ChartOfAccounts`, { params })); - all.push(...page.value); - if (page.value.length < PAGE) return all; - } - }, - }); - protected readonly list = computed(() => (this.accounts.hasValue() ? this.accounts.value() : [])); - protected readonly error = computed(() => (this.accounts.error() ? apiErrorMessage(this.accounts.error()) : '')); + protected readonly display = displayCode; + + protected readonly groups = signal([]); + private readonly children = signal>(new Map()); + private readonly expanded = signal>(new Set()); + private readonly picked = signal(null); + private readonly pathGroup = signal(null); + protected readonly level = signal(10); + protected readonly loading = signal(true); + protected readonly error = signal(''); protected readonly query = signal(''); - protected readonly level = signal(10); - private readonly picked = signal(null); - /** `null` hasta que el usuario despliega o contrae algo: arranca con las cuentas de título más profundas contraídas. */ - private readonly toggled = signal | null>(null); + protected readonly results = signal([]); + protected readonly searching = signal(false); + private timer: ReturnType | undefined; - protected readonly groups = computed(() => this.list().filter((a) => a.AccountLevel === 1 && !a.FatherAccountKey)); - /** Grupo a la vista: el elegido, si no el de la cuenta abierta (mismo primer carácter del código) y si no el primero. */ + /** Grupo a la vista: el elegido, si no el de la cuenta abierta y si no el primero. */ protected readonly group = computed(() => { const groups = this.groups(); - const chosen = this.picked(); - if (chosen && groups.some((g) => g.Code === chosen)) return chosen; - const first = this.selected().charAt(0); - return (first && groups.find((g) => g.Code.charAt(0) === first)?.Code) || groups[0]?.Code || null; + const wanted = this.picked() ?? this.pathGroup(); + return groups.find((g) => g.Code === wanted) ?? groups[0] ?? null; + }); + protected readonly rows = computed(() => { + const g = this.group(); + return g ? flattenBranch(g, this.children(), this.expanded(), this.level()) : []; }); - protected readonly collapsed = computed(() => this.toggled() ?? new Set()); - protected readonly rows = computed(() => visibleRows(this.list(), { collapsed: this.collapsed(), query: this.query(), root: this.group(), maxLevel: this.level() })); - protected readonly groupName = computed(() => this.groups().find((g) => g.Code === this.group())?.Name ?? ''); - protected readonly count = computed(() => `${this.rows().length} de ${this.list().length} cuentas`); - protected isTitle(a: TreeAccount): boolean { - return a.ActiveAccount !== 'tYES'; + constructor() { + void this.loadGroups(); + // al cambiar la cuenta abierta se despliega la rama hasta ella + effect(() => { + const code = this.selected(); + if (code && !this.loading()) untracked(() => void this.reveal(code)); + }); } - protected toggle(code: string): void { - const next = new Set(this.collapsed()); - if (!next.delete(code)) next.add(code); - this.toggled.set(next); + private async list(filter: string, top?: number): Promise { + const all: TreeAccount[] = []; + // el servidor pagina de a 200: se pide hasta que una página venga incompleta + for (let skip = 0; ; skip += 200) { + const params = new HttpParams().set('$select', TREE_FIELDS).set('$orderby', 'Code').set('$filter', filter).set('$top', top ?? 200).set('$skip', skip); + const page = await firstValueFrom(this.http.get>(`${API_URL}/sl/ChartOfAccounts`, { params })); + all.push(...page.value); + if (top || page.value.length < 200) return all; + } } - protected expandAll(): void { - this.toggled.set(new Set()); + + private async loadGroups(): Promise { + try { + this.groups.set(await this.list('AccountLevel eq 1')); + } catch (e) { + this.error.set(apiErrorMessage(e)); + } finally { + this.loading.set(false); + } } + + /** Pide las hijas de una cuenta si todavía no se tienen. */ + private async load(code: string): Promise { + if (this.children().has(code)) return; + const kids = await this.list(`FatherAccountKey eq '${code.replace(/'/g, "''")}'`); + this.children.update((m) => new Map(m).set(code, kids)); + } + + /** Despliega desde el grupo hasta la cuenta (subiendo por sus cuentas padre) y fija el grupo que se ve. */ + private async reveal(code: string): Promise { + try { + const chain: string[] = []; + let current: string | null = code; + while (current && chain.length < 15 && !chain.includes(current)) { + chain.push(current); + const a: { FatherAccountKey: string | null } = await firstValueFrom( + this.http.get<{ FatherAccountKey: string | null }>(`${API_URL}/sl/ChartOfAccounts/${encodeURIComponent(current)}`, { params: new HttpParams().set('$select', 'FatherAccountKey') }), + ); + current = a.FatherAccountKey; + } + this.pathGroup.set(chain[chain.length - 1]); + this.picked.set(null); + for (const parent of chain.slice(1)) await this.load(parent); + this.expanded.update((s) => new Set([...s, ...chain.slice(1)])); + } catch (e) { + this.error.set(apiErrorMessage(e)); + } + } + + protected async toggle(code: string): Promise { + if (this.expanded().has(code) && this.children().has(code)) { + this.expanded.update((s) => new Set([...s].filter((c) => c !== code))); + return; + } + try { + await this.load(code); + this.expanded.update((s) => new Set(s).add(code)); + } catch (e) { + this.error.set(apiErrorMessage(e)); + } + } + protected collapseAll(): void { - this.toggled.set(new Set(parentsOf(this.list()))); + this.expanded.set(new Set()); } + protected pickGroup(code: string): void { this.picked.set(code); } protected setLevel(value: string): void { this.level.set(Number(value) || 10); } + + protected onQuery(text: string): void { + this.query.set(text); + clearTimeout(this.timer); + if (text.trim() === '') { + this.results.set([]); + this.searching.set(false); + return; + } + this.searching.set(true); + this.timer = setTimeout(() => void this.search(text), DEBOUNCE_MS); + } + + private async search(text: string): Promise { + try { + const found = await this.list(searchFilter(text), MAX_RESULTS); + if (this.query() === text) this.results.set(found); + } catch (e) { + this.error.set(apiErrorMessage(e)); + } finally { + if (this.query() === text) this.searching.set(false); + } + } + protected open(code: string): void { void this.router.navigate([this.basePath(), code]); } diff --git a/apps/web/src/app/features/masters/finanzas/finanzas.screens.ts b/apps/web/src/app/features/masters/finanzas/finanzas.screens.ts index 30ab1bd..031a84e 100644 --- a/apps/web/src/app/features/masters/finanzas/finanzas.screens.ts +++ b/apps/web/src/app/features/masters/finanzas/finanzas.screens.ts @@ -33,10 +33,11 @@ export const FINANZAS_SCREENS: readonly MasterScreen[] = [ path: '/finanzas/plan-de-cuentas', resource: 'ChartOfAccounts', keyField: 'Code', + displayField: 'FormatCode', singular: 'Cuenta', createLabel: 'Nueva cuenta', orderby: 'Code', - extraFields: ['ActiveAccount'], + extraFields: ['ActiveAccount', 'FormatCode'], columns: [ { key: 'Code', title: 'Código', width: '170px', mono: true, searchable: true }, { key: 'Name', title: 'Nombre', searchable: true }, diff --git a/apps/web/src/app/features/masters/master-form.page.ts b/apps/web/src/app/features/masters/master-form.page.ts index 17167ab..99447a9 100644 --- a/apps/web/src/app/features/masters/master-form.page.ts +++ b/apps/web/src/app/features/masters/master-form.page.ts @@ -130,7 +130,8 @@ export class MasterFormPage { return d['Frozen'] === 'tYES' ? { label: 'Inactivo', tone: 'muted' } : { label: 'Activo', tone: 'ok' }; }); protected readonly windowTitle = computed(() => { - const code = this.doc()?.[this.screen.keyField]; + const d = this.doc(); + const code = (this.screen.displayField && text(d?.[this.screen.displayField]).trim()) || d?.[this.screen.keyField]; return code === undefined ? this.screen.singular : `${this.screen.singular} · ${text(code)}`; }); diff --git a/apps/web/src/app/features/masters/master-types.ts b/apps/web/src/app/features/masters/master-types.ts index 7f21c8e..093cecf 100644 --- a/apps/web/src/app/features/masters/master-types.ts +++ b/apps/web/src/app/features/masters/master-types.ts @@ -64,6 +64,8 @@ export interface MasterScreen { resource: string; /** Campo clave del recurso (`CardCode`, `ItemCode`). */ keyField: string; + /** Campo que se muestra en el título en vez de la clave, si la tiene (`FormatCode` del plan de cuentas). */ + displayField?: string; /** Nombre en singular, para el título del formulario (`Cliente`). */ singular: string; createLabel: string;