feat: add categorization feature for transaction groups with UI enhancements for category selection

This commit is contained in:
Julien Froidefond
2025-11-30 16:48:55 +01:00
parent c4e7df4091
commit f366ea02c5
4 changed files with 95 additions and 49 deletions

View File

@@ -210,6 +210,25 @@ export default function RulesPage() {
} }
}, [data, refresh]); }, [data, refresh]);
const handleCategorizeGroup = useCallback(
async (group: TransactionGroup, categoryId: string | null) => {
if (!data) return;
try {
await Promise.all(
group.transactions.map((t) =>
updateTransaction({ ...t, categoryId })
)
);
refresh();
} catch (error) {
console.error("Error categorizing group:", error);
alert("Erreur lors de la catégorisation");
}
},
[data, refresh]
);
if (isLoading || !data) { if (isLoading || !data) {
return <LoadingState />; return <LoadingState />;
} }
@@ -277,6 +296,9 @@ export default function RulesPage() {
isExpanded={expandedGroups.has(group.key)} isExpanded={expandedGroups.has(group.key)}
onToggleExpand={() => toggleExpand(group.key)} onToggleExpand={() => toggleExpand(group.key)}
onCreateRule={() => handleCreateRule(group)} onCreateRule={() => handleCreateRule(group)}
onCategorize={(categoryId) =>
handleCategorizeGroup(group, categoryId)
}
categories={data.categories} categories={data.categories}
formatCurrency={formatCurrency} formatCurrency={formatCurrency}
formatDate={formatDate} formatDate={formatDate}

View File

@@ -1,8 +1,10 @@
"use client"; "use client";
import { useState } from "react";
import { ChevronDown, ChevronRight, Plus, Tag } from "lucide-react"; import { ChevronDown, ChevronRight, Plus, Tag } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { CategoryCombobox } from "@/components/ui/category-combobox";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Transaction, Category } from "@/lib/types"; import type { Transaction, Category } from "@/lib/types";
@@ -19,6 +21,7 @@ interface RuleGroupCardProps {
isExpanded: boolean; isExpanded: boolean;
onToggleExpand: () => void; onToggleExpand: () => void;
onCreateRule: () => void; onCreateRule: () => void;
onCategorize: (categoryId: string | null) => void;
categories: Category[]; categories: Category[];
formatCurrency: (amount: number) => string; formatCurrency: (amount: number) => string;
formatDate: (date: string) => string; formatDate: (date: string) => string;
@@ -29,14 +32,23 @@ export function RuleGroupCard({
isExpanded, isExpanded,
onToggleExpand, onToggleExpand,
onCreateRule, onCreateRule,
onCategorize,
categories,
formatCurrency, formatCurrency,
formatDate, formatDate,
}: RuleGroupCardProps) { }: RuleGroupCardProps) {
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const avgAmount = const avgAmount =
group.transactions.reduce((sum, t) => sum + t.amount, 0) / group.transactions.reduce((sum, t) => sum + t.amount, 0) /
group.transactions.length; group.transactions.length;
const isDebit = avgAmount < 0; const isDebit = avgAmount < 0;
const handleCategorySelect = (categoryId: string | null) => {
setSelectedCategoryId(null); // Reset après sélection
onCategorize(categoryId);
};
return ( return (
<div className="border border-border rounded-lg bg-card overflow-hidden"> <div className="border border-border rounded-lg bg-card overflow-hidden">
{/* Header */} {/* Header */}
@@ -85,17 +97,28 @@ export function RuleGroupCard({
</div> </div>
</div> </div>
<Button <div className="flex items-center gap-2 shrink-0">
size="sm" <div onClick={(e) => e.stopPropagation()}>
onClick={(e) => { <CategoryCombobox
e.stopPropagation(); categories={categories}
onCreateRule(); value={selectedCategoryId}
}} onChange={handleCategorySelect}
className="shrink-0" placeholder="Catégoriser..."
> width="w-[300px]"
<Plus className="h-4 w-4 mr-1" /> buttonWidth="w-auto"
Créer règle />
</Button> </div>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
onCreateRule();
}}
>
<Plus className="h-4 w-4 mr-1" />
Créer règle
</Button>
</div>
</div> </div>
</div> </div>

View File

@@ -1,16 +1,10 @@
"use client"; "use client";
import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { import { CategoryCombobox } from "@/components/ui/category-combobox";
DropdownMenu, import { CheckCircle2, Circle } from "lucide-react";
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { CategoryIcon } from "@/components/ui/category-icon";
import { CheckCircle2, Circle, Tags } from "lucide-react";
import type { Category } from "@/lib/types"; import type { Category } from "@/lib/types";
interface TransactionBulkActionsProps { interface TransactionBulkActionsProps {
@@ -26,8 +20,15 @@ export function TransactionBulkActions({
onReconcile, onReconcile,
onSetCategory, onSetCategory,
}: TransactionBulkActionsProps) { }: TransactionBulkActionsProps) {
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
if (selectedCount === 0) return null; if (selectedCount === 0) return null;
const handleCategorySelect = (categoryId: string | null) => {
setSelectedCategoryId(null); // Reset après sélection
onSetCategory(categoryId);
};
return ( return (
<Card className="bg-primary/5 border-primary/20"> <Card className="bg-primary/5 border-primary/20">
<CardContent className="py-3"> <CardContent className="py-3">
@@ -47,34 +48,14 @@ export function TransactionBulkActions({
<Circle className="w-4 h-4 mr-1" /> <Circle className="w-4 h-4 mr-1" />
Dépointer Dépointer
</Button> </Button>
<DropdownMenu> <CategoryCombobox
<DropdownMenuTrigger asChild> categories={categories}
<Button size="sm" variant="outline"> value={selectedCategoryId}
<Tags className="w-4 h-4 mr-1" /> onChange={handleCategorySelect}
Catégoriser placeholder="Catégoriser..."
</Button> width="w-[300px]"
</DropdownMenuTrigger> buttonWidth="w-auto"
<DropdownMenuContent> />
<DropdownMenuItem onClick={() => onSetCategory(null)}>
Aucune catégorie
</DropdownMenuItem>
<DropdownMenuSeparator />
{categories.map((cat) => (
<DropdownMenuItem
key={cat.id}
onClick={() => onSetCategory(cat.id)}
>
<CategoryIcon
icon={cat.icon}
color={cat.color}
size={14}
className="mr-2"
/>
{cat.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -29,6 +29,7 @@ interface CategoryComboboxProps {
showBadge?: boolean; showBadge?: boolean;
align?: "start" | "center" | "end"; align?: "start" | "center" | "end";
width?: string; width?: string;
buttonWidth?: string;
} }
export function CategoryCombobox({ export function CategoryCombobox({
@@ -39,6 +40,7 @@ export function CategoryCombobox({
showBadge = false, showBadge = false,
align = "start", align = "start",
width = "w-[300px]", width = "w-[300px]",
buttonWidth,
}: CategoryComboboxProps) { }: CategoryComboboxProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -181,7 +183,10 @@ export function CategoryCombobox({
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="w-full justify-between" className={cn(
"justify-between",
buttonWidth || "w-full"
)}
> >
{selectedCategory ? ( {selectedCategory ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -207,6 +212,21 @@ export function CategoryCombobox({
<CommandInput placeholder="Rechercher une catégorie..." /> <CommandInput placeholder="Rechercher une catégorie..." />
<CommandList className="max-h-[250px]"> <CommandList className="max-h-[250px]">
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty> <CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => handleSelect(null)}
>
<X className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Aucune catégorie</span>
<Check
className={cn(
"ml-auto h-4 w-4",
value === null ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
</CommandGroup>
<CommandGroup> <CommandGroup>
{parentCategories.map((parent) => ( {parentCategories.map((parent) => (
<div key={parent.id}> <div key={parent.id}>