diff --git a/src/features/financial/api/financial.api.ts b/src/features/financial/api/financial.api.ts new file mode 100644 index 0000000..7afddbb --- /dev/null +++ b/src/features/financial/api/financial.api.ts @@ -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 => { + const response = await api.get(ACCOUNT_TYPES_URL); + return response.data; + }, + + // ==================== Accounts ==================== + + // Get all accounts with filters + getAccounts: async (filters?: AccountFilters): Promise => { + 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(`${ACCOUNTS_URL}?${params.toString()}`); + return response.data; + }, + + // Get account by ID + getAccountById: async (id: string): Promise => { + const response = await api.get(`${ACCOUNTS_URL}/${id}`); + return response.data; + }, + + // Create account + createAccount: async (data: CreateAccountDto): Promise => { + const response = await api.post(ACCOUNTS_URL, data); + return response.data; + }, + + // Update account + updateAccount: async (id: string, data: UpdateAccountDto): Promise => { + const response = await api.patch(`${ACCOUNTS_URL}/${id}`, data); + return response.data; + }, + + // Delete account + deleteAccount: async (id: string): Promise => { + await api.delete(`${ACCOUNTS_URL}/${id}`); + }, + + // Get accounts by type + getAccountsByType: async (accountTypeId: string, filters?: Omit): Promise => { + return financialApi.getAccounts({ ...filters, accountTypeId }); + }, + + // Get child accounts + getChildAccounts: async (parentId: string, filters?: Omit): Promise => { + return financialApi.getAccounts({ ...filters, parentId }); + }, + + // ==================== Journals ==================== + + // Get all journals with filters + getJournals: async (filters?: JournalFilters): Promise => { + 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(`${JOURNALS_URL}?${params.toString()}`); + return response.data; + }, + + // Get journal by ID + getJournalById: async (id: string): Promise => { + const response = await api.get(`${JOURNALS_URL}/${id}`); + return response.data; + }, + + // Create journal + createJournal: async (data: CreateJournalDto): Promise => { + const response = await api.post(JOURNALS_URL, data); + return response.data; + }, + + // Update journal + updateJournal: async (id: string, data: UpdateJournalDto): Promise => { + const response = await api.patch(`${JOURNALS_URL}/${id}`, data); + return response.data; + }, + + // Delete journal + deleteJournal: async (id: string): Promise => { + await api.delete(`${JOURNALS_URL}/${id}`); + }, + + // Get journals by type + getJournalsByType: async (journalType: string, filters?: Omit): Promise => { + return financialApi.getJournals({ ...filters, journalType: journalType as JournalFilters['journalType'] }); + }, + + // ==================== Journal Entries ==================== + + // Get all journal entries with filters + getEntries: async (filters?: JournalEntryFilters): Promise => { + 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(`${ENTRIES_URL}?${params.toString()}`); + return response.data; + }, + + // Get journal entry by ID + getEntryById: async (id: string): Promise => { + const response = await api.get(`${ENTRIES_URL}/${id}`); + return response.data; + }, + + // Create journal entry + createEntry: async (data: CreateJournalEntryDto): Promise => { + const response = await api.post(ENTRIES_URL, data); + return response.data; + }, + + // Update journal entry + updateEntry: async (id: string, data: UpdateJournalEntryDto): Promise => { + const response = await api.patch(`${ENTRIES_URL}/${id}`, data); + return response.data; + }, + + // Delete journal entry + deleteEntry: async (id: string): Promise => { + await api.delete(`${ENTRIES_URL}/${id}`); + }, + + // Post journal entry + postEntry: async (id: string): Promise => { + const response = await api.post(`${ENTRIES_URL}/${id}/post`); + return response.data; + }, + + // Cancel journal entry + cancelEntry: async (id: string): Promise => { + const response = await api.post(`${ENTRIES_URL}/${id}/cancel`); + return response.data; + }, + + // Get draft entries + getDraftEntries: async (filters?: Omit): Promise => { + return financialApi.getEntries({ ...filters, status: 'draft' }); + }, + + // Get posted entries + getPostedEntries: async (filters?: Omit): Promise => { + return financialApi.getEntries({ ...filters, status: 'posted' }); + }, + + // ==================== Invoices ==================== + + // Get all invoices with filters + getInvoices: async (filters?: InvoiceFilters): Promise => { + 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(`${INVOICES_URL}?${params.toString()}`); + return response.data; + }, + + // Get invoice by ID + getInvoiceById: async (id: string): Promise => { + const response = await api.get(`${INVOICES_URL}/${id}`); + return response.data; + }, + + // Create invoice + createInvoice: async (data: CreateInvoiceDto): Promise => { + const response = await api.post(INVOICES_URL, data); + return response.data; + }, + + // Update invoice + updateInvoice: async (id: string, data: UpdateInvoiceDto): Promise => { + const response = await api.patch(`${INVOICES_URL}/${id}`, data); + return response.data; + }, + + // Delete invoice + deleteInvoice: async (id: string): Promise => { + await api.delete(`${INVOICES_URL}/${id}`); + }, + + // Validate invoice (draft -> open) + validateInvoice: async (id: string): Promise => { + const response = await api.post(`${INVOICES_URL}/${id}/validate`); + return response.data; + }, + + // Cancel invoice + cancelInvoice: async (id: string): Promise => { + const response = await api.post(`${INVOICES_URL}/${id}/cancel`); + return response.data; + }, + + // Get customer invoices + getCustomerInvoices: async (filters?: Omit): Promise => { + return financialApi.getInvoices({ ...filters, invoiceType: 'customer' }); + }, + + // Get supplier invoices + getSupplierInvoices: async (filters?: Omit): Promise => { + return financialApi.getInvoices({ ...filters, invoiceType: 'supplier' }); + }, + + // Get open invoices + getOpenInvoices: async (filters?: Omit): Promise => { + return financialApi.getInvoices({ ...filters, status: 'open' }); + }, + + // Get invoices by partner + getInvoicesByPartner: async (partnerId: string, filters?: Omit): Promise => { + return financialApi.getInvoices({ ...filters, partnerId }); + }, + + // ==================== Payments ==================== + + // Get all payments with filters + getPayments: async (filters?: PaymentFilters): Promise => { + 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(`${PAYMENTS_URL}?${params.toString()}`); + return response.data; + }, + + // Get payment by ID + getPaymentById: async (id: string): Promise => { + const response = await api.get(`${PAYMENTS_URL}/${id}`); + return response.data; + }, + + // Create payment + createPayment: async (data: CreatePaymentDto): Promise => { + const response = await api.post(PAYMENTS_URL, data); + return response.data; + }, + + // Update payment + updatePayment: async (id: string, data: UpdatePaymentDto): Promise => { + const response = await api.patch(`${PAYMENTS_URL}/${id}`, data); + return response.data; + }, + + // Delete payment + deletePayment: async (id: string): Promise => { + await api.delete(`${PAYMENTS_URL}/${id}`); + }, + + // Post payment + postPayment: async (id: string): Promise => { + const response = await api.post(`${PAYMENTS_URL}/${id}/post`); + return response.data; + }, + + // Cancel payment + cancelPayment: async (id: string): Promise => { + const response = await api.post(`${PAYMENTS_URL}/${id}/cancel`); + return response.data; + }, + + // Get inbound payments (customer payments) + getInboundPayments: async (filters?: Omit): Promise => { + return financialApi.getPayments({ ...filters, paymentType: 'inbound' }); + }, + + // Get outbound payments (supplier payments) + getOutboundPayments: async (filters?: Omit): Promise => { + return financialApi.getPayments({ ...filters, paymentType: 'outbound' }); + }, + + // Get payments by partner + getPaymentsByPartner: async (partnerId: string, filters?: Omit): Promise => { + return financialApi.getPayments({ ...filters, partnerId }); + }, +}; diff --git a/src/features/financial/api/index.ts b/src/features/financial/api/index.ts new file mode 100644 index 0000000..a6bdc61 --- /dev/null +++ b/src/features/financial/api/index.ts @@ -0,0 +1 @@ +export { financialApi } from './financial.api'; diff --git a/src/features/financial/hooks/index.ts b/src/features/financial/hooks/index.ts new file mode 100644 index 0000000..57e5474 --- /dev/null +++ b/src/features/financial/hooks/index.ts @@ -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'; diff --git a/src/features/financial/hooks/useFinancial.ts b/src/features/financial/hooks/useFinancial.ts new file mode 100644 index 0000000..e73f395 --- /dev/null +++ b/src/features/financial/hooks/useFinancial.ts @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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([]); + 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(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(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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([]); + 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(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([]); + 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(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(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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([]); + 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(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(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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([]); + 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(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(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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, + }; +} diff --git a/src/features/financial/index.ts b/src/features/financial/index.ts new file mode 100644 index 0000000..1b45c3a --- /dev/null +++ b/src/features/financial/index.ts @@ -0,0 +1,3 @@ +export * from './api/financial.api'; +export * from './types'; +export * from './hooks'; diff --git a/src/features/financial/types/financial.types.ts b/src/features/financial/types/financial.types.ts new file mode 100644 index 0000000..31ba55f --- /dev/null +++ b/src/features/financial/types/financial.types.ts @@ -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; + }; +} diff --git a/src/features/financial/types/index.ts b/src/features/financial/types/index.ts new file mode 100644 index 0000000..6ceefb8 --- /dev/null +++ b/src/features/financial/types/index.ts @@ -0,0 +1 @@ +export * from './financial.types'; diff --git a/src/pages/financial/AccountsPage.tsx b/src/pages/financial/AccountsPage.tsx new file mode 100644 index 0000000..2df34d1 --- /dev/null +++ b/src/pages/financial/AccountsPage.tsx @@ -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 = { + asset: 'Activo', + liability: 'Pasivo', + equity: 'Capital', + income: 'Ingreso', + expense: 'Gasto', +}; + +const accountTypeColors: Record = { + 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(''); + const [searchTerm, setSearchTerm] = useState(''); + const [showDeprecated, setShowDeprecated] = useState(false); + const [accountToDelete, setAccountToDelete] = useState(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: , + onClick: () => console.log('View', account.id), + }, + { + key: 'edit', + label: 'Editar', + icon: , + onClick: () => console.log('Edit', account.id), + }, + { + key: 'children', + label: 'Ver subcuentas', + icon: , + onClick: () => console.log('Children', account.id), + }, + { + key: 'delete', + label: 'Eliminar', + icon: , + danger: true, + onClick: () => setAccountToDelete(account), + }, + ]; + }; + + const columns: Column[] = [ + { + key: 'code', + header: 'Cuenta', + render: (account) => ( +
+
+ +
+
+
{account.code}
+
{account.name}
+
+
+ ), + }, + { + key: 'accountType', + header: 'Tipo', + render: (account) => { + const accountType = accountTypes.find(t => t.id === account.accountTypeId); + const typeKey = accountType?.accountType as AccountTypeEnum; + return ( + + {accountTypeLabels[typeKey] || accountType?.name || 'Desconocido'} + + ); + }, + }, + { + key: 'parent', + header: 'Cuenta Padre', + render: (account) => ( + + {account.parentName || '-'} + + ), + }, + { + key: 'balance', + header: 'Saldo', + sortable: true, + render: (account) => ( +
+ + ${formatCurrency(account.balance || 0)} + +
+ ), + }, + { + key: 'reconcilable', + header: 'Conciliable', + render: (account) => ( + account.isReconcilable ? ( + + ) : ( + + ) + ), + }, + { + key: 'status', + header: 'Estado', + render: (account) => ( + + {account.isDeprecated ? 'Obsoleta' : 'Activa'} + + ), + }, + { + key: 'actions', + header: '', + render: (account) => ( + + + + } + 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Plan de Cuentas

+

+ Gestiona el catalogo de cuentas contables +

+
+
+ + +
+
+ + {/* Summary Stats */} +
+ setSelectedType('asset')}> + +
+
+ +
+
+
Activos
+
{assetAccounts}
+
+
+
+
+ + setSelectedType('liability')}> + +
+
+ +
+
+
Pasivos
+
{liabilityAccounts}
+
+
+
+
+ + setSelectedType('income')}> + +
+
+ +
+
+
Ingresos
+
{incomeAccounts}
+
+
+
+
+ + setSelectedType('expense')}> + +
+
+ +
+
+
Gastos
+
{expenseAccounts}
+
+
+
+
+
+ + + + Lista de Cuentas + + +
+ {/* Filters */} +
+
+ + 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" + /> +
+ + + + + + {(selectedType || searchTerm || showDeprecated) && ( + + )} +
+ + {/* Table */} + {filteredAccounts.length === 0 && !isLoading ? ( + + ) : ( + + )} +
+
+
+ + {/* Delete Account Modal */} + 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" + /> +
+ ); +} + +export default AccountsPage; diff --git a/src/pages/financial/InvoicesPage.tsx b/src/pages/financial/InvoicesPage.tsx new file mode 100644 index 0000000..b3da3ae --- /dev/null +++ b/src/pages/financial/InvoicesPage.tsx @@ -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 = { + draft: 'Borrador', + open: 'Abierta', + paid: 'Pagada', + cancelled: 'Cancelada', +}; + +const statusColors: Record = { + 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 = { + 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(''); + const [selectedType, setSelectedType] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [invoiceToValidate, setInvoiceToValidate] = useState(null); + const [invoiceToCancel, setInvoiceToCancel] = useState(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: , + onClick: () => console.log('View', invoice.id), + }, + ]; + + if (invoice.status === 'draft') { + items.push({ + key: 'validate', + label: 'Validar factura', + icon: , + onClick: () => setInvoiceToValidate(invoice), + }); + items.push({ + key: 'cancel', + label: 'Cancelar', + icon: , + danger: true, + onClick: () => setInvoiceToCancel(invoice), + }); + } + + if (invoice.status === 'open') { + items.push({ + key: 'payment', + label: 'Registrar pago', + icon: , + onClick: () => console.log('Register payment', invoice.id), + }); + items.push({ + key: 'cancel', + label: 'Cancelar factura', + icon: , + danger: true, + onClick: () => setInvoiceToCancel(invoice), + }); + } + + return items; + }; + + const columns: Column[] = [ + { + key: 'number', + header: 'Factura', + render: (invoice) => ( +
+
+ +
+
+
{invoice.number || 'Sin numero'}
+ {invoice.ref && ( +
Ref: {invoice.ref}
+ )} +
+
+ ), + }, + { + key: 'type', + header: 'Tipo', + render: (invoice) => ( + + {invoiceTypeLabels[invoice.invoiceType]} + + ), + }, + { + key: 'partner', + header: 'Cliente/Proveedor', + render: (invoice) => ( +
+
{invoice.partnerName || invoice.partnerId}
+
+ ), + }, + { + key: 'date', + header: 'Fecha', + sortable: true, + render: (invoice) => ( + + {formatDate(invoice.invoiceDate, 'short')} + + ), + }, + { + key: 'dueDate', + header: 'Vencimiento', + render: (invoice) => ( + + {invoice.dueDate ? formatDate(invoice.dueDate, 'short') : '-'} + + ), + }, + { + key: 'amount', + header: 'Total', + sortable: true, + render: (invoice) => ( +
+
+ ${formatCurrency(invoice.amountTotal)} +
+ {invoice.currencyCode && invoice.currencyCode !== 'MXN' && ( +
{invoice.currencyCode}
+ )} +
+ ), + }, + { + key: 'residual', + header: 'Pendiente', + render: (invoice) => ( +
+ 0 ? 'text-amber-600' : 'text-green-600'}`}> + ${formatCurrency(invoice.amountResidual)} + +
+ ), + }, + { + key: 'status', + header: 'Estado', + render: (invoice) => ( + + {statusLabels[invoice.status]} + + ), + }, + { + key: 'actions', + header: '', + render: (invoice) => ( + + + + } + 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Facturas

+

+ Gestiona facturas de clientes y proveedores +

+
+
+ + +
+
+ + {/* Summary Stats */} +
+ setSelectedStatus('draft')}> + +
+
+ +
+
+
Borradores
+
{draftCount}
+
+
+
+
+ + setSelectedStatus('open')}> + +
+
+ +
+
+
Abiertas
+
{openCount}
+
+
+
+
+ + + +
+
+ +
+
+
Por Cobrar
+
${formatCurrency(pendingAmount)}
+
+
+
+
+ + + +
+
+ +
+
+
Total Facturado
+
${formatCurrency(totalAmount)}
+
+
+
+
+
+ + + + Lista de Facturas + + +
+ {/* Filters */} +
+
+ + 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" + /> +
+ + + + + +
+ + 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" + /> + - + 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" + /> +
+ + {(selectedStatus || selectedType || searchTerm || dateFrom || dateTo) && ( + + )} +
+ + {/* Table */} + {invoices.length === 0 && !isLoading ? ( + + ) : ( + + )} +
+
+
+ + {/* Validate Invoice Modal */} + 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 */} + 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" + /> +
+ ); +} + +export default InvoicesPage; diff --git a/src/pages/financial/JournalEntriesPage.tsx b/src/pages/financial/JournalEntriesPage.tsx new file mode 100644 index 0000000..e108e3a --- /dev/null +++ b/src/pages/financial/JournalEntriesPage.tsx @@ -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 = { + draft: 'Borrador', + posted: 'Publicado', + cancelled: 'Cancelado', +}; + +const statusColors: Record = { + 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(''); + const [selectedJournal, setSelectedJournal] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [entryToPost, setEntryToPost] = useState(null); + const [entryToCancel, setEntryToCancel] = useState(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: , + onClick: () => console.log('View', entry.id), + }, + ]; + + if (entry.status === 'draft') { + items.push({ + key: 'post', + label: 'Publicar asiento', + icon: , + onClick: () => setEntryToPost(entry), + }); + items.push({ + key: 'cancel', + label: 'Cancelar', + icon: , + danger: true, + onClick: () => setEntryToCancel(entry), + }); + } + + if (entry.status === 'posted') { + items.push({ + key: 'cancel', + label: 'Cancelar asiento', + icon: , + danger: true, + onClick: () => setEntryToCancel(entry), + }); + } + + return items; + }; + + const columns: Column[] = [ + { + key: 'name', + header: 'Asiento', + render: (entry) => ( +
+
+ +
+
+
{entry.name}
+ {entry.ref && ( +
Ref: {entry.ref}
+ )} +
+
+ ), + }, + { + key: 'journal', + header: 'Diario', + render: (entry) => ( +
+
{entry.journalName || entry.journalCode}
+
+ ), + }, + { + key: 'date', + header: 'Fecha', + sortable: true, + render: (entry) => ( + + {formatDate(entry.date, 'short')} + + ), + }, + { + key: 'debit', + header: 'Debe', + render: (entry) => ( +
+ + ${formatCurrency(entry.totalDebit || 0)} + +
+ ), + }, + { + key: 'credit', + header: 'Haber', + render: (entry) => ( +
+ + ${formatCurrency(entry.totalCredit || 0)} + +
+ ), + }, + { + key: 'status', + header: 'Estado', + render: (entry) => ( + + {statusLabels[entry.status]} + + ), + }, + { + key: 'actions', + header: '', + render: (entry) => ( + + + + } + 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Asientos Contables

+

+ Registra y gestiona movimientos contables +

+
+
+ + +
+
+ + {/* Summary Stats */} +
+ setSelectedStatus('draft')}> + +
+
+ +
+
+
Borradores
+
{draftCount}
+
+
+
+
+ + setSelectedStatus('posted')}> + +
+
+ +
+
+
Publicados
+
{postedCount}
+
+
+
+
+ + + +
+
+ +
+
+
Hoy
+
{todayEntries}
+
+
+
+
+ + + +
+
+ +
+
+
Total Debe
+
${formatCurrency(totalDebit)}
+
+
+
+
+
+ + + + Lista de Asientos + + +
+ {/* Filters */} +
+
+ + 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" + /> +
+ + + + + +
+ + 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" + /> + - + 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" + /> +
+ + {(selectedStatus || selectedJournal || searchTerm || dateFrom || dateTo) && ( + + )} +
+ + {/* Table */} + {entries.length === 0 && !isLoading ? ( + + ) : ( + + )} +
+
+
+ + {/* Post Entry Modal */} + 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 */} + setEntryToCancel(null)} + onConfirm={handleCancel} + title="Cancelar asiento" + message={`¿Cancelar el asiento ${entryToCancel?.name}? Esta accion no se puede deshacer.`} + variant="danger" + confirmText="Cancelar asiento" + /> +
+ ); +} + +export default JournalEntriesPage; diff --git a/src/pages/financial/index.ts b/src/pages/financial/index.ts new file mode 100644 index 0000000..4ceac14 --- /dev/null +++ b/src/pages/financial/index.ts @@ -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';