feat(web): plan de cuentas como SAP (árbol con grupos y nivel al lado del formulario; propiedades, costos e info detallada)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
89e25ad8e6
commit
bb19e499fe
18 files changed
+292
-280
No files matched your search
@@ -1,41 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -53,6 +53,11 @@ describe('árbol del plan de cuentas', () => {
|
||||
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', () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
it('lista los códigos con hijas', () => {
|
||||
expect(parentsOf(accounts).sort()).toEqual(['1', '1-1']);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Campos de cada cuenta que pide el árbol. */
|
||||
export const TREE_FIELDS = 'Code,Name,AccountLevel,FatherAccountKey,ActiveAccount,AccountType,Balance';
|
||||
export const TREE_FIELDS = 'Code,Name,AccountLevel,FatherAccountKey,ActiveAccount,AccountType,Balance,PrimaryAccount';
|
||||
|
||||
export interface TreeAccount {
|
||||
Code: string;
|
||||
@@ -9,6 +9,8 @@ export interface TreeAccount {
|
||||
ActiveAccount: string;
|
||||
AccountType: string;
|
||||
Balance: number;
|
||||
/** Cuenta asociada (SAP la pinta en verde). */
|
||||
PrimaryAccount?: string;
|
||||
}
|
||||
|
||||
export interface TreeRow {
|
||||
@@ -26,6 +28,8 @@ export interface TreeOptions {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +57,8 @@ export function visibleRows(accounts: readonly TreeAccount[], opts: TreeOptions)
|
||||
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 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);
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
: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; }
|
||||
@@ -1,45 +0,0 @@
|
||||
<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>
|
||||
@@ -1,89 +1,19 @@
|
||||
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';
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { SboWindow } from '../../../shared/window';
|
||||
import { ChartTreePanel } from './chart-tree.panel';
|
||||
|
||||
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.
|
||||
*/
|
||||
/** Entrada al plan de cuentas: el árbol a pantalla completa; elegir una cuenta abre su formulario (con el árbol al lado). */
|
||||
@Component({
|
||||
selector: 'sbo-chart-tree-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink, SboWindow],
|
||||
templateUrl: './chart-tree.page.html',
|
||||
styleUrl: './chart-tree.page.css',
|
||||
imports: [ChartTreePanel, SboWindow],
|
||||
template: `
|
||||
<sbo-window [title]="title" [status]="{ kind: 'info', text: 'Elija una cuenta o cree una nueva.' }">
|
||||
<sbo-chart-tree-panel />
|
||||
</sbo-window>
|
||||
`,
|
||||
})
|
||||
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 });
|
||||
}
|
||||
protected readonly title = inject(ActivatedRoute).snapshot.data['title'] as string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
:host { display: block; min-width: 0; }
|
||||
.panel { display: grid; grid-template-columns: minmax(0, 1fr) 104px; gap: 8px; }
|
||||
.main { min-width: 0; }
|
||||
.bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.sp { flex: 1; }
|
||||
.search { width: 220px; height: 24px; padding: 1px 6px; background: var(--field); border: 1px solid var(--field-b); border-radius: var(--radius); }
|
||||
.lvl { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.lvl select { height: 24px; background: var(--field); border: 1px solid var(--field-b); border-radius: var(--radius); }
|
||||
.tree { height: calc(100vh - 300px); min-height: 280px; overflow: auto; background: var(--field); border: 1px solid var(--line); }
|
||||
.head { position: sticky; top: 0; padding: 2px 8px; background: var(--focus); color: #fff; font-weight: 600; z-index: 1; }
|
||||
.row { padding: 1px 6px; white-space: nowrap; cursor: pointer; }
|
||||
.row:hover { background: var(--btn-h); }
|
||||
.row:focus-visible { outline: 1px solid var(--focus); outline-offset: -1px; }
|
||||
.row.sel { background: var(--gold); }
|
||||
.row.title { color: var(--focus); }
|
||||
.row.assoc { color: var(--ok); }
|
||||
.tog { display: inline-block; width: 16px; padding: 0; background: none; border: none; color: var(--gold); cursor: pointer; text-align: center; }
|
||||
.none { padding: 14px; text-align: center; color: var(--quiet); }
|
||||
.count { margin-top: 4px; color: var(--quiet); font-size: 12px; }
|
||||
.tabs { display: flex; flex-direction: column; gap: 4px; }
|
||||
.gtab { min-height: 44px; padding: 4px 6px; background: var(--btn); border: 1px solid var(--btn-b); border-radius: var(--radius-lg); font-size: 12px; text-align: center; cursor: pointer; }
|
||||
.gtab:hover { background: var(--btn-h); }
|
||||
.gtab.on { background: var(--win); border-color: var(--focus); font-weight: 600; }
|
||||
@@ -0,0 +1,52 @@
|
||||
<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>
|
||||
<button type="button" class="btn" (click)="collapseAll()">Contraer</button>
|
||||
<button type="button" class="btn" (click)="create()">Nueva cuenta</button>
|
||||
<span class="sp"></span>
|
||||
<label class="lvl">Nivel
|
||||
<select aria-label="Nivel" [value]="level()" (change)="setLevel($any($event.target).value)">
|
||||
@for (n of levels; track n) {
|
||||
<option [value]="n" [selected]="n === level()">{{ n }}</option>
|
||||
}
|
||||
</select>
|
||||
</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>
|
||||
</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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,95 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input, resource, signal } 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';
|
||||
|
||||
const PAGE = 200;
|
||||
const LEVELS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'sbo-chart-tree-panel',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './chart-tree.panel.html',
|
||||
styleUrl: './chart-tree.panel.css',
|
||||
})
|
||||
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). */
|
||||
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 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 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. */
|
||||
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;
|
||||
});
|
||||
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';
|
||||
}
|
||||
|
||||
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.picked.set(code);
|
||||
}
|
||||
protected setLevel(value: string): void {
|
||||
this.level.set(Number(value) || 10);
|
||||
}
|
||||
protected open(code: string): void {
|
||||
void this.router.navigate([this.basePath(), code]);
|
||||
}
|
||||
protected create(): void {
|
||||
void this.router.navigate([this.basePath(), 'nuevo']);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SelectFilter } from '../../../core/sl/list-filters';
|
||||
import type { HeaderField } from '../../documents/document-layouts';
|
||||
import { ACCOUNT_LOOKUP } from '../../documents/dimension-lookups';
|
||||
import { ACCOUNT_LOOKUP, PROJECT_LOOKUP, dimensionLookup } from '../../documents/dimension-lookups';
|
||||
import { MASTER_LOOKUPS } from '../master-lookups';
|
||||
import type { MasterScreen } from '../master-types';
|
||||
|
||||
@@ -8,9 +8,9 @@ const field = (f: HeaderField): HeaderField => f;
|
||||
|
||||
/** Tipos de cuenta de SAP (`BoAccountTypes`). */
|
||||
const ACCOUNT_TYPES = [
|
||||
{ value: 'at_Other', label: 'Otras' },
|
||||
{ value: 'at_Revenues', label: 'Ingresos' },
|
||||
{ value: 'at_Expenses', label: 'Gastos' },
|
||||
{ value: 'at_Other', label: 'Otros' },
|
||||
{ value: 'at_Revenues', label: 'Entrada' },
|
||||
{ value: 'at_Expenses', label: 'Salida' },
|
||||
];
|
||||
|
||||
/** Una cuenta de título agrupa a otras y no recibe asientos; la de movimiento (`ActiveAccount`) sí. */
|
||||
@@ -60,29 +60,76 @@ export const FINANZAS_SCREENS: readonly MasterScreen[] = [
|
||||
layout: {
|
||||
header: {
|
||||
left: [
|
||||
field({ type: 'text', key: 'Code', label: 'Código', immutable: true, required: true, missing: 'el código' }),
|
||||
field({ type: 'text', key: 'Code', label: 'Cuenta de mayor', immutable: true, required: true, missing: 'el código' }),
|
||||
field({ type: 'text', key: 'Name', label: 'Nombre', required: true, missing: 'el nombre' }),
|
||||
field({ type: 'text', key: 'ExternalCode', label: 'Código externo' }),
|
||||
field({ type: 'lookup', key: 'AcctCurrency', label: 'Moneda', lookup: MASTER_LOOKUPS.currency }),
|
||||
],
|
||||
right: [
|
||||
field({ type: 'checkbox', key: 'ActiveAccount', label: 'Cuenta activa', immutable: true }),
|
||||
field({ type: 'checkbox', key: 'Protected', label: 'Confidencial' }),
|
||||
field({ type: 'number', key: 'AccountLevel', label: 'Nivel', readonly: true }),
|
||||
field({ type: 'number', key: 'Balance', label: 'Saldo', decimals: 2, readonly: true }),
|
||||
],
|
||||
right: [field({ type: 'checkbox', key: 'ActiveAccount', label: 'Cuenta de movimiento', immutable: true })],
|
||||
},
|
||||
tabs: [
|
||||
{
|
||||
id: 'general',
|
||||
label: 'General',
|
||||
id: 'propiedades',
|
||||
label: 'Propiedades',
|
||||
left: [
|
||||
field({ type: 'lookup', key: 'FatherAccountKey', label: 'Cuenta padre', lookup: TITLE_ACCOUNTS, immutable: true }),
|
||||
field({ type: 'select', key: 'AccountType', label: 'Tipo de cuenta', options: ACCOUNT_TYPES }),
|
||||
field({ type: 'lookup', key: 'AcctCurrency', label: 'Moneda', lookup: MASTER_LOOKUPS.currency }),
|
||||
field({ type: 'select', key: 'AccountType', label: 'Clase de cuenta', options: ACCOUNT_TYPES }),
|
||||
field({ type: 'checkbox', key: 'LockManualTransaction', label: 'Cuenta asociada' }),
|
||||
field({ type: 'checkbox', key: 'CashAccount', label: 'Cuenta monetaria' }),
|
||||
field({ type: 'checkbox', key: 'RevaluationCoordinated', label: 'Reval. según índice' }),
|
||||
],
|
||||
right: [
|
||||
field({ type: 'checkbox', key: 'CashAccount', label: 'Cuenta de caja' }),
|
||||
field({ type: 'number', key: 'AccountLevel', label: 'Nivel', readonly: true }),
|
||||
field({ type: 'number', key: 'Balance', label: 'Saldo', decimals: 2, readonly: true }),
|
||||
field({ type: 'checkbox', key: 'BlockManualPosting', label: 'Bloquear contabilización manual' }),
|
||||
field({ type: 'checkbox', key: 'CashFlowRelevant', label: 'Relevante para flujo de caja' }),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'costos',
|
||||
label: 'Contabilidad de costos',
|
||||
left: [
|
||||
field({ type: 'checkbox', key: 'ProjectRelevant', label: 'Relevante para proyecto' }),
|
||||
field({ type: 'lookup', key: 'ProjectCode', label: 'Proyecto', lookup: PROJECT_LOOKUP }),
|
||||
field({ type: 'checkbox', key: 'CostElementRelevant', label: 'Relevante para elemento de costo' }),
|
||||
field({ type: 'text', key: 'CostElementCode', label: 'Elemento de costo' }),
|
||||
],
|
||||
right: ([1, 2, 3, 4, 5] as const).flatMap((n) => [
|
||||
field({ type: 'checkbox', key: n === 1 ? 'DistributionRuleRelevant' : `DistributionRule${n}Relevant`, label: `Norma de reparto: dimensión ${n}` }),
|
||||
field({ type: 'lookup', key: n === 1 ? 'LoadingFactorCode' : `LoadingFactorCode${n}`, label: `Dimensión ${n}`, lookup: dimensionLookup(n) }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'detalle',
|
||||
label: 'Info detallada',
|
||||
left: [
|
||||
field({ type: 'text', key: 'ForeignName', label: 'Nombre extranjero' }),
|
||||
field({ type: 'text', key: 'DataExportCode', label: 'Código para exportación' }),
|
||||
field({ type: 'text', key: 'PlanningLevel', label: 'Nivel de planificación' }),
|
||||
field({ type: 'textarea', key: 'Details', label: 'Comentarios' }),
|
||||
],
|
||||
right: [
|
||||
field({ type: 'checkbox', key: 'CostAccountingOnly', label: 'Solo ajuste de contabilidad de costos' }),
|
||||
field({ type: 'checkbox', key: 'AllowMultipleLinking', label: 'Permitir enlace múltiple a modelos financieros' }),
|
||||
field({ type: 'checkbox', key: 'AllowChangeVatGroup', label: 'Permitir otro indicador de impuestos' }),
|
||||
field({ type: 'checkbox', key: 'ExchangeRateDifference', label: 'Diferencias de tipo de cambio' }),
|
||||
field({ type: 'checkbox', key: 'RateConversion', label: 'Conversión de tipo de cambio' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
accountGroups: true,
|
||||
newHeader: () => ({ ActiveAccount: 'tYES', AccountType: 'at_Other', CashAccount: 'tNO' }),
|
||||
chartTree: true,
|
||||
newHeader: () => ({
|
||||
ActiveAccount: 'tYES',
|
||||
AccountType: 'at_Other',
|
||||
CashAccount: 'tNO',
|
||||
Protected: 'tNO',
|
||||
AllowChangeVatGroup: 'tYES',
|
||||
ExchangeRateDifference: 'tYES',
|
||||
RateConversion: 'tYES',
|
||||
}),
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -9,6 +9,8 @@
|
||||
</sbo-toolbar>
|
||||
|
||||
<sbo-window [title]="windowTitle()" [badge]="badge()" [status]="status()">
|
||||
<div class="split" [class.two]="layout.chartTree">
|
||||
<div class="left">
|
||||
<div class="sbo-hdr">
|
||||
<div class="sbo-col">
|
||||
@for (f of layout.header.left; track $index) {
|
||||
@@ -22,10 +24,6 @@
|
||||
</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">
|
||||
@@ -57,6 +55,11 @@
|
||||
</sbo-tab>
|
||||
}
|
||||
</sbo-tabs>
|
||||
</div>
|
||||
@if (layout.chartTree) {
|
||||
<sbo-chart-tree-panel [selected]="groupCode()" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<button sboActions class="btn main" type="button" [disabled]="saving() || loading()" (click)="save()">
|
||||
{{ isNew() ? 'Crear' : 'Actualizar' }}
|
||||
|
||||
@@ -9,7 +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 { ChartTreePanel } from './finanzas/chart-tree.panel';
|
||||
import { MasterField } from './master-field';
|
||||
import {
|
||||
type CollectionRow,
|
||||
@@ -37,9 +37,14 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
|
||||
@Component({
|
||||
selector: 'sbo-master-form-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [AccountGroups, Icon, MasterField, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
|
||||
imports: [ChartTreePanel, Icon, MasterField, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
|
||||
templateUrl: './master-form.page.html',
|
||||
styles: '.addrow { margin: 6px 0 0; }',
|
||||
styles: `
|
||||
.addrow { margin: 6px 0 0; }
|
||||
.split.two { display: grid; grid-template-columns: minmax(380px, 460px) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||
.split.two .sbo-hdr { grid-template-columns: minmax(0, 1fr); gap: 4px; }
|
||||
.left { min-width: 0; }
|
||||
`,
|
||||
})
|
||||
export class MasterFormPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
@@ -166,9 +171,9 @@ 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. */
|
||||
/** Cuenta que se resalta en el árbol del plan de cuentas: la abierta o, si es nueva, su cuenta padre (que fija el grupo). */
|
||||
protected groupCode(): string {
|
||||
return text(this.header()['FatherAccountKey']) || text(this.header()['Code']);
|
||||
return this.key() ?? text(this.header()['FatherAccountKey']);
|
||||
}
|
||||
|
||||
protected value(key: string): unknown {
|
||||
|
||||
@@ -50,8 +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;
|
||||
/** Muestra el árbol del plan de cuentas al lado del formulario (como SAP); elegir una cuenta abre su formulario. */
|
||||
chartTree?: boolean;
|
||||
/** Campos que se ven pero no se editan (una extensión de cliente los dejó de solo lectura). */
|
||||
lockedKeys?: readonly string[];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user