feat(web): plan de cuentas como árbol y casillas de grupo en el formulario

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 14:46:07 -05:00
1 parent eb84d73719
commit 99c0a9259c
12 files changed
+341 -3

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@ Reglas de SAP verificadas: nota de crédito se aplica con `SumApplied` **negativ
## Finanzas (plan de cuentas y asientos)
Menú `Finanzas`: **Plan de cuentas** (`/finanzas/plan-de-cuentas`) es un maestro más (`masters/finanzas/`, recurso `ChartOfAccounts`, clave `Code`; extensiones por cliente en `finanzas.extensions.ts`) y **Asientos contables** (`/finanzas/asientos`, `features/finance/`, recurso `JournalEntries`, clave `JdtNum`) tiene lista y formulario propios (`journal-model.ts`: cuadre debe = haber, líneas con cuenta, dimensiones y proyecto). Verificado en SAP: en una cuenta ya creada solo se puede cambiar el nombre y similares; `ActiveAccount` (de movimiento / de título) y la cuenta padre no se cambian (campos `immutable`), y congelarla exige un rango de fechas (no se ofrece). Un asiento no se modifica; solo los **manuales** (`OriginalJournal = ttJournalEntry`) se cancelan con `Cancel`, que genera el asiento de reverso (el original no lleva marca de cancelado). Los asientos de documentos se cancelan con su documento. Pendiente: libro mayor y saldos, asientos recurrentes, árbol del plan de cuentas.
Menú `Finanzas`: **Plan de cuentas** (`/finanzas/plan-de-cuentas`) es un maestro más (`masters/finanzas/`, recurso `ChartOfAccounts`, clave `Code`; extensiones por cliente en `finanzas.extensions.ts`) y **Asientos contables** (`/finanzas/asientos`, `features/finance/`, recurso `JournalEntries`, clave `JdtNum`) tiene lista y formulario propios (`journal-model.ts`: cuadre debe = haber, líneas con cuenta, dimensiones y proyecto). Verificado en SAP: en una cuenta ya creada solo se puede cambiar el nombre y similares; `ActiveAccount` (de movimiento / de título) y la cuenta padre no se cambian (campos `immutable`), y congelarla exige un rango de fechas (no se ofrece). Un asiento no se modifica; solo los **manuales** (`OriginalJournal = ttJournalEntry`) se cancelan con `Cancel`, que genera el asiento de reverso (el original no lleva marca de cancelado). Los asientos de documentos se cancelan con su documento. El plan de cuentas se abre como **árbol** (`chart-tree.page`: se cargan todas las cuentas de a 200 y el árbol se arma en el navegador; buscar, expandir/contraer, ramas de primer nivel como casillas) y su formulario muestra las casillas de grupo (Activos, Pasivos, Patrimonio…; `account-groups.ts`, solo informativas: el grupo es la cuenta de primer nivel que empieza con el mismo carácter que el código, porque el Service Layer no expone `GroupMask` y `Category` viene vacía). El asiento lleva indicador, folio y referencias 1 a 3 en cabecera y línea, más `ShortName` y `DueDate` por línea; SAP ignoró el folio en el asiento de prueba. Pendiente: libro mayor y saldos, asientos recurrentes.
## Criterios de la interfaz
+8 -1
View File
@@ -53,7 +53,14 @@ const masterRoutes: Routes = MASTER_PATHS.map(
screen: () => import('./features/masters/master-screens').then((m) => m.MASTER_SCREENS.find((s) => s.path === path)!),
},
children: [
{ path: '', loadComponent: () => import('./features/masters/master-list.page').then((m) => m.MasterListPage) },
{
path: '',
// El plan de cuentas se ve como árbol; los demás maestros, como lista.
loadComponent: () =>
path === '/finanzas/plan-de-cuentas'
? import('./features/masters/finanzas/chart-tree.page').then((m) => m.ChartTreePage)
: import('./features/masters/master-list.page').then((m) => m.MasterListPage),
},
{ path: 'nuevo', loadComponent: () => import('./features/masters/master-form.page').then((m) => m.MasterFormPage) },
{ path: ':key', loadComponent: () => import('./features/masters/master-form.page').then((m) => m.MasterFormPage) },
],
@@ -0,0 +1,41 @@
import { httpResource } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import type { SlListResponse } from '@basic-erp/sap-types';
import { API_URL } from '../../../core/api';
interface Group {
Code: string;
Name: string;
}
/**
* Las casillas de grupo del plan de cuentas (Activos, Pasivos, Patrimonio, Ingresos…) como las muestra SAP: son
* las cuentas de primer nivel y la cuenta pertenece a la que empieza con su mismo carácter (SAP numera los
* grupos así). Solo informan: el grupo lo fija el código de la cuenta (o el de su cuenta padre, si es nueva).
*/
@Component({
selector: 'sbo-account-groups',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="groups" role="group" aria-label="Grupo de la cuenta">
@for (g of groups(); track g.Code) {
<label class="grp"><input type="checkbox" disabled [checked]="g.Code.charAt(0) === first()" />{{ g.Name }}</label>
}
</div>
`,
styles: `
:host { display: block; margin: 6px 0 10px; }
.groups { display: flex; gap: 14px; flex-wrap: wrap; }
.grp { display: inline-flex; align-items: center; gap: 4px; }
`,
})
export class AccountGroups {
/** Código de la cuenta o, si todavía no lo tiene, el de su cuenta padre. */
readonly code = input('');
private readonly resource = httpResource<SlListResponse<Group>>(
() => `${API_URL}/sl/ChartOfAccounts?$select=Code,Name&$filter=${encodeURIComponent('AccountLevel eq 1')}&$orderby=Code`,
);
protected readonly groups = computed(() => (this.resource.hasValue() ? this.resource.value().value : []));
protected readonly first = computed(() => this.code().trim().charAt(0));
}
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { type TreeAccount, parentsOf, visibleRows } from './chart-tree.model';
const acc = (Code: string, Name: string, level: number, father: string | null, postable = false): TreeAccount => ({
Code,
Name,
AccountLevel: level,
FatherAccountKey: father,
ActiveAccount: postable ? 'tYES' : 'tNO',
AccountType: 'at_Other',
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 };
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('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('lista los códigos con hijas', () => {
expect(parentsOf(accounts).sort()).toEqual(['1', '1-1']);
});
});
@@ -0,0 +1,66 @@
/** Campos de cada cuenta que pide el árbol. */
export const TREE_FIELDS = 'Code,Name,AccountLevel,FatherAccountKey,ActiveAccount,AccountType,Balance';
export interface TreeAccount {
Code: string;
Name: string;
AccountLevel: number;
FatherAccountKey: string | null;
ActiveAccount: string;
AccountType: string;
Balance: number;
}
export interface TreeRow {
account: TreeAccount;
/** 0 en las cuentas de primer nivel. */
depth: number;
hasChildren: 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;
}
/**
* 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.
*/
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);
}
}
const out: TreeRow[] = [];
const walk = (a: TreeAccount, depth: number): void => {
if (shown && !shown.has(a.Code)) return;
const kids = (children.get(a.Code) ?? []).filter((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);
};
for (const r of children.get(null) ?? []) if (opts.root === null || r.Code === opts.root) walk(r, 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] : [])))];
@@ -0,0 +1,18 @@
:host { display: block; }
.bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.sp { flex: 1; }
.search { width: 280px; height: 24px; padding: 1px 6px; background: var(--field); border: 1px solid var(--field-b); border-radius: var(--radius); }
.groups { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 8px; }
.grp { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
.wrap { max-height: calc(100vh - 290px); overflow: auto; border: 1px solid var(--line); }
table { width: 100%; border-collapse: collapse; }
th { position: sticky; top: 0; padding: 4px 8px; background: var(--grid-head); border-bottom: 1px solid var(--line); font-size: 12.5px; font-weight: 600; text-align: left; }
td { padding: 2px 8px; border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
tbody tr { cursor: pointer; }
tbody tr:hover, tbody tr:focus-visible { background: var(--btn-h); outline: none; }
tr.title td:first-child { font-weight: 600; }
.w-type { width: 110px; }
.w-num, td.num { width: 150px; text-align: right; font-variant-numeric: tabular-nums; }
.code { font-family: var(--mono, monospace); color: var(--quiet); margin-right: 6px; }
.tog { display: inline-block; width: 18px; padding: 0; background: none; border: none; color: var(--quiet); cursor: pointer; text-align: center; }
tr.none td { padding: 14px; text-align: center; color: var(--quiet); cursor: default; }
@@ -0,0 +1,45 @@
<sbo-window [title]="title" [status]="status()">
<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 todo</button>
<button type="button" class="btn" (click)="collapseAll()">Contraer todo</button>
<span class="sp"></span>
<a class="btn main" routerLink="nuevo">Nueva cuenta</a>
</div>
<div class="groups" role="group" aria-label="Grupo de cuentas">
@for (g of groups(); track g.Code) {
<label class="grp"><input type="checkbox" [checked]="root() === g.Code" (change)="pickGroup(g.Code)" />{{ g.Name }}</label>
}
</div>
<div class="wrap">
<table>
<thead>
<tr>
<th>Código / Nombre</th>
<th class="w-type">Tipo</th>
<th class="w-num">Saldo</th>
</tr>
</thead>
<tbody>
@for (r of rows(); track r.account.Code) {
<tr [class.title]="r.hasChildren || r.account.ActiveAccount !== 'tYES'" (click)="open(r.account.Code)" (keydown.enter)="open(r.account.Code)" tabindex="0">
<td [style.padding-left.px]="8 + r.depth * 18">
@if (r.hasChildren) {
<button type="button" class="tog" [attr.aria-label]="(r.expanded ? 'Contraer ' : 'Expandir ') + r.account.Name" [attr.aria-expanded]="r.expanded" (click)="$event.stopPropagation(); toggle(r.account.Code)">{{ r.expanded ? '▾' : '▸' }}</button>
} @else {
<span class="tog"></span>
}
<span class="code">{{ r.account.Code }}</span> {{ r.account.Name }}
</td>
<td>{{ typeOf(r.account) }}</td>
<td class="num">{{ r.account.ActiveAccount === 'tYES' ? fmt(r.account.Balance) : '' }}</td>
</tr>
} @empty {
<tr class="none"><td colspan="3">{{ accounts.isLoading() ? 'Cargando…' : 'Sin cuentas.' }}</td></tr>
}
</tbody>
</table>
</div>
</sbo-window>
@@ -0,0 +1,89 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, inject, resource, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import type { SlListResponse } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../../core/api';
import { formatNumber } from '../../../core/format';
import { SboWindow, type StatusMessage } from '../../../shared/window';
import { TREE_FIELDS, type TreeAccount, parentsOf, visibleRows } from './chart-tree.model';
const PAGE = 200;
const TYPES: Readonly<Record<string, string>> = { at_Other: 'Otras', at_Revenues: 'Ingresos', at_Expenses: 'Gastos' };
/**
* Plan de cuentas como árbol (como en SAP): cuentas de título que se despliegan y, debajo, las de movimiento.
* Se cargan todas las cuentas una sola vez (de a 200) y el árbol se arma en el navegador; las ramas de primer
* nivel (Activos, Pasivos, Patrimonio…) se eligen como casillas. Una fila abre el formulario de la cuenta.
*/
@Component({
selector: 'sbo-chart-tree-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, SboWindow],
templateUrl: './chart-tree.page.html',
styleUrl: './chart-tree.page.css',
})
export class ChartTreePage {
private readonly http = inject(HttpClient);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
protected readonly title = this.route.snapshot.data['title'] as string;
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 query = signal('');
protected readonly root = signal<string | null>(null);
/** Arranca con solo las cuentas de primer nivel a la vista: `null` hasta que el usuario despliega o contrae algo. */
private readonly toggled = signal<ReadonlySet<string> | null>(null);
protected readonly collapsed = computed(() => this.toggled() ?? new Set(parentsOf(this.list()).filter((c) => this.levelOf(c) >= 2)));
private levelOf(code: string): number {
return this.list().find((a) => a.Code === code)?.AccountLevel ?? 1;
}
protected readonly groups = computed(() => this.list().filter((a) => a.AccountLevel === 1 && !a.FatherAccountKey));
protected readonly rows = computed(() => visibleRows(this.list(), { collapsed: this.collapsed(), query: this.query(), root: this.root() }));
protected readonly status = computed<StatusMessage>(() => {
if (this.accounts.isLoading()) return { kind: 'info', text: 'Cargando el plan de cuentas…' };
if (this.accounts.error()) return { kind: 'err', text: apiErrorMessage(this.accounts.error()) };
return { kind: 'info', text: `${this.rows().length} de ${this.list().length} cuentas.` };
});
protected fmt(n: number): string {
return formatNumber(n, 2);
}
protected typeOf(a: TreeAccount): string {
return TYPES[a.AccountType] ?? a.AccountType;
}
protected toggle(code: string): void {
const next = new Set(this.collapsed());
if (!next.delete(code)) next.add(code);
this.toggled.set(next);
}
protected expandAll(): void {
this.toggled.set(new Set());
}
protected collapseAll(): void {
this.toggled.set(new Set(parentsOf(this.list())));
}
protected pickGroup(code: string): void {
this.root.update((r) => (r === code ? null : code));
}
protected open(code: string): void {
void this.router.navigate([code], { relativeTo: this.route });
}
}
@@ -81,6 +81,7 @@ export const FINANZAS_SCREENS: readonly MasterScreen[] = [
],
},
],
accountGroups: true,
newHeader: () => ({ ActiveAccount: 'tYES', AccountType: 'at_Other', CashAccount: 'tNO' }),
},
},
@@ -22,6 +22,10 @@
</div>
</div>
@if (layout.accountGroups) {
<sbo-account-groups [code]="groupCode()" />
}
<sbo-tabs ariaLabel="Datos del registro">
@for (tab of layout.tabs; track tab.id) {
<sbo-tab [label]="tab.label">
@@ -9,6 +9,7 @@ import { RowsTable } from '../../shared/grid/rows-table';
import { Icon, SboWindow, Tab, Tabs, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
import { updatePayload, type DocRecord } from '../documents/document-model';
import type { HeaderField } from '../documents/document-layouts';
import { AccountGroups } from './finanzas/account-groups';
import { MasterField } from './master-field';
import {
type CollectionRow,
@@ -36,7 +37,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
@Component({
selector: 'sbo-master-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Icon, MasterField, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
imports: [AccountGroups, Icon, MasterField, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
templateUrl: './master-form.page.html',
styles: '.addrow { margin: 6px 0 0; }',
})
@@ -165,6 +166,11 @@ export class MasterFormPage {
return !c.readonly && c.mode === 'replace' && c.growable !== false;
}
/** Código con el que se ve el grupo del plan de cuentas: el de la cuenta o, si es nueva, el de su cuenta padre. */
protected groupCode(): string {
return text(this.header()['FatherAccountKey']) || text(this.header()['Code']);
}
protected value(key: string): unknown {
return this.header()[key];
}
@@ -50,6 +50,8 @@ export interface MasterLayout {
newHeader: () => DocRecord;
/** Valores que se envían siempre al crear (ej. `CardType: 'cCustomer'`). */
fixed?: DocRecord;
/** Muestra las casillas de grupo del plan de cuentas (Activos, Pasivos…) bajo la cabecera. */
accountGroups?: boolean;
/** Campos que se ven pero no se editan (una extensión de cliente los dejó de solo lectura). */
lockedKeys?: readonly string[];
}