feat(web): árbol del plan de cuentas por ramas y con código con formato (planes segmentados)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 09:46:46 -05:00
1 parent bb19e499fe
commit 456b0f4ac0
8 files changed
+231 -160

No files matched your search

@@ -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 },
],
};
@@ -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<string>(), 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<string, TreeAccount[]>([
['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')");
});
});
@@ -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<string>;
/** 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<string | null, TreeAccount[]>();
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<string> | 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<string, readonly TreeAccount[]>, expanded: ReadonlySet<string>, 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}')`;
};
@@ -1,8 +1,7 @@
<div class="panel">
<div class="main">
<div class="bar">
<input class="search" type="search" placeholder="Buscar por código o nombre" aria-label="Buscar cuenta" [value]="query()" (input)="query.set($any($event.target).value)" />
<button type="button" class="btn" (click)="expandAll()">Expandir</button>
<input class="search" type="search" placeholder="Buscar por código o nombre" aria-label="Buscar cuenta" [value]="query()" (input)="onQuery($any($event.target).value)" />
<button type="button" class="btn" (click)="collapseAll()">Contraer</button>
<button type="button" class="btn" (click)="create()">Nueva cuenta</button>
<span class="sp"></span>
@@ -15,38 +14,51 @@
</label>
</div>
<div class="tree" role="tree" [attr.aria-label]="groupName()">
<div class="head">{{ groupName() }}</div>
@for (r of rows(); track r.account.Code) {
<div
class="row"
role="treeitem"
tabindex="0"
[class.sel]="r.account.Code === selected()"
[class.title]="isTitle(r.account)"
[class.assoc]="r.account.PrimaryAccount === 'tYES'"
[attr.aria-expanded]="r.hasChildren ? r.expanded : null"
[style.padding-left.px]="6 + r.depth * 16"
(click)="open(r.account.Code)"
(keydown.enter)="open(r.account.Code)"
>
@if (r.hasChildren) {
<button type="button" class="tog" [attr.aria-label]="(r.expanded ? 'Contraer ' : 'Expandir ') + r.account.Name" (click)="$event.stopPropagation(); toggle(r.account.Code)">{{ r.expanded ? '▾' : '▸' }}</button>
} @else {
<span class="tog"></span>
}
{{ r.account.Code }} - {{ r.account.Name }}
</div>
} @empty {
<div class="none">{{ error() || (accounts.isLoading() ? 'Cargando el plan de cuentas…' : 'Sin cuentas.') }}</div>
}
</div>
<div class="count">{{ count() }}</div>
@if (query().trim() !== '') {
<div class="tree" role="list" aria-label="Resultados de la búsqueda">
<div class="head">Resultados</div>
@for (a of results(); track a.Code) {
<div class="row" role="listitem" tabindex="0" [class.title]="a.ActiveAccount !== 'tYES'" [class.assoc]="a.PrimaryAccount === 'tYES'" (click)="open(a.Code)" (keydown.enter)="open(a.Code)">
{{ display(a) }} - {{ a.Name }}
</div>
} @empty {
<div class="none">{{ searching() ? 'Buscando…' : 'Sin resultados.' }}</div>
}
</div>
<div class="count">{{ results().length === 50 ? 'Primeros 50 resultados: afine la búsqueda.' : results().length + ' resultados' }}</div>
} @else {
<div class="tree" role="tree" [attr.aria-label]="group()?.Name">
<div class="head">{{ group()?.Name }}</div>
@for (r of rows(); track r.account.Code) {
<div
class="row"
role="treeitem"
tabindex="0"
[class.sel]="r.account.Code === selected()"
[class.title]="r.expandable"
[class.assoc]="r.account.PrimaryAccount === 'tYES'"
[attr.aria-expanded]="r.expandable ? r.expanded : null"
[style.padding-left.px]="6 + r.depth * 16"
(click)="open(r.account.Code)"
(keydown.enter)="open(r.account.Code)"
>
@if (r.expandable) {
<button type="button" class="tog" [attr.aria-label]="(r.expanded ? 'Contraer ' : 'Expandir ') + r.account.Name" (click)="$event.stopPropagation(); toggle(r.account.Code)">{{ r.expanded ? '▾' : '▸' }}</button>
} @else {
<span class="tog"></span>
}
{{ display(r.account) }} - {{ r.account.Name }}
</div>
} @empty {
<div class="none">{{ error() || (loading() ? 'Cargando el plan de cuentas…' : 'Sin cuentas.') }}</div>
}
</div>
}
</div>
<div class="tabs" role="tablist" aria-label="Grupo de cuentas">
@for (g of groups(); track g.Code) {
<button type="button" role="tab" class="gtab" [class.on]="g.Code === group()" [attr.aria-selected]="g.Code === group()" (click)="pickGroup(g.Code)">{{ g.Name }}</button>
<button type="button" role="tab" class="gtab" [class.on]="g.Code === group()?.Code" [attr.aria-selected]="g.Code === group()?.Code" (click)="pickGroup(g.Code)">{{ g.Name }}</button>
}
</div>
</div>
@@ -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 `<ruta>/<código>`. */
readonly basePath = input('/finanzas/plan-de-cuentas');
protected readonly levels = LEVELS;
protected readonly accounts = resource({
loader: async (): Promise<TreeAccount[]> => {
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<SlListResponse<TreeAccount>>(`${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<TreeAccount[]>([]);
private readonly children = signal<ReadonlyMap<string, readonly TreeAccount[]>>(new Map());
private readonly expanded = signal<ReadonlySet<string>>(new Set());
private readonly picked = signal<string | null>(null);
private readonly pathGroup = signal<string | null>(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<string | null>(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<ReadonlySet<string> | null>(null);
protected readonly results = signal<TreeAccount[]>([]);
protected readonly searching = signal(false);
private timer: ReturnType<typeof setTimeout> | 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<string>());
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<TreeAccount[]> {
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<SlListResponse<TreeAccount>>(`${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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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]);
}
@@ -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 },
@@ -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)}`;
});
@@ -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;