617 lines
19 KiB
TypeScript
617 lines
19 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";
|
|
import { format } from "date-fns";
|
|
import { fr } from "date-fns/locale";
|
|
|
|
type SortField = "date" | "amount" | "description";
|
|
type SortOrder = "asc" | "desc";
|
|
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
|
|
|
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 [period, setPeriod] = useState<Period>("all");
|
|
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined);
|
|
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined);
|
|
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
|
|
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);
|
|
|
|
// 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]);
|
|
|
|
// Transactions filtered for account filter (by categories, search, reconciled, period - not accounts)
|
|
const transactionsForAccountFilter = useMemo(() => {
|
|
if (!data) return [];
|
|
|
|
let transactions = [...data.transactions];
|
|
|
|
// Filter by period
|
|
transactions = transactions.filter((t) => {
|
|
const transactionDate = new Date(t.date);
|
|
if (endDate) {
|
|
// Custom date range
|
|
const endOfDay = new Date(endDate);
|
|
endOfDay.setHours(23, 59, 59, 999);
|
|
return transactionDate >= startDate && transactionDate <= endOfDay;
|
|
} else if (period !== "all") {
|
|
// Standard period
|
|
return transactionDate >= startDate;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
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, period, startDate, endDate]);
|
|
|
|
// Transactions filtered for category filter (by accounts, search, reconciled, period - not categories)
|
|
const transactionsForCategoryFilter = useMemo(() => {
|
|
if (!data) return [];
|
|
|
|
let transactions = [...data.transactions];
|
|
|
|
// Filter by period
|
|
transactions = transactions.filter((t) => {
|
|
const transactionDate = new Date(t.date);
|
|
if (endDate) {
|
|
// Custom date range
|
|
const endOfDay = new Date(endDate);
|
|
endOfDay.setHours(23, 59, 59, 999);
|
|
return transactionDate >= startDate && transactionDate <= endOfDay;
|
|
} else if (period !== "all") {
|
|
// Standard period
|
|
return transactionDate >= startDate;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
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, period, startDate, endDate]);
|
|
|
|
const filteredTransactions = useMemo(() => {
|
|
if (!data) return [];
|
|
|
|
let transactions = [...data.transactions];
|
|
|
|
// Filter by period
|
|
transactions = transactions.filter((t) => {
|
|
const transactionDate = new Date(t.date);
|
|
if (endDate) {
|
|
// Custom date range
|
|
const endOfDay = new Date(endDate);
|
|
endOfDay.setHours(23, 59, 59, 999);
|
|
return transactionDate >= startDate && transactionDate <= endOfDay;
|
|
} else if (period !== "all") {
|
|
// Standard period
|
|
return transactionDate >= startDate;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
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,
|
|
period,
|
|
startDate,
|
|
endDate,
|
|
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");
|
|
}
|
|
};
|
|
|
|
const deleteTransaction = async (transactionId: string) => {
|
|
// Optimistic update
|
|
const updatedTransactions = data.transactions.filter(
|
|
(t) => t.id !== transactionId
|
|
);
|
|
update({ ...data, transactions: updatedTransactions });
|
|
|
|
// 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");
|
|
} catch (error) {
|
|
console.error("Failed to delete transaction:", error);
|
|
refresh(); // Revert on error
|
|
}
|
|
};
|
|
|
|
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}
|
|
period={period}
|
|
onPeriodChange={(p) => {
|
|
setPeriod(p);
|
|
if (p !== "custom") {
|
|
setIsCustomDatePickerOpen(false);
|
|
} else {
|
|
setIsCustomDatePickerOpen(true);
|
|
}
|
|
}}
|
|
customStartDate={customStartDate}
|
|
customEndDate={customEndDate}
|
|
onCustomStartDateChange={setCustomStartDate}
|
|
onCustomEndDateChange={setCustomEndDate}
|
|
isCustomDatePickerOpen={isCustomDatePickerOpen}
|
|
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen}
|
|
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}
|
|
onDelete={deleteTransaction}
|
|
formatCurrency={formatCurrency}
|
|
formatDate={formatDate}
|
|
/>
|
|
|
|
<RuleCreateDialog
|
|
open={ruleDialogOpen}
|
|
onOpenChange={setRuleDialogOpen}
|
|
group={ruleGroup}
|
|
categories={data.categories}
|
|
onSave={handleSaveRule}
|
|
/>
|
|
</PageLayout>
|
|
);
|
|
}
|