"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 { useBankingData } from "@/lib/hooks"; import { updateCategory, updateTransaction } from "@/lib/store-db"; import { Button } from "@/components/ui/button"; import { Upload } from "lucide-react"; import type { Transaction } from "@/lib/types"; import { normalizeDescription, suggestKeyword, } from "@/components/rules/constants"; type SortField = "date" | "amount" | "description"; type SortOrder = "asc" | "desc"; export default function TransactionsPage() { const searchParams = useSearchParams(); const { data, isLoading, refresh, update } = useBankingData(); const [searchQuery, setSearchQuery] = useState(""); const [selectedAccounts, setSelectedAccounts] = useState(["all"]); useEffect(() => { const accountId = searchParams.get("accountId"); if (accountId) { setSelectedAccounts([accountId]); } }, [searchParams]); const [selectedCategories, setSelectedCategories] = useState(["all"]); const [showReconciled, setShowReconciled] = useState("all"); 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 filteredTransactions = useMemo(() => { if (!data) return []; let transactions = [...data.transactions]; if (searchQuery) { const query = searchQuery.toLowerCase(); transactions = transactions.filter( (t) => t.description.toLowerCase().includes(query) || t.memo?.toLowerCase().includes(query) ); } if (!selectedAccounts.includes("all")) { transactions = transactions.filter( (t) => selectedAccounts.includes(t.accountId) ); } if (!selectedCategories.includes("all")) { if (selectedCategories.includes("uncategorized")) { transactions = transactions.filter((t) => !t.categoryId); } else { transactions = transactions.filter( (t) => t.categoryId && selectedCategories.includes(t.categoryId) ); } } if (showReconciled !== "all") { const isReconciled = showReconciled === "reconciled"; transactions = transactions.filter( (t) => t.isReconciled === isReconciled ); } transactions.sort((a, b) => { let comparison = 0; switch (sortField) { case "date": comparison = new Date(a.date).getTime() - new Date(b.date).getTime(); break; case "amount": comparison = a.amount - b.amount; break; case "description": comparison = a.description.localeCompare(b.description); break; } return sortOrder === "asc" ? comparison : -comparison; }); return transactions; }, [ data, searchQuery, selectedAccounts, selectedCategories, showReconciled, sortField, sortOrder, ]); const handleCreateRule = useCallback((transaction: Transaction) => { setRuleTransaction(transaction); setRuleDialogOpen(true); }, []); // Create a virtual group for the rule dialog based on selected transaction const ruleGroup = useMemo(() => { if (!ruleTransaction || !data) return null; // Find similar transactions (same normalized description) const normalizedDesc = normalizeDescription(ruleTransaction.description); const similarTransactions = data.transactions.filter( (t) => normalizeDescription(t.description) === normalizedDesc ); 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, data]); const handleSaveRule = useCallback( async (ruleData: { keyword: string; categoryId: string; applyToExisting: boolean; transactionIds: string[]; }) => { if (!data) return; // 1. Add keyword to category const category = data.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) => 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) { const transactions = data.transactions.filter((t) => ruleData.transactionIds.includes(t.id) ); await Promise.all( transactions.map((t) => updateTransaction({ ...t, categoryId: ruleData.categoryId }) ) ); } refresh(); setRuleDialogOpen(false); }, [data, refresh] ); if (isLoading || !data) { 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) => { const transaction = data.transactions.find((t) => t.id === transactionId); if (!transaction) return; const updatedTransaction = { ...transaction, isReconciled: !transaction.isReconciled, }; const updatedTransactions = data.transactions.map((t) => t.id === transactionId ? updatedTransaction : t ); update({ ...data, transactions: updatedTransactions }); try { await fetch("/api/banking/transactions", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updatedTransaction), }); } catch (error) { console.error("Failed to update transaction:", error); refresh(); } }; const markReconciled = async (transactionId: string) => { const transaction = data.transactions.find((t) => t.id === transactionId); if (!transaction || transaction.isReconciled) return; // Skip if already reconciled const updatedTransaction = { ...transaction, isReconciled: true, }; const updatedTransactions = data.transactions.map((t) => t.id === transactionId ? updatedTransaction : t ); update({ ...data, transactions: updatedTransactions }); try { await fetch("/api/banking/transactions", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updatedTransaction), }); } catch (error) { console.error("Failed to update transaction:", error); refresh(); } }; const setCategory = async ( transactionId: string, categoryId: string | null ) => { const transaction = data.transactions.find((t) => t.id === transactionId); if (!transaction) return; const updatedTransaction = { ...transaction, categoryId }; const updatedTransactions = data.transactions.map((t) => t.id === transactionId ? updatedTransaction : t ); update({ ...data, transactions: updatedTransactions }); try { await fetch("/api/banking/transactions", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updatedTransaction), }); } catch (error) { console.error("Failed to update transaction:", error); refresh(); } }; const bulkReconcile = async (reconciled: boolean) => { const transactionsToUpdate = data.transactions.filter((t) => selectedTransactions.has(t.id) ); const updatedTransactions = data.transactions.map((t) => selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t ); update({ ...data, transactions: updatedTransactions }); 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 }), }) ) ); } catch (error) { console.error("Failed to update transactions:", error); refresh(); } }; const bulkSetCategory = async (categoryId: string | null) => { const transactionsToUpdate = data.transactions.filter((t) => selectedTransactions.has(t.id) ); const updatedTransactions = data.transactions.map((t) => selectedTransactions.has(t.id) ? { ...t, categoryId } : t ); update({ ...data, transactions: updatedTransactions }); 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 }), }) ) ); } catch (error) { console.error("Failed to update transactions:", error); refresh(); } }; const toggleSelectAll = () => { if (selectedTransactions.size === filteredTransactions.length) { setSelectedTransactions(new Set()); } else { setSelectedTransactions(new Set(filteredTransactions.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"); } }; return ( 1 ? "s" : ""}`} actions={ } /> ); }