"use client"; import { useState, useMemo, useEffect, useCallback } from "react"; import { useSearchParams } from "next/navigation"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout"; import { TransactionFilters, TransactionBulkActions, TransactionTable, } from "@/components/transactions"; import { RuleCreateDialog } from "@/components/rules"; import { OFXImportDialog } from "@/components/import/ofx-import-dialog"; import { useBankingMetadata, useTransactions } 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; 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 ); // 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, selectedAccounts, selectedCategories, debouncedSearchQuery, showReconciled, sortField, sortOrder, period, ]); // Fetch transactions with pagination const { data: transactionsData, isLoading: isLoadingTransactions, invalidate: invalidateTransactions, } = useTransactions(transactionParams, !!metadata); // Reset page when filters change useEffect(() => { setPage(0); }, [ startDate, endDate, selectedAccounts, selectedCategories, debouncedSearchQuery, showReconciled, sortField, sortOrder, ]); // 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] ); const invalidateAll = useCallback(() => { invalidateTransactions(); queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); }, [invalidateTransactions, queryClient]); if (isLoadingMetadata || !metadata) { return ; } const formatCurrency = (amount: number) => { return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", }).format(amount); }; 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; const updatedTransaction = { ...transaction, categoryId }; 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 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) ); 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, categoryId }), }) ) ); invalidateTransactions(); } catch (error) { console.error("Failed to update transactions:", error); } }; 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); try { const response = await fetch( `/api/banking/transactions?id=${transactionId}`, { method: "DELETE", } ); if (!response.ok) throw new Error("Failed to delete transaction"); invalidateTransactions(); } catch (error) { console.error("Failed to delete transaction:", error); } }; const filteredTransactions = transactionsData?.transactions || []; const totalTransactions = transactionsData?.total || 0; const hasMore = transactionsData?.hasMore || false; return ( 1 ? "s" : ""}`} actions={ } /> { setSelectedAccounts(accounts); setPage(0); }} selectedCategories={selectedCategories} onCategoriesChange={(categories) => { setSelectedCategories(categories); setPage(0); }} showReconciled={showReconciled} onReconciledChange={(value) => { setShowReconciled(value); setPage(0); }} period={period} onPeriodChange={(p) => { setPeriod(p); setPage(0); if (p !== "custom") { setIsCustomDatePickerOpen(false); } else { setIsCustomDatePickerOpen(true); } }} customStartDate={customStartDate} customEndDate={customEndDate} onCustomStartDateChange={(date) => { setCustomStartDate(date); setPage(0); }} onCustomEndDateChange={(date) => { setCustomEndDate(date); setPage(0); }} isCustomDatePickerOpen={isCustomDatePickerOpen} onCustomDatePickerOpenChange={setIsCustomDatePickerOpen} accounts={metadata.accounts} folders={metadata.folders} categories={metadata.categories} transactionsForAccountFilter={transactionsForAccountFilter} transactionsForCategoryFilter={transactionsForCategoryFilter} /> {isLoadingTransactions ? ( ) : ( <> {/* Pagination controls */} {totalTransactions > PAGE_SIZE && (
Affichage de {page * PAGE_SIZE + 1} à{" "} {Math.min((page + 1) * PAGE_SIZE, totalTransactions)} sur{" "} {totalTransactions}
)} )}
); }