feat: update transaction filters to support multiple category selection and enhance UI with active filters display

This commit is contained in:
Julien Froidefond
2025-11-29 17:44:26 +01:00
parent 0fb3222ba2
commit 3c142c3782
3 changed files with 237 additions and 37 deletions

View File

@@ -36,7 +36,7 @@ export default function TransactionsPage() {
}
}, [searchParams]);
const [selectedCategory, setSelectedCategory] = useState<string>("all");
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
const [showReconciled, setShowReconciled] = useState<string>("all");
const [sortField, setSortField] = useState<SortField>("date");
const [sortOrder, setSortOrder] = useState<SortOrder>("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}

View File

@@ -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({
<CategoryFilterCombobox
categories={categories}
value={selectedCategory}
onChange={onCategoryChange}
className="w-[200px]"
value={selectedCategories}
onChange={onCategoriesChange}
className="w-[220px]"
/>
<Select value={showReconciled} onValueChange={onReconciledChange}>
@@ -86,8 +88,141 @@ export function TransactionFilters({
</SelectContent>
</Select>
</div>
<ActiveFilters
searchQuery={searchQuery}
onClearSearch={() => 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}
/>
</CardContent>
</Card>
);
}
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 (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border flex-wrap">
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
{hasSearch && (
<Badge variant="secondary" className="gap-1 text-xs font-normal">
Recherche: &quot;{searchQuery}&quot;
<button onClick={onClearSearch} className="ml-1 hover:text-foreground">
<X className="h-3 w-3" />
</button>
</Badge>
)}
{hasAccount && account && (
<Badge variant="secondary" className="gap-1 text-xs font-normal">
Compte: {account.name}
<button onClick={onClearAccount} className="ml-1 hover:text-foreground">
<X className="h-3 w-3" />
</button>
</Badge>
)}
{isUncategorized && (
<Badge variant="secondary" className="gap-1 text-xs font-normal">
Non catégorisé
<button onClick={onClearCategories} className="ml-1 hover:text-foreground">
<X className="h-3 w-3" />
</button>
</Badge>
)}
{selectedCats.map((cat) => (
<Badge
key={cat.id}
variant="secondary"
className="gap-1 text-xs font-normal"
style={{
backgroundColor: `${cat.color}15`,
borderColor: `${cat.color}30`,
}}
>
<CategoryIcon icon={cat.icon} color={cat.color} size={12} />
{cat.name}
<button
onClick={() => onRemoveCategory(cat.id)}
className="ml-1 hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
{hasReconciled && (
<Badge variant="secondary" className="gap-1 text-xs font-normal">
{showReconciled === "reconciled" ? "Pointées" : "Non pointées"}
<button onClick={onClearReconciled} className="ml-1 hover:text-foreground">
<X className="h-3 w-3" />
</button>
</Badge>
)}
<button
onClick={clearAll}
className="text-xs text-muted-foreground hover:text-foreground ml-auto"
>
Effacer tout
</button>
</div>
);
}

View File

@@ -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)}
>
<div className="flex items-center gap-2">
{selectedCategory ? (
<div className="flex items-center gap-2 min-w-0">
{selectedCategories.length === 1 ? (
<>
<CategoryIcon
icon={selectedCategory.icon}
color={selectedCategory.color}
icon={selectedCategories[0].icon}
color={selectedCategories[0].color}
size={16}
/>
<span>{selectedCategory.name}</span>
<span className="truncate">{selectedCategories[0].name}</span>
</>
) : value === "uncategorized" ? (
) : selectedCategories.length > 1 ? (
<>
<div className="flex -space-x-1">
{selectedCategories.slice(0, 3).map((cat) => (
<div
key={cat.id}
className="w-4 h-4 rounded-full border-2 border-background"
style={{ backgroundColor: cat.color }}
/>
))}
</div>
<span className="truncate">{selectedCategories.length} catégories</span>
</>
) : isUncategorized ? (
<>
<CircleSlash className="h-4 w-4 text-muted-foreground" />
<span>Non catégorisé</span>
@@ -97,11 +149,25 @@ export function CategoryFilterCombobox({
</>
)}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<div className="flex items-center gap-1">
{!isAll && (
<div
role="button"
className="h-4 w-4 rounded-sm hover:bg-muted flex items-center justify-center"
onClick={(e) => {
e.stopPropagation();
clearSelection();
}}
>
<X className="h-3 w-3" />
</div>
)}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[250px] p-0"
className="w-[280px] p-0"
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
@@ -116,7 +182,7 @@ export function CategoryFilterCombobox({
<Check
className={cn(
"ml-auto h-4 w-4",
value === "all" ? "opacity-100" : "opacity-0"
isAll ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
@@ -129,7 +195,7 @@ export function CategoryFilterCombobox({
<Check
className={cn(
"ml-auto h-4 w-4",
value === "uncategorized" ? "opacity-100" : "opacity-0"
isUncategorized ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
@@ -150,7 +216,7 @@ export function CategoryFilterCombobox({
<Check
className={cn(
"ml-auto h-4 w-4",
value === parent.id ? "opacity-100" : "opacity-0"
value.includes(parent.id) ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
@@ -170,7 +236,7 @@ export function CategoryFilterCombobox({
<Check
className={cn(
"ml-auto h-4 w-4",
value === child.id ? "opacity-100" : "opacity-0"
value.includes(child.id) ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
@@ -184,4 +250,3 @@ export function CategoryFilterCombobox({
</Popover>
);
}