feat(financial): Add complete Financial module frontend

- Create financial types (Account, Journal, JournalEntry, Invoice, Payment)
- Create financial API client with full CRUD operations
- Create comprehensive hooks (useAccounts, useJournals, useJournalEntries, etc.)
- Create AccountsPage with account type filtering and stats
- Create JournalEntriesPage with journal filtering and post/cancel actions
- Create InvoicesPage with customer/supplier filtering and validation

MGN-010 Financial frontend implementation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-18 10:12:43 -06:00
parent 32f2c06264
commit 836ebaf638
11 changed files with 2849 additions and 0 deletions

View File

@ -0,0 +1,356 @@
import { api } from '@services/api/axios-instance';
import type {
Account,
CreateAccountDto,
UpdateAccountDto,
AccountFilters,
AccountsResponse,
AccountType,
Journal,
CreateJournalDto,
UpdateJournalDto,
JournalFilters,
JournalsResponse,
JournalEntry,
CreateJournalEntryDto,
UpdateJournalEntryDto,
JournalEntryFilters,
JournalEntriesResponse,
FinancialInvoice,
CreateInvoiceDto,
UpdateInvoiceDto,
InvoiceFilters,
InvoicesResponse,
Payment,
CreatePaymentDto,
UpdatePaymentDto,
PaymentFilters,
PaymentsResponse,
} from '../types';
const ACCOUNTS_URL = '/api/v1/financial/accounts';
const ACCOUNT_TYPES_URL = '/api/v1/financial/account-types';
const JOURNALS_URL = '/api/v1/financial/journals';
const ENTRIES_URL = '/api/v1/financial/journal-entries';
const INVOICES_URL = '/api/v1/financial/invoices';
const PAYMENTS_URL = '/api/v1/financial/payments';
export const financialApi = {
// ==================== Account Types ====================
// Get all account types
getAccountTypes: async (): Promise<AccountType[]> => {
const response = await api.get<AccountType[]>(ACCOUNT_TYPES_URL);
return response.data;
},
// ==================== Accounts ====================
// Get all accounts with filters
getAccounts: async (filters?: AccountFilters): Promise<AccountsResponse> => {
const params = new URLSearchParams();
if (filters?.companyId) params.append('companyId', filters.companyId);
if (filters?.accountTypeId) params.append('accountTypeId', filters.accountTypeId);
if (filters?.parentId) params.append('parentId', filters.parentId);
if (filters?.isReconcilable !== undefined) params.append('isReconcilable', String(filters.isReconcilable));
if (filters?.isDeprecated !== undefined) params.append('isDeprecated', String(filters.isDeprecated));
if (filters?.search) params.append('search', filters.search);
if (filters?.page) params.append('page', String(filters.page));
if (filters?.limit) params.append('limit', String(filters.limit));
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
const response = await api.get<AccountsResponse>(`${ACCOUNTS_URL}?${params.toString()}`);
return response.data;
},
// Get account by ID
getAccountById: async (id: string): Promise<Account> => {
const response = await api.get<Account>(`${ACCOUNTS_URL}/${id}`);
return response.data;
},
// Create account
createAccount: async (data: CreateAccountDto): Promise<Account> => {
const response = await api.post<Account>(ACCOUNTS_URL, data);
return response.data;
},
// Update account
updateAccount: async (id: string, data: UpdateAccountDto): Promise<Account> => {
const response = await api.patch<Account>(`${ACCOUNTS_URL}/${id}`, data);
return response.data;
},
// Delete account
deleteAccount: async (id: string): Promise<void> => {
await api.delete(`${ACCOUNTS_URL}/${id}`);
},
// Get accounts by type
getAccountsByType: async (accountTypeId: string, filters?: Omit<AccountFilters, 'accountTypeId'>): Promise<AccountsResponse> => {
return financialApi.getAccounts({ ...filters, accountTypeId });
},
// Get child accounts
getChildAccounts: async (parentId: string, filters?: Omit<AccountFilters, 'parentId'>): Promise<AccountsResponse> => {
return financialApi.getAccounts({ ...filters, parentId });
},
// ==================== Journals ====================
// Get all journals with filters
getJournals: async (filters?: JournalFilters): Promise<JournalsResponse> => {
const params = new URLSearchParams();
if (filters?.companyId) params.append('companyId', filters.companyId);
if (filters?.journalType) params.append('journalType', filters.journalType);
if (filters?.active !== undefined) params.append('active', String(filters.active));
if (filters?.search) params.append('search', filters.search);
if (filters?.page) params.append('page', String(filters.page));
if (filters?.limit) params.append('limit', String(filters.limit));
const response = await api.get<JournalsResponse>(`${JOURNALS_URL}?${params.toString()}`);
return response.data;
},
// Get journal by ID
getJournalById: async (id: string): Promise<Journal> => {
const response = await api.get<Journal>(`${JOURNALS_URL}/${id}`);
return response.data;
},
// Create journal
createJournal: async (data: CreateJournalDto): Promise<Journal> => {
const response = await api.post<Journal>(JOURNALS_URL, data);
return response.data;
},
// Update journal
updateJournal: async (id: string, data: UpdateJournalDto): Promise<Journal> => {
const response = await api.patch<Journal>(`${JOURNALS_URL}/${id}`, data);
return response.data;
},
// Delete journal
deleteJournal: async (id: string): Promise<void> => {
await api.delete(`${JOURNALS_URL}/${id}`);
},
// Get journals by type
getJournalsByType: async (journalType: string, filters?: Omit<JournalFilters, 'journalType'>): Promise<JournalsResponse> => {
return financialApi.getJournals({ ...filters, journalType: journalType as JournalFilters['journalType'] });
},
// ==================== Journal Entries ====================
// Get all journal entries with filters
getEntries: async (filters?: JournalEntryFilters): Promise<JournalEntriesResponse> => {
const params = new URLSearchParams();
if (filters?.companyId) params.append('companyId', filters.companyId);
if (filters?.journalId) params.append('journalId', filters.journalId);
if (filters?.status) params.append('status', filters.status);
if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom);
if (filters?.dateTo) params.append('dateTo', filters.dateTo);
if (filters?.search) params.append('search', filters.search);
if (filters?.page) params.append('page', String(filters.page));
if (filters?.limit) params.append('limit', String(filters.limit));
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
const response = await api.get<JournalEntriesResponse>(`${ENTRIES_URL}?${params.toString()}`);
return response.data;
},
// Get journal entry by ID
getEntryById: async (id: string): Promise<JournalEntry> => {
const response = await api.get<JournalEntry>(`${ENTRIES_URL}/${id}`);
return response.data;
},
// Create journal entry
createEntry: async (data: CreateJournalEntryDto): Promise<JournalEntry> => {
const response = await api.post<JournalEntry>(ENTRIES_URL, data);
return response.data;
},
// Update journal entry
updateEntry: async (id: string, data: UpdateJournalEntryDto): Promise<JournalEntry> => {
const response = await api.patch<JournalEntry>(`${ENTRIES_URL}/${id}`, data);
return response.data;
},
// Delete journal entry
deleteEntry: async (id: string): Promise<void> => {
await api.delete(`${ENTRIES_URL}/${id}`);
},
// Post journal entry
postEntry: async (id: string): Promise<JournalEntry> => {
const response = await api.post<JournalEntry>(`${ENTRIES_URL}/${id}/post`);
return response.data;
},
// Cancel journal entry
cancelEntry: async (id: string): Promise<JournalEntry> => {
const response = await api.post<JournalEntry>(`${ENTRIES_URL}/${id}/cancel`);
return response.data;
},
// Get draft entries
getDraftEntries: async (filters?: Omit<JournalEntryFilters, 'status'>): Promise<JournalEntriesResponse> => {
return financialApi.getEntries({ ...filters, status: 'draft' });
},
// Get posted entries
getPostedEntries: async (filters?: Omit<JournalEntryFilters, 'status'>): Promise<JournalEntriesResponse> => {
return financialApi.getEntries({ ...filters, status: 'posted' });
},
// ==================== Invoices ====================
// Get all invoices with filters
getInvoices: async (filters?: InvoiceFilters): Promise<InvoicesResponse> => {
const params = new URLSearchParams();
if (filters?.companyId) params.append('companyId', filters.companyId);
if (filters?.partnerId) params.append('partnerId', filters.partnerId);
if (filters?.invoiceType) params.append('invoiceType', filters.invoiceType);
if (filters?.status) params.append('status', filters.status);
if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom);
if (filters?.dateTo) params.append('dateTo', filters.dateTo);
if (filters?.search) params.append('search', filters.search);
if (filters?.page) params.append('page', String(filters.page));
if (filters?.limit) params.append('limit', String(filters.limit));
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
const response = await api.get<InvoicesResponse>(`${INVOICES_URL}?${params.toString()}`);
return response.data;
},
// Get invoice by ID
getInvoiceById: async (id: string): Promise<FinancialInvoice> => {
const response = await api.get<FinancialInvoice>(`${INVOICES_URL}/${id}`);
return response.data;
},
// Create invoice
createInvoice: async (data: CreateInvoiceDto): Promise<FinancialInvoice> => {
const response = await api.post<FinancialInvoice>(INVOICES_URL, data);
return response.data;
},
// Update invoice
updateInvoice: async (id: string, data: UpdateInvoiceDto): Promise<FinancialInvoice> => {
const response = await api.patch<FinancialInvoice>(`${INVOICES_URL}/${id}`, data);
return response.data;
},
// Delete invoice
deleteInvoice: async (id: string): Promise<void> => {
await api.delete(`${INVOICES_URL}/${id}`);
},
// Validate invoice (draft -> open)
validateInvoice: async (id: string): Promise<FinancialInvoice> => {
const response = await api.post<FinancialInvoice>(`${INVOICES_URL}/${id}/validate`);
return response.data;
},
// Cancel invoice
cancelInvoice: async (id: string): Promise<FinancialInvoice> => {
const response = await api.post<FinancialInvoice>(`${INVOICES_URL}/${id}/cancel`);
return response.data;
},
// Get customer invoices
getCustomerInvoices: async (filters?: Omit<InvoiceFilters, 'invoiceType'>): Promise<InvoicesResponse> => {
return financialApi.getInvoices({ ...filters, invoiceType: 'customer' });
},
// Get supplier invoices
getSupplierInvoices: async (filters?: Omit<InvoiceFilters, 'invoiceType'>): Promise<InvoicesResponse> => {
return financialApi.getInvoices({ ...filters, invoiceType: 'supplier' });
},
// Get open invoices
getOpenInvoices: async (filters?: Omit<InvoiceFilters, 'status'>): Promise<InvoicesResponse> => {
return financialApi.getInvoices({ ...filters, status: 'open' });
},
// Get invoices by partner
getInvoicesByPartner: async (partnerId: string, filters?: Omit<InvoiceFilters, 'partnerId'>): Promise<InvoicesResponse> => {
return financialApi.getInvoices({ ...filters, partnerId });
},
// ==================== Payments ====================
// Get all payments with filters
getPayments: async (filters?: PaymentFilters): Promise<PaymentsResponse> => {
const params = new URLSearchParams();
if (filters?.companyId) params.append('companyId', filters.companyId);
if (filters?.partnerId) params.append('partnerId', filters.partnerId);
if (filters?.paymentType) params.append('paymentType', filters.paymentType);
if (filters?.paymentMethod) params.append('paymentMethod', filters.paymentMethod);
if (filters?.status) params.append('status', filters.status);
if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom);
if (filters?.dateTo) params.append('dateTo', filters.dateTo);
if (filters?.search) params.append('search', filters.search);
if (filters?.page) params.append('page', String(filters.page));
if (filters?.limit) params.append('limit', String(filters.limit));
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
const response = await api.get<PaymentsResponse>(`${PAYMENTS_URL}?${params.toString()}`);
return response.data;
},
// Get payment by ID
getPaymentById: async (id: string): Promise<Payment> => {
const response = await api.get<Payment>(`${PAYMENTS_URL}/${id}`);
return response.data;
},
// Create payment
createPayment: async (data: CreatePaymentDto): Promise<Payment> => {
const response = await api.post<Payment>(PAYMENTS_URL, data);
return response.data;
},
// Update payment
updatePayment: async (id: string, data: UpdatePaymentDto): Promise<Payment> => {
const response = await api.patch<Payment>(`${PAYMENTS_URL}/${id}`, data);
return response.data;
},
// Delete payment
deletePayment: async (id: string): Promise<void> => {
await api.delete(`${PAYMENTS_URL}/${id}`);
},
// Post payment
postPayment: async (id: string): Promise<Payment> => {
const response = await api.post<Payment>(`${PAYMENTS_URL}/${id}/post`);
return response.data;
},
// Cancel payment
cancelPayment: async (id: string): Promise<Payment> => {
const response = await api.post<Payment>(`${PAYMENTS_URL}/${id}/cancel`);
return response.data;
},
// Get inbound payments (customer payments)
getInboundPayments: async (filters?: Omit<PaymentFilters, 'paymentType'>): Promise<PaymentsResponse> => {
return financialApi.getPayments({ ...filters, paymentType: 'inbound' });
},
// Get outbound payments (supplier payments)
getOutboundPayments: async (filters?: Omit<PaymentFilters, 'paymentType'>): Promise<PaymentsResponse> => {
return financialApi.getPayments({ ...filters, paymentType: 'outbound' });
},
// Get payments by partner
getPaymentsByPartner: async (partnerId: string, filters?: Omit<PaymentFilters, 'partnerId'>): Promise<PaymentsResponse> => {
return financialApi.getPayments({ ...filters, partnerId });
},
};

