diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx index 6d9e9c9..527a00f 100644 --- a/app/transactions/page.tsx +++ b/app/transactions/page.tsx @@ -36,7 +36,7 @@ export default function TransactionsPage() { } }, [searchParams]); - const [selectedCategory, setSelectedCategory] = useState("all"); + const [selectedCategories, setSelectedCategories] = useState(["all"]); const [showReconciled, setShowReconciled] = useState("all"); const [sortField, setSortField] = useState("date"); const [sortOrder, setSortOrder] = useState("desc"); @@ -66,12 +66,12 @@ export default function TransactionsPage() { ); } - if (selectedCategory !== "all") { - if (selectedCategory === "uncategorized") { + if (!selectedCategories.includes("all")) { + if (selectedCategories.includes("uncategorized")) { transactions = transactions.filter((t) => !t.categoryId); } else { transactions = transactions.filter( - (t) => t.categoryId === selectedCategory + (t) => t.categoryId && selectedCategories.includes(t.categoryId) ); } } @@ -104,7 +104,7 @@ export default function TransactionsPage() { data, searchQuery, selectedAccount, - selectedCategory, + selectedCategories, showReconciled, sortField, sortOrder, @@ -378,8 +378,8 @@ export default function TransactionsPage() { onSearchChange={setSearchQuery} selectedAccount={selectedAccount} onAccountChange={setSelectedAccount} - selectedCategory={selectedCategory} - onCategoryChange={setSelectedCategory} + selectedCategories={selectedCategories} + onCategoriesChange={setSelectedCategories} showReconciled={showReconciled} onReconciledChange={setShowReconciled} accounts={data.accounts} diff --git a/components/transactions/transaction-filters.tsx b/components/transactions/transaction-filters.tsx index 31af09c..883a501 100644 --- a/components/transactions/transaction-filters.tsx +++ b/components/transactions/transaction-filters.tsx @@ -2,6 +2,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, @@ -10,7 +11,8 @@ import { SelectValue, } from "@/components/ui/select"; import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox"; -import { Search } from "lucide-react"; +import { CategoryIcon } from "@/components/ui/category-icon"; +import { Search, X, Filter } from "lucide-react"; import type { Account, Category } from "@/lib/types"; interface TransactionFiltersProps { @@ -18,8 +20,8 @@ interface TransactionFiltersProps { onSearchChange: (query: string) => void; selectedAccount: string; onAccountChange: (account: string) => void; - selectedCategory: string; - onCategoryChange: (category: string) => void; + selectedCategories: string[]; + onCategoriesChange: (categories: string[]) => void; showReconciled: string; onReconciledChange: (value: string) => void; accounts: Account[]; @@ -31,8 +33,8 @@ export function TransactionFilters({ onSearchChange, selectedAccount, onAccountChange, - selectedCategory, - onCategoryChange, + selectedCategories, + onCategoriesChange, showReconciled, onReconciledChange, accounts, @@ -70,9 +72,9 @@ export function TransactionFilters({ + + onSearchChange("")} + selectedAccount={selectedAccount} + onClearAccount={() => onAccountChange("all")} + selectedCategories={selectedCategories} + onRemoveCategory={(id) => { + const newCategories = selectedCategories.filter((c) => c !== id); + onCategoriesChange(newCategories.length > 0 ? newCategories : ["all"]); + }} + onClearCategories={() => onCategoriesChange(["all"])} + showReconciled={showReconciled} + onClearReconciled={() => onReconciledChange("all")} + accounts={accounts} + categories={categories} + /> ); } +function ActiveFilters({ + searchQuery, + onClearSearch, + selectedAccount, + onClearAccount, + selectedCategories, + onRemoveCategory, + onClearCategories, + showReconciled, + onClearReconciled, + accounts, + categories, +}: { + searchQuery: string; + onClearSearch: () => void; + selectedAccount: string; + onClearAccount: () => void; + selectedCategories: string[]; + onRemoveCategory: (id: string) => void; + onClearCategories: () => void; + showReconciled: string; + onClearReconciled: () => void; + accounts: Account[]; + categories: Category[]; +}) { + const hasSearch = searchQuery.trim() !== ""; + const hasAccount = selectedAccount !== "all"; + const hasCategories = !selectedCategories.includes("all"); + const hasReconciled = showReconciled !== "all"; + + const hasActiveFilters = hasSearch || hasAccount || hasCategories || hasReconciled; + + if (!hasActiveFilters) return null; + + const account = accounts.find((a) => a.id === selectedAccount); + const selectedCats = categories.filter((c) => selectedCategories.includes(c.id)); + const isUncategorized = selectedCategories.includes("uncategorized"); + + const clearAll = () => { + onClearSearch(); + onClearAccount(); + onClearCategories(); + onClearReconciled(); + }; + + return ( +
+ + + {hasSearch && ( + + Recherche: "{searchQuery}" + + + )} + + {hasAccount && account && ( + + Compte: {account.name} + + + )} + + {isUncategorized && ( + + Non catégorisé + + + )} + + {selectedCats.map((cat) => ( + + + {cat.name} + + + ))} + + {hasReconciled && ( + + {showReconciled === "reconciled" ? "Pointées" : "Non pointées"} + + + )} + + +
+ ); +} + diff --git a/components/ui/category-filter-combobox.tsx b/components/ui/category-filter-combobox.tsx index 827e1e2..dc6f156 100644 --- a/components/ui/category-filter-combobox.tsx +++ b/components/ui/category-filter-combobox.tsx @@ -2,6 +2,7 @@ import { useState, useMemo } from "react"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { Command, CommandEmpty, @@ -16,14 +17,14 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { CategoryIcon } from "@/components/ui/category-icon"; -import { ChevronsUpDown, Check, Tags, CircleSlash } from "lucide-react"; +import { ChevronsUpDown, Check, Tags, CircleSlash, X } from "lucide-react"; import { cn } from "@/lib/utils"; import type { Category } from "@/lib/types"; interface CategoryFilterComboboxProps { categories: Category[]; - value: string; // "all" | "uncategorized" | categoryId - onChange: (value: string) => void; + value: string[]; // ["all"] | ["uncategorized"] | [categoryId, categoryId, ...] + onChange: (value: string[]) => void; className?: string; } @@ -52,17 +53,55 @@ export function CategoryFilterCombobox({ return { parentCategories: parents, childrenByParent: children }; }, [categories]); - const selectedCategory = categories.find((c) => c.id === value); + const selectedCategories = categories.filter((c) => value.includes(c.id)); + const isAll = value.includes("all") || value.length === 0; + const isUncategorized = value.includes("uncategorized"); const handleSelect = (newValue: string) => { - onChange(newValue); - setOpen(false); + // Special cases: "all" and "uncategorized" are exclusive + if (newValue === "all") { + onChange(["all"]); + return; + } + + if (newValue === "uncategorized") { + if (isUncategorized) { + onChange(["all"]); + } else { + onChange(["uncategorized"]); + } + return; + } + + // Category selection - toggle + let newSelection: string[]; + + if (isAll || isUncategorized) { + // Start fresh with just this category + newSelection = [newValue]; + } else if (value.includes(newValue)) { + // Remove category + newSelection = value.filter((v) => v !== newValue); + if (newSelection.length === 0) { + newSelection = ["all"]; + } + } else { + // Add category + newSelection = [...value, newValue]; + } + + onChange(newSelection); + }; + + const clearSelection = () => { + onChange(["all"]); }; const getDisplayValue = () => { - if (value === "all") return "Toutes catégories"; - if (value === "uncategorized") return "Non catégorisé"; - if (selectedCategory) return selectedCategory.name; + if (isAll) return "Toutes catégories"; + if (isUncategorized) return "Non catégorisé"; + if (selectedCategories.length === 1) return selectedCategories[0].name; + if (selectedCategories.length > 1) return `${selectedCategories.length} catégories`; return "Catégorie"; }; @@ -75,17 +114,30 @@ export function CategoryFilterCombobox({ aria-expanded={open} className={cn("justify-between", className)} > -
- {selectedCategory ? ( +
+ {selectedCategories.length === 1 ? ( <> - {selectedCategory.name} + {selectedCategories[0].name} - ) : value === "uncategorized" ? ( + ) : selectedCategories.length > 1 ? ( + <> +
+ {selectedCategories.slice(0, 3).map((cat) => ( +
+ ))} +
+ {selectedCategories.length} catégories + + ) : isUncategorized ? ( <> Non catégorisé @@ -97,11 +149,25 @@ export function CategoryFilterCombobox({ )}
- +
+ {!isAll && ( +
{ + e.stopPropagation(); + clearSelection(); + }} + > + +
+ )} + +
e.preventDefault()} > @@ -116,7 +182,7 @@ export function CategoryFilterCombobox({ @@ -129,7 +195,7 @@ export function CategoryFilterCombobox({ @@ -150,7 +216,7 @@ export function CategoryFilterCombobox({ @@ -170,7 +236,7 @@ export function CategoryFilterCombobox({ @@ -184,4 +250,3 @@ export function CategoryFilterCombobox({ ); } -