feat: add icon support to category creation and editing, enhance transaction rule creation with new dialog and filters

This commit is contained in:
Julien Froidefond
2025-11-29 17:42:11 +01:00
parent 0ce50d1477
commit 0fb3222ba2
8 changed files with 820 additions and 38 deletions

View File

@@ -0,0 +1,187 @@
"use client";
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { CategoryIcon } from "@/components/ui/category-icon";
import { ChevronsUpDown, Check, Tags, CircleSlash } 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;
className?: string;
}
export function CategoryFilterCombobox({
categories,
value,
onChange,
className,
}: CategoryFilterComboboxProps) {
const [open, setOpen] = useState(false);
// Organize categories by parent
const { parentCategories, childrenByParent } = useMemo(() => {
const parents = categories.filter((c) => c.parentId === null);
const children: Record<string, Category[]> = {};
categories
.filter((c) => c.parentId !== null)
.forEach((child) => {
if (!children[child.parentId!]) {
children[child.parentId!] = [];
}
children[child.parentId!].push(child);
});
return { parentCategories: parents, childrenByParent: children };
}, [categories]);
const selectedCategory = categories.find((c) => c.id === value);
const handleSelect = (newValue: string) => {
onChange(newValue);
setOpen(false);
};
const getDisplayValue = () => {
if (value === "all") return "Toutes catégories";
if (value === "uncategorized") return "Non catégorisé";
if (selectedCategory) return selectedCategory.name;
return "Catégorie";
};
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("justify-between", className)}
>
<div className="flex items-center gap-2">
{selectedCategory ? (
<>
<CategoryIcon
icon={selectedCategory.icon}
color={selectedCategory.color}
size={16}
/>
<span>{selectedCategory.name}</span>
</>
) : value === "uncategorized" ? (
<>
<CircleSlash className="h-4 w-4 text-muted-foreground" />
<span>Non catégorisé</span>
</>
) : (
<>
<Tags className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">{getDisplayValue()}</span>
</>
)}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[250px] p-0"
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command>
<CommandInput placeholder="Rechercher..." />
<CommandList className="max-h-[300px]">
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
<CommandGroup>
<CommandItem value="all" onSelect={() => handleSelect("all")}>
<Tags className="h-4 w-4 text-muted-foreground" />
<span>Toutes catégories</span>
<Check
className={cn(
"ml-auto h-4 w-4",
value === "all" ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
<CommandItem
value="uncategorized non catégorisé"
onSelect={() => handleSelect("uncategorized")}
>
<CircleSlash className="h-4 w-4 text-muted-foreground" />
<span>Non catégorisé</span>
<Check
className={cn(
"ml-auto h-4 w-4",
value === "uncategorized" ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
</CommandGroup>
<CommandGroup heading="Catégories">
{parentCategories.map((parent) => (
<div key={parent.id}>
<CommandItem
value={`${parent.name}`}
onSelect={() => handleSelect(parent.id)}
>
<CategoryIcon
icon={parent.icon}
color={parent.color}
size={16}
/>
<span className="font-medium">{parent.name}</span>
<Check
className={cn(
"ml-auto h-4 w-4",
value === parent.id ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
{childrenByParent[parent.id]?.map((child) => (
<CommandItem
key={child.id}
value={`${parent.name} ${child.name}`}
onSelect={() => handleSelect(child.id)}
className="pl-8"
>
<CategoryIcon
icon={child.icon}
color={child.color}
size={16}
/>
<span>{child.name}</span>
<Check
className={cn(
"ml-auto h-4 w-4",
value === child.id ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</div>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,52 +1,180 @@
"use client";
import {
// Alimentation
ShoppingCart,
Utensils,
Croissant,
Coffee,
Wine,
Beer,
Pizza,
Apple,
Cherry,
Salad,
Sandwich,
IceCream,
Cake,
CupSoda,
Milk,
Egg,
Fish,
Beef,
// Transport
Fuel,
Train,
Car,
SquareParking,
Bike,
Plane,
Bus,
Ship,
Sailboat,
Truck,
CarFront,
CircleParking,
TrainFront,
// Logement
Home,
Zap,
Droplet,
Hammer,
Sofa,
Refrigerator,
WashingMachine,
Lamp,
LampDesk,
Armchair,
Bath,
ShowerHead,
DoorOpen,
Fence,
Trees,
Flower,
Leaf,
Sun,
Snowflake,
Wind,
Thermometer,
// Santé
Pill,
Stethoscope,
Hospital,
Glasses,
Dumbbell,
Sparkles,
Heart,
HeartPulse,
Activity,
Syringe,
Bandage,
Brain,
Eye,
Ear,
Hand,
Footprints,
PersonStanding,
// Loisirs
Tv,
Music,
Film,
Gamepad,
Book,
Ticket,
Clapperboard,
Headphones,
Speaker,
Radio,
Camera,
Image,
Palette,
Brush,
PenTool,
Scissors,
Drama,
PartyPopper,
// Sport
Trophy,
Medal,
Target,
Volleyball,
// Shopping
Shirt,
Smartphone,
Package,
ShoppingBag,
Store,
Gem,
Watch,
Glasses as SunGlasses,
Crown,
Laptop,
Monitor,
Keyboard,
Mouse,
Printer,
TabletSmartphone,
Headset,
// Services
Wifi,
Repeat,
Landmark,
Shield,
HeartPulse,
Receipt,
FileText,
Mail,
Phone,
MessageSquare,
Send,
Globe,
Cloud,
Server,
Lock,
Unlock,
Settings,
// Finance
PiggyBank,
Banknote,
Wallet,
HandCoins,
Undo,
Coins,
CreditCard,
Building,
Building2,
TrendingUp,
TrendingDown,
BarChart,
PieChart,
LineChart,
Calculator,
Percent,
DollarSign,
Euro,
// Voyage
Bed,
Luggage,
Map,
MapPin,
Compass,
Mountain,
Tent,
Palmtree,
Umbrella,
Globe2,
Flag,
// Famille
GraduationCap,
Baby,
PawPrint,
Users,
User,
UserPlus,
Dog,
Cat,
Bird,
Rabbit,
// Autre
Wrench,
HeartHandshake,
Gift,
@@ -56,61 +184,215 @@ import {
Tag,
Folder,
Key,
Refrigerator,
Star,
Bookmark,
Clock,
Calendar,
Bell,
AlertTriangle,
Info,
CheckCircle,
XCircle,
Plus,
Minus,
Search,
Trash,
Edit,
Download,
Upload,
Share,
Link,
Paperclip,
Archive,
Box,
Boxes,
Container,
Briefcase,
GraduationCap as Education,
Award,
Lightbulb,
Flame,
Rocket,
Atom,
type LucideIcon,
} from "lucide-react";
// Map icon names to Lucide components
const iconMap: Record<string, LucideIcon> = {
// Alimentation
"shopping-cart": ShoppingCart,
utensils: Utensils,
croissant: Croissant,
coffee: Coffee,
wine: Wine,
beer: Beer,
pizza: Pizza,
apple: Apple,
cherry: Cherry,
salad: Salad,
sandwich: Sandwich,
"ice-cream": IceCream,
cake: Cake,
"cup-soda": CupSoda,
milk: Milk,
egg: Egg,
fish: Fish,
beef: Beef,
// Transport
fuel: Fuel,
train: Train,
car: Car,
"car-taxi": Car, // Using Car as fallback for car-taxi
"car-key": Key, // Using Key as fallback
parking: SquareParking,
bike: Bike,
plane: Plane,
bus: Bus,
ship: Ship,
sailboat: Sailboat,
truck: Truck,
"car-front": CarFront,
"circle-parking": CircleParking,
"train-front": TrainFront,
// Logement
home: Home,
zap: Zap,
droplet: Droplet,
hammer: Hammer,
sofa: Sofa,
refrigerator: Refrigerator,
"washing-machine": WashingMachine,
lamp: Lamp,
"lamp-desk": LampDesk,
armchair: Armchair,
bath: Bath,
"shower-head": ShowerHead,
"door-open": DoorOpen,
fence: Fence,
trees: Trees,
flower: Flower,
leaf: Leaf,
sun: Sun,
snowflake: Snowflake,
wind: Wind,
thermometer: Thermometer,
// Santé
pill: Pill,
stethoscope: Stethoscope,
hospital: Hospital,
glasses: Glasses,
dumbbell: Dumbbell,
sparkles: Sparkles,
heart: Heart,
"heart-pulse": HeartPulse,
activity: Activity,
syringe: Syringe,
bandage: Bandage,
brain: Brain,
eye: Eye,
ear: Ear,
hand: Hand,
footprints: Footprints,
"person-standing": PersonStanding,
// Loisirs
tv: Tv,
music: Music,
film: Film,
gamepad: Gamepad,
book: Book,
ticket: Ticket,
clapperboard: Clapperboard,
headphones: Headphones,
speaker: Speaker,
radio: Radio,
camera: Camera,
image: Image,
palette: Palette,
brush: Brush,
"pen-tool": PenTool,
scissors: Scissors,
drama: Drama,
"party-popper": PartyPopper,
// Sport
trophy: Trophy,
medal: Medal,
target: Target,
volleyball: Volleyball,
// Shopping
shirt: Shirt,
smartphone: Smartphone,
package: Package,
"shopping-bag": ShoppingBag,
store: Store,
gem: Gem,
watch: Watch,
sunglasses: SunGlasses,
crown: Crown,
laptop: Laptop,
monitor: Monitor,
keyboard: Keyboard,
mouse: Mouse,
printer: Printer,
"tablet-smartphone": TabletSmartphone,
headset: Headset,
// Services
wifi: Wifi,
repeat: Repeat,
landmark: Landmark,
shield: Shield,
"heart-pulse": HeartPulse,
receipt: Receipt,
"file-text": FileText,
mail: Mail,
phone: Phone,
"message-square": MessageSquare,
send: Send,
globe: Globe,
cloud: Cloud,
server: Server,
lock: Lock,
unlock: Unlock,
settings: Settings,
// Finance
"piggy-bank": PiggyBank,
banknote: Banknote,
wallet: Wallet,
"hand-coins": HandCoins,
undo: Undo,
coins: Coins,
"credit-card": CreditCard,
building: Building,
building2: Building2,
"trending-up": TrendingUp,
"trending-down": TrendingDown,
"bar-chart": BarChart,
"pie-chart": PieChart,
"line-chart": LineChart,
calculator: Calculator,
percent: Percent,
"dollar-sign": DollarSign,
euro: Euro,
// Voyage
bed: Bed,
luggage: Luggage,
map: Map,
"map-pin": MapPin,
compass: Compass,
mountain: Mountain,
tent: Tent,
palmtree: Palmtree,
umbrella: Umbrella,
globe2: Globe2,
flag: Flag,
// Famille
"graduation-cap": GraduationCap,
baby: Baby,
"paw-print": PawPrint,
users: Users,
user: User,
"user-plus": UserPlus,
dog: Dog,
cat: Cat,
bird: Bird,
rabbit: Rabbit,
// Autre
wrench: Wrench,
"heart-handshake": HeartHandshake,
gift: Gift,
@@ -119,6 +401,37 @@ const iconMap: Record<string, LucideIcon> = {
"help-circle": HelpCircle,
tag: Tag,
folder: Folder,
key: Key,
star: Star,
bookmark: Bookmark,
clock: Clock,
calendar: Calendar,
bell: Bell,
"alert-triangle": AlertTriangle,
info: Info,
"check-circle": CheckCircle,
"x-circle": XCircle,
plus: Plus,
minus: Minus,
search: Search,
trash: Trash,
edit: Edit,
download: Download,
upload: Upload,
share: Share,
link: Link,
paperclip: Paperclip,
archive: Archive,
box: Box,
boxes: Boxes,
container: Container,
briefcase: Briefcase,
education: Education,
award: Award,
lightbulb: Lightbulb,
flame: Flame,
rocket: Rocket,
atom: Atom,
};
// Get all available icon names

View File

@@ -0,0 +1,175 @@
"use client";
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { CategoryIcon } from "@/components/ui/category-icon";
import { ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
// Group icons by category for better organization
const iconGroups: Record<string, string[]> = {
"Alimentation": [
"shopping-cart", "utensils", "croissant", "coffee", "wine", "beer",
"pizza", "apple", "cherry", "salad", "sandwich", "ice-cream",
"cake", "cup-soda", "milk", "egg", "fish", "beef"
],
"Transport": [
"fuel", "train", "car", "parking", "bike", "plane", "bus",
"ship", "sailboat", "truck", "car-front", "circle-parking",
"train-front"
],
"Logement": [
"home", "zap", "droplet", "hammer", "sofa", "refrigerator",
"washing-machine", "lamp", "lamp-desk", "armchair", "bath",
"shower-head", "door-open", "fence", "trees", "flower",
"leaf", "sun", "snowflake", "wind", "thermometer"
],
"Santé": [
"pill", "stethoscope", "hospital", "glasses", "dumbbell", "sparkles",
"heart", "heart-pulse", "activity", "syringe", "bandage", "brain",
"eye", "ear", "hand", "footprints", "person-standing"
],
"Loisirs": [
"tv", "music", "film", "gamepad", "book", "ticket", "clapperboard",
"headphones", "speaker", "radio", "camera", "image", "palette",
"brush", "pen-tool", "scissors", "drama", "party-popper"
],
"Sport": ["trophy", "medal", "target", "volleyball"],
"Shopping": [
"shirt", "smartphone", "package", "shopping-bag", "store", "gem",
"watch", "sunglasses", "crown", "laptop", "monitor", "keyboard",
"mouse", "printer", "tablet-smartphone", "headset"
],
"Services": [
"wifi", "repeat", "landmark", "shield", "receipt", "file-text",
"mail", "phone", "message-square", "send", "globe", "cloud",
"server", "lock", "unlock", "settings", "wrench"
],
"Finance": [
"piggy-bank", "banknote", "wallet", "hand-coins", "undo", "coins",
"credit-card", "building", "building2", "trending-up", "trending-down",
"bar-chart", "pie-chart", "line-chart", "calculator", "percent",
"dollar-sign", "euro"
],
"Voyage": [
"bed", "luggage", "map", "map-pin", "compass", "mountain",
"tent", "palmtree", "umbrella", "globe2", "flag"
],
"Famille": [
"graduation-cap", "baby", "paw-print", "users", "user", "user-plus",
"dog", "cat", "bird", "rabbit"
],
"Autre": [
"heart-handshake", "gift", "cigarette", "arrow-right-left",
"help-circle", "tag", "folder", "key", "star", "bookmark", "clock",
"calendar", "bell", "alert-triangle", "info", "check-circle", "x-circle",
"plus", "minus", "search", "trash", "edit", "download", "upload",
"share", "link", "paperclip", "archive", "box", "boxes", "container",
"briefcase", "education", "award", "lightbulb", "flame", "rocket", "atom"
],
};
interface IconPickerProps {
value: string;
onChange: (icon: string) => void;
color?: string;
}
export function IconPicker({ value, onChange, color }: IconPickerProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
// Filter icons based on search
const filteredGroups = useMemo(() => {
if (!search.trim()) return iconGroups;
const query = search.toLowerCase();
const result: Record<string, string[]> = {};
Object.entries(iconGroups).forEach(([group, icons]) => {
const filtered = icons.filter(
(icon) =>
icon.toLowerCase().includes(query) ||
group.toLowerCase().includes(query)
);
if (filtered.length > 0) {
result[group] = filtered;
}
});
return result;
}, [search]);
const handleSelect = (icon: string) => {
onChange(icon);
setOpen(false);
setSearch("");
};
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<CategoryIcon icon={value} color={color} size={20} />
<span className="text-muted-foreground text-sm">{value}</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[350px] p-0"
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command>
<CommandInput
placeholder="Rechercher une icône..."
value={search}
onValueChange={setSearch}
/>
<CommandList className="max-h-[300px]">
<CommandEmpty>Aucune icône trouvée.</CommandEmpty>
{Object.entries(filteredGroups).map(([group, icons]) => (
<CommandGroup key={group} heading={group}>
<div className="grid grid-cols-6 gap-1 p-1">
{icons.map((icon) => (
<button
key={icon}
onClick={() => handleSelect(icon)}
className={cn(
"flex items-center justify-center p-2 rounded-md hover:bg-accent transition-colors",
value === icon && "bg-accent ring-2 ring-primary"
)}
title={icon}
>
<CategoryIcon icon={icon} color={color} size={20} />
</button>
))}
</div>
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}