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: [
|
columns: [
|
||||||
{ key: 'Code', title: 'Código', width: '160px', mono: true },
|
{ key: 'Code', title: 'Código', width: '160px', mono: true },
|
||||||
{ key: 'Name', title: 'Nombre' },
|
{ 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 { 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,
|
Code,
|
||||||
|
FormatCode,
|
||||||
Name,
|
Name,
|
||||||
AccountLevel: level,
|
AccountLevel: level,
|
||||||
FatherAccountKey: father,
|
FatherAccountKey: father,
|
||||||
@@ -11,54 +12,45 @@ const acc = (Code: string, Name: string, level: number, father: string | null, p
|
|||||||
Balance: 0,
|
Balance: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const accounts = [
|
const root = acc('1', 'Activos', 1, null);
|
||||||
acc('2', 'Pasivos', 1, null),
|
const circ = acc('1-1', 'Circulante', 2, '1');
|
||||||
acc('1', 'Activos', 1, null),
|
const caja = acc('_SYS1', 'Caja', 3, '1-1', true, '1110');
|
||||||
acc('1-1', 'Circulante', 2, '1'),
|
const fijo = acc('1-2', 'Fijo', 2, '1');
|
||||||
acc('1-1-001', 'Caja', 3, '1-1', true),
|
const children = new Map<string, TreeAccount[]>([
|
||||||
acc('1-1-002', 'Banco', 3, '1-1', true),
|
['1', [circ, fijo]],
|
||||||
acc('1-2', 'Fijo', 2, '1'),
|
['1-1', [caja]],
|
||||||
];
|
]);
|
||||||
const open = { collapsed: new Set<string>(), query: '', root: null };
|
|
||||||
|
|
||||||
describe('árbol del plan de cuentas', () => {
|
describe('árbol del plan de cuentas', () => {
|
||||||
it('ordena por código, con cada cuenta seguida de sus hijas', () => {
|
it('sin desplegar solo muestra la raíz', () => {
|
||||||
expect(visibleRows(accounts, open).map((r) => [r.account.Code, r.depth])).toEqual([
|
expect(flattenBranch(root, children, new Set()).map((r) => r.account.Code)).toEqual(['1']);
|
||||||
['1', 0],
|
});
|
||||||
['1-1', 1],
|
|
||||||
['1-1-001', 2],
|
it('muestra las hijas cargadas de lo desplegado, en orden y con su profundidad', () => {
|
||||||
['1-1-002', 2],
|
const rows = flattenBranch(root, children, new Set(['1', '1-1']));
|
||||||
['1-2', 1],
|
expect(rows.map((r) => [r.account.Code, r.depth, r.expandable, r.expanded])).toEqual([
|
||||||
['2', 0],
|
['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', () => {
|
it('una cuenta desplegada cuyas hijas aún no se cargaron se muestra sin desplegar', () => {
|
||||||
const rows = visibleRows(accounts, { ...open, collapsed: new Set(['1-1']) });
|
expect(flattenBranch(root, new Map(), new Set(['1']))[0]).toMatchObject({ expandable: true, expanded: false });
|
||||||
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('el nivel máximo oculta las cuentas más profundas', () => {
|
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(flattenBranch(root, children, new Set(['1', '1-1']), 2).map((r) => r.account.Code)).toEqual(['1', '1-1', '1-2']);
|
||||||
expect(visibleRows(accounts, { ...open, maxLevel: 1 }).map((r) => r.account.Code)).toEqual(['1', '2']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lista los códigos con hijas', () => {
|
it('el código visible es el FormatCode y, si falta, el Code', () => {
|
||||||
expect(parentsOf(accounts).sort()).toEqual(['1', '1-1']);
|
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. */
|
/** 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 {
|
export interface TreeAccount {
|
||||||
Code: string;
|
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;
|
Name: string;
|
||||||
AccountLevel: number;
|
AccountLevel: number;
|
||||||
FatherAccountKey: string | null;
|
FatherAccountKey: string | null;
|
||||||
@@ -13,59 +15,40 @@ export interface TreeAccount {
|
|||||||
PrimaryAccount?: string;
|
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 {
|
export interface TreeRow {
|
||||||
account: TreeAccount;
|
account: TreeAccount;
|
||||||
/** 0 en las cuentas de primer nivel. */
|
/** 0 en la cuenta de primer nivel. */
|
||||||
depth: number;
|
depth: number;
|
||||||
hasChildren: boolean;
|
/** Puede tener hijas (una cuenta de título); se cargan al desplegarla. */
|
||||||
|
expandable: boolean;
|
||||||
expanded: 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
|
* Las filas de una rama del árbol, en orden: cada cuenta seguida de sus hijas ya cargadas si está desplegada.
|
||||||
* cuyo padre no está en la lista se trata como de primer nivel, para no perderla.
|
* `maxLevel` oculta las cuentas más profundas (el selector «Nivel» de SAP).
|
||||||
*/
|
*/
|
||||||
export function visibleRows(accounts: readonly TreeAccount[], opts: TreeOptions): TreeRow[] {
|
export function flattenBranch(root: TreeAccount, children: ReadonlyMap<string, readonly TreeAccount[]>, expanded: ReadonlySet<string>, maxLevel = Infinity): 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const out: TreeRow[] = [];
|
const out: TreeRow[] = [];
|
||||||
const walk = (a: TreeAccount, depth: number): void => {
|
const walk = (a: TreeAccount, depth: number): void => {
|
||||||
if (shown && !shown.has(a.Code)) return;
|
const expandable = a.ActiveAccount !== 'tYES';
|
||||||
const within = (k: TreeAccount): boolean => k.AccountLevel <= (opts.maxLevel ?? Infinity);
|
const open = expandable && expanded.has(a.Code) && children.has(a.Code);
|
||||||
const kids = (children.get(a.Code) ?? []).filter((k) => within(k) && (!shown || shown.has(k.Code)));
|
out.push({ account: a, depth, expandable, expanded: open });
|
||||||
const expanded = kids.length > 0 && (shown !== null || !opts.collapsed.has(a.Code));
|
if (!open) return;
|
||||||
out.push({ account: a, depth, hasChildren: kids.length > 0, expanded });
|
for (const k of children.get(a.Code)!) if (k.AccountLevel <= maxLevel) walk(k, depth + 1);
|
||||||
if (expanded) for (const k of kids) 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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Códigos de todas las cuentas que tienen hijas (para contraer todo el árbol). */
|
/** Filtro OData de la búsqueda por código (interno o con formato) o por nombre; la comilla simple se duplica. */
|
||||||
export const parentsOf = (accounts: readonly TreeAccount[]): string[] => [...new Set(accounts.flatMap((a) => (a.FatherAccountKey ? [a.FatherAccountKey] : [])))];
|
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="panel">
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="bar">
|
<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)" />
|
<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)="expandAll()">Expandir</button>
|
|
||||||
<button type="button" class="btn" (click)="collapseAll()">Contraer</button>
|
<button type="button" class="btn" (click)="collapseAll()">Contraer</button>
|
||||||
<button type="button" class="btn" (click)="create()">Nueva cuenta</button>
|
<button type="button" class="btn" (click)="create()">Nueva cuenta</button>
|
||||||
<span class="sp"></span>
|
<span class="sp"></span>
|
||||||
@@ -15,38 +14,51 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tree" role="tree" [attr.aria-label]="groupName()">
|
@if (query().trim() !== '') {
|
||||||
<div class="head">{{ groupName() }}</div>
|
<div class="tree" role="list" aria-label="Resultados de la búsqueda">
|
||||||
@for (r of rows(); track r.account.Code) {
|
<div class="head">Resultados</div>
|
||||||
<div
|
@for (a of results(); track a.Code) {
|
||||||
class="row"
|
<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)">
|
||||||
role="treeitem"
|
{{ display(a) }} - {{ a.Name }}
|
||||||
tabindex="0"
|
</div>
|
||||||
[class.sel]="r.account.Code === selected()"
|
} @empty {
|
||||||
[class.title]="isTitle(r.account)"
|
<div class="none">{{ searching() ? 'Buscando…' : 'Sin resultados.' }}</div>
|
||||||
[class.assoc]="r.account.PrimaryAccount === 'tYES'"
|
}
|
||||||
[attr.aria-expanded]="r.hasChildren ? r.expanded : null"
|
</div>
|
||||||
[style.padding-left.px]="6 + r.depth * 16"
|
<div class="count">{{ results().length === 50 ? 'Primeros 50 resultados: afine la búsqueda.' : results().length + ' resultados' }}</div>
|
||||||
(click)="open(r.account.Code)"
|
} @else {
|
||||||
(keydown.enter)="open(r.account.Code)"
|
<div class="tree" role="tree" [attr.aria-label]="group()?.Name">
|
||||||
>
|
<div class="head">{{ group()?.Name }}</div>
|
||||||
@if (r.hasChildren) {
|
@for (r of rows(); track r.account.Code) {
|
||||||
<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>
|
<div
|
||||||
} @else {
|
class="row"
|
||||||
<span class="tog"></span>
|
role="treeitem"
|
||||||
}
|
tabindex="0"
|
||||||
{{ r.account.Code }} - {{ r.account.Name }}
|
[class.sel]="r.account.Code === selected()"
|
||||||
</div>
|
[class.title]="r.expandable"
|
||||||
} @empty {
|
[class.assoc]="r.account.PrimaryAccount === 'tYES'"
|
||||||
<div class="none">{{ error() || (accounts.isLoading() ? 'Cargando el plan de cuentas…' : 'Sin cuentas.') }}</div>
|
[attr.aria-expanded]="r.expandable ? r.expanded : null"
|
||||||
}
|
[style.padding-left.px]="6 + r.depth * 16"
|
||||||
</div>
|
(click)="open(r.account.Code)"
|
||||||
<div class="count">{{ count() }}</div>
|
(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>
|
||||||
|
|
||||||
<div class="tabs" role="tablist" aria-label="Grupo de cuentas">
|
<div class="tabs" role="tablist" aria-label="Grupo de cuentas">
|
||||||
@for (g of groups(); track g.Code) {
|
@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>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
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 { Router } from '@angular/router';
|
||||||
import type { SlListResponse } from '@basic-erp/sap-types';
|
import type { SlListResponse } from '@basic-erp/sap-types';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { API_URL, apiErrorMessage } from '../../../core/api';
|
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 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,
|
* El árbol del plan de cuentas como lo muestra SAP: a la derecha una pestaña por grupo (las cuentas de primer
|
||||||
* Patrimonio…, las cuentas de primer nivel), arriba el selector «Nivel» y, en el centro, las cuentas de título
|
* nivel), arriba el selector «Nivel» y, en el centro, las cuentas de título (azul), las asociadas (verde) y las de
|
||||||
* (azul), las asociadas (verde) y las de movimiento. Se cargan todas las cuentas una vez (de a 200) y el árbol
|
* movimiento. Las hijas de una cuenta se piden al desplegarla (un plan segmentado puede tener miles de cuentas) y
|
||||||
* se arma en el navegador. Elegir una cuenta abre su formulario.
|
* se guardan; buscar consulta al servidor. Cada cuenta se muestra con su código con formato. Elegir una abre su formulario.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'sbo-chart-tree-panel',
|
selector: 'sbo-chart-tree-panel',
|
||||||
@@ -25,67 +26,144 @@ export class ChartTreePanel {
|
|||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly router = inject(Router);
|
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('');
|
readonly selected = input('');
|
||||||
/** Ruta de la pantalla; la cuenta elegida se abre en `<ruta>/<código>`. */
|
/** Ruta de la pantalla; la cuenta elegida se abre en `<ruta>/<código>`. */
|
||||||
readonly basePath = input('/finanzas/plan-de-cuentas');
|
readonly basePath = input('/finanzas/plan-de-cuentas');
|
||||||
|
|
||||||
protected readonly levels = LEVELS;
|
protected readonly levels = LEVELS;
|
||||||
protected readonly accounts = resource({
|
protected readonly display = displayCode;
|
||||||
loader: async (): Promise<TreeAccount[]> => {
|
|
||||||
const all: TreeAccount[] = [];
|
protected readonly groups = signal<TreeAccount[]>([]);
|
||||||
for (let skip = 0; ; skip += PAGE) {
|
private readonly children = signal<ReadonlyMap<string, readonly TreeAccount[]>>(new Map());
|
||||||
const params = new HttpParams().set('$select', TREE_FIELDS).set('$orderby', 'Code').set('$top', PAGE).set('$skip', skip);
|
private readonly expanded = signal<ReadonlySet<string>>(new Set());
|
||||||
const page = await firstValueFrom(this.http.get<SlListResponse<TreeAccount>>(`${API_URL}/sl/ChartOfAccounts`, { params }));
|
private readonly picked = signal<string | null>(null);
|
||||||
all.push(...page.value);
|
private readonly pathGroup = signal<string | null>(null);
|
||||||
if (page.value.length < PAGE) return all;
|
protected readonly level = signal(10);
|
||||||
}
|
protected readonly loading = signal(true);
|
||||||
},
|
protected readonly error = signal('');
|
||||||
});
|
|
||||||
protected readonly list = computed(() => (this.accounts.hasValue() ? this.accounts.value() : []));
|
|
||||||
protected readonly error = computed(() => (this.accounts.error() ? apiErrorMessage(this.accounts.error()) : ''));
|
|
||||||
|
|
||||||
protected readonly query = signal('');
|
protected readonly query = signal('');
|
||||||
protected readonly level = signal(10);
|
protected readonly results = signal<TreeAccount[]>([]);
|
||||||
private readonly picked = signal<string | null>(null);
|
protected readonly searching = signal(false);
|
||||||
/** `null` hasta que el usuario despliega o contrae algo: arranca con las cuentas de título más profundas contraídas. */
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
private readonly toggled = signal<ReadonlySet<string> | null>(null);
|
|
||||||
|
|
||||||
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 y si no el primero. */
|
||||||
/** 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. */
|
|
||||||
protected readonly group = computed(() => {
|
protected readonly group = computed(() => {
|
||||||
const groups = this.groups();
|
const groups = this.groups();
|
||||||
const chosen = this.picked();
|
const wanted = this.picked() ?? this.pathGroup();
|
||||||
if (chosen && groups.some((g) => g.Code === chosen)) return chosen;
|
return groups.find((g) => g.Code === wanted) ?? groups[0] ?? null;
|
||||||
const first = this.selected().charAt(0);
|
});
|
||||||
return (first && groups.find((g) => g.Code.charAt(0) === first)?.Code) || groups[0]?.Code || 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 {
|
constructor() {
|
||||||
return a.ActiveAccount !== 'tYES';
|
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 {
|
private async list(filter: string, top?: number): Promise<TreeAccount[]> {
|
||||||
const next = new Set(this.collapsed());
|
const all: TreeAccount[] = [];
|
||||||
if (!next.delete(code)) next.add(code);
|
// el servidor pagina de a 200: se pide hasta que una página venga incompleta
|
||||||
this.toggled.set(next);
|
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 {
|
protected collapseAll(): void {
|
||||||
this.toggled.set(new Set(parentsOf(this.list())));
|
this.expanded.set(new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
protected pickGroup(code: string): void {
|
protected pickGroup(code: string): void {
|
||||||
this.picked.set(code);
|
this.picked.set(code);
|
||||||
}
|
}
|
||||||
protected setLevel(value: string): void {
|
protected setLevel(value: string): void {
|
||||||
this.level.set(Number(value) || 10);
|
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 {
|
protected open(code: string): void {
|
||||||
void this.router.navigate([this.basePath(), code]);
|
void this.router.navigate([this.basePath(), code]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,11 @@ export const FINANZAS_SCREENS: readonly MasterScreen[] = [
|
|||||||
path: '/finanzas/plan-de-cuentas',
|
path: '/finanzas/plan-de-cuentas',
|
||||||
resource: 'ChartOfAccounts',
|
resource: 'ChartOfAccounts',
|
||||||
keyField: 'Code',
|
keyField: 'Code',
|
||||||
|
displayField: 'FormatCode',
|
||||||
singular: 'Cuenta',
|
singular: 'Cuenta',
|
||||||
createLabel: 'Nueva cuenta',
|
createLabel: 'Nueva cuenta',
|
||||||
orderby: 'Code',
|
orderby: 'Code',
|
||||||
extraFields: ['ActiveAccount'],
|
extraFields: ['ActiveAccount', 'FormatCode'],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: 'Code', title: 'Código', width: '170px', mono: true, searchable: true },
|
{ key: 'Code', title: 'Código', width: '170px', mono: true, searchable: true },
|
||||||
{ key: 'Name', title: 'Nombre', 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' };
|
return d['Frozen'] === 'tYES' ? { label: 'Inactivo', tone: 'muted' } : { label: 'Activo', tone: 'ok' };
|
||||||
});
|
});
|
||||||
protected readonly windowTitle = computed(() => {
|
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)}`;
|
return code === undefined ? this.screen.singular : `${this.screen.singular} · ${text(code)}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export interface MasterScreen {
|
|||||||
resource: string;
|
resource: string;
|
||||||
/** Campo clave del recurso (`CardCode`, `ItemCode`). */
|
/** Campo clave del recurso (`CardCode`, `ItemCode`). */
|
||||||
keyField: string;
|
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`). */
|
/** Nombre en singular, para el título del formulario (`Cliente`). */
|
||||||
singular: string;
|
singular: string;
|
||||||
createLabel: string;
|
createLabel: string;
|
||||||
|
|||||||
Reference in new issue
Block a user