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:
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;
|
||||
|
||||
Reference in new issue
Block a user