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

@@ -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>
);
}