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:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 09:06:12 -05:00
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[];
}