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

@@ -29,6 +29,7 @@ export default function CategoriesPage() {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
color: "#22c55e", color: "#22c55e",
icon: "tag",
keywords: [] as string[], keywords: [] as string[],
parentId: null as string | null, parentId: null as string | null,
}); });
@@ -132,7 +133,7 @@ export default function CategoriesPage() {
const handleNewCategory = (parentId: string | null = null) => { const handleNewCategory = (parentId: string | null = null) => {
setEditingCategory(null); setEditingCategory(null);
setFormData({ name: "", color: "#22c55e", keywords: [], parentId }); setFormData({ name: "", color: "#22c55e", icon: "tag", keywords: [], parentId });
setIsDialogOpen(true); setIsDialogOpen(true);
}; };
@@ -141,6 +142,7 @@ export default function CategoriesPage() {
setFormData({ setFormData({
name: category.name, name: category.name,
color: category.color, color: category.color,
icon: category.icon,
keywords: [...category.keywords], keywords: [...category.keywords],
parentId: category.parentId, parentId: category.parentId,
}); });
@@ -154,6 +156,7 @@ export default function CategoriesPage() {
...editingCategory, ...editingCategory,
name: formData.name, name: formData.name,
color: formData.color, color: formData.color,
icon: formData.icon,
keywords: formData.keywords, keywords: formData.keywords,
parentId: formData.parentId, parentId: formData.parentId,
}); });
@@ -161,8 +164,8 @@ export default function CategoriesPage() {
await addCategory({ await addCategory({
name: formData.name, name: formData.name,
color: formData.color, color: formData.color,
icon: formData.icon,
keywords: formData.keywords, keywords: formData.keywords,
icon: "tag",
parentId: formData.parentId, parentId: formData.parentId,
}); });
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useMemo, useEffect } from "react"; import { useState, useMemo, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { PageLayout, LoadingState, PageHeader } from "@/components/layout"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import { import {
@@ -8,10 +8,17 @@ import {
TransactionBulkActions, TransactionBulkActions,
TransactionTable, TransactionTable,
} from "@/components/transactions"; } from "@/components/transactions";
import { RuleCreateDialog } from "@/components/rules";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog"; import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import { useBankingData } from "@/lib/hooks"; import { useBankingData } from "@/lib/hooks";
import { updateCategory, updateTransaction } from "@/lib/store-db";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Upload } from "lucide-react"; import { Upload } from "lucide-react";
import type { Transaction } from "@/lib/types";
import {
normalizeDescription,
suggestKeyword,
} from "@/components/rules/constants";
type SortField = "date" | "amount" | "description"; type SortField = "date" | "amount" | "description";
type SortOrder = "asc" | "desc"; type SortOrder = "asc" | "desc";
@@ -36,6 +43,8 @@ export default function TransactionsPage() {
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>( const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
new Set() new Set()
); );
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(null);
const filteredTransactions = useMemo(() => { const filteredTransactions = useMemo(() => {
if (!data) return []; if (!data) return [];
@@ -101,6 +110,76 @@ export default function TransactionsPage() {
sortOrder, sortOrder,
]); ]);
const handleCreateRule = useCallback((transaction: Transaction) => {
setRuleTransaction(transaction);
setRuleDialogOpen(true);
}, []);
// Create a virtual group for the rule dialog based on selected transaction
const ruleGroup = useMemo(() => {
if (!ruleTransaction || !data) return null;
// Find similar transactions (same normalized description)
const normalizedDesc = normalizeDescription(ruleTransaction.description);
const similarTransactions = data.transactions.filter(
(t) => normalizeDescription(t.description) === normalizedDesc
);
return {
key: normalizedDesc,
displayName: ruleTransaction.description,
transactions: similarTransactions,
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
suggestedKeyword: suggestKeyword(similarTransactions.map((t) => t.description)),
};
}, [ruleTransaction, data]);
const handleSaveRule = useCallback(
async (ruleData: {
keyword: string;
categoryId: string;
applyToExisting: boolean;
transactionIds: string[];
}) => {
if (!data) return;
// 1. Add keyword to category
const category = data.categories.find((c) => c.id === ruleData.categoryId);
if (!category) {
throw new Error("Category not found");
}
// Check if keyword already exists
const keywordExists = category.keywords.some(
(k) => k.toLowerCase() === ruleData.keyword.toLowerCase()
);
if (!keywordExists) {
await updateCategory({
...category,
keywords: [...category.keywords, ruleData.keyword],
});
}
// 2. Apply to existing transactions if requested
if (ruleData.applyToExisting) {
const transactions = data.transactions.filter((t) =>
ruleData.transactionIds.includes(t.id)
);
await Promise.all(
transactions.map((t) =>
updateTransaction({ ...t, categoryId: ruleData.categoryId })
)
);
}
refresh();
setRuleDialogOpen(false);
},
[data, refresh]
);
if (isLoading || !data) { if (isLoading || !data) {
return <LoadingState />; return <LoadingState />;
} }
@@ -327,9 +406,18 @@ export default function TransactionsPage() {
onToggleReconciled={toggleReconciled} onToggleReconciled={toggleReconciled}
onMarkReconciled={markReconciled} onMarkReconciled={markReconciled}
onSetCategory={setCategory} onSetCategory={setCategory}
onCreateRule={handleCreateRule}
formatCurrency={formatCurrency} formatCurrency={formatCurrency}
formatDate={formatDate} formatDate={formatDate}
/> />
<RuleCreateDialog
open={ruleDialogOpen}
onOpenChange={setRuleDialogOpen}
group={ruleGroup}
categories={data.categories}
onSave={handleSaveRule}
/>
</PageLayout> </PageLayout>
); );
} }

