Files
fintrack/app/transactions/page.tsx

493 lines
15 KiB
TypeScript

"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<string[]>(["all"]);
useEffect(() => {
const accountId = searchParams.get("accountId");
if (accountId) {
setSelectedAccounts([accountId]);
}
}, [searchParams]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
const [showReconciled, setShowReconciled] = useState<string>("all");
const [sortField, setSortField] = useState<SortField>("date");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
new Set()
);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(null);
// Transactions filtered for account filter (by categories, search, reconciled - not accounts)
const transactionsForAccountFilter = 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 (!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
);
}
return transactions;
}, [data, searchQuery, selectedCategories, showReconciled]);
// Transactions filtered for category filter (by accounts, search, reconciled - not categories)
const transactionsForCategoryFilter = 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 (showReconciled !== "all") {
const isReconciled = showReconciled === "reconciled";
transactions = transactions.filter(
(t) => t.isReconciled === isReconciled
);
}
return transactions;
}, [data, searchQuery, selectedAccounts, showReconciled]);
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 <LoadingState />;
}
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 (
<PageLayout>
<PageHeader
title="Transactions"
description={`${filteredTransactions.length} transaction${filteredTransactions.length > 1 ? "s" : ""}`}
actions={
<OFXImportDialog onImportComplete={refresh}>
<Button>
<Upload className="w-4 h-4 mr-2" />
Importer OFX
</Button>
</OFXImportDialog>
}
/>
<TransactionFilters
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
selectedAccounts={selectedAccounts}
onAccountsChange={setSelectedAccounts}
selectedCategories={selectedCategories}
onCategoriesChange={setSelectedCategories}
showReconciled={showReconciled}
onReconciledChange={setShowReconciled}
accounts={data.accounts}
folders={data.folders}
categories={data.categories}
transactionsForAccountFilter={transactionsForAccountFilter}
transactionsForCategoryFilter={transactionsForCategoryFilter}
/>
<TransactionBulkActions
selectedCount={selectedTransactions.size}
categories={data.categories}
onReconcile={bulkReconcile}
onSetCategory={bulkSetCategory}
/>
<TransactionTable
transactions={filteredTransactions}
accounts={data.accounts}
categories={data.categories}
selectedTransactions={selectedTransactions}
sortField={sortField}
sortOrder={sortOrder}
onSortChange={handleSortChange}
onToggleSelectAll={toggleSelectAll}
onToggleSelectTransaction={toggleSelectTransaction}
onToggleReconciled={toggleReconciled}
onMarkReconciled={markReconciled}
onSetCategory={setCategory}
onCreateRule={handleCreateRule}
formatCurrency={formatCurrency}
formatDate={formatDate}
/>
<RuleCreateDialog
open={ruleDialogOpen}
onOpenChange={setRuleDialogOpen}
group={ruleGroup}
categories={data.categories}
onSave={handleSaveRule}
/>
</PageLayout>
);
}