diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx index d9bc8c8..bba67a6 100644 --- a/app/transactions/page.tsx +++ b/app/transactions/page.tsx @@ -1,537 +1,124 @@ "use client"; -import { useState, useMemo, useEffect, useCallback } from "react"; -import { useSearchParams } from "next/navigation"; +import { useCallback } from "react"; import { PageLayout, PageHeader } from "@/components/layout"; import { RefreshCw } from "lucide-react"; import { TransactionFilters, TransactionBulkActions, TransactionTable, + TransactionPagination, + formatCurrency, + formatDate, } from "@/components/transactions"; import { RuleCreateDialog } from "@/components/rules"; import { OFXImportDialog } from "@/components/import/ofx-import-dialog"; -import { - useBankingMetadata, - useTransactions, - getTransactionsQueryKey, - useDuplicateIds, -} from "@/lib/hooks"; -import { updateCategory } from "@/lib/store-db"; import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Upload } from "lucide-react"; -import type { Transaction } from "@/lib/types"; -import type { TransactionsPaginatedParams } from "@/services/banking.service"; -import { - normalizeDescription, - suggestKeyword, -} from "@/components/rules/constants"; - -type SortField = "date" | "amount" | "description"; -type SortOrder = "asc" | "desc"; -type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all"; - -const PAGE_SIZE = 100; +import { useTransactionsPage } from "@/hooks/use-transactions-page"; +import { useTransactionMutations } from "@/hooks/use-transaction-mutations"; +import { useTransactionRules } from "@/hooks/use-transaction-rules"; export default function TransactionsPage() { - const searchParams = useSearchParams(); const queryClient = useQueryClient(); - const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata(); - const [searchQuery, setSearchQuery] = useState(""); - const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); - const [selectedAccounts, setSelectedAccounts] = useState(["all"]); - const [page, setPage] = useState(0); - // Debounce search query - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchQuery(searchQuery); - setPage(0); // Reset to first page when search changes - }, 300); - return () => clearTimeout(timer); - }, [searchQuery]); - - useEffect(() => { - const accountId = searchParams.get("accountId"); - if (accountId) { - setSelectedAccounts([accountId]); - setPage(0); - } - }, [searchParams]); - - const [selectedCategories, setSelectedCategories] = useState([ - "all", - ]); - const [showReconciled, setShowReconciled] = useState("all"); - const [period, setPeriod] = useState("all"); - const [customStartDate, setCustomStartDate] = useState( - undefined - ); - const [customEndDate, setCustomEndDate] = useState( - undefined - ); - const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false); - const [sortField, setSortField] = useState("date"); - const [sortOrder, setSortOrder] = useState("desc"); - const [selectedTransactions, setSelectedTransactions] = useState>( - new Set() - ); - const [ruleDialogOpen, setRuleDialogOpen] = useState(false); - const [ruleTransaction, setRuleTransaction] = useState( - null - ); - const [updatingTransactionIds, setUpdatingTransactionIds] = useState< - Set - >(new Set()); - const [showDuplicates, setShowDuplicates] = useState(false); - - // Get start date based on period - const startDate = useMemo(() => { - const now = new Date(); - switch (period) { - case "1month": - return new Date(now.getFullYear(), now.getMonth() - 1, 1); - case "3months": - return new Date(now.getFullYear(), now.getMonth() - 3, 1); - case "6months": - return new Date(now.getFullYear(), now.getMonth() - 6, 1); - case "12months": - return new Date(now.getFullYear(), now.getMonth() - 12, 1); - case "custom": - return customStartDate || new Date(0); - default: - return new Date(0); - } - }, [period, customStartDate]); - - // Get end date (only for custom period) - const endDate = useMemo(() => { - if (period === "custom" && customEndDate) { - return customEndDate; - } - return undefined; - }, [period, customEndDate]); - - // Build transaction query params - const transactionParams = useMemo(() => { - const params: TransactionsPaginatedParams = { - limit: PAGE_SIZE, - offset: page * PAGE_SIZE, - sortField, - sortOrder, - }; - - if (startDate && period !== "all") { - params.startDate = startDate.toISOString().split("T")[0]; - } - if (endDate) { - params.endDate = endDate.toISOString().split("T")[0]; - } - if (!selectedAccounts.includes("all")) { - params.accountIds = selectedAccounts; - } - if (!selectedCategories.includes("all")) { - if (selectedCategories.includes("uncategorized")) { - params.includeUncategorized = true; - } else { - params.categoryIds = selectedCategories; - } - } - if (debouncedSearchQuery) { - params.search = debouncedSearchQuery; - } - if (showReconciled !== "all") { - params.isReconciled = showReconciled === "reconciled"; - } - - return params; - }, [ - page, - startDate, - endDate, + // Main page state and logic + const { + metadata, + isLoadingMetadata, + searchQuery, + setSearchQuery, selectedAccounts, + onAccountsChange, selectedCategories, - debouncedSearchQuery, + onCategoriesChange, showReconciled, + onReconciledChange, + period, + onPeriodChange, + customStartDate, + customEndDate, + onCustomStartDateChange, + onCustomEndDateChange, + isCustomDatePickerOpen, + onCustomDatePickerOpenChange, + showDuplicates, + onShowDuplicatesChange, + page, + pageSize, + onPageChange, sortField, sortOrder, - period, - ]); + onSortChange, + selectedTransactions, + onToggleSelectAll, + onToggleSelectTransaction, + clearSelection, + transactionsData, + isLoadingTransactions, + invalidateTransactions, + duplicateIds, + transactionParams, + } = useTransactionsPage(); - // Fetch transactions with pagination + // Transaction mutations const { - data: transactionsData, - isLoading: isLoadingTransactions, - invalidate: invalidateTransactions, - } = useTransactions(transactionParams, !!metadata); + toggleReconciled, + markReconciled, + setCategory, + deleteTransaction, + bulkReconcile: handleBulkReconcile, + bulkSetCategory: handleBulkSetCategory, + updatingTransactionIds, + } = useTransactionMutations({ + transactionParams, + transactionsData, + invalidateTransactions, + }); - // Fetch duplicate IDs - const { data: duplicateIds = new Set() } = useDuplicateIds(); - - // For filter comboboxes, we'll use empty arrays for now - // They can be enhanced later with separate queries if needed - const transactionsForAccountFilter: Transaction[] = []; - const transactionsForCategoryFilter: Transaction[] = []; - - const handleCreateRule = useCallback((transaction: Transaction) => { - setRuleTransaction(transaction); - setRuleDialogOpen(true); - }, []); - - // Create a virtual group for the rule dialog based on selected transaction - // Note: This requires fetching similar transactions - simplified for now - const ruleGroup = useMemo(() => { - if (!ruleTransaction || !transactionsData) return null; - - // Use transactions from current page to find similar ones - const normalizedDesc = normalizeDescription(ruleTransaction.description); - const similarTransactions = transactionsData.transactions.filter( - (t) => normalizeDescription(t.description) === normalizedDesc - ); - - if (similarTransactions.length === 0) return null; - - return { - key: normalizedDesc, - displayName: ruleTransaction.description, - transactions: similarTransactions, - totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0), - suggestedKeyword: suggestKeyword( - similarTransactions.map((t) => t.description) - ), - }; - }, [ruleTransaction, transactionsData]); - - const handleSaveRule = useCallback( - async (ruleData: { - keyword: string; - categoryId: string; - applyToExisting: boolean; - transactionIds: string[]; - }) => { - if (!metadata) return; - - // 1. Add keyword to category - const category = metadata.categories.find( - (c: { id: string }) => c.id === ruleData.categoryId - ); - if (!category) { - throw new Error("Category not found"); - } - - // Check if keyword already exists - const keywordExists = category.keywords.some( - (k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase() - ); - - if (!keywordExists) { - await updateCategory({ - ...category, - keywords: [...category.keywords, ruleData.keyword], - }); - } - - // 2. Apply to existing transactions if requested - if (ruleData.applyToExisting) { - await Promise.all( - ruleData.transactionIds.map((id) => - fetch("/api/banking/transactions", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id, categoryId: ruleData.categoryId }), - }) - ) - ); - } - - // Invalidate queries - queryClient.invalidateQueries({ queryKey: ["transactions"] }); - queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); - setRuleDialogOpen(false); - }, - [metadata, queryClient] - ); + // Transaction rules + const { + ruleDialogOpen, + setRuleDialogOpen, + ruleGroup, + handleCreateRule, + handleSaveRule, + } = useTransactionRules({ + transactionsData, + metadata, + }); const invalidateAll = useCallback(() => { invalidateTransactions(); queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); }, [invalidateTransactions, queryClient]); - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat("fr-FR", { - style: "currency", - currency: "EUR", - }).format(amount); - }; + const handleBulkReconcileWithClear = useCallback( + (reconciled: boolean) => { + handleBulkReconcile(reconciled, selectedTransactions); + clearSelection(); + }, + [handleBulkReconcile, selectedTransactions, clearSelection] + ); - const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleDateString("fr-FR", { - day: "2-digit", - month: "short", - year: "numeric", - }); - }; - - const toggleReconciled = async (transactionId: string) => { - if (!transactionsData) return; - - const transaction = transactionsData.transactions.find( - (t) => t.id === transactionId - ); - if (!transaction) return; - - const updatedTransaction = { - ...transaction, - isReconciled: !transaction.isReconciled, - }; - - try { - await fetch("/api/banking/transactions", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updatedTransaction), - }); - invalidateTransactions(); - } catch (error) { - console.error("Failed to update transaction:", error); - } - }; - - const markReconciled = async (transactionId: string) => { - if (!transactionsData) return; - - const transaction = transactionsData.transactions.find( - (t) => t.id === transactionId - ); - if (!transaction || transaction.isReconciled) return; - - const updatedTransaction = { - ...transaction, - isReconciled: true, - }; - - try { - await fetch("/api/banking/transactions", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updatedTransaction), - }); - invalidateTransactions(); - } catch (error) { - console.error("Failed to update transaction:", error); - } - }; - - const setCategory = async ( - transactionId: string, - categoryId: string | null - ) => { - if (!transactionsData) return; - - const transaction = transactionsData.transactions.find( - (t) => t.id === transactionId - ); - if (!transaction) return; - - setUpdatingTransactionIds((prev) => new Set(prev).add(transactionId)); - - try { - const response = await fetch("/api/banking/transactions", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...transaction, categoryId }), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - // Mise à jour directe du cache après succès - const queryKey = getTransactionsQueryKey(transactionParams); - queryClient.setQueryData(queryKey, (oldData) => { - if (!oldData) return oldData; - - return { - ...oldData, - transactions: oldData.transactions.map((t) => - t.id === transactionId ? { ...t, categoryId } : t - ), - }; - }); - } catch (error) { - console.error("Failed to update transaction:", error); - invalidateTransactions(); - } finally { - setUpdatingTransactionIds((prev) => { - const next = new Set(prev); - next.delete(transactionId); - return next; - }); - } - }; - - const bulkReconcile = async (reconciled: boolean) => { - if (!transactionsData) return; - - const transactionsToUpdate = transactionsData.transactions.filter((t) => - selectedTransactions.has(t.id) - ); - - setSelectedTransactions(new Set()); - - try { - await Promise.all( - transactionsToUpdate.map((t) => - fetch("/api/banking/transactions", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...t, isReconciled: reconciled }), - }) - ) - ); - invalidateTransactions(); - } catch (error) { - console.error("Failed to update transactions:", error); - } - }; - - const bulkSetCategory = async (categoryId: string | null) => { - if (!transactionsData) return; - - const transactionsToUpdate = transactionsData.transactions.filter((t) => - selectedTransactions.has(t.id) - ); - - const transactionIds = transactionsToUpdate.map((t) => t.id); - setSelectedTransactions(new Set()); - setUpdatingTransactionIds((prev) => { - const next = new Set(prev); - transactionIds.forEach((id) => next.add(id)); - return next; - }); - - try { - await Promise.all( - transactionsToUpdate.map((t) => - fetch("/api/banking/transactions", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...t, categoryId }), - }) - ) - ); - - // Mise à jour directe du cache après succès - const queryKey = getTransactionsQueryKey(transactionParams); - queryClient.setQueryData(queryKey, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - transactions: oldData.transactions.map((t) => - transactionIds.includes(t.id) ? { ...t, categoryId } : t - ), - }; - }); - } catch (error) { - console.error("Failed to update transactions:", error); - invalidateTransactions(); - } finally { - setUpdatingTransactionIds((prev) => { - const next = new Set(prev); - transactionIds.forEach((id) => next.delete(id)); - return next; - }); - } - }; - - const toggleSelectAll = () => { - if (!transactionsData) return; - if (selectedTransactions.size === transactionsData.transactions.length) { - setSelectedTransactions(new Set()); - } else { - setSelectedTransactions( - new Set(transactionsData.transactions.map((t) => t.id)) - ); - } - }; - - const toggleSelectTransaction = (id: string) => { - const newSelected = new Set(selectedTransactions); - if (newSelected.has(id)) { - newSelected.delete(id); - } else { - newSelected.add(id); - } - setSelectedTransactions(newSelected); - }; - - const handleSortChange = (field: SortField) => { - if (sortField === field) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortField(field); - setSortOrder(field === "date" ? "desc" : "asc"); - } - setPage(0); - }; - - const deleteTransaction = async (transactionId: string) => { - // Remove from selected if selected - const newSelected = new Set(selectedTransactions); - newSelected.delete(transactionId); - setSelectedTransactions(newSelected); - - // Sauvegarder les données actuelles pour pouvoir les restaurer en cas d'erreur - const queryKey = getTransactionsQueryKey(transactionParams); - const previousData = - queryClient.getQueryData(queryKey); - - // Mise à jour optimiste du cache - queryClient.setQueryData(queryKey, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - transactions: oldData.transactions.filter( - (t) => t.id !== transactionId - ), - total: oldData.total - 1, - }; - }); - - try { - const response = await fetch( - `/api/banking/transactions?id=${transactionId}`, - { - method: "DELETE", - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.error || `Failed to delete transaction: ${response.status}` - ); - } - - // Ne pas invalider immédiatement - la mise à jour optimiste est déjà correcte - // On invalide seulement les autres queries qui pourraient être affectées (métadonnées, stats) - queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); - } catch (error) { - console.error("Failed to delete transaction:", error); - // Restaurer les données précédentes en cas d'erreur - if (previousData) { - queryClient.setQueryData(queryKey, previousData); - } - // Invalider pour récupérer les données correctes du serveur - invalidateTransactions(); - } - }; + const handleBulkSetCategoryWithClear = useCallback( + (categoryId: string | null) => { + handleBulkSetCategory(categoryId, selectedTransactions); + clearSelection(); + }, + [handleBulkSetCategory, selectedTransactions, clearSelection] + ); const filteredTransactions = transactionsData?.transactions || []; const totalTransactions = transactionsData?.total || 0; const hasMore = transactionsData?.hasMore || false; + // For filter comboboxes, we'll use empty arrays for now + // They can be enhanced later with separate queries if needed + const transactionsForAccountFilter: never[] = []; + const transactionsForCategoryFilter: never[] = []; + // Early return for loading state - prevents sidebar flash if (isLoadingMetadata || !metadata) { return ( @@ -562,44 +149,21 @@ export default function TransactionsPage() { searchQuery={searchQuery} onSearchChange={setSearchQuery} selectedAccounts={selectedAccounts} - onAccountsChange={(accounts) => { - setSelectedAccounts(accounts); - setPage(0); - }} + onAccountsChange={onAccountsChange} selectedCategories={selectedCategories} - onCategoriesChange={(categories) => { - setPage(0); - setSelectedCategories(categories); - }} + onCategoriesChange={onCategoriesChange} showReconciled={showReconciled} - onReconciledChange={(value) => { - setShowReconciled(value); - setPage(0); - }} + onReconciledChange={onReconciledChange} period={period} - onPeriodChange={(p) => { - setPeriod(p); - setPage(0); - if (p !== "custom") { - setIsCustomDatePickerOpen(false); - } else { - setIsCustomDatePickerOpen(true); - } - }} + onPeriodChange={onPeriodChange} customStartDate={customStartDate} customEndDate={customEndDate} - onCustomStartDateChange={(date) => { - setCustomStartDate(date); - setPage(0); - }} - onCustomEndDateChange={(date) => { - setCustomEndDate(date); - setPage(0); - }} + onCustomStartDateChange={onCustomStartDateChange} + onCustomEndDateChange={onCustomEndDateChange} isCustomDatePickerOpen={isCustomDatePickerOpen} - onCustomDatePickerOpenChange={setIsCustomDatePickerOpen} + onCustomDatePickerOpenChange={onCustomDatePickerOpenChange} showDuplicates={showDuplicates} - onShowDuplicatesChange={setShowDuplicates} + onShowDuplicatesChange={onShowDuplicatesChange} accounts={metadata.accounts} folders={metadata.folders} categories={metadata.categories} @@ -610,8 +174,8 @@ export default function TransactionsPage() { {isLoadingTransactions ? ( @@ -627,9 +191,9 @@ export default function TransactionsPage() { selectedTransactions={selectedTransactions} sortField={sortField} sortOrder={sortOrder} - onSortChange={handleSortChange} - onToggleSelectAll={toggleSelectAll} - onToggleSelectTransaction={toggleSelectTransaction} + onSortChange={onSortChange} + onToggleSelectAll={onToggleSelectAll} + onToggleSelectTransaction={onToggleSelectTransaction} onToggleReconciled={toggleReconciled} onMarkReconciled={markReconciled} onSetCategory={setCategory} @@ -642,34 +206,13 @@ export default function TransactionsPage() { highlightDuplicates={showDuplicates} /> - {/* Pagination controls */} - {totalTransactions > PAGE_SIZE && ( -
-
- Affichage de {page * PAGE_SIZE + 1} à{" "} - {Math.min((page + 1) * PAGE_SIZE, totalTransactions)} sur{" "} - {totalTransactions} -
-
- - -
-
- )} + )} diff --git a/components/transactions/index.ts b/components/transactions/index.ts index 4ee5d53..48be156 100644 --- a/components/transactions/index.ts +++ b/components/transactions/index.ts @@ -1,3 +1,5 @@ export { TransactionFilters } from "./transaction-filters"; export { TransactionBulkActions } from "./transaction-bulk-actions"; export { TransactionTable } from "./transaction-table"; +export { TransactionPagination } from "./transaction-pagination"; +export { formatCurrency, formatDate } from "./transaction-utils"; diff --git a/components/transactions/transaction-pagination.tsx b/components/transactions/transaction-pagination.tsx new file mode 100644 index 0000000..3ace454 --- /dev/null +++ b/components/transactions/transaction-pagination.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Button } from "@/components/ui/button"; + +interface TransactionPaginationProps { + page: number; + pageSize: number; + total: number; + hasMore: boolean; + onPageChange: (page: number) => void; +} + +export function TransactionPagination({ + page, + pageSize, + total, + hasMore, + onPageChange, +}: TransactionPaginationProps) { + if (total <= pageSize) { + return null; + } + + return ( +
+
+ Affichage de {page * pageSize + 1} à{" "} + {Math.min((page + 1) * pageSize, total)} sur {total} +
+
+ + +
+
+ ); +} + diff --git a/components/transactions/transaction-utils.ts b/components/transactions/transaction-utils.ts new file mode 100644 index 0000000..2bc0760 --- /dev/null +++ b/components/transactions/transaction-utils.ts @@ -0,0 +1,18 @@ +/** + * Utility functions for transaction formatting + */ + +export const formatCurrency = (amount: number): string => { + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + }).format(amount); +}; + +export const formatDate = (dateStr: string): string => { + return new Date(dateStr).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "short", + year: "numeric", + }); +}; diff --git a/hooks/use-transaction-mutations.ts b/hooks/use-transaction-mutations.ts new file mode 100644 index 0000000..970a8de --- /dev/null +++ b/hooks/use-transaction-mutations.ts @@ -0,0 +1,353 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { Transaction } from "@/lib/types"; +import { getTransactionsQueryKey } from "@/lib/hooks"; +import type { TransactionsPaginatedParams } from "@/services/banking.service"; + +interface UseTransactionMutationsProps { + transactionParams: TransactionsPaginatedParams; + transactionsData: { transactions: Transaction[]; total: number } | undefined; + invalidateTransactions: () => void; +} + +export function useTransactionMutations({ + transactionParams, + transactionsData, + invalidateTransactions, +}: UseTransactionMutationsProps) { + const queryClient = useQueryClient(); + const [updatingTransactionIds, setUpdatingTransactionIds] = useState< + Set + >(new Set()); + + const toggleReconciled = useCallback( + async (transactionId: string) => { + if (!transactionsData) return; + + const transaction = transactionsData.transactions.find( + (t) => t.id === transactionId + ); + if (!transaction) return; + + const newReconciledState = !transaction.isReconciled; + const updatedTransaction = { + ...transaction, + isReconciled: newReconciledState, + }; + + // Optimistic cache update + const queryKey = getTransactionsQueryKey(transactionParams); + const previousData = + queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + transactions: oldData.transactions.map((t) => + t.id === transactionId + ? { ...t, isReconciled: newReconciledState } + : t + ), + }; + }); + + try { + const response = await fetch("/api/banking/transactions", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updatedTransaction), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } catch (error) { + console.error("Failed to update transaction:", error); + // Rollback on error + if (previousData) { + queryClient.setQueryData(queryKey, previousData); + } + invalidateTransactions(); + } + }, + [ + transactionsData, + transactionParams, + queryClient, + invalidateTransactions, + ] + ); + + const markReconciled = useCallback( + async (transactionId: string) => { + if (!transactionsData) return; + + const transaction = transactionsData.transactions.find( + (t) => t.id === transactionId + ); + if (!transaction || transaction.isReconciled) return; + + const updatedTransaction = { + ...transaction, + isReconciled: true, + }; + + // Optimistic cache update + const queryKey = getTransactionsQueryKey(transactionParams); + const previousData = + queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + transactions: oldData.transactions.map((t) => + t.id === transactionId ? { ...t, isReconciled: true } : t + ), + }; + }); + + try { + const response = await fetch("/api/banking/transactions", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updatedTransaction), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } catch (error) { + console.error("Failed to update transaction:", error); + // Rollback on error + if (previousData) { + queryClient.setQueryData(queryKey, previousData); + } + invalidateTransactions(); + } + }, + [ + transactionsData, + transactionParams, + queryClient, + invalidateTransactions, + ] + ); + + const setCategory = useCallback( + async (transactionId: string, categoryId: string | null) => { + if (!transactionsData) return; + + const transaction = transactionsData.transactions.find( + (t) => t.id === transactionId + ); + if (!transaction) return; + + setUpdatingTransactionIds((prev) => new Set(prev).add(transactionId)); + + try { + const response = await fetch("/api/banking/transactions", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...transaction, categoryId }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Optimistic cache update + const queryKey = getTransactionsQueryKey(transactionParams); + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return oldData; + + return { + ...oldData, + transactions: oldData.transactions.map((t) => + t.id === transactionId ? { ...t, categoryId } : t + ), + }; + }); + } catch (error) { + console.error("Failed to update transaction:", error); + invalidateTransactions(); + } finally { + setUpdatingTransactionIds((prev) => { + const next = new Set(prev); + next.delete(transactionId); + return next; + }); + } + }, + [transactionsData, transactionParams, queryClient, invalidateTransactions] + ); + + const deleteTransaction = useCallback( + async (transactionId: string) => { + if (!transactionsData) return; + + // Save current data for rollback + const queryKey = getTransactionsQueryKey(transactionParams); + const previousData = + queryClient.getQueryData(queryKey); + + // Optimistic cache update + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + transactions: oldData.transactions.filter( + (t) => t.id !== transactionId + ), + total: oldData.total - 1, + }; + }); + + try { + const response = await fetch( + `/api/banking/transactions?id=${transactionId}`, + { + method: "DELETE", + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.error || `Failed to delete transaction: ${response.status}` + ); + } + + // Invalidate related queries + queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); + } catch (error) { + console.error("Failed to delete transaction:", error); + // Rollback on error + if (previousData) { + queryClient.setQueryData(queryKey, previousData); + } + invalidateTransactions(); + } + }, + [transactionsData, transactionParams, queryClient, invalidateTransactions] + ); + + const bulkReconcile = useCallback( + async (reconciled: boolean, selectedTransactionIds: Set) => { + if (!transactionsData) return; + + const transactionsToUpdate = transactionsData.transactions.filter((t) => + selectedTransactionIds.has(t.id) + ); + + const transactionIds = transactionsToUpdate.map((t) => t.id); + + // Optimistic cache update + const queryKey = getTransactionsQueryKey(transactionParams); + const previousData = + queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + transactions: oldData.transactions.map((t) => + transactionIds.includes(t.id) + ? { ...t, isReconciled: reconciled } + : t + ), + }; + }); + + try { + await Promise.all( + transactionsToUpdate.map((t) => + fetch("/api/banking/transactions", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...t, isReconciled: reconciled }), + }) + ) + ); + } catch (error) { + console.error("Failed to update transactions:", error); + // Rollback on error + if (previousData) { + queryClient.setQueryData(queryKey, previousData); + } + invalidateTransactions(); + } + }, + [ + transactionsData, + transactionParams, + queryClient, + invalidateTransactions, + ] + ); + + const bulkSetCategory = useCallback( + async (categoryId: string | null, selectedTransactionIds: Set) => { + if (!transactionsData) return; + + const transactionsToUpdate = transactionsData.transactions.filter((t) => + selectedTransactionIds.has(t.id) + ); + + const transactionIds = transactionsToUpdate.map((t) => t.id); + setUpdatingTransactionIds((prev) => { + const next = new Set(prev); + transactionIds.forEach((id) => next.add(id)); + return next; + }); + + try { + await Promise.all( + transactionsToUpdate.map((t) => + fetch("/api/banking/transactions", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...t, categoryId }), + }) + ) + ); + + // Optimistic cache update + const queryKey = getTransactionsQueryKey(transactionParams); + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + transactions: oldData.transactions.map((t) => + transactionIds.includes(t.id) ? { ...t, categoryId } : t + ), + }; + }); + } catch (error) { + console.error("Failed to update transactions:", error); + invalidateTransactions(); + } finally { + setUpdatingTransactionIds((prev) => { + const next = new Set(prev); + transactionIds.forEach((id) => next.delete(id)); + return next; + }); + } + }, + [transactionsData, transactionParams, queryClient, invalidateTransactions] + ); + + return { + toggleReconciled, + markReconciled, + setCategory, + deleteTransaction, + bulkReconcile, + bulkSetCategory, + updatingTransactionIds, + }; +} + diff --git a/hooks/use-transaction-rules.ts b/hooks/use-transaction-rules.ts new file mode 100644 index 0000000..bec1adb --- /dev/null +++ b/hooks/use-transaction-rules.ts @@ -0,0 +1,113 @@ +"use client"; + +import { useState, useMemo, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { Transaction, Category } from "@/lib/types"; +import { updateCategory } from "@/lib/store-db"; +import { + normalizeDescription, + suggestKeyword, +} from "@/components/rules/constants"; + +interface UseTransactionRulesProps { + transactionsData: { transactions: Transaction[] } | undefined; + metadata: { + categories: Category[]; + } | null; +} + +export function useTransactionRules({ + transactionsData, + metadata, +}: UseTransactionRulesProps) { + const queryClient = useQueryClient(); + const [ruleDialogOpen, setRuleDialogOpen] = useState(false); + const [ruleTransaction, setRuleTransaction] = useState( + null + ); + + const handleCreateRule = useCallback((transaction: Transaction) => { + setRuleTransaction(transaction); + setRuleDialogOpen(true); + }, []); + + const ruleGroup = useMemo(() => { + if (!ruleTransaction || !transactionsData) return null; + + const normalizedDesc = normalizeDescription(ruleTransaction.description); + const similarTransactions = transactionsData.transactions.filter( + (t) => normalizeDescription(t.description) === normalizedDesc + ); + + if (similarTransactions.length === 0) return null; + + return { + key: normalizedDesc, + displayName: ruleTransaction.description, + transactions: similarTransactions, + totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0), + suggestedKeyword: suggestKeyword( + similarTransactions.map((t) => t.description) + ), + }; + }, [ruleTransaction, transactionsData]); + + const handleSaveRule = useCallback( + async (ruleData: { + keyword: string; + categoryId: string; + applyToExisting: boolean; + transactionIds: string[]; + }) => { + if (!metadata) return; + + // Add keyword to category + const category = metadata.categories.find( + (c) => c.id === ruleData.categoryId + ); + if (!category) { + throw new Error("Category not found"); + } + + // Check if keyword already exists + const keywordExists = category.keywords.some( + (k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase() + ); + + if (!keywordExists) { + await updateCategory({ + ...category, + keywords: [...category.keywords, ruleData.keyword], + }); + } + + // Apply to existing transactions if requested + if (ruleData.applyToExisting) { + await Promise.all( + ruleData.transactionIds.map((id) => + fetch("/api/banking/transactions", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, categoryId: ruleData.categoryId }), + }) + ) + ); + } + + // Invalidate queries + queryClient.invalidateQueries({ queryKey: ["transactions"] }); + queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); + setRuleDialogOpen(false); + }, + [metadata, queryClient] + ); + + return { + ruleDialogOpen, + setRuleDialogOpen, + ruleGroup, + handleCreateRule, + handleSaveRule, + }; +} + diff --git a/hooks/use-transactions-page.ts b/hooks/use-transactions-page.ts new file mode 100644 index 0000000..eaca98f --- /dev/null +++ b/hooks/use-transactions-page.ts @@ -0,0 +1,286 @@ +"use client"; + +import { useState, useMemo, useEffect, useCallback } from "react"; +import { useSearchParams } from "next/navigation"; +import { + useBankingMetadata, + useTransactions, + useDuplicateIds, +} from "@/lib/hooks"; +import type { TransactionsPaginatedParams } from "@/services/banking.service"; + +type SortField = "date" | "amount" | "description"; +type SortOrder = "asc" | "desc"; +type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all"; + +const PAGE_SIZE = 100; + +export function useTransactionsPage() { + const searchParams = useSearchParams(); + const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata(); + + // Search state + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); + + // Filter state + const [selectedAccounts, setSelectedAccounts] = useState(["all"]); + const [selectedCategories, setSelectedCategories] = useState([ + "all", + ]); + const [showReconciled, setShowReconciled] = useState("all"); + const [period, setPeriod] = useState("all"); + const [customStartDate, setCustomStartDate] = useState( + undefined + ); + const [customEndDate, setCustomEndDate] = useState( + undefined + ); + const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false); + const [showDuplicates, setShowDuplicates] = useState(false); + + // Pagination state + const [page, setPage] = useState(0); + + // Sort state + const [sortField, setSortField] = useState("date"); + const [sortOrder, setSortOrder] = useState("desc"); + + // Selection state + const [selectedTransactions, setSelectedTransactions] = useState>( + new Set() + ); + + // Debounce search query + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + setPage(0); + }, 300); + return () => clearTimeout(timer); + }, [searchQuery]); + + // Handle accountId from URL params + useEffect(() => { + const accountId = searchParams.get("accountId"); + if (accountId) { + setSelectedAccounts([accountId]); + setPage(0); + } + }, [searchParams]); + + // Calculate start date based on period + const startDate = useMemo(() => { + const now = new Date(); + switch (period) { + case "1month": + return new Date(now.getFullYear(), now.getMonth() - 1, 1); + case "3months": + return new Date(now.getFullYear(), now.getMonth() - 3, 1); + case "6months": + return new Date(now.getFullYear(), now.getMonth() - 6, 1); + case "12months": + return new Date(now.getFullYear(), now.getMonth() - 12, 1); + case "custom": + return customStartDate || new Date(0); + default: + return new Date(0); + } + }, [period, customStartDate]); + + // Calculate end date (only for custom period) + const endDate = useMemo(() => { + if (period === "custom" && customEndDate) { + return customEndDate; + } + return undefined; + }, [period, customEndDate]); + + // Build transaction query params + const transactionParams = useMemo(() => { + const params: TransactionsPaginatedParams = { + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + sortField, + sortOrder, + }; + + if (startDate && period !== "all") { + params.startDate = startDate.toISOString().split("T")[0]; + } + if (endDate) { + params.endDate = endDate.toISOString().split("T")[0]; + } + if (!selectedAccounts.includes("all")) { + params.accountIds = selectedAccounts; + } + if (!selectedCategories.includes("all")) { + if (selectedCategories.includes("uncategorized")) { + params.includeUncategorized = true; + } else { + params.categoryIds = selectedCategories; + } + } + if (debouncedSearchQuery) { + params.search = debouncedSearchQuery; + } + if (showReconciled !== "all") { + params.isReconciled = showReconciled === "reconciled"; + } + + return params; + }, [ + page, + startDate, + endDate, + selectedAccounts, + selectedCategories, + debouncedSearchQuery, + showReconciled, + sortField, + sortOrder, + period, + ]); + + // Fetch transactions + const { + data: transactionsData, + isLoading: isLoadingTransactions, + invalidate: invalidateTransactions, + } = useTransactions(transactionParams, !!metadata); + + // Fetch duplicate IDs + const { data: duplicateIds = new Set() } = useDuplicateIds(); + + // Handlers + const handleAccountsChange = useCallback((accounts: string[]) => { + setSelectedAccounts(accounts); + setPage(0); + }, []); + + const handleCategoriesChange = useCallback((categories: string[]) => { + setSelectedCategories(categories); + setPage(0); + }, []); + + const handleReconciledChange = useCallback((value: string) => { + setShowReconciled(value); + setPage(0); + }, []); + + const handlePeriodChange = useCallback( + (p: Period) => { + setPeriod(p); + setPage(0); + if (p !== "custom") { + setIsCustomDatePickerOpen(false); + } else { + setIsCustomDatePickerOpen(true); + } + }, + [] + ); + + const handleCustomStartDateChange = useCallback((date: Date | undefined) => { + setCustomStartDate(date); + setPage(0); + }, []); + + const handleCustomEndDateChange = useCallback((date: Date | undefined) => { + setCustomEndDate(date); + setPage(0); + }, []); + + const handleSortChange = useCallback((field: SortField) => { + if (sortField === field) { + setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortField(field); + setSortOrder(field === "date" ? "desc" : "asc"); + } + setPage(0); + }, [sortField]); + + const toggleSelectAll = useCallback(() => { + if (!transactionsData) return; + if (selectedTransactions.size === transactionsData.transactions.length) { + setSelectedTransactions(new Set()); + } else { + setSelectedTransactions( + new Set(transactionsData.transactions.map((t) => t.id)) + ); + } + }, [transactionsData, selectedTransactions.size]); + + const toggleSelectTransaction = useCallback((id: string) => { + setSelectedTransactions((prev) => { + const newSelected = new Set(prev); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + return newSelected; + }); + }, []); + + const handlePageChange = useCallback((newPage: number) => { + setPage(newPage); + }, []); + + const clearSelection = useCallback(() => { + setSelectedTransactions(new Set()); + }, []); + + return { + // Metadata + metadata, + isLoadingMetadata, + + // Search + searchQuery, + setSearchQuery, + + // Filters + selectedAccounts, + onAccountsChange: handleAccountsChange, + selectedCategories, + onCategoriesChange: handleCategoriesChange, + showReconciled, + onReconciledChange: handleReconciledChange, + period, + onPeriodChange: handlePeriodChange, + customStartDate, + customEndDate, + onCustomStartDateChange: handleCustomStartDateChange, + onCustomEndDateChange: handleCustomEndDateChange, + isCustomDatePickerOpen, + onCustomDatePickerOpenChange: setIsCustomDatePickerOpen, + showDuplicates, + onShowDuplicatesChange: setShowDuplicates, + + // Pagination + page, + pageSize: PAGE_SIZE, + onPageChange: handlePageChange, + + // Sort + sortField, + sortOrder, + onSortChange: handleSortChange, + + // Selection + selectedTransactions, + onToggleSelectAll: toggleSelectAll, + onToggleSelectTransaction: toggleSelectTransaction, + clearSelection, + + // Data + transactionsData, + isLoadingTransactions, + invalidateTransactions, + duplicateIds, + transactionParams, + }; +} +