diff --git a/CLAUDE.md b/CLAUDE.md
index 424e423..3e29d30 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts
index 9c8a0de..a23bb26 100644
--- a/apps/web/src/app/app.routes.ts
+++ b/apps/web/src/app/app.routes.ts
@@ -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) },
],
diff --git a/apps/web/src/app/features/masters/finanzas/account-groups.ts b/apps/web/src/app/features/masters/finanzas/account-groups.ts
new file mode 100644
index 0000000..5087260
--- /dev/null
+++ b/apps/web/src/app/features/masters/finanzas/account-groups.ts
@@ -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: `
+
+ @for (g of groups(); track g.Code) {
+
+ }
+
+ `,
+ 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>(
+ () => `${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));
+}
diff --git a/apps/web/src/app/features/masters/finanzas/chart-tree.model.spec.ts b/apps/web/src/app/features/masters/finanzas/chart-tree.model.spec.ts
new file mode 100644
index 0000000..544f2dd
--- /dev/null
+++ b/apps/web/src/app/features/masters/finanzas/chart-tree.model.spec.ts
@@ -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(), 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']);
+ });
+});
diff --git a/apps/web/src/app/features/masters/finanzas/chart-tree.model.ts b/apps/web/src/app/features/masters/finanzas/chart-tree.model.ts
new file mode 100644
index 0000000..a221aef
--- /dev/null
+++ b/apps/web/src/app/features/masters/finanzas/chart-tree.model.ts
@@ -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;
+ /** 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();
+ for (const a of [...accounts].sort((x, y) => (x.Code < y.Code ? -1 : x.Code > y.Code ? 1 : 0))) {
+ const parent = a.FatherAccountKey && byCode.has(a.FatherAccountKey) && a.FatherAccountKey !== a.Code ? a.FatherAccountKey : null;
+ children.set(parent, [...(children.get(parent) ?? []), a]);
+ }
+
+ const query = opts.query.trim().toLowerCase();
+ let shown: Set | null = null;
+ if (query) {
+ shown = new Set();
+ for (const a of accounts) {
+ if (!a.Code.toLowerCase().includes(query) && !a.Name.toLowerCase().includes(query)) continue;
+ for (let c: TreeAccount | undefined = a; c && !shown.has(c.Code); c = c.FatherAccountKey ? byCode.get(c.FatherAccountKey) : undefined) shown.add(c.Code);
+ }
+ }
+
+ 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] : [])))];
diff --git a/apps/web/src/app/features/masters/finanzas/chart-tree.page.css b/apps/web/src/app/features/masters/finanzas/chart-tree.page.css
new file mode 100644
index 0000000..a56e7bf
--- /dev/null
+++ b/apps/web/src/app/features/masters/finanzas/chart-tree.page.css
@@ -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; }
diff --git a/apps/web/src/app/features/masters/finanzas/chart-tree.page.html b/apps/web/src/app/features/masters/finanzas/chart-tree.page.html
new file mode 100644
index 0000000..febce1f
--- /dev/null
+++ b/apps/web/src/app/features/masters/finanzas/chart-tree.page.html
@@ -0,0 +1,45 @@
+
+