feat: add categorization feature for transaction groups with UI enhancements for category selection
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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,19 +97,30 @@ export function RuleGroupCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<CategoryCombobox
|
||||||
|
categories={categories}
|
||||||
|
value={selectedCategoryId}
|
||||||
|
onChange={handleCategorySelect}
|
||||||
|
placeholder="Catégoriser..."
|
||||||
|
width="w-[300px]"
|
||||||
|
buttonWidth="w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onCreateRule();
|
onCreateRule();
|
||||||
}}
|
}}
|
||||||
className="shrink-0"
|
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
Créer règle
|
Créer règle
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Expanded transactions list */}
|
{/* Expanded transactions list */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user