36 KiB
36 KiB
🌳 Guia de Implementação de Dados Hierárquicos
📋 Resumo
Documentação para implementação de telas com apresentação hierárquica (árvore) no framework PraFrota, utilizando registros com parent_id para criar estruturas de categorias, departamentos, planos de contas e outras hierarquias do ERP.
🎯 Casos de Uso no ERP
Módulos que Necessitam Hierarquia:
- 📦 Categoria de Produtos - Categoria > Subcategoria > Produto
- 💰 Plano de Contas - Receitas/Despesas > Grupos > Subgrupos > Contas
- 👥 Departamentos - Empresa > Diretoria > Gerência > Setor
- 📊 DRE (Demonstração de Resultado) - Receita Bruta > Deduções > Receita Líquida
- 💸 Fluxo de Caixa - Operacional > Investimento > Financiamento
- 🏢 Estrutura Organizacional - Holding > Empresas > Filiais > Setores
- 📋 Centro de Custos - Principal > Secundário > Auxiliar
🏗️ Arquitetura Proposta
1. Interface Base para Dados Hierárquicos
// shared/interfaces/hierarchical-entity.interface.ts
export interface HierarchicalEntity {
id: string | number;
name: string;
parent_id: string | number | null;
level?: number; // Nível na hierarquia (0 = raiz)
path?: string; // Caminho completo (ex: "1/2/3")
children?: HierarchicalEntity[];
expanded?: boolean; // Para controle de expansão na UI
order?: number; // Ordem de exibição
// Campos específicos do domínio
[key: string]: any;
}
// Exemplo específico para Categoria de Produtos
export interface ProductCategory extends HierarchicalEntity {
code?: string;
description?: string;
active: boolean;
created_at: string;
updated_at: string;
}
2. Service Base para Hierarquia
// shared/services/hierarchical-base.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { HierarchicalEntity } from '../interfaces/hierarchical-entity.interface';
@Injectable()
export abstract class HierarchicalBaseService<T extends HierarchicalEntity> {
/**
* Converte lista plana em estrutura hierárquica
*/
buildHierarchy(flatData: T[], rootParentId: any = null): T[] {
const itemMap = new Map<any, T>();
const rootItems: T[] = [];
// Primeiro, criar mapa de todos os itens
flatData.forEach(item => {
item.children = [];
itemMap.set(item.id, item);
});
// Depois, construir a hierarquia
flatData.forEach(item => {
if (item.parent_id === rootParentId || item.parent_id === null) {
rootItems.push(item);
} else {
const parent = itemMap.get(item.parent_id);
if (parent) {
parent.children = parent.children || [];
parent.children.push(item);
}
}
});
return this.sortHierarchy(rootItems);
}
/**
* Ordena hierarquia por ordem ou nome
*/
private sortHierarchy(items: T[]): T[] {
return items.sort((a, b) => {
const orderA = a.order || 0;
const orderB = b.order || 0;
if (orderA !== orderB) {
return orderA - orderB;
}
return a.name.localeCompare(b.name);
}).map(item => {
if (item.children && item.children.length > 0) {
item.children = this.sortHierarchy(item.children);
}
return item;
});
}
/**
* Converte hierarquia em lista plana
*/
flattenHierarchy(hierarchicalData: T[]): T[] {
const result: T[] = [];
const flatten = (items: T[], level: number = 0) => {
items.forEach(item => {
const flatItem = { ...item };
flatItem.level = level;
delete flatItem.children; // Remove children para evitar referência circular
result.push(flatItem);
if (item.children && item.children.length > 0) {
flatten(item.children, level + 1);
}
});
};
flatten(hierarchicalData);
return result;
}
/**
* Encontra todos os filhos de um item
*/
findAllChildren(items: T[], parentId: any): T[] {
const children: T[] = [];
const findChildren = (currentItems: T[]) => {
currentItems.forEach(item => {
if (item.parent_id === parentId) {
children.push(item);
if (item.children) {
findChildren(item.children);
}
} else if (item.children) {
findChildren(item.children);
}
});
};
findChildren(items);
return children;
}
/**
* Calcula o caminho completo do item
*/
buildPath(items: T[], targetId: any): string {
const path: string[] = [];
const findPath = (currentItems: T[], searchId: any): boolean => {
for (const item of currentItems) {
path.push(item.id.toString());
if (item.id === searchId) {
return true;
}
if (item.children && findPath(item.children, searchId)) {
return true;
}
path.pop();
}
return false;
};
findPath(items, targetId);
return path.join('/');
}
/**
* Valida se um item pode ser movido para um novo pai
*/
canMoveTo(items: T[], itemId: any, newParentId: any): boolean {
// Não pode mover para si mesmo
if (itemId === newParentId) {
return false;
}
// Não pode mover para um de seus filhos (evitar loop)
const children = this.findAllChildren(items, itemId);
return !children.some(child => child.id === newParentId);
}
}
3. Component Base para Hierarquia
// shared/components/hierarchical-base/hierarchical-base.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { HierarchicalEntity } from '../../interfaces/hierarchical-entity.interface';
import { HierarchicalBaseService } from '../../services/hierarchical-base.service';
@Component({
selector: 'app-hierarchical-base',
template: `
<div class="hierarchical-container">
<!-- Toolbar -->
<div class="hierarchy-toolbar">
<button class="btn btn-primary" (click)="onAdd(null)">
<i class="fas fa-plus"></i> Adicionar Raiz
</button>
<button class="btn btn-secondary" (click)="expandAll()">
<i class="fas fa-expand-arrows-alt"></i> Expandir Todos
</button>
<button class="btn btn-secondary" (click)="collapseAll()">
<i class="fas fa-compress-arrows-alt"></i> Recolher Todos
</button>
<div class="search-box">
<input
type="text"
[(ngModel)]="searchTerm"
(input)="onSearch()"
placeholder="Buscar..."
class="form-control">
</div>
</div>
<!-- Árvore Hierárquica -->
<div class="hierarchy-tree"
cdkDropList
[cdkDropListData]="hierarchicalData"
(cdkDropListDropped)="onDrop($event)">
<div *ngFor="let item of filteredData" class="tree-node-container">
<app-tree-node
[item]="item"
[level]="0"
[allowDrag]="allowDrag"
[allowEdit]="allowEdit"
[allowDelete]="allowDelete"
(nodeExpanded)="onNodeExpanded($event)"
(nodeSelected)="onNodeSelected($event)"
(nodeEdit)="onEdit($event)"
(nodeDelete)="onDelete($event)"
(nodeAdd)="onAdd($event)">
</app-tree-node>
</div>
</div>
<!-- Painel de Detalhes -->
<div class="details-panel" *ngIf="selectedItem">
<h3>{{ selectedItem.name }}</h3>
<div class="details-content">
<ng-content select="[slot=details]"></ng-content>
</div>
</div>
</div>
`,
styleUrls: ['./hierarchical-base.component.scss']
})
export class HierarchicalBaseComponent<T extends HierarchicalEntity> {
@Input() hierarchicalData: T[] = [];
@Input() allowDrag: boolean = true;
@Input() allowEdit: boolean = true;
@Input() allowDelete: boolean = true;
@Input() allowAdd: boolean = true;
@Output() itemSelected = new EventEmitter<T>();
@Output() itemAdded = new EventEmitter<T | null>(); // null = adicionar raiz
@Output() itemEdited = new EventEmitter<T>();
@Output() itemDeleted = new EventEmitter<T>();
@Output() itemMoved = new EventEmitter<{item: T, newParent: T | null}>();
selectedItem: T | null = null;
searchTerm: string = '';
filteredData: T[] = [];
constructor(protected hierarchicalService: HierarchicalBaseService<T>) {}
ngOnInit() {
this.filteredData = [...this.hierarchicalData];
}
ngOnChanges() {
this.filteredData = [...this.hierarchicalData];
}
onDrop(event: CdkDragDrop<T[]>) {
const movedItem = event.previousContainer.data[event.previousIndex];
const newParent = this.findDropTarget(event);
if (this.hierarchicalService.canMoveTo(this.hierarchicalData, movedItem.id, newParent?.id)) {
if (event.previousContainer === event.container) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
} else {
transferArrayItem(
event.previousContainer.data,
event.container.data,
event.previousIndex,
event.currentIndex
);
}
movedItem.parent_id = newParent?.id ?? null;
this.itemMoved.emit({ item: movedItem, newParent });
}
}
onNodeExpanded(item: T) {
item.expanded = !item.expanded;
}
onNodeSelected(item: T) {
this.selectedItem = item;
this.itemSelected.emit(item);
}
onAdd(parent: T | null) {
this.itemAdded.emit(parent);
}
onEdit(item: T) {
this.itemEdited.emit(item);
}
onDelete(item: T) {
this.itemDeleted.emit(item);
}
onSearch() {
if (!this.searchTerm.trim()) {
this.filteredData = [...this.hierarchicalData];
return;
}
const searchLower = this.searchTerm.toLowerCase();
this.filteredData = this.filterHierarchy(this.hierarchicalData, searchLower);
}
private filterHierarchy(items: T[], searchTerm: string): T[] {
return items.filter(item => {
const matchesSearch = item.name.toLowerCase().includes(searchTerm);
const hasMatchingChildren = item.children &&
this.filterHierarchy(item.children, searchTerm).length > 0;
if (matchesSearch || hasMatchingChildren) {
if (hasMatchingChildren) {
item.children = this.filterHierarchy(item.children!, searchTerm);
}
return true;
}
return false;
});
}
expandAll() {
this.setExpandedState(this.hierarchicalData, true);
}
collapseAll() {
this.setExpandedState(this.hierarchicalData, false);
}
private setExpandedState(items: T[], expanded: boolean) {
items.forEach(item => {
item.expanded = expanded;
if (item.children) {
this.setExpandedState(item.children, expanded);
}
});
}
private findDropTarget(event: CdkDragDrop<T[]>): T | null {
// Lógica para determinar o novo pai baseado na posição do drop
// Implementação específica dependendo do layout
return null;
}
}
🎨 Estilos CSS
// hierarchical-base.component.scss
.hierarchical-container {
display: flex;
flex-direction: column;
height: 100%;
.hierarchy-toolbar {
display: flex;
gap: 1rem;
padding: 1rem;
border-bottom: 1px solid var(--divider);
align-items: center;
.search-box {
margin-left: auto;
width: 300px;
}
}
.hierarchy-tree {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.details-panel {
width: 300px;
border-left: 1px solid var(--divider);
padding: 1rem;
}
}
// tree-node.component.scss
.tree-node {
display: flex;
align-items: center;
padding: 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: var(--surface-hover);
}
&.selected {
background-color: var(--primary-light);
color: var(--primary);
}
.expand-btn {
width: 20px;
height: 20px;
border: none;
background: transparent;
cursor: pointer;
margin-right: 0.5rem;
}
.item-icon {
margin-right: 0.5rem;
color: var(--text-secondary);
}
.item-name {
flex: 1;
font-weight: 500;
}
.item-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s;
.btn-action {
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 2px;
cursor: pointer;
&:hover {
background-color: var(--surface-hover);
}
&.btn-danger:hover {
background-color: var(--error);
color: white;
}
}
}
&:hover .item-actions {
opacity: 1;
}
}
.children-container {
border-left: 1px dashed var(--divider);
margin-left: 10px;
}
🚀 Implementação Específica por Domínio
Exemplo: Categoria de Produtos
// domain/product-categories/product-categories.component.ts
import { Component } from '@angular/core';
import { HierarchicalBaseComponent } from '../../shared/components/hierarchical-base/hierarchical-base.component';
import { ProductCategory } from './product-category.interface';
import { ProductCategoriesService } from './product-categories.service';
@Component({
selector: 'app-product-categories',
template: `
<app-hierarchical-base
[hierarchicalData]="categories"
[allowDrag]="true"
[allowEdit]="true"
[allowDelete]="true"
(itemSelected)="onCategorySelected($event)"
(itemAdded)="onCategoryAdd($event)"
(itemEdited)="onCategoryEdit($event)"
(itemDeleted)="onCategoryDelete($event)"
(itemMoved)="onCategoryMoved($event)">
<!-- Painel de Detalhes Customizado -->
<div slot="details" *ngIf="selectedCategory">
<div class="form-group">
<label>Código:</label>
<span>{{ selectedCategory.code }}</span>
</div>
<div class="form-group">
<label>Descrição:</label>
<p>{{ selectedCategory.description }}</p>
</div>
<div class="form-group">
<label>Status:</label>
<span class="badge" [class.badge-success]="selectedCategory.active">
{{ selectedCategory.active ? 'Ativo' : 'Inativo' }}
</span>
</div>
</div>
</app-hierarchical-base>
`
})
export class ProductCategoriesComponent extends HierarchicalBaseComponent<ProductCategory> {
categories: ProductCategory[] = [];
selectedCategory: ProductCategory | null = null;
constructor(
private categoriesService: ProductCategoriesService,
hierarchicalService: HierarchicalBaseService<ProductCategory>
) {
super(hierarchicalService);
}
ngOnInit() {
this.loadCategories();
}
loadCategories() {
this.categoriesService.getAll().subscribe(flatData => {
this.categories = this.hierarchicalService.buildHierarchy(flatData);
this.hierarchicalData = this.categories;
});
}
onCategorySelected(category: ProductCategory) {
this.selectedCategory = category;
}
onCategoryAdd(parent: ProductCategory | null) {
// Abrir modal de criação
const newCategory: Partial<ProductCategory> = {
name: '',
parent_id: parent?.id || null,
active: true
};
// Implementar modal...
}
onCategoryEdit(category: ProductCategory) {
// Abrir modal de edição
// Implementar modal...
}
onCategoryDelete(category: ProductCategory) {
this.categoriesService.delete(category.id).subscribe(() => {
this.loadCategories();
});
}
onCategoryMoved(event: {item: ProductCategory, newParent: ProductCategory | null}) {
this.categoriesService.updateParent(event.item.id, event.newParent?.id || null)
.subscribe(() => {
this.loadCategories();
});
}
}
📊 Casos de Uso Específicos
1. Plano de Contas
export interface ChartOfAccounts extends HierarchicalEntity {
code: string; // 1.1.01.001
account_type: 'ASSET' | 'LIABILITY' | 'EQUITY' | 'REVENUE' | 'EXPENSE';
balance_type: 'DEBIT' | 'CREDIT';
accepts_entries: boolean; // Se aceita lançamentos diretos
current_balance: number;
}
Implementação Completa: Plano de Contas
// domain/chart-of-accounts/chart-of-accounts.interface.ts
export interface ChartOfAccounts extends HierarchicalEntity {
code: string; // Código da conta (ex: 1.1.01.001)
account_type: AccountType; // Tipo da conta
balance_type: BalanceType; // Natureza do saldo
accepts_entries: boolean; // Se aceita lançamentos diretos
current_balance: number; // Saldo atual
description?: string; // Descrição detalhada
is_synthetic: boolean; // Se é conta sintética (apenas agrupamento)
is_active: boolean; // Se está ativa
created_at: string;
updated_at: string;
}
export enum AccountType {
ASSET = 'ASSET', // 1 - Ativo
LIABILITY = 'LIABILITY', // 2 - Passivo
EQUITY = 'EQUITY', // 3 - Patrimônio Líquido
REVENUE = 'REVENUE', // 4 - Receitas
EXPENSE = 'EXPENSE' // 5 - Despesas
}
export enum BalanceType {
DEBIT = 'DEBIT', // Natureza devedora
CREDIT = 'CREDIT' // Natureza credora
}
// domain/chart-of-accounts/chart-of-accounts.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { HierarchicalBaseService } from '../../shared/services/hierarchical-base.service';
import { ChartOfAccounts, AccountType } from './chart-of-accounts.interface';
@Injectable()
export class ChartOfAccountsService extends HierarchicalBaseService<ChartOfAccounts> {
private readonly apiUrl = 'https://prafrota-be-bff-tenant-api.grupopra.tech/api';
constructor(private http: HttpClient) {
super();
}
getAll(): Observable<ChartOfAccounts[]> {
return this.http.get<ChartOfAccounts[]>(`${this.apiUrl}/chart-of-accounts`);
}
getByType(accountType: AccountType): Observable<ChartOfAccounts[]> {
return this.http.get<ChartOfAccounts[]>(`${this.apiUrl}/chart-of-accounts`, {
params: { account_type: accountType }
});
}
create(account: Partial<ChartOfAccounts>): Observable<ChartOfAccounts> {
return this.http.post<ChartOfAccounts>(`${this.apiUrl}/chart-of-accounts`, account);
}
update(id: any, account: Partial<ChartOfAccounts>): Observable<ChartOfAccounts> {
return this.http.put<ChartOfAccounts>(`${this.apiUrl}/chart-of-accounts/${id}`, account);
}
delete(id: any): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/chart-of-accounts/${id}`);
}
updateParent(id: any, newParentId: any): Observable<ChartOfAccounts> {
return this.http.patch<ChartOfAccounts>(`${this.apiUrl}/chart-of-accounts/${id}`, {
parent_id: newParentId
});
}
/**
* Gera próximo código disponível baseado no pai
*/
generateNextCode(parentId: any): Observable<string> {
return this.http.get<{next_code: string}>(`${this.apiUrl}/chart-of-accounts/next-code`, {
params: { parent_id: parentId || '' }
}).pipe(map(response => response.next_code));
}
/**
* Valida se o código já existe
*/
validateCode(code: string, excludeId?: any): Observable<boolean> {
const params: any = { code };
if (excludeId) {
params.exclude_id = excludeId;
}
return this.http.get<{available: boolean}>(`${this.apiUrl}/chart-of-accounts/validate-code`, {
params
}).pipe(map(response => response.available));
}
/**
* Busca contas que aceitam lançamentos
*/
getAnalyticalAccounts(): Observable<ChartOfAccounts[]> {
return this.http.get<ChartOfAccounts[]>(`${this.apiUrl}/chart-of-accounts/analytical`);
}
}
// domain/chart-of-accounts/chart-of-accounts.component.ts
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HierarchicalBaseComponent } from '../../shared/components/hierarchical-base/hierarchical-base.component';
import { ChartOfAccounts, AccountType, BalanceType } from './chart-of-accounts.interface';
import { ChartOfAccountsService } from './chart-of-accounts.service';
import { HierarchicalBaseService } from '../../shared/services/hierarchical-base.service';
@Component({
selector: 'app-chart-of-accounts',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="chart-of-accounts-container">
<!-- Filtros por Tipo de Conta -->
<div class="account-type-filters">
<button
*ngFor="let type of accountTypes"
class="filter-btn"
[class.active]="selectedAccountType === type.value"
(click)="filterByAccountType(type.value)">
<i [class]="type.icon"></i>
{{ type.label }}
</button>
<button
class="filter-btn"
[class.active]="selectedAccountType === null"
(click)="filterByAccountType(null)">
<i class="fas fa-list"></i>
Todas
</button>
</div>
<!-- Componente Hierárquico -->
<app-hierarchical-base
[hierarchicalData]="filteredAccounts"
[allowDrag]="true"
[allowEdit]="true"
[allowDelete]="true"
(itemSelected)="onAccountSelected($event)"
(itemAdded)="onAccountAdd($event)"
(itemEdited)="onAccountEdit($event)"
(itemDeleted)="onAccountDelete($event)"
(itemMoved)="onAccountMoved($event)">
<!-- Painel de Detalhes Customizado -->
<div slot="details" *ngIf="selectedAccount">
<div class="account-details">
<h4>{{ selectedAccount.name }}</h4>
<div class="detail-row">
<label>Código:</label>
<span class="account-code">{{ selectedAccount.code }}</span>
</div>
<div class="detail-row">
<label>Tipo:</label>
<span class="badge" [class]="'badge-' + selectedAccount.account_type.toLowerCase()">
{{ getAccountTypeLabel(selectedAccount.account_type) }}
</span>
</div>
<div class="detail-row">
<label>Natureza:</label>
<span class="badge" [class]="'badge-' + selectedAccount.balance_type.toLowerCase()">
{{ selectedAccount.balance_type === 'DEBIT' ? 'Devedora' : 'Credora' }}
</span>
</div>
<div class="detail-row">
<label>Saldo Atual:</label>
<span class="balance" [class.negative]="selectedAccount.current_balance < 0">
{{ selectedAccount.current_balance | currency:'BRL':'symbol':'1.2-2' }}
</span>
</div>
<div class="detail-row">
<label>Aceita Lançamentos:</label>
<span class="badge" [class.badge-success]="selectedAccount.accepts_entries">
{{ selectedAccount.accepts_entries ? 'Sim' : 'Não' }}
</span>
</div>
<div class="detail-row" *ngIf="selectedAccount.description">
<label>Descrição:</label>
<p>{{ selectedAccount.description }}</p>
</div>
<div class="detail-row">
<label>Status:</label>
<span class="badge" [class.badge-success]="selectedAccount.is_active">
{{ selectedAccount.is_active ? 'Ativa' : 'Inativa' }}
</span>
</div>
<!-- Ações Rápidas -->
<div class="quick-actions">
<button class="btn btn-primary btn-sm" (click)="viewTransactions(selectedAccount)">
<i class="fas fa-list"></i> Ver Lançamentos
</button>
<button class="btn btn-secondary btn-sm" (click)="viewBalance(selectedAccount)">
<i class="fas fa-chart-line"></i> Balancete
</button>
</div>
</div>
</div>
</app-hierarchical-base>
</div>
`,
styleUrls: ['./chart-of-accounts.component.scss']
})
export class ChartOfAccountsComponent extends HierarchicalBaseComponent<ChartOfAccounts> implements OnInit {
accounts: ChartOfAccounts[] = [];
filteredAccounts: ChartOfAccounts[] = [];
selectedAccount: ChartOfAccounts | null = null;
selectedAccountType: AccountType | null = null;
accountTypes = [
{ value: AccountType.ASSET, label: 'Ativo', icon: 'fas fa-coins' },
{ value: AccountType.LIABILITY, label: 'Passivo', icon: 'fas fa-credit-card' },
{ value: AccountType.EQUITY, label: 'Patrimônio', icon: 'fas fa-building' },
{ value: AccountType.REVENUE, label: 'Receitas', icon: 'fas fa-arrow-up' },
{ value: AccountType.EXPENSE, label: 'Despesas', icon: 'fas fa-arrow-down' }
];
constructor(
private accountsService: ChartOfAccountsService,
hierarchicalService: HierarchicalBaseService<ChartOfAccounts>
) {
super(hierarchicalService);
}
ngOnInit() {
this.loadAccounts();
}
loadAccounts() {
this.accountsService.getAll().subscribe(flatData => {
this.accounts = this.hierarchicalService.buildHierarchy(flatData);
this.applyFilters();
});
}
filterByAccountType(accountType: AccountType | null) {
this.selectedAccountType = accountType;
this.applyFilters();
}
private applyFilters() {
if (this.selectedAccountType) {
this.filteredAccounts = this.filterAccountsByType(this.accounts, this.selectedAccountType);
} else {
this.filteredAccounts = [...this.accounts];
}
this.hierarchicalData = this.filteredAccounts;
}
private filterAccountsByType(accounts: ChartOfAccounts[], accountType: AccountType): ChartOfAccounts[] {
return accounts.filter(account => {
const matchesType = account.account_type === accountType;
const hasMatchingChildren = account.children &&
this.filterAccountsByType(account.children, accountType).length > 0;
if (matchesType || hasMatchingChildren) {
if (hasMatchingChildren) {
account.children = this.filterAccountsByType(account.children!, accountType);
}
return true;
}
return false;
});
}
onAccountSelected(account: ChartOfAccounts) {
this.selectedAccount = account;
}
onAccountAdd(parent: ChartOfAccounts | null) {
// Gerar próximo código
this.accountsService.generateNextCode(parent?.id || null).subscribe(nextCode => {
const newAccount: Partial<ChartOfAccounts> = {
name: '',
code: nextCode,
parent_id: parent?.id || null,
account_type: parent?.account_type || AccountType.ASSET,
balance_type: this.getDefaultBalanceType(parent?.account_type || AccountType.ASSET),
accepts_entries: true,
is_synthetic: false,
is_active: true,
current_balance: 0
};
// Abrir modal de criação com dados pré-preenchidos
this.openAccountModal(newAccount);
});
}
onAccountEdit(account: ChartOfAccounts) {
this.openAccountModal(account);
}
onAccountDelete(account: ChartOfAccounts) {
if (account.children && account.children.length > 0) {
alert('Não é possível excluir uma conta que possui subcontas.');
return;
}
if (account.current_balance !== 0) {
alert('Não é possível excluir uma conta com saldo.');
return;
}
this.accountsService.delete(account.id).subscribe(() => {
this.loadAccounts();
});
}
onAccountMoved(event: {item: ChartOfAccounts, newParent: ChartOfAccounts | null}) {
// Validar se o movimento é permitido
if (!this.canMoveAccount(event.item, event.newParent)) {
alert('Movimento não permitido. Verifique os tipos de conta.');
return;
}
this.accountsService.updateParent(event.item.id, event.newParent?.id || null)
.subscribe(() => {
this.loadAccounts();
});
}
private canMoveAccount(account: ChartOfAccounts, newParent: ChartOfAccounts | null): boolean {
// Não pode mover para um tipo de conta diferente
if (newParent && account.account_type !== newParent.account_type) {
return false;
}
// Outras validações específicas do plano de contas
return true;
}
getAccountTypeLabel(accountType: AccountType): string {
const type = this.accountTypes.find(t => t.value === accountType);
return type?.label || accountType;
}
private getDefaultBalanceType(accountType: AccountType): BalanceType {
switch (accountType) {
case AccountType.ASSET:
case AccountType.EXPENSE:
return BalanceType.DEBIT;
case AccountType.LIABILITY:
case AccountType.EQUITY:
case AccountType.REVENUE:
return BalanceType.CREDIT;
default:
return BalanceType.DEBIT;
}
}
private openAccountModal(account: Partial<ChartOfAccounts>) {
// Implementar modal de criação/edição
console.log('Abrir modal para:', account);
}
viewTransactions(account: ChartOfAccounts) {
// Navegar para tela de lançamentos da conta
console.log('Ver lançamentos da conta:', account.code);
}
viewBalance(account: ChartOfAccounts) {
// Abrir relatório de balancete da conta
console.log('Ver balancete da conta:', account.code);
}
}
Estilos Específicos para Plano de Contas
// chart-of-accounts.component.scss
.chart-of-accounts-container {
height: 100%;
display: flex;
flex-direction: column;
}
.account-type-filters {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-bottom: 1px solid var(--divider);
flex-wrap: wrap;
.filter-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--divider);
background: var(--surface);
color: var(--text-primary);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
&:hover {
background: var(--surface-hover);
}
&.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
i {
font-size: 0.875rem;
}
}
}
.account-details {
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--divider-light);
label {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.875rem;
}
.account-code {
font-family: 'Courier New', monospace;
font-weight: bold;
color: var(--primary);
}
.balance {
font-weight: bold;
color: var(--success);
&.negative {
color: var(--error);
}
}
}
.quick-actions {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
}
// Badges específicos para tipos de conta
.badge-asset { background: #3b82f6; color: white; }
.badge-liability { background: #ef4444; color: white; }
.badge-equity { background: #8b5cf6; color: white; }
.badge-revenue { background: #10b981; color: white; }
.badge-expense { background: #f59e0b; color: white; }
.badge-debit { background: #6b7280; color: white; }
.badge-credit { background: #374151; color: white; }
Estrutura Típica de Plano de Contas
// Exemplo de estrutura hierárquica padrão
const sampleChartOfAccounts: ChartOfAccounts[] = [
{
id: '1',
name: 'ATIVO',
code: '1',
parent_id: null,
account_type: AccountType.ASSET,
balance_type: BalanceType.DEBIT,
accepts_entries: false,
is_synthetic: true,
children: [
{
id: '1.1',
name: 'ATIVO CIRCULANTE',
code: '1.1',
parent_id: '1',
account_type: AccountType.ASSET,
balance_type: BalanceType.DEBIT,
accepts_entries: false,
is_synthetic: true,
children: [
{
id: '1.1.01',
name: 'DISPONÍVEL',
code: '1.1.01',
parent_id: '1.1',
account_type: AccountType.ASSET,
balance_type: BalanceType.DEBIT,
accepts_entries: false,
is_synthetic: true,
children: [
{
id: '1.1.01.001',
name: 'Caixa',
code: '1.1.01.001',
parent_id: '1.1.01',
account_type: AccountType.ASSET,
balance_type: BalanceType.DEBIT,
accepts_entries: true,
is_synthetic: false,
current_balance: 5000.00
},
{
id: '1.1.01.002',
name: 'Banco Conta Movimento',
code: '1.1.01.002',
parent_id: '1.1.01',
account_type: AccountType.ASSET,
balance_type: BalanceType.DEBIT,
accepts_entries: true,
is_synthetic: false,
current_balance: 25000.00
}
]
}
]
}
]
},
{
id: '2',
name: 'PASSIVO',
code: '2',
parent_id: null,
account_type: AccountType.LIABILITY,
balance_type: BalanceType.CREDIT,
accepts_entries: false,
is_synthetic: true,
children: [
// ... estrutura do passivo
]
}
// ... outras contas principais
];
2. Estrutura Organizacional
export interface Department extends HierarchicalEntity {
code: string;
manager_id?: string;
cost_center: string;
budget: number;
employee_count: number;
department_type: 'OPERATIONAL' | 'ADMINISTRATIVE' | 'STRATEGIC';
}
3. Categoria de Produtos
export interface ProductCategory extends HierarchicalEntity {
code: string;
description?: string;
active: boolean;
tax_rate?: number;
commission_rate?: number;
product_count: number;
}
🎯 Funcionalidades Avançadas
1. Drag & Drop com Validação
// Validações específicas por domínio
canMoveTo(item: T, newParent: T | null): boolean {
// Regras específicas do negócio
if (item.account_type === 'REVENUE' && newParent?.account_type === 'EXPENSE') {
return false; // Não pode mover receita para despesa
}
return super.canMoveTo(this.hierarchicalData, item.id, newParent?.id);
}
2. Busca Inteligente
// Busca por código, nome ou caminho completo
searchItems(term: string): T[] {
return this.flattenHierarchy(this.hierarchicalData).filter(item =>
item.name.toLowerCase().includes(term.toLowerCase()) ||
item.code?.toLowerCase().includes(term.toLowerCase()) ||
this.buildPath(this.hierarchicalData, item.id).includes(term)
);
}
🚀 Próximos Passos
Fase 1: Implementação Base
- ✅ Criar interfaces e services base
- ✅ Implementar componentes de árvore
- ✅ Adicionar drag & drop básico
- ✅ Integrar com sistema de formulários
Fase 2: Funcionalidades Avançadas
- 🔄 Busca e filtros avançados
- 🔄 Validações de negócio
- 🔄 Importação/exportação
- 🔄 Histórico de mudanças
Fase 3: Otimizações
- ⏳ Lazy loading para hierarquias grandes
- ⏳ Virtualização de lista
- ⏳ Cache inteligente
- ⏳ Performance para milhares de itens
🔗 Links Relacionados
- Base Domain Component: base-domain.component.ts
- Tab System: tab-system/README.md
- API Integration: API_INTEGRATION_GUIDE.md
💡 Esta documentação serve como base para implementação de todas as telas hierárquicas do ERP PraFrota, mantendo consistência e reutilização de código. 🌳