View File

@@ -18,6 +18,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { IconPicker } from "@/components/ui/icon-picker";
import { Plus, X } from "lucide-react"; import { Plus, 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";
@@ -26,6 +27,7 @@ import { categoryColors } from "./constants";
interface CategoryFormData { interface CategoryFormData {
name: string; name: string;
color: string; color: string;
icon: string;
keywords: string[]; keywords: string[];
parentId: string | null; parentId: string | null;
} }
@@ -128,22 +130,32 @@ export function CategoryEditDialog({
/> />
</div> </div>
{/* Couleur */} {/* Couleur et Icône */}
<div className="space-y-2"> <div className="grid grid-cols-2 gap-4">
<Label>Couleur</Label> <div className="space-y-2">
<div className="flex flex-wrap gap-2"> <Label>Couleur</Label>
{categoryColors.map((color) => ( <div className="flex flex-wrap gap-2">
<button {categoryColors.map((color) => (
key={color} <button
onClick={() => onFormDataChange({ ...formData, color })} key={color}
className={cn( onClick={() => onFormDataChange({ ...formData, color })}
"w-8 h-8 rounded-full transition-transform", className={cn(
formData.color === color && "w-7 h-7 rounded-full transition-transform",
"ring-2 ring-offset-2 ring-primary scale-110" formData.color === color &&
)} "ring-2 ring-offset-2 ring-primary scale-110"
style={{ backgroundColor: color }} )}
/> style={{ backgroundColor: color }}
))} />
))}
</div>
</div>
<div className="space-y-2">
<Label>Icône</Label>
<IconPicker
value={formData.icon}
onChange={(icon) => onFormDataChange({ ...formData, icon })}
color={formData.color}
/>
</div> </div>
</div> </div>

View File

@@ -9,6 +9,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import type { Account, Category } from "@/lib/types"; import type { Account, Category } from "@/lib/types";
@@ -67,20 +68,12 @@ export function TransactionFilters({
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={selectedCategory} onValueChange={onCategoryChange}> <CategoryFilterCombobox
<SelectTrigger className="w-[180px]"> categories={categories}
<SelectValue placeholder="Catégorie" /> value={selectedCategory}
</SelectTrigger> onChange={onCategoryChange}
<SelectContent> className="w-[200px]"
<SelectItem value="all">Toutes catégories</SelectItem> />
<SelectItem value="uncategorized">Non catégorisé</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={showReconciled} onValueChange={onReconciledChange}> <Select value={showReconciled} onValueChange={onReconciledChange}>
<SelectTrigger className="w-[160px]"> <SelectTrigger className="w-[160px]">

View File

@@ -16,7 +16,9 @@ import {
Circle, Circle,
MoreVertical, MoreVertical,
ArrowUpDown, ArrowUpDown,
Wand2,
} from "lucide-react"; } from "lucide-react";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Transaction, Account, Category } from "@/lib/types"; import type { Transaction, Account, Category } from "@/lib/types";
@@ -36,6 +38,7 @@ interface TransactionTableProps {
onToggleReconciled: (id: string) => void; onToggleReconciled: (id: string) => void;
onMarkReconciled: (id: string) => void; onMarkReconciled: (id: string) => void;
onSetCategory: (transactionId: string, categoryId: string | null) => void; onSetCategory: (transactionId: string, categoryId: string | null) => void;
onCreateRule: (transaction: Transaction) => void;
formatCurrency: (amount: number) => string; formatCurrency: (amount: number) => string;
formatDate: (dateStr: string) => string; formatDate: (dateStr: string) => string;
} }
@@ -53,6 +56,7 @@ export function TransactionTable({
onToggleReconciled, onToggleReconciled,
onMarkReconciled, onMarkReconciled,
onSetCategory, onSetCategory,
onCreateRule,
formatCurrency, formatCurrency,
formatDate, formatDate,
}: TransactionTableProps) { }: TransactionTableProps) {
@@ -266,6 +270,13 @@ export function TransactionTable({
? "Dépointer" ? "Dépointer"
: "Pointer"} : "Pointer"}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onCreateRule(transaction)}
>
<Wand2 className="w-4 h-4 mr-2" />
Créer une règle
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</td> </td>

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"; "use client";
import { import {
// Alimentation
ShoppingCart, ShoppingCart,
Utensils, Utensils,
Croissant, Croissant,
Coffee,
Wine,
Beer,
Pizza,
Apple,
Cherry,
Salad,
Sandwich,
IceCream,
Cake,
CupSoda,
Milk,
Egg,
Fish,
Beef,
// Transport
Fuel, Fuel,
Train, Train,
Car, Car,
SquareParking, SquareParking,
Bike, Bike,
Plane, Plane,
Bus,
Ship,
Sailboat,
Truck,
CarFront,
CircleParking,
TrainFront,
// Logement
Home, Home,
Zap, Zap,
Droplet, Droplet,
Hammer, Hammer,
Sofa, Sofa,
Refrigerator,
WashingMachine,
Lamp,
LampDesk,
Armchair,
Bath,
ShowerHead,
DoorOpen,
Fence,
Trees,
Flower,
Leaf,
Sun,
Snowflake,
Wind,
Thermometer,
// Santé
Pill, Pill,
Stethoscope, Stethoscope,
Hospital, Hospital,
Glasses, Glasses,
Dumbbell, Dumbbell,
Sparkles, Sparkles,
Heart,
HeartPulse,
Activity,
Syringe,
Bandage,
Brain,
Eye,
Ear,
Hand,
Footprints,
PersonStanding,
// Loisirs
Tv, Tv,
Music, Music,
Film, Film,
Gamepad, Gamepad,
Book, Book,
Ticket, Ticket,
Clapperboard,
Headphones,
Speaker,
Radio,
Camera,
Image,
Palette,
Brush,
PenTool,
Scissors,
Drama,
PartyPopper,
// Sport
Trophy,
Medal,
Target,
Volleyball,
// Shopping
Shirt, Shirt,
Smartphone, Smartphone,
Package, Package,
ShoppingBag,
Store,
Gem,
Watch,
Glasses as SunGlasses,
Crown,
Laptop,
Monitor,
Keyboard,
Mouse,
Printer,
TabletSmartphone,
Headset,
// Services
Wifi, Wifi,
Repeat, Repeat,
Landmark, Landmark,
Shield, Shield,
HeartPulse,
Receipt, Receipt,
FileText,
Mail,
Phone,
MessageSquare,
Send,
Globe,
Cloud,
Server,
Lock,
Unlock,
Settings,
// Finance
PiggyBank, PiggyBank,
Banknote, Banknote,
Wallet, Wallet,
HandCoins, HandCoins,
Undo, Undo,
Coins, Coins,
CreditCard,
Building,
Building2,
TrendingUp,
TrendingDown,
BarChart,
PieChart,
LineChart,
Calculator,
Percent,
DollarSign,
Euro,
// Voyage
Bed, Bed,
Luggage, Luggage,
Map,
MapPin,
Compass,
Mountain,
Tent,
Palmtree,
Umbrella,
Globe2,
Flag,
// Famille
GraduationCap, GraduationCap,
Baby, Baby,
PawPrint, PawPrint,
Users,
User,
UserPlus,
Dog,
Cat,
Bird,
Rabbit,
// Autre
Wrench, Wrench,
HeartHandshake, HeartHandshake,
Gift, Gift,
@@ -56,61 +184,215 @@ import {
Tag, Tag,
Folder, Folder,
Key, 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, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
// Map icon names to Lucide components // Map icon names to Lucide components
const iconMap: Record<string, LucideIcon> = { const iconMap: Record<string, LucideIcon> = {
// Alimentation
"shopping-cart": ShoppingCart, "shopping-cart": ShoppingCart,
utensils: Utensils, utensils: Utensils,
croissant: Croissant, 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, fuel: Fuel,
train: Train, train: Train,
car: Car, car: Car,
"car-taxi": Car, // Using Car as fallback for car-taxi
"car-key": Key, // Using Key as fallback
parking: SquareParking, parking: SquareParking,
bike: Bike, bike: Bike,
plane: Plane, plane: Plane,
bus: Bus,
ship: Ship,
sailboat: Sailboat,
truck: Truck,
"car-front": CarFront,
"circle-parking": CircleParking,
"train-front": TrainFront,
// Logement
home: Home, home: Home,
zap: Zap, zap: Zap,
droplet: Droplet, droplet: Droplet,
hammer: Hammer, hammer: Hammer,
sofa: Sofa, sofa: Sofa,
refrigerator: Refrigerator, 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, pill: Pill,
stethoscope: Stethoscope, stethoscope: Stethoscope,
hospital: Hospital, hospital: Hospital,
glasses: Glasses, glasses: Glasses,
dumbbell: Dumbbell, dumbbell: Dumbbell,
sparkles: Sparkles, 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, tv: Tv,
music: Music, music: Music,
film: Film, film: Film,
gamepad: Gamepad, gamepad: Gamepad,
book: Book, book: Book,
ticket: Ticket, 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, shirt: Shirt,
smartphone: Smartphone, smartphone: Smartphone,
package: Package, 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, wifi: Wifi,
repeat: Repeat, repeat: Repeat,
landmark: Landmark, landmark: Landmark,
shield: Shield, shield: Shield,
"heart-pulse": HeartPulse,
receipt: Receipt, 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, "piggy-bank": PiggyBank,
banknote: Banknote, banknote: Banknote,
wallet: Wallet, wallet: Wallet,
"hand-coins": HandCoins, "hand-coins": HandCoins,
undo: Undo, undo: Undo,
coins: Coins, 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, bed: Bed,
luggage: Luggage, 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, "graduation-cap": GraduationCap,
baby: Baby, baby: Baby,
"paw-print": PawPrint, "paw-print": PawPrint,
users: Users,
user: User,
"user-plus": UserPlus,
dog: Dog,
cat: Cat,
bird: Bird,
rabbit: Rabbit,
// Autre
wrench: Wrench, wrench: Wrench,
"heart-handshake": HeartHandshake, "heart-handshake": HeartHandshake,
gift: Gift, gift: Gift,
@@ -119,6 +401,37 @@ const iconMap: Record<string, LucideIcon> = {
"help-circle": HelpCircle, "help-circle": HelpCircle,
tag: Tag, tag: Tag,
folder: Folder, 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 // 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>
);
}