ET-FIN-FRONTEND: Componentes React
Identificacion
| Campo |
Valor |
| ID |
ET-FIN-FRONTEND |
| Modulo |
MGN-010 Financial |
| Version |
1.0 |
| Estado |
En Diseno |
| Framework |
React + TypeScript |
| UI Library |
shadcn/ui |
| Tree Component |
react-arborist |
| Decimal |
Decimal.js |
| State |
Zustand |
| Autor |
Requirements-Analyst |
| Fecha |
2025-12-05 |
Descripcion General
Especificacion tecnica del modulo frontend de Financial. Incluye gestion del plan de cuentas con arbol interactivo, formulario de asientos contables con partida doble, gestion de periodos fiscales, y monedas con tipos de cambio.
Estructura de Archivos
apps/frontend/src/modules/financial/
├── index.ts
├── routes.tsx
├── pages/
│ ├── ChartOfAccountsPage.tsx
│ ├── AccountDetailPage.tsx
│ ├── JournalEntriesPage.tsx
│ ├── JournalEntryFormPage.tsx
│ ├── JournalEntryDetailPage.tsx
│ ├── FiscalYearsPage.tsx
│ ├── FiscalPeriodsPage.tsx
│ ├── CurrenciesPage.tsx
│ └── ExchangeRatesPage.tsx
├── components/
│ ├── accounts/
│ │ ├── AccountTree.tsx
│ │ ├── AccountTreeNode.tsx
│ │ ├── AccountForm.tsx
│ │ ├── AccountCard.tsx
│ │ ├── AccountSelect.tsx
│ │ ├── AccountSearch.tsx
│ │ ├── AccountBalance.tsx
│ │ ├── AccountMovements.tsx
│ │ └── AccountTemplateImport.tsx
│ ├── journal/
│ │ ├── JournalList.tsx
│ │ ├── JournalFilters.tsx
│ │ ├── JournalEntryForm.tsx
│ │ ├── JournalLineRow.tsx
│ │ ├── JournalLineTotals.tsx
│ │ ├── JournalEntryCard.tsx
│ │ ├── JournalEntryDetail.tsx
│ │ ├── JournalPostButton.tsx
│ │ ├── JournalReverseDialog.tsx
│ │ └── JournalPrint.tsx
│ ├── periods/
│ │ ├── FiscalYearList.tsx
│ │ ├── FiscalYearCard.tsx
│ │ ├── FiscalYearForm.tsx
│ │ ├── PeriodList.tsx
│ │ ├── PeriodCard.tsx
│ │ ├── PeriodCloseDialog.tsx
│ │ ├── PeriodReopenDialog.tsx
│ │ └── YearCloseWizard.tsx
│ ├── currencies/
│ │ ├── CurrencyList.tsx
│ │ ├── CurrencyCard.tsx
│ │ ├── CurrencyActivate.tsx
│ │ ├── ExchangeRateList.tsx
│ │ ├── ExchangeRateForm.tsx
│ │ ├── ExchangeRateChart.tsx
│ │ ├── CurrencyConverter.tsx
│ │ └── AutoSyncSettings.tsx
│ └── shared/
│ ├── MoneyInput.tsx
│ ├── AccountCodeInput.tsx
│ ├── PeriodSelect.tsx
│ ├── CurrencySelect.tsx
│ ├── DebitCreditInput.tsx
│ ├── BalanceDisplay.tsx
│ └── AccountingNumber.tsx
├── stores/
│ ├── accounts.store.ts
│ ├── journal.store.ts
│ ├── fiscal-periods.store.ts
│ └── currencies.store.ts
├── hooks/
│ ├── useAccounts.ts
│ ├── useAccountTree.ts
│ ├── useJournalEntry.ts
│ ├── useJournalValidation.ts
│ ├── useFiscalPeriods.ts
│ ├── useCurrencies.ts
│ └── useCurrencyConversion.ts
├── services/
│ ├── accounts.service.ts
│ ├── journal.service.ts
│ ├── fiscal-periods.service.ts
│ └── currencies.service.ts
├── lib/
│ ├── accounting-utils.ts
│ ├── account-code-utils.ts
│ ├── decimal-utils.ts
│ └── journal-validation.ts
└── types/
├── account.types.ts
├── journal.types.ts
├── fiscal-period.types.ts
└── currency.types.ts
Types
Account Types
// types/account.types.ts
import Decimal from 'decimal.js';
export type AccountType = 'asset' | 'liability' | 'equity' | 'income' | 'expense';
export type AccountNature = 'debit' | 'credit';
export interface Account {
id: string;
tenantId: string;
code: string;
name: string;
fullName: string; // With parent names
path: string; // LTREE path
// Classification
accountType: AccountType;
nature: AccountNature;
level: number;
isGroup: boolean; // Has children, can't post to
// Hierarchy
parentId?: string;
parent?: Account;
children?: Account[];
childCount: number;
// Balance
debitBalance: string; // Decimal as string
creditBalance: string;
balance: string;
balanceNature: AccountNature;
// Settings
currencyId?: string;
currency?: Currency;
allowMultiCurrency: boolean;
requireCostCenter: boolean;
requireProject: boolean;
// Status
isActive: boolean;
isReconcilable: boolean;
isProtected: boolean;
createdAt: string;
updatedAt: string;
}
export interface AccountTreeNode extends Account {
children: AccountTreeNode[];
isExpanded?: boolean;
isSelected?: boolean;
matchesFilter?: boolean;
}
export interface CreateAccountDto {
code: string;
name: string;
accountType: AccountType;
parentId?: string;
currencyId?: string;
allowMultiCurrency?: boolean;
requireCostCenter?: boolean;
isReconcilable?: boolean;
}
export interface UpdateAccountDto {
name?: string;
currencyId?: string;
allowMultiCurrency?: boolean;
requireCostCenter?: boolean;
isReconcilable?: boolean;
isActive?: boolean;
}
export interface AccountFilters {
search?: string;
accountType?: AccountType;
level?: number;
isGroup?: boolean;
isActive?: boolean;
parentId?: string;
}
export interface AccountBalance {
accountId: string;
periodId: string;
openingDebit: string;
openingCredit: string;
periodDebit: string;
periodCredit: string;
closingDebit: string;
closingCredit: string;
balance: string;
}
export interface AccountMovement {
id: string;
journalEntryId: string;
entryNumber: string;
entryDate: string;
description: string;
debit: string;
credit: string;
balance: string;
postedAt: string;
}
Journal Entry Types
// types/journal.types.ts
export type JournalEntryStatus = 'draft' | 'posted' | 'reversed';
export interface JournalEntry {
id: string;
tenantId: string;
entryNumber?: string;
status: JournalEntryStatus;
// Header
entryDate: string;
description: string;
reference?: string;
sourceType?: string; // 'manual', 'invoice', 'payment', etc
sourceId?: string;
// Lines
lines: JournalLine[];
// Totals
totalDebit: string;
totalCredit: string;
isBalanced: boolean;
// Period
periodId: string;
period?: FiscalPeriod;
// Reversal
reversedById?: string;
reversalOf?: string;
reversalReason?: string;
// Audit
createdBy: string;
createdAt: string;
postedBy?: string;
postedAt?: string;
}
export interface JournalLine {
id: string;
journalEntryId: string;
lineNumber: number;
// Account
accountId: string;
account?: Account;
// Amounts (original currency)
debit: string;
credit: string;
currencyId: string;
currency?: Currency;
exchangeRate: string;
// Amounts (base currency)
debitBase: string;
creditBase: string;
// Details
description?: string;
costCenterId?: string;
costCenter?: CostCenter;
projectId?: string;
// Reconciliation
isReconciled: boolean;
reconciliationId?: string;
}
export interface CreateJournalEntryDto {
entryDate: string;
description: string;
reference?: string;
lines: CreateJournalLineDto[];
}
export interface CreateJournalLineDto {
accountId: string;
debit?: string;
credit?: string;
currencyId?: string;
exchangeRate?: string;
description?: string;
costCenterId?: string;
}
export interface JournalEntryFilters {
periodId?: string;
status?: JournalEntryStatus;
search?: string;
fromDate?: string;
toDate?: string;
accountId?: string;
minAmount?: string;
maxAmount?: string;
page?: number;
limit?: number;
}
export interface PostJournalEntryResult {
entryNumber: string;
postedAt: string;
affectedAccounts: string[];
}
export interface ReverseJournalEntryDto {
reversalDate: string;
reason: string;
}
Fiscal Period Types
// types/fiscal-period.types.ts
export type FiscalYearStatus = 'future' | 'open' | 'closed';
export type FiscalPeriodStatus = 'future' | 'open' | 'closed';
export interface FiscalYear {
id: string;
tenantId: string;
name: string;
startDate: string;
endDate: string;
status: FiscalYearStatus;
// Settings
periodType: 'monthly' | 'quarterly';
hasAdjustmentPeriod: boolean;
// Periods
periods: FiscalPeriod[];
openPeriodsCount: number;
closedPeriodsCount: number;
createdAt: string;
updatedAt: string;
}
export interface FiscalPeriod {
id: string;
fiscalYearId: string;
fiscalYear?: FiscalYear;
name: string;
periodNumber: number;
startDate: string;
endDate: string;
status: FiscalPeriodStatus;
isAdjustment: boolean;
// Stats
entryCount: number;
totalDebit: string;
totalCredit: string;
// Closing
closedBy?: string;
closedAt?: string;
reopenedBy?: string;
reopenedAt?: string;
reopenReason?: string;
}
export interface CreateFiscalYearDto {
name: string;
startDate: string;
endDate: string;
periodType: 'monthly' | 'quarterly';
hasAdjustmentPeriod?: boolean;
}
export interface ClosePeriodResult {
periodId: string;
closedAt: string;
warnings: string[];
closingBalances: AccountBalance[];
}
export interface CloseYearResult {
fiscalYearId: string;
closedAt: string;
closingEntryId: string;
openingEntryId: string;
netIncome: string;
retainedEarningsAccountId: string;
}
Currency Types
// types/currency.types.ts
export interface Currency {
id: string;
code: string; // ISO 4217
name: string;
symbol: string;
decimalPlaces: number;
symbolPosition: 'before' | 'after';
thousandsSeparator: string;
decimalSeparator: string;
isActive: boolean;
}
export interface TenantCurrency {
id: string;
tenantId: string;
currencyId: string;
currency: Currency;
isBase: boolean;
isActive: boolean;
currentRate?: ExchangeRate;
}
export interface ExchangeRate {
id: string;
tenantId: string;
currencyId: string;
currency?: Currency;
rate: string; // Decimal as string
inverseRate: string;
effectiveDate: string;
source: 'manual' | 'banxico' | 'bce' | 'openexchange';
createdAt: string;
}
export interface CreateExchangeRateDto {
currencyId: string;
rate: string;
effectiveDate: string;
}
export interface CurrencyConversionRequest {
amount: string;
fromCurrencyId: string;
toCurrencyId: string;
date?: string;
}
export interface CurrencyConversionResult {
originalAmount: string;
convertedAmount: string;
rate: string;
rateDate: string;
isApproximate: boolean;
}
export interface AutoSyncSettings {
enabled: boolean;
provider: 'banxico' | 'bce' | 'openexchange';
currencies: string[];
schedule: string; // cron expression
lastSyncAt?: string;
lastSyncStatus?: 'success' | 'failed';
}
Stores (Zustand)
Accounts Store
// stores/accounts.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { Account, AccountTreeNode, AccountFilters, CreateAccountDto, UpdateAccountDto } from '../types/account.types';
import { accountsService } from '../services/accounts.service';
import { buildAccountTree, filterTree, expandPath } from '../lib/account-tree-utils';
interface AccountsState {
// Data
accounts: Account[];
tree: AccountTreeNode[];
selectedAccount: Account | null;
// UI
isLoading: boolean;
error: string | null;
filters: AccountFilters;
expandedIds: Set<string>;
// Actions
fetchAccounts: () => Promise<void>;
fetchAccount: (id: string) => Promise<void>;
createAccount: (dto: CreateAccountDto) => Promise<Account>;
updateAccount: (id: string, dto: UpdateAccountDto) => Promise<void>;
deleteAccount: (id: string) => Promise<void>;
// Tree
toggleExpand: (id: string) => void;
expandAll: () => void;
collapseAll: () => void;
expandToAccount: (id: string) => void;
// Filters
setFilters: (filters: AccountFilters) => void;
clearFilters: () => void;
// Selection
selectAccount: (account: Account | null) => void;
}
export const useAccountsStore = create<AccountsState>()(
devtools(
(set, get) => ({
accounts: [],
tree: [],
selectedAccount: null,
isLoading: false,
error: null,
filters: {},
expandedIds: new Set(),
fetchAccounts: async () => {
set({ isLoading: true, error: null });
try {
const accounts = await accountsService.getAll(get().filters);
const tree = buildAccountTree(accounts);
set({ accounts, tree, isLoading: false });
} catch (error: any) {
set({ error: error.message, isLoading: false });
}
},
fetchAccount: async (id: string) => {
set({ isLoading: true, error: null });
try {
const account = await accountsService.getById(id);
set({ selectedAccount: account, isLoading: false });
} catch (error: any) {
set({ error: error.message, isLoading: false });
}
},
createAccount: async (dto: CreateAccountDto) => {
set({ isLoading: true, error: null });
try {
const account = await accountsService.create(dto);
await get().fetchAccounts();
return account;
} catch (error: any) {
set({ error: error.message, isLoading: false });
throw error;
}
},
updateAccount: async (id: string, dto: UpdateAccountDto) => {
try {
await accountsService.update(id, dto);
await get().fetchAccounts();
if (get().selectedAccount?.id === id) {
await get().fetchAccount(id);
}
} catch (error: any) {
set({ error: error.message });
throw error;
}
},
deleteAccount: async (id: string) => {
try {
await accountsService.delete(id);
set((state) => ({
selectedAccount: state.selectedAccount?.id === id ? null : state.selectedAccount,
}));
await get().fetchAccounts();
} catch (error: any) {
set({ error: error.message });
throw error;
}
},
toggleExpand: (id: string) => {
set((state) => {
const newExpanded = new Set(state.expandedIds);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
return { expandedIds: newExpanded };
});
},
expandAll: () => {
const allIds = new Set(get().accounts.filter((a) => a.childCount > 0).map((a) => a.id));
set({ expandedIds: allIds });
},
collapseAll: () => {
set({ expandedIds: new Set() });
},
expandToAccount: (id: string) => {
const account = get().accounts.find((a) => a.id === id);
if (!account) return;
const pathIds = expandPath(get().accounts, account.path);
set((state) => ({
expandedIds: new Set([...state.expandedIds, ...pathIds]),
}));
},
setFilters: (filters: AccountFilters) => {
set({ filters: { ...get().filters, ...filters } });
get().fetchAccounts();
},
clearFilters: () => {
set({ filters: {} });
get().fetchAccounts();
},
selectAccount: (account: Account | null) => {
set({ selectedAccount: account });
if (account) {
get().expandToAccount(account.id);
}
},
}),
{ name: 'accounts-store' }
)
);
Journal Store
// stores/journal.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import Decimal from 'decimal.js';
import {
JournalEntry,
JournalLine,
JournalEntryFilters,
CreateJournalEntryDto,
CreateJournalLineDto,
PostJournalEntryResult,
ReverseJournalEntryDto,
} from '../types/journal.types';
import { journalService } from '../services/journal.service';
interface JournalState {
// List
entries: JournalEntry[];
total: number;
page: number;
isLoading: boolean;
error: string | null;
filters: JournalEntryFilters;
// Current entry (form)
currentEntry: Partial<JournalEntry>;
currentLines: CreateJournalLineDto[];
totals: { debit: string; credit: string; difference: string; isBalanced: boolean };
// Actions - List
fetchEntries: () => Promise<void>;
setFilters: (filters: Partial<JournalEntryFilters>) => void;
// Actions - Entry
initNewEntry: () => void;
setEntryField: (field: string, value: any) => void;
addLine: (line: CreateJournalLineDto) => void;
updateLine: (index: number, line: Partial<CreateJournalLineDto>) => void;
removeLine: (index: number) => void;
reorderLines: (fromIndex: number, toIndex: number) => void;
calculateTotals: () => void;
// Actions - CRUD
saveEntry: () => Promise<JournalEntry>;
postEntry: (id: string) => Promise<PostJournalEntryResult>;
reverseEntry: (id: string, dto: ReverseJournalEntryDto) => Promise<JournalEntry>;
deleteEntry: (id: string) => Promise<void>;
// Reset
resetForm: () => void;
}
const emptyTotals = { debit: '0', credit: '0', difference: '0', isBalanced: true };
export const useJournalStore = create<JournalState>()(
devtools(
(set, get) => ({
entries: [],
total: 0,
page: 1,
isLoading: false,
error: null,
filters: { limit: 20 },
currentEntry: {},
currentLines: [],
totals: emptyTotals,
fetchEntries: async () => {
set({ isLoading: true, error: null });
try {
const result = await journalService.getAll(get().filters);
set({
entries: result.data,
total: result.meta.total,
page: result.meta.page,
isLoading: false,
});
} catch (error: any) {
set({ error: error.message, isLoading: false });
}
},
setFilters: (filters) => {
set((state) => ({ filters: { ...state.filters, ...filters, page: 1 } }));
get().fetchEntries();
},
initNewEntry: () => {
set({
currentEntry: {
entryDate: new Date().toISOString().split('T')[0],
status: 'draft',
},
currentLines: [
{ accountId: '', debit: '', credit: '' },
{ accountId: '', debit: '', credit: '' },
],
totals: emptyTotals,
});
},
setEntryField: (field, value) => {
set((state) => ({
currentEntry: { ...state.currentEntry, [field]: value },
}));
},
addLine: (line) => {
set((state) => ({
currentLines: [...state.currentLines, line],
}));
get().calculateTotals();
},
updateLine: (index, updates) => {
set((state) => ({
currentLines: state.currentLines.map((line, i) =>
i === index ? { ...line, ...updates } : line
),
}));
get().calculateTotals();
},
removeLine: (index) => {
set((state) => ({
currentLines: state.currentLines.filter((_, i) => i !== index),
}));
get().calculateTotals();
},
reorderLines: (fromIndex, toIndex) => {
set((state) => {
const lines = [...state.currentLines];
const [removed] = lines.splice(fromIndex, 1);
lines.splice(toIndex, 0, removed);
return { currentLines: lines };
});
},
calculateTotals: () => {
const lines = get().currentLines;
let totalDebit = new Decimal(0);
let totalCredit = new Decimal(0);
lines.forEach((line) => {
if (line.debit) totalDebit = totalDebit.plus(line.debit);
if (line.credit) totalCredit = totalCredit.plus(line.credit);
});
const difference = totalDebit.minus(totalCredit).abs();
const isBalanced = difference.lessThanOrEqualTo('0.01');
set({
totals: {
debit: totalDebit.toFixed(2),
credit: totalCredit.toFixed(2),
difference: difference.toFixed(2),
isBalanced,
},
});
},
saveEntry: async () => {
const { currentEntry, currentLines } = get();
set({ isLoading: true, error: null });
try {
const dto: CreateJournalEntryDto = {
entryDate: currentEntry.entryDate!,
description: currentEntry.description!,
reference: currentEntry.reference,
lines: currentLines.filter((l) => l.accountId && (l.debit || l.credit)),
};
const entry = await journalService.create(dto);
await get().fetchEntries();
set({ isLoading: false });
return entry;
} catch (error: any) {
set({ error: error.message, isLoading: false });
throw error;
}
},
postEntry: async (id: string) => {
set({ isLoading: true, error: null });
try {
const result = await journalService.post(id);
await get().fetchEntries();
set({ isLoading: false });
return result;
} catch (error: any) {
set({ error: error.message, isLoading: false });
throw error;
}
},
reverseEntry: async (id: string, dto: ReverseJournalEntryDto) => {
set({ isLoading: true, error: null });
try {
const entry = await journalService.reverse(id, dto);
await get().fetchEntries();
set({ isLoading: false });
return entry;
} catch (error: any) {
set({ error: error.message, isLoading: false });
throw error;
}
},
deleteEntry: async (id: string) => {
try {
await journalService.delete(id);
await get().fetchEntries();
} catch (error: any) {
set({ error: error.message });
throw error;
}
},
resetForm: () => {
set({
currentEntry: {},
currentLines: [],
totals: emptyTotals,
});
},
}),
{ name: 'journal-store' }
)
);
Custom Hooks
useJournalValidation
// hooks/useJournalValidation.ts
import { useMemo } from 'react';
import Decimal from 'decimal.js';
import { CreateJournalLineDto } from '../types/journal.types';
import { useFiscalPeriodsStore } from '../stores/fiscal-periods.store';
interface ValidationError {
type: 'error' | 'warning';
field?: string;
lineIndex?: number;
message: string;
}
interface ValidationResult {
isValid: boolean;
canPost: boolean;
errors: ValidationError[];
warnings: ValidationError[];
}
export function useJournalValidation(
entryDate: string | undefined,
description: string | undefined,
lines: CreateJournalLineDto[]
): ValidationResult {
const { getOpenPeriod, isPeriodOpen } = useFiscalPeriodsStore();
return useMemo(() => {
const errors: ValidationError[] = [];
const warnings: ValidationError[] = [];
// Required fields
if (!entryDate) {
errors.push({ type: 'error', field: 'entryDate', message: 'Fecha requerida' });
}
if (!description?.trim()) {
errors.push({ type: 'error', field: 'description', message: 'Descripcion requerida' });
}
// Period validation
if (entryDate) {
const period = getOpenPeriod(entryDate);
if (!period) {
errors.push({
type: 'error',
field: 'entryDate',
message: 'No hay periodo abierto para esta fecha',
});
}
}
// Lines validation
const validLines = lines.filter((l) => l.accountId && (l.debit || l.credit));
if (validLines.length < 2) {
errors.push({
type: 'error',
message: 'Se requieren al menos 2 lineas con monto',
});
}
// Check each line
lines.forEach((line, index) => {
if (line.accountId && !line.debit && !line.credit) {
errors.push({
type: 'error',
lineIndex: index,
message: `Linea ${index + 1}: Ingrese monto al debe o haber`,
});
}
if (line.debit && line.credit) {
errors.push({
type: 'error',
lineIndex: index,
message: `Linea ${index + 1}: No puede tener monto en debe y haber`,
});
}
// Validate amounts are positive
if (line.debit && new Decimal(line.debit).isNegative()) {
errors.push({
type: 'error',
lineIndex: index,
message: `Linea ${index + 1}: Monto al debe debe ser positivo`,
});
}
if (line.credit && new Decimal(line.credit).isNegative()) {
errors.push({
type: 'error',
lineIndex: index,
message: `Linea ${index + 1}: Monto al haber debe ser positivo`,
});
}
});
// Balance check
let totalDebit = new Decimal(0);
let totalCredit = new Decimal(0);
validLines.forEach((line) => {
if (line.debit) totalDebit = totalDebit.plus(line.debit);
if (line.credit) totalCredit = totalCredit.plus(line.credit);
});
const difference = totalDebit.minus(totalCredit).abs();
if (difference.greaterThan('0.01')) {
errors.push({
type: 'error',
message: `Asiento descuadrado: Debe=${totalDebit.toFixed(2)}, Haber=${totalCredit.toFixed(2)}, Diferencia=${difference.toFixed(2)}`,
});
}
// Warnings
if (totalDebit.isZero() && totalCredit.isZero()) {
warnings.push({
type: 'warning',
message: 'El asiento no tiene montos',
});
}
return {
isValid: errors.length === 0,
canPost: errors.length === 0 && validLines.length >= 2,
errors,
warnings,
};
}, [entryDate, description, lines, getOpenPeriod]);
}
useCurrencyConversion
// hooks/useCurrencyConversion.ts
import { useState, useCallback, useMemo } from 'react';
import Decimal from 'decimal.js';
import { useCurrenciesStore } from '../stores/currencies.store';
import { CurrencyConversionResult } from '../types/currency.types';
interface UseCurrencyConversionOptions {
fromCurrencyId?: string;
toCurrencyId?: string;
date?: string;
}
export function useCurrencyConversion(options: UseCurrencyConversionOptions = {}) {
const { baseCurrency, getRate, convert } = useCurrenciesStore();
const [isConverting, setIsConverting] = useState(false);
const fromCurrency = options.fromCurrencyId || baseCurrency?.id;
const toCurrency = options.toCurrencyId || baseCurrency?.id;
const date = options.date || new Date().toISOString().split('T')[0];
const currentRate = useMemo(() => {
if (!fromCurrency || !toCurrency) return null;
if (fromCurrency === toCurrency) return new Decimal(1);
return getRate(fromCurrency, date);
}, [fromCurrency, toCurrency, date, getRate]);
const convertAmount = useCallback(async (
amount: string | number
): Promise<CurrencyConversionResult> => {
if (!fromCurrency || !toCurrency) {
throw new Error('Currencies not specified');
}
setIsConverting(true);
try {
const result = await convert({
amount: amount.toString(),
fromCurrencyId: fromCurrency,
toCurrencyId: toCurrency,
date,
});
return result;
} finally {
setIsConverting(false);
}
}, [fromCurrency, toCurrency, date, convert]);
const quickConvert = useCallback((amount: string | number): string => {
if (!currentRate) return '0';
const amountDecimal = new Decimal(amount);
return amountDecimal.times(currentRate).toFixed(2);
}, [currentRate]);
return {
currentRate: currentRate?.toString() ?? '1',
isConverting,
convertAmount,
quickConvert,
hasRateForDate: currentRate !== null,
};
}
Components
AccountTree
// components/accounts/AccountTree.tsx
import { useEffect, useMemo } from 'react';
import { Tree, NodeApi } from 'react-arborist';
import { ChevronRight, ChevronDown, Folder, FileText, DollarSign } from 'lucide-react';
import { useAccountsStore } from '../../stores/accounts.store';
import { AccountTreeNode, AccountType } from '../../types/account.types';
import { cn, formatCurrency } from '@/lib/utils';
interface AccountTreeProps {
onSelect?: (account: AccountTreeNode) => void;
selectable?: boolean;
showBalances?: boolean;
height?: number;
}
export function AccountTree({
onSelect,
selectable = true,
showBalances = true,
height = 600,
}: AccountTreeProps) {
const {
tree,
expandedIds,
selectedAccount,
isLoading,
fetchAccounts,
toggleExpand,
selectAccount,
} = useAccountsStore();
useEffect(() => {
fetchAccounts();
}, [fetchAccounts]);
const accountTypeColors: Record<AccountType, string> = {
asset: 'text-blue-600',
liability: 'text-red-600',
equity: 'text-purple-600',
income: 'text-green-600',
expense: 'text-orange-600',
};
return (
<div className="border rounded-md">
<Tree<AccountTreeNode>
data={tree}
openByDefault={false}
width="100%"
height={height}
indent={24}
rowHeight={36}
paddingBottom={10}
idAccessor="id"
childrenAccessor="children"
disableMultiSelection
disableEdit
disableDrag
selection={selectedAccount?.id}
onSelect={(nodes) => {
const node = nodes[0];
if (node && onSelect) {
onSelect(node.data);
}
selectAccount(node?.data ?? null);
}}
>
{({ node, style, dragHandle }) => (
<div
style={style}
ref={dragHandle}
onClick={() => {
if (node.data.isGroup) {
toggleExpand(node.data.id);
}
}}
className={cn(
'flex items-center gap-2 px-2 py-1 cursor-pointer rounded',
'hover:bg-muted/50 transition-colors',
node.isSelected && 'bg-primary/10',
!node.data.isActive && 'opacity-50'
)}
>
{/* Expand/Collapse */}
<span className="w-4 flex-shrink-0">
{node.data.isGroup && (
expandedIds.has(node.data.id) ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)
)}
</span>
{/* Icon */}
{node.data.isGroup ? (
<Folder className={cn('h-4 w-4', accountTypeColors[node.data.accountType])} />
) : (
<FileText className={cn('h-4 w-4', accountTypeColors[node.data.accountType])} />
)}
{/* Code & Name */}
<div className="flex-1 flex items-center gap-2 min-w-0">
<span className="font-mono text-sm text-muted-foreground">
{node.data.code}
</span>
<span className="truncate text-sm">
{node.data.name}
</span>
</div>
{/* Balance */}
{showBalances && !node.data.isGroup && (
<span
className={cn(
'text-sm font-medium tabular-nums',
node.data.balanceNature === 'debit' ? 'text-blue-600' : 'text-red-600'
)}
>
{formatCurrency(node.data.balance)}
</span>
)}
</div>
)}
</Tree>
</div>
);
}
JournalEntryForm
// components/journal/JournalEntryForm.tsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Trash2, Save, Send, AlertCircle } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Separator } from '@/components/ui/separator';
import { AccountSelect } from '../accounts/AccountSelect';
import { DebitCreditInput } from '../shared/DebitCreditInput';
import { JournalLineTotals } from './JournalLineTotals';
import { useJournalStore } from '../../stores/journal.store';
import { useJournalValidation } from '../../hooks/useJournalValidation';
import { toast } from 'sonner';
export function JournalEntryForm() {
const navigate = useNavigate();
const {
currentEntry,
currentLines,
totals,
isLoading,
initNewEntry,
setEntryField,
addLine,
updateLine,
removeLine,
saveEntry,
postEntry,
resetForm,
} = useJournalStore();
const validation = useJournalValidation(
currentEntry.entryDate,
currentEntry.description,
currentLines
);
useEffect(() => {
initNewEntry();
return () => resetForm();
}, [initNewEntry, resetForm]);
const handleSaveDraft = async () => {
try {
const entry = await saveEntry();
toast.success('Asiento guardado como borrador');
navigate(`/financial/journal/${entry.id}`);
} catch (error: any) {
toast.error(error.message);
}
};
const handleSaveAndPost = async () => {
try {
const entry = await saveEntry();
await postEntry(entry.id);
toast.success(`Asiento ${entry.entryNumber} contabilizado`);
navigate(`/financial/journal/${entry.id}`);
} catch (error: any) {
toast.error(error.message);
}
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Nuevo Asiento Contable</CardTitle>
<div className="flex gap-2">
<Button variant="outline" onClick={handleSaveDraft} disabled={isLoading}>
<Save className="mr-2 h-4 w-4" />
Guardar Borrador
</Button>
<Button
onClick={handleSaveAndPost}
disabled={isLoading || !validation.canPost}
>
<Send className="mr-2 h-4 w-4" />
Contabilizar
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Validation errors */}
{validation.errors.length > 0 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<ul className="list-disc list-inside">
{validation.errors.map((error, i) => (
<li key={i}>{error.message}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{/* Header */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Fecha *</Label>
<Input
type="date"
value={currentEntry.entryDate ?? ''}
onChange={(e) => setEntryField('entryDate', e.target.value)}
/>
</div>
<div className="space-y-2 col-span-2">
<Label>Concepto / Descripcion *</Label>
<Input
value={currentEntry.description ?? ''}
onChange={(e) => setEntryField('description', e.target.value)}
placeholder="Descripcion del asiento contable"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Referencia</Label>
<Input
value={currentEntry.reference ?? ''}
onChange={(e) => setEntryField('reference', e.target.value)}
placeholder="No. documento, factura, etc."
/>
</div>
</div>
<Separator />
{/* Lines */}
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">Lineas del Asiento</h3>
<Button
variant="outline"
size="sm"
onClick={() => addLine({ accountId: '', debit: '', credit: '' })}
>
<Plus className="mr-2 h-4 w-4" />
Agregar Linea
</Button>
</div>
{/* Lines table */}
<div className="border rounded-md">
{/* Header */}
<div className="grid grid-cols-12 gap-2 p-3 bg-muted text-sm font-medium">
<div className="col-span-1">#</div>
<div className="col-span-5">Cuenta</div>
<div className="col-span-2 text-right">Debe</div>
<div className="col-span-2 text-right">Haber</div>
<div className="col-span-1">Desc.</div>
<div className="col-span-1"></div>
</div>
{/* Lines */}
{currentLines.map((line, index) => (
<div
key={index}
className="grid grid-cols-12 gap-2 p-3 border-t items-center"
>
<div className="col-span-1 text-sm text-muted-foreground">
{index + 1}
</div>
<div className="col-span-5">
<AccountSelect
value={line.accountId}
onChange={(accountId) => updateLine(index, { accountId })}
onlyDetail
placeholder="Selecciona cuenta..."
/>
</div>
<div className="col-span-2">
<DebitCreditInput
type="debit"
value={line.debit ?? ''}
onChange={(debit) => updateLine(index, { debit, credit: '' })}
disabled={!!line.credit}
/>
</div>
<div className="col-span-2">
<DebitCreditInput
type="credit"
value={line.credit ?? ''}
onChange={(credit) => updateLine(index, { credit, debit: '' })}
disabled={!!line.debit}
/>
</div>
<div className="col-span-1">
<Input
value={line.description ?? ''}
onChange={(e) => updateLine(index, { description: e.target.value })}
placeholder="..."
className="h-8 text-sm"
/>
</div>
<div className="col-span-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => removeLine(index)}
disabled={currentLines.length <= 2}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</div>
))}
{/* Totals */}
<JournalLineTotals
totalDebit={totals.debit}
totalCredit={totals.credit}
difference={totals.difference}
isBalanced={totals.isBalanced}
/>
</div>
</div>
</CardContent>
</Card>
);
}
DebitCreditInput
// components/shared/DebitCreditInput.tsx
import { useState, useEffect, useRef } from 'react';
import Decimal from 'decimal.js';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
interface DebitCreditInputProps {
type: 'debit' | 'credit';
value: string;
onChange: (value: string) => void;
disabled?: boolean;
currency?: string;
className?: string;
}
export function DebitCreditInput({
type,
value,
onChange,
disabled,
currency = 'MXN',
className,
}: DebitCreditInputProps) {
const [displayValue, setDisplayValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
// Format on blur
useEffect(() => {
if (value) {
try {
const decimal = new Decimal(value);
setDisplayValue(decimal.toFixed(2));
} catch {
setDisplayValue(value);
}
} else {
setDisplayValue('');
}
}, [value]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const rawValue = e.target.value.replace(/[^0-9.-]/g, '');
setDisplayValue(rawValue);
};
const handleBlur = () => {
if (!displayValue) {
onChange('');
return;
}
try {
const decimal = new Decimal(displayValue);
if (decimal.isNegative()) {
onChange('');
setDisplayValue('');
return;
}
const formatted = decimal.toFixed(2);
onChange(formatted);
setDisplayValue(formatted);
} catch {
onChange('');
setDisplayValue('');
}
};
const handleFocus = () => {
// Remove formatting on focus
if (displayValue) {
const raw = displayValue.replace(/,/g, '');
setDisplayValue(raw);
}
inputRef.current?.select();
};
return (
<Input
ref={inputRef}
type="text"
inputMode="decimal"
value={displayValue}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
disabled={disabled}
placeholder="0.00"
className={cn(
'text-right font-mono h-8',
type === 'debit' && value && 'text-blue-600',
type === 'credit' && value && 'text-red-600',
disabled && 'bg-muted',
className
)}
/>
);
}
ExchangeRateChart
// components/currencies/ExchangeRateChart.tsx
import { useMemo } from 'react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js';
import { Line } from 'react-chartjs-2';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ExchangeRate } from '../../types/currency.types';
import { format, parseISO } from 'date-fns';
import { es } from 'date-fns/locale';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
interface ExchangeRateChartProps {
rates: ExchangeRate[];
currencyCode: string;
}
export function ExchangeRateChart({ rates, currencyCode }: ExchangeRateChartProps) {
const chartData = useMemo(() => {
const sortedRates = [...rates].sort(
(a, b) => new Date(a.effectiveDate).getTime() - new Date(b.effectiveDate).getTime()
);
return {
labels: sortedRates.map((r) =>
format(parseISO(r.effectiveDate), 'dd MMM', { locale: es })
),
datasets: [
{
label: `${currencyCode} / MXN`,
data: sortedRates.map((r) => parseFloat(r.rate)),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 3,
pointHoverRadius: 5,
},
],
};
}, [rates, currencyCode]);
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: (context: any) => {
return `1 ${currencyCode} = ${context.parsed.y.toFixed(4)} MXN`;
},
},
},
},
scales: {
y: {
beginAtZero: false,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
},
x: {
grid: {
display: false,
},
},
},
};
if (rates.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay datos de tipos de cambio para mostrar
</CardContent>
</Card>
);
}
// Calculate stats
const values = rates.map((r) => parseFloat(r.rate));
const min = Math.min(...values);
const max = Math.max(...values);
const avg = values.reduce((a, b) => a + b, 0) / values.length;
const latest = values[values.length - 1];
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Tendencia de Tipo de Cambio</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-4 gap-4 mb-4">
<div>
<div className="text-sm text-muted-foreground">Actual</div>
<div className="text-lg font-bold">{latest.toFixed(4)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Minimo</div>
<div className="text-lg font-bold text-green-600">{min.toFixed(4)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Maximo</div>
<div className="text-lg font-bold text-red-600">{max.toFixed(4)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Promedio</div>
<div className="text-lg font-bold">{avg.toFixed(4)}</div>
</div>
</div>
<div className="h-64">
<Line data={chartData} options={options} />
</div>
</CardContent>
</Card>
);
}
Routes
// routes.tsx
import { lazy } from 'react';
import { RouteObject } from 'react-router-dom';
const ChartOfAccountsPage = lazy(() => import('./pages/ChartOfAccountsPage'));
const AccountDetailPage = lazy(() => import('./pages/AccountDetailPage'));
const JournalEntriesPage = lazy(() => import('./pages/JournalEntriesPage'));
const JournalEntryFormPage = lazy(() => import('./pages/JournalEntryFormPage'));
const JournalEntryDetailPage = lazy(() => import('./pages/JournalEntryDetailPage'));
const FiscalYearsPage = lazy(() => import('./pages/FiscalYearsPage'));
const FiscalPeriodsPage = lazy(() => import('./pages/FiscalPeriodsPage'));
const CurrenciesPage = lazy(() => import('./pages/CurrenciesPage'));
const ExchangeRatesPage = lazy(() => import('./pages/ExchangeRatesPage'));
export const financialRoutes: RouteObject[] = [
{
path: 'financial',
children: [
{
path: 'accounts',
element: <ChartOfAccountsPage />,
},
{
path: 'accounts/:id',
element: <AccountDetailPage />,
},
{
path: 'journal',
element: <JournalEntriesPage />,
},
{
path: 'journal/new',
element: <JournalEntryFormPage />,
},
{
path: 'journal/:id',
element: <JournalEntryDetailPage />,
},
{
path: 'journal/:id/edit',
element: <JournalEntryFormPage />,
},
{
path: 'fiscal-years',
element: <FiscalYearsPage />,
},
{
path: 'fiscal-years/:id/periods',
element: <FiscalPeriodsPage />,
},
{
path: 'currencies',
element: <CurrenciesPage />,
},
{
path: 'currencies/:code/rates',
element: <ExchangeRatesPage />,
},
],
},
];
Wireframes
Plan de Cuentas
+------------------------------------------------------------------+
| Plan de Cuentas [Buscar...] [+ Nueva Cta] |
+------------------------------------------------------------------+
| [Expandir todo] [Colapsar todo] [Tipo: Todos v] [Filtrar] |
+------------------------------------------------------------------+
| |
| > [📁] 1 ACTIVO $1,234,567 |
| > [📁] 1.1 Activo Circulante $987,654 |
| > [📁] 1.1.01 Caja y Bancos $456,789 |
| [📄] 1.1.01.001 Caja General $12,345 |
| [📄] 1.1.01.002 Banco BBVA MXN $234,567 |
| [📄] 1.1.01.003 Banco BBVA USD $189,877 |
| > [📁] 1.1.02 Clientes $530,865 |
| [📄] 1.1.02.001 Clientes Nacionales $430,865 |
| [📄] 1.1.02.002 Clientes Extranjeros $100,000 |
| > [📁] 1.2 Activo Fijo $246,913 |
| |
| > [📁] 2 PASIVO ($567,890) |
| > [📁] 3 CAPITAL ($500,000) |
| > [📁] 4 INGRESOS ($850,000) |
| > [📁] 5 GASTOS $683,323 |
+------------------------------------------------------------------+
| Seleccionada: 1.1.01.002 Banco BBVA MXN |
| Tipo: Activo | Naturaleza: Debitora | Saldo: $234,567.00 |
| [Ver Movimientos] [Editar] [Desactivar] |
+------------------------------------------------------------------+
Formulario de Asiento
+------------------------------------------------------------------+
| Nuevo Asiento Contable [Guardar Borrador] [Contabilizar] |
+------------------------------------------------------------------+
| [!] Asiento descuadrado: Debe=$11,600, Haber=$10,000, Dif=$1,600 |
+------------------------------------------------------------------+
| |
| Fecha * | Concepto / Descripcion * |
| [2025-12-05 ] [📅] | [Registro de venta factura #1234 ] |
| |
| Referencia | |
| [FAC-2025-1234 ] | |
+------------------------------------------------------------------+
| |
| LINEAS [+ Agregar] |
+------------------------------------------------------------------+
| # | CUENTA | DEBE | HABER | [X] |
+------------------------------------------------------------------+
| 1 | [1.1.02.001 Clientes Nac. v]| [11,600 ] | [ ] | [🗑] |
| 2 | [4.1.01.001 Ingresos Ven. v]| [ ] | [10,000 ] | [🗑] |
| 3 | [2.1.05.001 IVA por Pagar v]| [ ] | [ ] | [🗑] |
+------------------------------------------------------------------+
| TOTALES | $11,600 | $10,000 |
| DIFERENCIA | $1,600.00 ❌ |
+------------------------------------------------------------------+
Periodos Fiscales
+------------------------------------------------------------------+
| Anos Fiscales [+ Nuevo Ano] |
+------------------------------------------------------------------+
| ANO | INICIO | FIN | PERIODOS | ESTADO |
+------------------------------------------------------------------+
| Ejercicio 2025 | 2025-01-01 | 2025-12-31 | 11/12 | Abierto |
| [Ver Periodos] |
+------------------------------------------------------------------+
| Ejercicio 2024 | 2024-01-01 | 2024-12-31 | 12/12 | Cerrado |
| [Ver Periodos] |
+------------------------------------------------------------------+
+------------------------------------------------------------------+
| PERIODOS - Ejercicio 2025 [Cerrar Ano] |
+------------------------------------------------------------------+
| # | PERIODO | INICIO | FIN | ESTADO | ACCION |
+------------------------------------------------------------------+
| 1 | Enero 2025 | 2025-01-01 | 2025-01-31 | Cerrado | |
| 2 | Febrero 2025 | 2025-02-01 | 2025-02-28 | Cerrado | |
| .. | ... | ... | ... | ... | |
| 11 | Noviembre 2025 | 2025-11-01 | 2025-11-30 | Cerrado |[Reabrir]|
| 12 | Diciembre 2025 | 2025-12-01 | 2025-12-31 | Abierto |[Cerrar] |
+------------------------------------------------------------------+
Modal: Cerrar Periodo
+------------------------------------------------------------------+
| CERRAR PERIODO: Diciembre 2025 [X] |
+------------------------------------------------------------------+
| Validaciones: |
| [✓] Todos los asientos cuadrados |
| [✓] Saldos de cuentas verificados |
| [⚠] 3 facturas pendientes de contabilizar |
| |
| Resumen del periodo: |
| - Asientos: 245 |
| - Total debe: $5,234,567.00 |
| - Total haber: $5,234,567.00 |
| |
| [Cancelar] [=== Cerrar Periodo ===] |
+------------------------------------------------------------------+
Historial de Cambios
| Version |
Fecha |
Autor |
Cambios |
| 1.0 |
2025-12-05 |
Requirements-Analyst |
Creacion inicial |
Aprobaciones
| Rol |
Nombre |
Fecha |
Firma |
| Frontend Lead |
- |
- |
[ ] |
| UX Designer |
- |
- |
[ ] |