feat: implement enhanced transaction filtering capabilities with support for account and category filters, improving data visibility and user interaction

This commit is contained in:
Julien Froidefond
2025-11-29 19:19:21 +01:00
parent 921ee4a5f0
commit 5195f4adad
5 changed files with 234 additions and 47 deletions

View File

@@ -33,25 +33,55 @@ export default function StatisticsPage() {
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]); const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]); const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
const stats = useMemo(() => { // Get start date based on period
if (!data) return null; const startDate = useMemo(() => {
const now = new Date(); const now = new Date();
let startDate: Date;
switch (period) { switch (period) {
case "3months": case "3months":
startDate = new Date(now.getFullYear(), now.getMonth() - 3, 1); return new Date(now.getFullYear(), now.getMonth() - 3, 1);
break;
case "6months": case "6months":
startDate = new Date(now.getFullYear(), now.getMonth() - 6, 1); return new Date(now.getFullYear(), now.getMonth() - 6, 1);
break;
case "12months": case "12months":
startDate = new Date(now.getFullYear(), now.getMonth() - 12, 1); return new Date(now.getFullYear(), now.getMonth() - 12, 1);
break;
default: default:
startDate = new Date(0); return new Date(0);
} }
}, [period]);
// Transactions filtered for account filter (by categories, period - not accounts)
const transactionsForAccountFilter = useMemo(() => {
if (!data) return [];
return data.transactions.filter(
(t) => new Date(t.date) >= startDate
).filter((t) => {
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
return !t.categoryId;
} else {
return t.categoryId && selectedCategories.includes(t.categoryId);
}
}
return true;
});
}, [data, startDate, selectedCategories]);
// Transactions filtered for category filter (by accounts, period - not categories)
const transactionsForCategoryFilter = useMemo(() => {
if (!data) return [];
return data.transactions.filter(
(t) => new Date(t.date) >= startDate
).filter((t) => {
if (!selectedAccounts.includes("all")) {
return selectedAccounts.includes(t.accountId);
}
return true;
});
}, [data, startDate, selectedAccounts]);
const stats = useMemo(() => {
if (!data) return null;
let transactions = data.transactions.filter( let transactions = data.transactions.filter(
(t) => new Date(t.date) >= startDate (t) => new Date(t.date) >= startDate
@@ -226,7 +256,7 @@ export default function StatisticsPage() {
perAccountBalanceData, perAccountBalanceData,
transactionCount: transactions.length, transactionCount: transactions.length,
}; };
}, [data, period, selectedAccounts, selectedCategories]); }, [data, startDate, selectedAccounts, selectedCategories]);
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
@@ -255,6 +285,7 @@ export default function StatisticsPage() {
value={selectedAccounts} value={selectedAccounts}
onChange={setSelectedAccounts} onChange={setSelectedAccounts}
className="w-[200px]" className="w-[200px]"
filteredTransactions={transactionsForAccountFilter}
/> />
<CategoryFilterCombobox <CategoryFilterCombobox
@@ -262,6 +293,7 @@ export default function StatisticsPage() {
value={selectedCategories} value={selectedCategories}
onChange={setSelectedCategories} onChange={setSelectedCategories}
className="w-[220px]" className="w-[220px]"
filteredTransactions={transactionsForCategoryFilter}
/> />
<Select <Select

View File

@@ -46,6 +46,72 @@ export default function TransactionsPage() {
const [ruleDialogOpen, setRuleDialogOpen] = useState(false); const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(null); 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(() => { const filteredTransactions = useMemo(() => {
if (!data) return []; if (!data) return [];
@@ -385,6 +451,8 @@ export default function TransactionsPage() {
accounts={data.accounts} accounts={data.accounts}
folders={data.folders} folders={data.folders}
categories={data.categories} categories={data.categories}
transactionsForAccountFilter={transactionsForAccountFilter}
transactionsForCategoryFilter={transactionsForCategoryFilter}
/> />
<TransactionBulkActions <TransactionBulkActions

View File

@@ -14,7 +14,7 @@ import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox
import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox"; import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox";
import { CategoryIcon } from "@/components/ui/category-icon"; import { CategoryIcon } from "@/components/ui/category-icon";
import { Search, X, Filter, Wallet } from "lucide-react"; import { Search, X, Filter, Wallet } from "lucide-react";
import type { Account, Category, Folder } from "@/lib/types"; import type { Account, Category, Folder, Transaction } from "@/lib/types";
interface TransactionFiltersProps { interface TransactionFiltersProps {
searchQuery: string; searchQuery: string;
@@ -28,6 +28,8 @@ interface TransactionFiltersProps {
accounts: Account[]; accounts: Account[];
folders: Folder[]; folders: Folder[];
categories: Category[]; categories: Category[];
transactionsForAccountFilter?: Transaction[]; // Filtered by categories, search, reconciled (not accounts)
transactionsForCategoryFilter?: Transaction[]; // Filtered by accounts, search, reconciled (not categories)
} }
export function TransactionFilters({ export function TransactionFilters({
@@ -42,6 +44,8 @@ export function TransactionFilters({
accounts, accounts,
folders, folders,
categories, categories,
transactionsForAccountFilter,
transactionsForCategoryFilter,
}: TransactionFiltersProps) { }: TransactionFiltersProps) {
return ( return (
<Card> <Card>
@@ -65,6 +69,7 @@ export function TransactionFilters({
value={selectedAccounts} value={selectedAccounts}
onChange={onAccountsChange} onChange={onAccountsChange}
className="w-[200px]" className="w-[200px]"
filteredTransactions={transactionsForAccountFilter}
/> />
<CategoryFilterCombobox <CategoryFilterCombobox
@@ -72,6 +77,7 @@ export function TransactionFilters({
value={selectedCategories} value={selectedCategories}
onChange={onCategoriesChange} onChange={onCategoriesChange}
className="w-[220px]" className="w-[220px]"
filteredTransactions={transactionsForCategoryFilter}
/> />
<Select value={showReconciled} onValueChange={onReconciledChange}> <Select value={showReconciled} onValueChange={onReconciledChange}>

View File

@@ -18,7 +18,7 @@ import {
import { CategoryIcon } from "@/components/ui/category-icon"; import { CategoryIcon } from "@/components/ui/category-icon";
import { ChevronsUpDown, Check, Wallet, X } from "lucide-react"; import { ChevronsUpDown, Check, Wallet, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Account, Folder } from "@/lib/types"; import type { Account, Folder, Transaction } from "@/lib/types";
interface AccountFilterComboboxProps { interface AccountFilterComboboxProps {
accounts: Account[]; accounts: Account[];
@@ -26,6 +26,7 @@ interface AccountFilterComboboxProps {
value: string[]; // ["all"] | [accountId, accountId, ...] value: string[]; // ["all"] | [accountId, accountId, ...]
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
className?: string; className?: string;
filteredTransactions?: Transaction[]; // Transactions filtered by other filters (categories, period, etc.)
} }
export function AccountFilterCombobox({ export function AccountFilterCombobox({
@@ -34,9 +35,31 @@ export function AccountFilterCombobox({
value, value,
onChange, onChange,
className, className,
filteredTransactions,
}: AccountFilterComboboxProps) { }: AccountFilterComboboxProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Calculate total amount per account based on filtered transactions
const accountTotals = useMemo(() => {
if (!filteredTransactions) return {};
const totals: Record<string, number> = {};
filteredTransactions.forEach((t) => {
totals[t.accountId] = (totals[t.accountId] || 0) + t.amount;
});
return totals;
}, [filteredTransactions]);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
// Get root folders (folders without parent) - same as folders/page.tsx // Get root folders (folders without parent) - same as folders/page.tsx
const rootFolders = useMemo( const rootFolders = useMemo(
() => folders.filter((f) => f.parentId === null), () => folders.filter((f) => f.parentId === null),
@@ -166,7 +189,9 @@ export function AccountFilterCombobox({
</CommandItem> </CommandItem>
{/* Accounts in this folder */} {/* Accounts in this folder */}
{folderAccounts.map((account) => ( {folderAccounts.map((account) => {
const total = accountTotals[account.id];
return (
<CommandItem <CommandItem
key={account.id} key={account.id}
value={`${currentPath} ${account.name}`} value={`${currentPath} ${account.name}`}
@@ -175,6 +200,11 @@ export function AccountFilterCombobox({
> >
<Wallet className="h-4 w-4 text-muted-foreground" /> <Wallet className="h-4 w-4 text-muted-foreground" />
<span>{account.name}</span> <span>{account.name}</span>
{total !== undefined && (
<span className="text-xs text-muted-foreground ml-1">
({formatCurrency(total)})
</span>
)}
<Check <Check
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
@@ -182,7 +212,8 @@ export function AccountFilterCombobox({
)} )}
/> />
</CommandItem> </CommandItem>
))} );
})}
{/* Child folders - recursive */} {/* Child folders - recursive */}
{childFoldersList.map((childFolder) => {childFoldersList.map((childFolder) =>
@@ -251,6 +282,13 @@ export function AccountFilterCombobox({
<CommandItem value="all" onSelect={() => handleSelect("all")}> <CommandItem value="all" onSelect={() => handleSelect("all")}>
<Wallet className="h-4 w-4 text-muted-foreground" /> <Wallet className="h-4 w-4 text-muted-foreground" />
<span>Tous les comptes</span> <span>Tous les comptes</span>
{filteredTransactions && (
<span className="text-xs text-muted-foreground ml-1">
({formatCurrency(
filteredTransactions.reduce((sum, t) => sum + t.amount, 0)
)})
</span>
)}
<Check <Check
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
@@ -266,7 +304,9 @@ export function AccountFilterCombobox({
{orphanAccounts.length > 0 && ( {orphanAccounts.length > 0 && (
<CommandGroup heading="Sans dossier"> <CommandGroup heading="Sans dossier">
{orphanAccounts.map((account) => ( {orphanAccounts.map((account) => {
const total = accountTotals[account.id];
return (
<CommandItem <CommandItem
key={account.id} key={account.id}
value={`sans-dossier ${account.name}`} value={`sans-dossier ${account.name}`}
@@ -274,6 +314,11 @@ export function AccountFilterCombobox({
> >
<Wallet className="h-4 w-4 text-muted-foreground" /> <Wallet className="h-4 w-4 text-muted-foreground" />
<span>{account.name}</span> <span>{account.name}</span>
{total !== undefined && (
<span className="text-xs text-muted-foreground ml-1">
({formatCurrency(total)})
</span>
)}
<Check <Check
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
@@ -281,7 +326,8 @@ export function AccountFilterCombobox({
)} )}
/> />
</CommandItem> </CommandItem>
))} );
})}
</CommandGroup> </CommandGroup>
)} )}
</CommandList> </CommandList>

View File

@@ -18,13 +18,14 @@ import {
import { CategoryIcon } from "@/components/ui/category-icon"; import { CategoryIcon } from "@/components/ui/category-icon";
import { ChevronsUpDown, Check, Tags, CircleSlash, X } from "lucide-react"; import { ChevronsUpDown, Check, Tags, CircleSlash, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Category } from "@/lib/types"; import type { Category, Transaction } from "@/lib/types";
interface CategoryFilterComboboxProps { interface CategoryFilterComboboxProps {
categories: Category[]; categories: Category[];
value: string[]; // ["all"] | ["uncategorized"] | [categoryId, categoryId, ...] value: string[]; // ["all"] | ["uncategorized"] | [categoryId, categoryId, ...]
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
className?: string; className?: string;
filteredTransactions?: Transaction[]; // Transactions filtered by other filters (accounts, period, etc.)
} }
export function CategoryFilterCombobox({ export function CategoryFilterCombobox({
@@ -32,9 +33,23 @@ export function CategoryFilterCombobox({
value, value,
onChange, onChange,
className, className,
filteredTransactions,
}: CategoryFilterComboboxProps) { }: CategoryFilterComboboxProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Calculate transaction counts per category based on filtered transactions
const categoryCounts = useMemo(() => {
if (!filteredTransactions) return {};
const counts: Record<string, number> = {};
filteredTransactions.forEach((t) => {
const catId = t.categoryId || "uncategorized";
counts[catId] = (counts[catId] || 0) + 1;
});
return counts;
}, [filteredTransactions]);
// Organize categories by parent // Organize categories by parent
const { parentCategories, childrenByParent } = useMemo(() => { const { parentCategories, childrenByParent } = useMemo(() => {
const parents = categories.filter((c) => c.parentId === null); const parents = categories.filter((c) => c.parentId === null);
@@ -178,6 +193,11 @@ export function CategoryFilterCombobox({
<CommandItem value="all" onSelect={() => handleSelect("all")}> <CommandItem value="all" onSelect={() => handleSelect("all")}>
<Tags className="h-4 w-4 text-muted-foreground" /> <Tags className="h-4 w-4 text-muted-foreground" />
<span>Toutes catégories</span> <span>Toutes catégories</span>
{filteredTransactions && (
<span className="text-xs text-muted-foreground ml-1">
({filteredTransactions.length})
</span>
)}
<Check <Check
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
@@ -191,6 +211,11 @@ export function CategoryFilterCombobox({
> >
<CircleSlash className="h-4 w-4 text-muted-foreground" /> <CircleSlash className="h-4 w-4 text-muted-foreground" />
<span>Non catégorisé</span> <span>Non catégorisé</span>
{categoryCounts["uncategorized"] !== undefined && (
<span className="text-xs text-muted-foreground ml-1">
({categoryCounts["uncategorized"]})
</span>
)}
<Check <Check
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
@@ -212,6 +237,11 @@ export function CategoryFilterCombobox({
size={16} size={16}
/> />
<span className="font-medium">{parent.name}</span> <span className="font-medium">{parent.name}</span>
{categoryCounts[parent.id] !== undefined && (
<span className="text-xs text-muted-foreground ml-1">
({categoryCounts[parent.id]})
</span>
)}
<Check <Check
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
@@ -232,6 +262,11 @@ export function CategoryFilterCombobox({
size={16} size={16}
/> />
<span>{child.name}</span> <span>{child.name}</span>
{categoryCounts[child.id] !== undefined && (
<span className="text-xs text-muted-foreground ml-1">
({categoryCounts[child.id]})
</span>
)}
<Check <Check
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",