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 @@ + +
+ + + + + Nueva cuenta +
+ +
+ @for (g of groups(); track g.Code) { + + } +
+ +
+ + + + + + + + + + @for (r of rows(); track r.account.Code) { + + + + + + } @empty { + + } + +
Código / NombreTipoSaldo
+ @if (r.hasChildren) { + + } @else { + + } + {{ r.account.Code }} {{ r.account.Name }} + {{ typeOf(r.account) }}{{ r.account.ActiveAccount === 'tYES' ? fmt(r.account.Balance) : '' }}
{{ accounts.isLoading() ? 'Cargando…' : 'Sin cuentas.' }}
+
+
diff --git a/apps/web/src/app/features/masters/finanzas/chart-tree.page.ts b/apps/web/src/app/features/masters/finanzas/chart-tree.page.ts new file mode 100644 index 0000000..ac4cad7 --- /dev/null +++ b/apps/web/src/app/features/masters/finanzas/chart-tree.page.ts @@ -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> = { 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 => { + 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>(`${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(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 | 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(() => { + 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 }); + } +} diff --git a/apps/web/src/app/features/masters/finanzas/finanzas.screens.ts b/apps/web/src/app/features/masters/finanzas/finanzas.screens.ts index f07ac99..2b87ae1 100644 --- a/apps/web/src/app/features/masters/finanzas/finanzas.screens.ts +++ b/apps/web/src/app/features/masters/finanzas/finanzas.screens.ts @@ -81,6 +81,7 @@ export const FINANZAS_SCREENS: readonly MasterScreen[] = [ ], }, ], + accountGroups: true, newHeader: () => ({ ActiveAccount: 'tYES', AccountType: 'at_Other', CashAccount: 'tNO' }), }, }, diff --git a/apps/web/src/app/features/masters/master-form.page.html b/apps/web/src/app/features/masters/master-form.page.html index b534e0c..db9c59a 100644 --- a/apps/web/src/app/features/masters/master-form.page.html +++ b/apps/web/src/app/features/masters/master-form.page.html @@ -22,6 +22,10 @@ + @if (layout.accountGroups) { + + } + @for (tab of layout.tabs; track tab.id) { diff --git a/apps/web/src/app/features/masters/master-form.page.ts b/apps/web/src/app/features/masters/master-form.page.ts index 592f49e..c0b5ed7 100644 --- a/apps/web/src/app/features/masters/master-form.page.ts +++ b/apps/web/src/app/features/masters/master-form.page.ts @@ -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]; } diff --git a/apps/web/src/app/features/masters/master-types.ts b/apps/web/src/app/features/masters/master-types.ts index da15fb7..9c018ae 100644 --- a/apps/web/src/app/features/masters/master-types.ts +++ b/apps/web/src/app/features/masters/master-types.ts @@ -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[]; }