feat: update transaction filters to support multiple category selection and enhance UI with active filters display
This commit is contained in:
@@ -36,7 +36,7 @@ export default function TransactionsPage() {
|
|||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
|
||||||
const [showReconciled, setShowReconciled] = useState<string>("all");
|
const [showReconciled, setShowReconciled] = useState<string>("all");
|
||||||
const [sortField, setSortField] = useState<SortField>("date");
|
const [sortField, setSortField] = useState<SortField>("date");
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||||
@@ -66,12 +66,12 @@ export default function TransactionsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedCategory !== "all") {
|
if (!selectedCategories.includes("all")) {
|
||||||
if (selectedCategory === "uncategorized") {
|
if (selectedCategories.includes("uncategorized")) {
|
||||||
transactions = transactions.filter((t) => !t.categoryId);
|
transactions = transactions.filter((t) => !t.categoryId);
|
||||||
} else {
|
} else {
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
(t) => t.categoryId === selectedCategory
|
(t) => t.categoryId && selectedCategories.includes(t.categoryId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ export default function TransactionsPage() {
|
|||||||
data,
|
data,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
selectedAccount,
|
selectedAccount,
|
||||||
selectedCategory,
|
selectedCategories,
|
||||||
showReconciled,
|
showReconciled,
|
||||||
sortField,
|
sortField,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
@@ -378,8 +378,8 @@ export default function TransactionsPage() {
|
|||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
selectedAccount={selectedAccount}
|
selectedAccount={selectedAccount}
|
||||||
onAccountChange={setSelectedAccount}
|
onAccountChange={setSelectedAccount}
|
||||||
selectedCategory={selectedCategory}
|
selectedCategories={selectedCategories}
|
||||||
onCategoryChange={setSelectedCategory}
|
onCategoriesChange={setSelectedCategories}
|
||||||
showReconciled={showReconciled}
|
showReconciled={showReconciled}
|
||||||
onReconciledChange={setShowReconciled}
|
onReconciledChange={setShowReconciled}
|
||||||
accounts={data.accounts}
|
accounts={data.accounts}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -10,7 +11,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox";
|
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";
|
import type { Account, Category } from "@/lib/types";
|
||||||
|
|
||||||
interface TransactionFiltersProps {
|
interface TransactionFiltersProps {
|
||||||
@@ -18,8 +20,8 @@ interface TransactionFiltersProps {
|
|||||||
onSearchChange: (query: string) => void;
|
onSearchChange: (query: string) => void;
|
||||||
selectedAccount: string;
|
selectedAccount: string;
|
||||||
onAccountChange: (account: string) => void;
|
onAccountChange: (account: string) => void;
|
||||||
selectedCategory: string;
|
selectedCategories: string[];
|
||||||
onCategoryChange: (category: string) => void;
|
onCategoriesChange: (categories: string[]) => void;
|
||||||
showReconciled: string;
|
showReconciled: string;
|
||||||
onReconciledChange: (value: string) => void;
|
onReconciledChange: (value: string) => void;
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
@@ -31,8 +33,8 @@ export function TransactionFilters({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
selectedAccount,
|
selectedAccount,
|
||||||
onAccountChange,
|
onAccountChange,
|
||||||
selectedCategory,
|
selectedCategories,
|
||||||
onCategoryChange,
|
onCategoriesChange,
|
||||||
showReconciled,
|
showReconciled,
|
||||||
onReconciledChange,
|
onReconciledChange,
|
||||||
accounts,
|
accounts,
|
||||||
@@ -70,9 +72,9 @@ export function TransactionFilters({
|
|||||||
|
|
||||||
<CategoryFilterCombobox
|
<CategoryFilterCombobox
|
||||||
categories={categories}
|
categories={categories}
|
||||||
value={selectedCategory}
|
value={selectedCategories}
|
||||||
onChange={onCategoryChange}
|
onChange={onCategoriesChange}
|
||||||
className="w-[200px]"
|
className="w-[220px]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select value={showReconciled} onValueChange={onReconciledChange}>
|
<Select value={showReconciled} onValueChange={onReconciledChange}>
|
||||||
@@ -86,8 +88,141 @@ export function TransactionFilters({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</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: "{searchQuery}"
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -16,14 +17,14 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
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 { cn } from "@/lib/utils";
|
||||||
import type { Category } from "@/lib/types";
|
import type { Category } from "@/lib/types";
|
||||||
|
|
||||||
interface CategoryFilterComboboxProps {
|
interface CategoryFilterComboboxProps {
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
value: string; // "all" | "uncategorized" | categoryId
|
value: string[]; // ["all"] | ["uncategorized"] | [categoryId, categoryId, ...]
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string[]) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,17 +53,55 @@ export function CategoryFilterCombobox({
|
|||||||
return { parentCategories: parents, childrenByParent: children };
|
return { parentCategories: parents, childrenByParent: children };
|
||||||
}, [categories]);
|
}, [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) => {
|
const handleSelect = (newValue: string) => {
|
||||||
onChange(newValue);
|
// Special cases: "all" and "uncategorized" are exclusive
|
||||||
setOpen(false);
|
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 = () => {
|
const getDisplayValue = () => {
|
||||||
if (value === "all") return "Toutes catégories";
|
if (isAll) return "Toutes catégories";
|
||||||
if (value === "uncategorized") return "Non catégorisé";
|
if (isUncategorized) return "Non catégorisé";
|
||||||
if (selectedCategory) return selectedCategory.name;
|
if (selectedCategories.length === 1) return selectedCategories[0].name;
|
||||||
|
if (selectedCategories.length > 1) return `${selectedCategories.length} catégories`;
|
||||||
return "Catégorie";
|
return "Catégorie";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,17 +114,30 @@ export function CategoryFilterCombobox({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className={cn("justify-between", className)}
|
className={cn("justify-between", className)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{selectedCategory ? (
|
{selectedCategories.length === 1 ? (
|
||||||
<>
|
<>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
icon={selectedCategory.icon}
|
icon={selectedCategories[0].icon}
|
||||||
color={selectedCategory.color}
|
color={selectedCategories[0].color}
|
||||||
size={16}
|
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" />
|
<CircleSlash className="h-4 w-4 text-muted-foreground" />
|
||||||
<span>Non catégorisé</span>
|
<span>Non catégorisé</span>
|
||||||
@@ -97,11 +149,25 @@ export function CategoryFilterCombobox({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[250px] p-0"
|
className="w-[280px] p-0"
|
||||||
align="start"
|
align="start"
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
@@ -116,7 +182,7 @@ export function CategoryFilterCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
value === "all" ? "opacity-100" : "opacity-0"
|
isAll ? "opacity-100" : "opacity-0"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -129,7 +195,7 @@ export function CategoryFilterCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
value === "uncategorized" ? "opacity-100" : "opacity-0"
|
isUncategorized ? "opacity-100" : "opacity-0"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -150,7 +216,7 @@ export function CategoryFilterCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
value === parent.id ? "opacity-100" : "opacity-0"
|
value.includes(parent.id) ? "opacity-100" : "opacity-0"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -170,7 +236,7 @@ export function CategoryFilterCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
value === child.id ? "opacity-100" : "opacity-0"
|
value.includes(child.id) ? "opacity-100" : "opacity-0"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -184,4 +250,3 @@ export function CategoryFilterCombobox({
|
|||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user