feat: add transaction deduplication feature and enhance filtering options in settings and transactions pages

This commit is contained in:
Julien Froidefond
2025-11-30 13:02:03 +01:00
parent e087143675
commit d5aa00a885
9 changed files with 616 additions and 26 deletions

View File

@@ -19,9 +19,12 @@ 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();
@@ -38,6 +41,10 @@ export default function TransactionsPage() {
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>>(
@@ -46,12 +53,54 @@ export default function TransactionsPage() {
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(null);
// Transactions filtered for account filter (by categories, search, reconciled - not accounts)
// 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(
@@ -79,14 +128,29 @@ export default function TransactionsPage() {
}
return transactions;
}, [data, searchQuery, selectedCategories, showReconciled]);
}, [data, searchQuery, selectedCategories, showReconciled, period, startDate, endDate]);
// Transactions filtered for category filter (by accounts, search, reconciled - not categories)
// 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(
@@ -110,13 +174,28 @@ export default function TransactionsPage() {
}
return transactions;
}, [data, searchQuery, selectedAccounts, showReconciled]);
}, [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(
@@ -172,6 +251,9 @@ export default function TransactionsPage() {
selectedAccounts,
selectedCategories,
showReconciled,
period,
startDate,
endDate,
sortField,
sortOrder,
]);
@@ -424,6 +506,32 @@ export default function TransactionsPage() {
}
};
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
@@ -448,6 +556,21 @@ export default function TransactionsPage() {
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}
@@ -476,6 +599,7 @@ export default function TransactionsPage() {
onMarkReconciled={markReconciled}
onSetCategory={setCategory}
onCreateRule={handleCreateRule}
onDelete={deleteTransaction}
formatCurrency={formatCurrency}
formatDate={formatDate}
/>