# 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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; // Actions fetchAccounts: () => Promise; fetchAccount: (id: string) => Promise; createAccount: (dto: CreateAccountDto) => Promise; updateAccount: (id: string, dto: UpdateAccountDto) => Promise; deleteAccount: (id: string) => Promise; // 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()( 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 ```typescript // 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; currentLines: CreateJournalLineDto[]; totals: { debit: string; credit: string; difference: string; isBalanced: boolean }; // Actions - List fetchEntries: () => Promise; setFilters: (filters: Partial) => void; // Actions - Entry initNewEntry: () => void; setEntryField: (field: string, value: any) => void; addLine: (line: CreateJournalLineDto) => void; updateLine: (index: number, line: Partial) => void; removeLine: (index: number) => void; reorderLines: (fromIndex: number, toIndex: number) => void; calculateTotals: () => void; // Actions - CRUD saveEntry: () => Promise; postEntry: (id: string) => Promise; reverseEntry: (id: string, dto: ReverseJournalEntryDto) => Promise; deleteEntry: (id: string) => Promise; // Reset resetForm: () => void; } const emptyTotals = { debit: '0', credit: '0', difference: '0', isBalanced: true }; export const useJournalStore = create()( 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 ```typescript // 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 ```typescript // 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 => { 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 ```tsx // 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 = { asset: 'text-blue-600', liability: 'text-red-600', equity: 'text-purple-600', income: 'text-green-600', expense: 'text-orange-600', }; return (
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 }) => (
{ 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 */} {node.data.isGroup && ( expandedIds.has(node.data.id) ? ( ) : ( ) )} {/* Icon */} {node.data.isGroup ? ( ) : ( )} {/* Code & Name */}
{node.data.code} {node.data.name}
{/* Balance */} {showBalances && !node.data.isGroup && ( {formatCurrency(node.data.balance)} )}
)}
); } ``` ### JournalEntryForm ```tsx // 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 ( Nuevo Asiento Contable
{/* Validation errors */} {validation.errors.length > 0 && (
    {validation.errors.map((error, i) => (
  • {error.message}
  • ))}
)} {/* Header */}
setEntryField('entryDate', e.target.value)} />
setEntryField('description', e.target.value)} placeholder="Descripcion del asiento contable" />
setEntryField('reference', e.target.value)} placeholder="No. documento, factura, etc." />
{/* Lines */}

Lineas del Asiento

{/* Lines table */}
{/* Header */}
#
Cuenta
Debe
Haber
Desc.
{/* Lines */} {currentLines.map((line, index) => (
{index + 1}
updateLine(index, { accountId })} onlyDetail placeholder="Selecciona cuenta..." />
updateLine(index, { debit, credit: '' })} disabled={!!line.credit} />
updateLine(index, { credit, debit: '' })} disabled={!!line.debit} />
updateLine(index, { description: e.target.value })} placeholder="..." className="h-8 text-sm" />
))} {/* Totals */}
); } ``` ### DebitCreditInput ```tsx // 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(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) => { 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 ( ); } ``` ### ExchangeRateChart ```tsx // 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 ( No hay datos de tipos de cambio para mostrar ); } // 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 ( Tendencia de Tipo de Cambio
Actual
{latest.toFixed(4)}
Minimo
{min.toFixed(4)}
Maximo
{max.toFixed(4)}
Promedio
{avg.toFixed(4)}
); } ``` --- ## Routes ```tsx // 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: , }, { path: 'accounts/:id', element: , }, { path: 'journal', element: , }, { path: 'journal/new', element: , }, { path: 'journal/:id', element: , }, { path: 'journal/:id/edit', element: , }, { path: 'fiscal-years', element: , }, { path: 'fiscal-years/:id/periods', element: , }, { path: 'currencies', element: , }, { path: 'currencies/:code/rates', element: , }, ], }, ]; ``` --- ## 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 | - | - | [ ] |