View File

@ -0,0 +1 @@
export { financialApi } from './financial.api';

View File

@ -0,0 +1,20 @@
export {
useAccountTypes,
useAccounts,
useAccount,
useJournals,
useJournalEntries,
useJournalEntry,
useInvoices,
useInvoice,
usePayments,
usePayment,
} from './useFinancial';
export type {
UseAccountsOptions,
UseJournalsOptions,
UseJournalEntriesOptions,
UseInvoicesOptions,
UsePaymentsOptions,
} from './useFinancial';

View File

@ -0,0 +1,749 @@
import { useState, useEffect, useCallback } from 'react';
import { financialApi } from '../api/financial.api';
import type {
Account,
AccountFilters,
CreateAccountDto,
UpdateAccountDto,
AccountType,
Journal,
JournalFilters,
CreateJournalDto,
UpdateJournalDto,
JournalEntry,
JournalEntryFilters,
CreateJournalEntryDto,
UpdateJournalEntryDto,
FinancialInvoice,
InvoiceFilters,
CreateInvoiceDto,
UpdateInvoiceDto,
Payment,
PaymentFilters,
CreatePaymentDto,
UpdatePaymentDto,
} from '../types';
// ==================== Account Types Hook ====================
export function useAccountTypes() {
const [accountTypes, setAccountTypes] = useState<AccountType[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchAccountTypes = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await financialApi.getAccountTypes();
setAccountTypes(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar tipos de cuenta');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchAccountTypes();
}, [fetchAccountTypes]);
return {
accountTypes,
isLoading,
error,
refresh: fetchAccountTypes,
};
}
// ==================== Accounts Hook ====================
export interface UseAccountsOptions extends AccountFilters {
autoFetch?: boolean;
}
export function useAccounts(options: UseAccountsOptions = {}) {
const { autoFetch = true, ...filters } = options;
const [accounts, setAccounts] = useState<Account[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(filters.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchAccounts = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await financialApi.getAccounts({ ...filters, page });
setAccounts(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar cuentas');
} finally {
setIsLoading(false);
}
}, [filters.companyId, filters.accountTypeId, filters.parentId, filters.isReconcilable, filters.isDeprecated, filters.search, filters.limit, filters.sortBy, filters.sortOrder, page]);
useEffect(() => {
if (autoFetch) {
fetchAccounts();
}
}, [fetchAccounts, autoFetch]);
const createAccount = async (data: CreateAccountDto) => {
setIsLoading(true);
try {
const newAccount = await financialApi.createAccount(data);
await fetchAccounts();
return newAccount;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear cuenta');
throw err;
} finally {
setIsLoading(false);
}
};
const updateAccount = async (id: string, data: UpdateAccountDto) => {
setIsLoading(true);
try {
const updated = await financialApi.updateAccount(id, data);
await fetchAccounts();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar cuenta');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteAccount = async (id: string) => {
setIsLoading(true);
try {
await financialApi.deleteAccount(id);
await fetchAccounts();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar cuenta');
throw err;
} finally {
setIsLoading(false);
}
};
return {
accounts,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchAccounts,
createAccount,
updateAccount,
deleteAccount,
};
}
// ==================== Single Account Hook ====================
export function useAccount(accountId: string | null) {
const [account, setAccount] = useState<Account | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchAccount = useCallback(async () => {
if (!accountId) {
setAccount(null);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await financialApi.getAccountById(accountId);
setAccount(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar cuenta');
} finally {
setIsLoading(false);
}
}, [accountId]);
useEffect(() => {
fetchAccount();
}, [fetchAccount]);
return {
account,
isLoading,
error,
refresh: fetchAccount,
};
}
// ==================== Journals Hook ====================
export interface UseJournalsOptions extends JournalFilters {
autoFetch?: boolean;
}
export function useJournals(options: UseJournalsOptions = {}) {
const { autoFetch = true, ...filters } = options;
const [journals, setJournals] = useState<Journal[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(filters.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchJournals = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await financialApi.getJournals({ ...filters, page });
setJournals(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar diarios');
} finally {
setIsLoading(false);
}
}, [filters.companyId, filters.journalType, filters.active, filters.search, filters.limit, page]);
useEffect(() => {
if (autoFetch) {
fetchJournals();
}
}, [fetchJournals, autoFetch]);
const createJournal = async (data: CreateJournalDto) => {
setIsLoading(true);
try {
const newJournal = await financialApi.createJournal(data);
await fetchJournals();
return newJournal;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear diario');
throw err;
} finally {
setIsLoading(false);
}
};
const updateJournal = async (id: string, data: UpdateJournalDto) => {
setIsLoading(true);
try {
const updated = await financialApi.updateJournal(id, data);
await fetchJournals();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar diario');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteJournal = async (id: string) => {
setIsLoading(true);
try {
await financialApi.deleteJournal(id);
await fetchJournals();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar diario');
throw err;
} finally {
setIsLoading(false);
}
};
return {
journals,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchJournals,
createJournal,
updateJournal,
deleteJournal,
};
}
// ==================== Journal Entries Hook ====================
export interface UseJournalEntriesOptions extends JournalEntryFilters {
autoFetch?: boolean;
}
export function useJournalEntries(options: UseJournalEntriesOptions = {}) {
const { autoFetch = true, ...filters } = options;
const [entries, setEntries] = useState<JournalEntry[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(filters.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchEntries = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await financialApi.getEntries({ ...filters, page });
setEntries(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar asientos');
} finally {
setIsLoading(false);
}
}, [filters.companyId, filters.journalId, filters.status, filters.dateFrom, filters.dateTo, filters.search, filters.limit, filters.sortBy, filters.sortOrder, page]);
useEffect(() => {
if (autoFetch) {
fetchEntries();
}
}, [fetchEntries, autoFetch]);
const createEntry = async (data: CreateJournalEntryDto) => {
setIsLoading(true);
try {
const newEntry = await financialApi.createEntry(data);
await fetchEntries();
return newEntry;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear asiento');
throw err;
} finally {
setIsLoading(false);
}
};
const updateEntry = async (id: string, data: UpdateJournalEntryDto) => {
setIsLoading(true);
try {
const updated = await financialApi.updateEntry(id, data);
await fetchEntries();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar asiento');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteEntry = async (id: string) => {
setIsLoading(true);
try {
await financialApi.deleteEntry(id);
await fetchEntries();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar asiento');
throw err;
} finally {
setIsLoading(false);
}
};
const postEntry = async (id: string) => {
setIsLoading(true);
try {
await financialApi.postEntry(id);
await fetchEntries();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al publicar asiento');
throw err;
} finally {
setIsLoading(false);
}
};
const cancelEntry = async (id: string) => {
setIsLoading(true);
try {
await financialApi.cancelEntry(id);
await fetchEntries();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cancelar asiento');
throw err;
} finally {
setIsLoading(false);
}
};
return {
entries,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchEntries,
createEntry,
updateEntry,
deleteEntry,
postEntry,
cancelEntry,
};
}
// ==================== Single Journal Entry Hook ====================
export function useJournalEntry(entryId: string | null) {
const [entry, setEntry] = useState<JournalEntry | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchEntry = useCallback(async () => {
if (!entryId) {
setEntry(null);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await financialApi.getEntryById(entryId);
setEntry(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar asiento');
} finally {
setIsLoading(false);
}
}, [entryId]);
useEffect(() => {
fetchEntry();
}, [fetchEntry]);
return {
entry,
isLoading,
error,
refresh: fetchEntry,
};
}
// ==================== Invoices Hook ====================
export interface UseInvoicesOptions extends InvoiceFilters {
autoFetch?: boolean;
}
export function useInvoices(options: UseInvoicesOptions = {}) {
const { autoFetch = true, ...filters } = options;
const [invoices, setInvoices] = useState<FinancialInvoice[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(filters.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchInvoices = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await financialApi.getInvoices({ ...filters, page });
setInvoices(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar facturas');
} finally {
setIsLoading(false);
}
}, [filters.companyId, filters.partnerId, filters.invoiceType, filters.status, filters.dateFrom, filters.dateTo, filters.search, filters.limit, filters.sortBy, filters.sortOrder, page]);
useEffect(() => {
if (autoFetch) {
fetchInvoices();
}
}, [fetchInvoices, autoFetch]);
const createInvoice = async (data: CreateInvoiceDto) => {
setIsLoading(true);
try {
const newInvoice = await financialApi.createInvoice(data);
await fetchInvoices();
return newInvoice;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear factura');
throw err;
} finally {
setIsLoading(false);
}
};
const updateInvoice = async (id: string, data: UpdateInvoiceDto) => {
setIsLoading(true);
try {
const updated = await financialApi.updateInvoice(id, data);
await fetchInvoices();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar factura');
throw err;
} finally {
setIsLoading(false);
}
};
const deleteInvoice = async (id: string) => {
setIsLoading(true);
try {
await financialApi.deleteInvoice(id);
await fetchInvoices();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar factura');
throw err;
} finally {
setIsLoading(false);
}
};
const validateInvoice = async (id: string) => {
setIsLoading(true);
try {
await financialApi.validateInvoice(id);
await fetchInvoices();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al validar factura');
throw err;
} finally {
setIsLoading(false);
}
};
const cancelInvoice = async (id: string) => {
setIsLoading(true);
try {
await financialApi.cancelInvoice(id);
await fetchInvoices();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cancelar factura');
throw err;
} finally {
setIsLoading(false);
}
};
return {
invoices,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchInvoices,
createInvoice,
updateInvoice,
deleteInvoice,
validateInvoice,
cancelInvoice,
};
}
// ==================== Single Invoice Hook ====================
export function useInvoice(invoiceId: string | null) {
const [invoice, setInvoice] = useState<FinancialInvoice | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchInvoice = useCallback(async () => {
if (!invoiceId) {
setInvoice(null);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await financialApi.getInvoiceById(invoiceId);
setInvoice(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar factura');
} finally {
setIsLoading(false);
}
}, [invoiceId]);
useEffect(() => {
fetchInvoice();
}, [fetchInvoice]);
return {
invoice,
isLoading,
error,
refresh: fetchInvoice,
};
}
// ==================== Payments Hook ====================
export interface UsePaymentsOptions extends PaymentFilters {
autoFetch?: boolean;
}
export function usePayments(options: UsePaymentsOptions = {}) {
const { autoFetch = true, ...filters } = options;
const [payments, setPayments] = useState<Payment[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(filters.page || 1);
const [totalPages, setTotalPages] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchPayments = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await financialApi.getPayments({ ...filters, page });
setPayments(response.data);
setTotal(response.meta.total);
setTotalPages(response.meta.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar pagos');
} finally {
setIsLoading(false);
}
}, [filters.companyId, filters.partnerId, filters.paymentType, filters.paymentMethod, filters.status, filters.dateFrom, filters.dateTo, filters.search, filters.limit, filters.sortBy, filters.sortOrder, page]);
useEffect(() => {
if (autoFetch) {
fetchPayments();
}
}, [fetchPayments, autoFetch]);
const createPayment = async (data: CreatePaymentDto) => {
setIsLoading(true);
try {
const newPayment = await financialApi.createPayment(data);
await fetchPayments();
return newPayment;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear pago');
throw err;
} finally {
setIsLoading(false);
}
};
const updatePayment = async (id: string, data: UpdatePaymentDto) => {
setIsLoading(true);
try {
const updated = await financialApi.updatePayment(id, data);
await fetchPayments();
return updated;
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al actualizar pago');
throw err;
} finally {
setIsLoading(false);
}
};
const deletePayment = async (id: string) => {
setIsLoading(true);
try {
await financialApi.deletePayment(id);
await fetchPayments();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al eliminar pago');
throw err;
} finally {
setIsLoading(false);
}
};
const postPayment = async (id: string) => {
setIsLoading(true);
try {
await financialApi.postPayment(id);
await fetchPayments();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al publicar pago');
throw err;
} finally {
setIsLoading(false);
}
};
const cancelPayment = async (id: string) => {
setIsLoading(true);
try {
await financialApi.cancelPayment(id);
await fetchPayments();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cancelar pago');
throw err;
} finally {
setIsLoading(false);
}
};
return {
payments,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh: fetchPayments,
createPayment,
updatePayment,
deletePayment,
postPayment,
cancelPayment,
};
}
// ==================== Single Payment Hook ====================
export function usePayment(paymentId: string | null) {
const [payment, setPayment] = useState<Payment | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchPayment = useCallback(async () => {
if (!paymentId) {
setPayment(null);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await financialApi.getPaymentById(paymentId);
setPayment(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar pago');
} finally {
setIsLoading(false);
}
}, [paymentId]);
useEffect(() => {
fetchPayment();
}, [fetchPayment]);
return {
payment,
isLoading,
error,
refresh: fetchPayment,
};
}

View File

@ -0,0 +1,3 @@
export * from './api/financial.api';
export * from './types';
export * from './hooks';

View File

@ -0,0 +1,400 @@
// Account Types
export type AccountTypeEnum = 'asset' | 'liability' | 'equity' | 'income' | 'expense';
export interface AccountType {
id: string;
code: string;
name: string;
accountType: AccountTypeEnum;
description?: string | null;
}
export interface Account {
id: string;
tenantId: string;
companyId: string;
companyName?: string;
code: string;
name: string;
accountTypeId: string;
accountTypeName?: string;
accountTypeCode?: string;
parentId: string | null;
parentName?: string;
currencyId: string | null;
currencyCode?: string;
isReconcilable: boolean;
isDeprecated: boolean;
notes: string | null;
balance?: number;
createdAt: string;
}
export interface CreateAccountDto {
companyId: string;
code: string;
name: string;
accountTypeId: string;
parentId?: string;
currencyId?: string;
isReconcilable?: boolean;
notes?: string;
}
export interface UpdateAccountDto {
name?: string;
accountTypeId?: string;
parentId?: string | null;
currencyId?: string | null;
isReconcilable?: boolean;
isDeprecated?: boolean;
notes?: string | null;
}
export interface AccountFilters {
companyId?: string;
accountTypeId?: string;
parentId?: string;
isReconcilable?: boolean;
isDeprecated?: boolean;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface AccountsResponse {
data: Account[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Journal Types
export type JournalType = 'sale' | 'purchase' | 'cash' | 'bank' | 'general';
export interface Journal {
id: string;
tenantId: string;
companyId: string;
companyName?: string;
name: string;
code: string;
journalType: JournalType;
defaultAccountId: string | null;
defaultAccountName?: string;
sequenceId: string | null;
currencyId: string | null;
currencyCode?: string;
active: boolean;
createdAt: string;
}
export interface CreateJournalDto {
companyId: string;
name: string;
code: string;
journalType: JournalType;
defaultAccountId?: string;
currencyId?: string;
}
export interface UpdateJournalDto {
name?: string;
defaultAccountId?: string | null;
currencyId?: string | null;
active?: boolean;
}
export interface JournalFilters {
companyId?: string;
journalType?: JournalType;
active?: boolean;
search?: string;
page?: number;
limit?: number;
}
export interface JournalsResponse {
data: Journal[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Journal Entry Types
export type EntryStatus = 'draft' | 'posted' | 'cancelled';
export interface JournalEntryLine {
id: string;
entryId: string;
tenantId: string;
accountId: string;
accountCode?: string;
accountName?: string;
partnerId: string | null;
partnerName?: string;
debit: number;
credit: number;
description: string | null;
ref: string | null;
}
export interface JournalEntry {
id: string;
tenantId: string;
companyId: string;
companyName?: string;
journalId: string;
journalName?: string;
journalCode?: string;
name: string;
ref: string | null;
date: string;
status: EntryStatus;
notes: string | null;
fiscalPeriodId: string | null;
lines?: JournalEntryLine[];
totalDebit?: number;
totalCredit?: number;
createdAt: string;
postedAt: string | null;
}
export interface CreateJournalEntryDto {
companyId: string;
journalId: string;
ref?: string;
date: string;
notes?: string;
lines: CreateJournalEntryLineDto[];
}
export interface CreateJournalEntryLineDto {
accountId: string;
partnerId?: string;
debit: number;
credit: number;
description?: string;
ref?: string;
}
export interface UpdateJournalEntryDto {
ref?: string | null;
date?: string;
notes?: string | null;
}
export interface JournalEntryFilters {
companyId?: string;
journalId?: string;
status?: EntryStatus;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface JournalEntriesResponse {
data: JournalEntry[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Invoice Types
export type InvoiceType = 'customer' | 'supplier';
export type FinancialInvoiceStatus = 'draft' | 'open' | 'paid' | 'cancelled';
export interface InvoiceLine {
id: string;
invoiceId: string;
productId: string | null;
productName?: string;
accountId: string;
accountName?: string;
description: string;
quantity: number;
priceUnit: number;
discount: number;
amountUntaxed: number;
amountTax: number;
amountTotal: number;
}
export interface FinancialInvoice {
id: string;
tenantId: string;
companyId: string;
companyName?: string;
partnerId: string;
partnerName?: string;
invoiceType: InvoiceType;
number: string | null;
ref: string | null;
invoiceDate: string;
dueDate: string | null;
currencyId: string;
currencyCode?: string;
amountUntaxed: number;
amountTax: number;
amountTotal: number;
amountPaid: number;
amountResidual: number;
status: FinancialInvoiceStatus;
paymentTermId: string | null;
journalId: string | null;
journalEntryId: string | null;
notes: string | null;
lines?: InvoiceLine[];
createdAt: string;
validatedAt: string | null;
}
export interface CreateInvoiceDto {
companyId: string;
partnerId: string;
invoiceType: InvoiceType;
ref?: string;
invoiceDate: string;
dueDate?: string;
currencyId: string;
paymentTermId?: string;
journalId?: string;
notes?: string;
lines?: CreateInvoiceLineDto[];
}
export interface CreateInvoiceLineDto {
productId?: string;
accountId: string;
description: string;
quantity: number;
priceUnit: number;
discount?: number;
}
export interface UpdateInvoiceDto {
partnerId?: string;
ref?: string | null;
invoiceDate?: string;
dueDate?: string | null;
currencyId?: string;
paymentTermId?: string | null;
journalId?: string | null;
notes?: string | null;
}
export interface InvoiceFilters {
companyId?: string;
partnerId?: string;
invoiceType?: InvoiceType;
status?: FinancialInvoiceStatus;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface InvoicesResponse {
data: FinancialInvoice[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
// Payment Types
export type PaymentType = 'inbound' | 'outbound';
export type PaymentMethod = 'cash' | 'bank_transfer' | 'check' | 'card' | 'other';
export type PaymentStatus = 'draft' | 'posted' | 'reconciled' | 'cancelled';
export interface Payment {
id: string;
tenantId: string;
companyId: string;
companyName?: string;
partnerId: string;
partnerName?: string;
paymentType: PaymentType;
paymentMethod: PaymentMethod;
amount: number;
currencyId: string;
currencyCode?: string;
paymentDate: string;
ref: string | null;
status: PaymentStatus;
journalId: string;
journalName?: string;
journalEntryId: string | null;
notes: string | null;
createdAt: string;
postedAt: string | null;
}
export interface CreatePaymentDto {
companyId: string;
partnerId: string;
paymentType: PaymentType;
paymentMethod: PaymentMethod;
amount: number;
currencyId: string;
paymentDate: string;
ref?: string;
journalId: string;
notes?: string;
}
export interface UpdatePaymentDto {
partnerId?: string;
paymentMethod?: PaymentMethod;
amount?: number;
currencyId?: string;
paymentDate?: string;
ref?: string | null;
journalId?: string;
notes?: string | null;
}
export interface PaymentFilters {
companyId?: string;
partnerId?: string;
paymentType?: PaymentType;
paymentMethod?: PaymentMethod;
status?: PaymentStatus;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface PaymentsResponse {
data: Payment[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}

View File

@ -0,0 +1 @@
export * from './financial.types';

View File

@ -0,0 +1,409 @@
import { useState } from 'react';
import {
BookOpen,
Plus,
MoreVertical,
Eye,
Edit,
Trash2,
RefreshCw,
Search,
FolderTree,
CheckCircle,
XCircle,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { DataTable, type Column } from '@components/organisms/DataTable';
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { ConfirmModal } from '@components/organisms/Modal';
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
import { useAccounts, useAccountTypes } from '@features/financial/hooks';
import type { Account, AccountTypeEnum } from '@features/financial/types';
import { formatNumber } from '@utils/formatters';
const accountTypeLabels: Record<AccountTypeEnum, string> = {
asset: 'Activo',
liability: 'Pasivo',
equity: 'Capital',
income: 'Ingreso',
expense: 'Gasto',
};
const accountTypeColors: Record<AccountTypeEnum, string> = {
asset: 'bg-blue-100 text-blue-700',
liability: 'bg-red-100 text-red-700',
equity: 'bg-purple-100 text-purple-700',
income: 'bg-green-100 text-green-700',
expense: 'bg-amber-100 text-amber-700',
};
// Helper function to format currency with 2 decimals
const formatCurrency = (value: number): string => {
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
export function AccountsPage() {
const [selectedType, setSelectedType] = useState<AccountTypeEnum | ''>('');
const [searchTerm, setSearchTerm] = useState('');
const [showDeprecated, setShowDeprecated] = useState(false);
const [accountToDelete, setAccountToDelete] = useState<Account | null>(null);
const { accountTypes } = useAccountTypes();
const {
accounts,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh,
deleteAccount,
} = useAccounts({
search: searchTerm || undefined,
isDeprecated: showDeprecated ? undefined : false,
limit: 20,
});
// Filter accounts by type if selected
const filteredAccounts = selectedType
? accounts.filter(a => {
const accountType = accountTypes.find(t => t.id === a.accountTypeId);
return accountType?.accountType === selectedType;
})
: accounts;
const getActionsMenu = (account: Account): DropdownItem[] => {
return [
{
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', account.id),
},
{
key: 'edit',
label: 'Editar',
icon: <Edit className="h-4 w-4" />,
onClick: () => console.log('Edit', account.id),
},
{
key: 'children',
label: 'Ver subcuentas',
icon: <FolderTree className="h-4 w-4" />,
onClick: () => console.log('Children', account.id),
},
{
key: 'delete',
label: 'Eliminar',
icon: <Trash2 className="h-4 w-4" />,
danger: true,
onClick: () => setAccountToDelete(account),
},
];
};
const columns: Column<Account>[] = [
{
key: 'code',
header: 'Cuenta',
render: (account) => (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50">
<BookOpen className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="font-medium text-gray-900">{account.code}</div>
<div className="text-sm text-gray-500">{account.name}</div>
</div>
</div>
),
},
{
key: 'accountType',
header: 'Tipo',
render: (account) => {
const accountType = accountTypes.find(t => t.id === account.accountTypeId);
const typeKey = accountType?.accountType as AccountTypeEnum;
return (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${accountTypeColors[typeKey] || 'bg-gray-100 text-gray-700'}`}>
{accountTypeLabels[typeKey] || accountType?.name || 'Desconocido'}
</span>
);
},
},
{
key: 'parent',
header: 'Cuenta Padre',
render: (account) => (
<span className="text-sm text-gray-600">
{account.parentName || '-'}
</span>
),
},
{
key: 'balance',
header: 'Saldo',
sortable: true,
render: (account) => (
<div className="text-right">
<span className="font-medium text-gray-900">
${formatCurrency(account.balance || 0)}
</span>
</div>
),
},
{
key: 'reconcilable',
header: 'Conciliable',
render: (account) => (
account.isReconcilable ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-gray-300" />
)
),
},
{
key: 'status',
header: 'Estado',
render: (account) => (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${account.isDeprecated ? 'bg-gray-100 text-gray-500' : 'bg-green-100 text-green-700'}`}>
{account.isDeprecated ? 'Obsoleta' : 'Activa'}
</span>
),
},
{
key: 'actions',
header: '',
render: (account) => (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
items={getActionsMenu(account)}
align="right"
/>
),
},
];
const handleDelete = async () => {
if (accountToDelete) {
await deleteAccount(accountToDelete.id);
setAccountToDelete(null);
}
};
// Calculate summary stats
const assetAccounts = accounts.filter(a => {
const type = accountTypes.find(t => t.id === a.accountTypeId);
return type?.accountType === 'asset';
}).length;
const liabilityAccounts = accounts.filter(a => {
const type = accountTypes.find(t => t.id === a.accountTypeId);
return type?.accountType === 'liability';
}).length;
const incomeAccounts = accounts.filter(a => {
const type = accountTypes.find(t => t.id === a.accountTypeId);
return type?.accountType === 'income';
}).length;
const expenseAccounts = accounts.filter(a => {
const type = accountTypes.find(t => t.id === a.accountTypeId);
return type?.accountType === 'expense';
}).length;
if (error) {
return (
<div className="p-6">
<ErrorEmptyState onRetry={refresh} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[
{ label: 'Contabilidad', href: '/financial' },
{ label: 'Plan de Cuentas' },
]} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Plan de Cuentas</h1>
<p className="text-sm text-gray-500">
Gestiona el catalogo de cuentas contables
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={refresh} disabled={isLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button>
<Plus className="mr-2 h-4 w-4" />
Nueva cuenta
</Button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedType('asset')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<BookOpen className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">Activos</div>
<div className="text-xl font-bold text-blue-600">{assetAccounts}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedType('liability')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
<BookOpen className="h-5 w-5 text-red-600" />
</div>
<div>
<div className="text-sm text-gray-500">Pasivos</div>
<div className="text-xl font-bold text-red-600">{liabilityAccounts}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedType('income')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<BookOpen className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Ingresos</div>
<div className="text-xl font-bold text-green-600">{incomeAccounts}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedType('expense')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
<BookOpen className="h-5 w-5 text-amber-600" />
</div>
<div>
<div className="text-sm text-gray-500">Gastos</div>
<div className="text-xl font-bold text-amber-600">{expenseAccounts}</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Lista de Cuentas</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Buscar cuentas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value as AccountTypeEnum | '')}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Todos los tipos</option>
{Object.entries(accountTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={showDeprecated}
onChange={(e) => setShowDeprecated(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-600">Mostrar obsoletas</span>
</label>
{(selectedType || searchTerm || showDeprecated) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedType('');
setSearchTerm('');
setShowDeprecated(false);
}}
>
Limpiar filtros
</Button>
)}
</div>
{/* Table */}
{filteredAccounts.length === 0 && !isLoading ? (
<NoDataEmptyState
entityName="cuentas contables"
/>
) : (
<DataTable
data={filteredAccounts}
columns={columns}
isLoading={isLoading}
pagination={{
page,
totalPages,
total,
limit: 20,
onPageChange: setPage,
}}
/>
)}
</div>
</CardContent>
</Card>
{/* Delete Account Modal */}
<ConfirmModal
isOpen={!!accountToDelete}
onClose={() => setAccountToDelete(null)}
onConfirm={handleDelete}
title="Eliminar cuenta"
message={`¿Eliminar la cuenta ${accountToDelete?.code} - ${accountToDelete?.name}? Esta accion no se puede deshacer.`}
variant="danger"
confirmText="Eliminar"
/>
</div>
);
}
export default AccountsPage;

View File

@ -0,0 +1,468 @@
import { useState } from 'react';
import {
Receipt,
Plus,
MoreVertical,
Eye,
CheckCircle,
XCircle,
Calendar,
DollarSign,
RefreshCw,
Search,
Send,
CreditCard,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { DataTable, type Column } from '@components/organisms/DataTable';
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { ConfirmModal } from '@components/organisms/Modal';
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
import { useInvoices } from '@features/financial/hooks';
import type { FinancialInvoice, FinancialInvoiceStatus, InvoiceType } from '@features/financial/types';
import { formatDate, formatNumber } from '@utils/formatters';
const statusLabels: Record<FinancialInvoiceStatus, string> = {
draft: 'Borrador',
open: 'Abierta',
paid: 'Pagada',
cancelled: 'Cancelada',
};
const statusColors: Record<FinancialInvoiceStatus, string> = {
draft: 'bg-gray-100 text-gray-700',
open: 'bg-blue-100 text-blue-700',
paid: 'bg-green-100 text-green-700',
cancelled: 'bg-red-100 text-red-700',
};
const invoiceTypeLabels: Record<InvoiceType, string> = {
customer: 'Cliente',
supplier: 'Proveedor',
};
// Helper function to format currency with 2 decimals
const formatCurrency = (value: number): string => {
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
export function InvoicesPage() {
const [selectedStatus, setSelectedStatus] = useState<FinancialInvoiceStatus | ''>('');
const [selectedType, setSelectedType] = useState<InvoiceType | ''>('');
const [searchTerm, setSearchTerm] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [invoiceToValidate, setInvoiceToValidate] = useState<FinancialInvoice | null>(null);
const [invoiceToCancel, setInvoiceToCancel] = useState<FinancialInvoice | null>(null);
const {
invoices,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh,
validateInvoice,
cancelInvoice,
} = useInvoices({
status: selectedStatus || undefined,
invoiceType: selectedType || undefined,
search: searchTerm || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
limit: 20,
});
const getActionsMenu = (invoice: FinancialInvoice): DropdownItem[] => {
const items: DropdownItem[] = [
{
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', invoice.id),
},
];
if (invoice.status === 'draft') {
items.push({
key: 'validate',
label: 'Validar factura',
icon: <Send className="h-4 w-4" />,
onClick: () => setInvoiceToValidate(invoice),
});
items.push({
key: 'cancel',
label: 'Cancelar',
icon: <XCircle className="h-4 w-4" />,
danger: true,
onClick: () => setInvoiceToCancel(invoice),
});
}
if (invoice.status === 'open') {
items.push({
key: 'payment',
label: 'Registrar pago',
icon: <CreditCard className="h-4 w-4" />,
onClick: () => console.log('Register payment', invoice.id),
});
items.push({
key: 'cancel',
label: 'Cancelar factura',
icon: <XCircle className="h-4 w-4" />,
danger: true,
onClick: () => setInvoiceToCancel(invoice),
});
}
return items;
};
const columns: Column<FinancialInvoice>[] = [
{
key: 'number',
header: 'Factura',
render: (invoice) => (
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${invoice.invoiceType === 'customer' ? 'bg-blue-50' : 'bg-amber-50'}`}>
<Receipt className={`h-5 w-5 ${invoice.invoiceType === 'customer' ? 'text-blue-600' : 'text-amber-600'}`} />
</div>
<div>
<div className="font-medium text-gray-900">{invoice.number || 'Sin numero'}</div>
{invoice.ref && (
<div className="text-sm text-gray-500">Ref: {invoice.ref}</div>
)}
</div>
</div>
),
},
{
key: 'type',
header: 'Tipo',
render: (invoice) => (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${invoice.invoiceType === 'customer' ? 'bg-blue-100 text-blue-700' : 'bg-amber-100 text-amber-700'}`}>
{invoiceTypeLabels[invoice.invoiceType]}
</span>
),
},
{
key: 'partner',
header: 'Cliente/Proveedor',
render: (invoice) => (
<div>
<div className="text-sm text-gray-900">{invoice.partnerName || invoice.partnerId}</div>
</div>
),
},
{
key: 'date',
header: 'Fecha',
sortable: true,
render: (invoice) => (
<span className="text-sm text-gray-600">
{formatDate(invoice.invoiceDate, 'short')}
</span>
),
},
{
key: 'dueDate',
header: 'Vencimiento',
render: (invoice) => (
<span className="text-sm text-gray-600">
{invoice.dueDate ? formatDate(invoice.dueDate, 'short') : '-'}
</span>
),
},
{
key: 'amount',
header: 'Total',
sortable: true,
render: (invoice) => (
<div className="text-right">
<div className="font-medium text-gray-900">
${formatCurrency(invoice.amountTotal)}
</div>
{invoice.currencyCode && invoice.currencyCode !== 'MXN' && (
<div className="text-xs text-gray-500">{invoice.currencyCode}</div>
)}
</div>
),
},
{
key: 'residual',
header: 'Pendiente',
render: (invoice) => (
<div className="text-right">
<span className={`font-medium ${invoice.amountResidual > 0 ? 'text-amber-600' : 'text-green-600'}`}>
${formatCurrency(invoice.amountResidual)}
</span>
</div>
),
},
{
key: 'status',
header: 'Estado',
render: (invoice) => (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusColors[invoice.status]}`}>
{statusLabels[invoice.status]}
</span>
),
},
{
key: 'actions',
header: '',
render: (invoice) => (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
items={getActionsMenu(invoice)}
align="right"
/>
),
},
];
const handleValidate = async () => {
if (invoiceToValidate) {
await validateInvoice(invoiceToValidate.id);
setInvoiceToValidate(null);
}
};
const handleCancel = async () => {
if (invoiceToCancel) {
await cancelInvoice(invoiceToCancel.id);
setInvoiceToCancel(null);
}
};
// Calculate summary stats
const draftCount = invoices.filter(i => i.status === 'draft').length;
const openCount = invoices.filter(i => i.status === 'open').length;
const totalAmount = invoices.reduce((sum, i) => sum + i.amountTotal, 0);
const pendingAmount = invoices.filter(i => i.status === 'open').reduce((sum, i) => sum + i.amountResidual, 0);
if (error) {
return (
<div className="p-6">
<ErrorEmptyState onRetry={refresh} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[
{ label: 'Contabilidad', href: '/financial' },
{ label: 'Facturas' },
]} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Facturas</h1>
<p className="text-sm text-gray-500">
Gestiona facturas de clientes y proveedores
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={refresh} disabled={isLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button>
<Plus className="mr-2 h-4 w-4" />
Nueva factura
</Button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('draft')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
<Receipt className="h-5 w-5 text-gray-600" />
</div>
<div>
<div className="text-sm text-gray-500">Borradores</div>
<div className="text-xl font-bold text-gray-900">{draftCount}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('open')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<CheckCircle className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">Abiertas</div>
<div className="text-xl font-bold text-blue-600">{openCount}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
<DollarSign className="h-5 w-5 text-amber-600" />
</div>
<div>
<div className="text-sm text-gray-500">Por Cobrar</div>
<div className="text-xl font-bold text-amber-600">${formatCurrency(pendingAmount)}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<DollarSign className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Total Facturado</div>
<div className="text-xl font-bold text-green-600">${formatCurrency(totalAmount)}</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Lista de Facturas</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Buscar facturas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value as InvoiceType | '')}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Todos los tipos</option>
{Object.entries(invoiceTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as FinancialInvoiceStatus | '')}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Todos los estados</option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-400" />
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<span className="text-gray-400">-</span>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{(selectedStatus || selectedType || searchTerm || dateFrom || dateTo) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedStatus('');
setSelectedType('');
setSearchTerm('');
setDateFrom('');
setDateTo('');
}}
>
Limpiar filtros
</Button>
)}
</div>
{/* Table */}
{invoices.length === 0 && !isLoading ? (
<NoDataEmptyState
entityName="facturas"
/>
) : (
<DataTable
data={invoices}
columns={columns}
isLoading={isLoading}
pagination={{
page,
totalPages,
total,
limit: 20,
onPageChange: setPage,
}}
/>
)}
</div>
</CardContent>
</Card>
{/* Validate Invoice Modal */}
<ConfirmModal
isOpen={!!invoiceToValidate}
onClose={() => setInvoiceToValidate(null)}
onConfirm={handleValidate}
title="Validar factura"
message={`¿Validar la factura ${invoiceToValidate?.number || 'borrador'}? Total: $${invoiceToValidate ? formatCurrency(invoiceToValidate.amountTotal) : '0.00'}`}
variant="success"
confirmText="Validar"
/>
{/* Cancel Invoice Modal */}
<ConfirmModal
isOpen={!!invoiceToCancel}
onClose={() => setInvoiceToCancel(null)}
onConfirm={handleCancel}
title="Cancelar factura"
message={`¿Cancelar la factura ${invoiceToCancel?.number || 'borrador'}? Esta accion no se puede deshacer.`}
variant="danger"
confirmText="Cancelar factura"
/>
</div>
);
}
export default InvoicesPage;

View File

@ -0,0 +1,439 @@
import { useState } from 'react';
import {
FileText,
Plus,
MoreVertical,
Eye,
CheckCircle,
XCircle,
Calendar,
RefreshCw,
Search,
Send,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { DataTable, type Column } from '@components/organisms/DataTable';
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { ConfirmModal } from '@components/organisms/Modal';
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
import { useJournalEntries, useJournals } from '@features/financial/hooks';
import type { JournalEntry, EntryStatus } from '@features/financial/types';
import { formatDate, formatNumber } from '@utils/formatters';
const statusLabels: Record<EntryStatus, string> = {
draft: 'Borrador',
posted: 'Publicado',
cancelled: 'Cancelado',
};
const statusColors: Record<EntryStatus, string> = {
draft: 'bg-gray-100 text-gray-700',
posted: 'bg-green-100 text-green-700',
cancelled: 'bg-red-100 text-red-700',
};
// Helper function to format currency with 2 decimals
const formatCurrency = (value: number): string => {
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
// Helper function to get current date
const getToday = (): string => {
const today = new Date().toISOString().split('T')[0];
return today || '';
};
export function JournalEntriesPage() {
const [selectedStatus, setSelectedStatus] = useState<EntryStatus | ''>('');
const [selectedJournal, setSelectedJournal] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [entryToPost, setEntryToPost] = useState<JournalEntry | null>(null);
const [entryToCancel, setEntryToCancel] = useState<JournalEntry | null>(null);
const { journals } = useJournals();
const {
entries,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh,
postEntry,
cancelEntry,
} = useJournalEntries({
status: selectedStatus || undefined,
journalId: selectedJournal || undefined,
search: searchTerm || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
limit: 20,
});
const getActionsMenu = (entry: JournalEntry): DropdownItem[] => {
const items: DropdownItem[] = [
{
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', entry.id),
},
];
if (entry.status === 'draft') {
items.push({
key: 'post',
label: 'Publicar asiento',
icon: <Send className="h-4 w-4" />,
onClick: () => setEntryToPost(entry),
});
items.push({
key: 'cancel',
label: 'Cancelar',
icon: <XCircle className="h-4 w-4" />,
danger: true,
onClick: () => setEntryToCancel(entry),
});
}
if (entry.status === 'posted') {
items.push({
key: 'cancel',
label: 'Cancelar asiento',
icon: <XCircle className="h-4 w-4" />,
danger: true,
onClick: () => setEntryToCancel(entry),
});
}
return items;
};
const columns: Column<JournalEntry>[] = [
{
key: 'name',
header: 'Asiento',
render: (entry) => (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-50">
<FileText className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="font-medium text-gray-900">{entry.name}</div>
{entry.ref && (
<div className="text-sm text-gray-500">Ref: {entry.ref}</div>
)}
</div>
</div>
),
},
{
key: 'journal',
header: 'Diario',
render: (entry) => (
<div>
<div className="text-sm text-gray-900">{entry.journalName || entry.journalCode}</div>
</div>
),
},
{
key: 'date',
header: 'Fecha',
sortable: true,
render: (entry) => (
<span className="text-sm text-gray-600">
{formatDate(entry.date, 'short')}
</span>
),
},
{
key: 'debit',
header: 'Debe',
render: (entry) => (
<div className="text-right">
<span className="font-medium text-gray-900">
${formatCurrency(entry.totalDebit || 0)}
</span>
</div>
),
},
{
key: 'credit',
header: 'Haber',
render: (entry) => (
<div className="text-right">
<span className="font-medium text-gray-900">
${formatCurrency(entry.totalCredit || 0)}
</span>
</div>
),
},
{
key: 'status',
header: 'Estado',
render: (entry) => (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusColors[entry.status]}`}>
{statusLabels[entry.status]}
</span>
),
},
{
key: 'actions',
header: '',
render: (entry) => (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
items={getActionsMenu(entry)}
align="right"
/>
),
},
];
const handlePost = async () => {
if (entryToPost) {
await postEntry(entryToPost.id);
setEntryToPost(null);
}
};
const handleCancel = async () => {
if (entryToCancel) {
await cancelEntry(entryToCancel.id);
setEntryToCancel(null);
}
};
// Calculate summary stats
const draftCount = entries.filter(e => e.status === 'draft').length;
const postedCount = entries.filter(e => e.status === 'posted').length;
const totalDebit = entries.reduce((sum, e) => sum + (e.totalDebit || 0), 0);
const todayEntries = entries.filter(e => e.date === getToday()).length;
if (error) {
return (
<div className="p-6">
<ErrorEmptyState onRetry={refresh} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[
{ label: 'Contabilidad', href: '/financial' },
{ label: 'Asientos Contables' },
]} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Asientos Contables</h1>
<p className="text-sm text-gray-500">
Registra y gestiona movimientos contables
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={refresh} disabled={isLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button>
<Plus className="mr-2 h-4 w-4" />
Nuevo asiento
</Button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('draft')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
<FileText className="h-5 w-5 text-gray-600" />
</div>
<div>
<div className="text-sm text-gray-500">Borradores</div>
<div className="text-xl font-bold text-gray-900">{draftCount}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('posted')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<CheckCircle className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Publicados</div>
<div className="text-xl font-bold text-green-600">{postedCount}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<Calendar className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">Hoy</div>
<div className="text-xl font-bold text-blue-600">{todayEntries}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
<FileText className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="text-sm text-gray-500">Total Debe</div>
<div className="text-xl font-bold text-purple-600">${formatCurrency(totalDebit)}</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Lista de Asientos</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Buscar asientos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as EntryStatus | '')}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Todos los estados</option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<select
value={selectedJournal}
onChange={(e) => setSelectedJournal(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Todos los diarios</option>
{journals.map((journal) => (
<option key={journal.id} value={journal.id}>{journal.name}</option>
))}
</select>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-400" />
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<span className="text-gray-400">-</span>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{(selectedStatus || selectedJournal || searchTerm || dateFrom || dateTo) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedStatus('');
setSelectedJournal('');
setSearchTerm('');
setDateFrom('');
setDateTo('');
}}
>
Limpiar filtros
</Button>
)}
</div>
{/* Table */}
{entries.length === 0 && !isLoading ? (
<NoDataEmptyState
entityName="asientos contables"
/>
) : (
<DataTable
data={entries}
columns={columns}
isLoading={isLoading}
pagination={{
page,
totalPages,
total,
limit: 20,
onPageChange: setPage,
}}
/>
)}
</div>
</CardContent>
</Card>
{/* Post Entry Modal */}
<ConfirmModal
isOpen={!!entryToPost}
onClose={() => setEntryToPost(null)}
onConfirm={handlePost}
title="Publicar asiento"
message={`¿Publicar el asiento ${entryToPost?.name}? Una vez publicado, no podra modificarse.`}
variant="success"
confirmText="Publicar"
/>
{/* Cancel Entry Modal */}
<ConfirmModal
isOpen={!!entryToCancel}
onClose={() => setEntryToCancel(null)}
onConfirm={handleCancel}
title="Cancelar asiento"
message={`¿Cancelar el asiento ${entryToCancel?.name}? Esta accion no se puede deshacer.`}
variant="danger"
confirmText="Cancelar asiento"
/>
</div>
);
}
export default JournalEntriesPage;

View File

@ -0,0 +1,3 @@
export { AccountsPage, default as AccountsPageDefault } from './AccountsPage';
export { JournalEntriesPage, default as JournalEntriesPageDefault } from './JournalEntriesPage';
export { InvoicesPage, default as InvoicesPageDefault } from './InvoicesPage';