erp-core/docs/02-fase-core-business/MGN-010-financial/especificaciones/ET-FIN-frontend.md

53 KiB

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 - - [ ]