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:
parent
32f2c06264
commit
836ebaf638
356
src/features/financial/api/financial.api.ts
Normal file
356
src/features/financial/api/financial.api.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
1
src/features/financial/api/index.ts
Normal file
1
src/features/financial/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { financialApi } from './financial.api';
|
||||
20
src/features/financial/hooks/index.ts
Normal file
20
src/features/financial/hooks/index.ts
Normal 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';
|
||||
749
src/features/financial/hooks/useFinancial.ts
Normal file
749
src/features/financial/hooks/useFinancial.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
3
src/features/financial/index.ts
Normal file
3
src/features/financial/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './api/financial.api';
|
||||
export * from './types';
|
||||
export * from './hooks';
|
||||
400
src/features/financial/types/financial.types.ts
Normal file
400
src/features/financial/types/financial.types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
1
src/features/financial/types/index.ts
Normal file
1
src/features/financial/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './financial.types';
|
||||
409
src/pages/financial/AccountsPage.tsx
Normal file
409
src/pages/financial/AccountsPage.tsx
Normal 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;
|
||||
468
src/pages/financial/InvoicesPage.tsx
Normal file
468
src/pages/financial/InvoicesPage.tsx
Normal 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;
|
||||
439
src/pages/financial/JournalEntriesPage.tsx
Normal file
439
src/pages/financial/JournalEntriesPage.tsx
Normal 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;
|
||||
3
src/pages/financial/index.ts
Normal file
3
src/pages/financial/index.ts
Normal 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';
|
||||
Loading…
Reference in New Issue
Block a user