feat: add new navigation item for rules in the dashboard sidebar with associated icon
This commit is contained in:
278
components/rules/rule-create-dialog.tsx
Normal file
278
components/rules/rule-create-dialog.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||
import { Tag, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import type { Category, Transaction } from "@/lib/types";
|
||||
|
||||
interface TransactionGroup {
|
||||
key: string;
|
||||
displayName: string;
|
||||
transactions: Transaction[];
|
||||
totalAmount: number;
|
||||
suggestedKeyword: string;
|
||||
}
|
||||
|
||||
interface RuleCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
group: TransactionGroup | null;
|
||||
categories: Category[];
|
||||
onSave: (data: {
|
||||
keyword: string;
|
||||
categoryId: string;
|
||||
applyToExisting: boolean;
|
||||
transactionIds: string[];
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RuleCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
group,
|
||||
categories,
|
||||
onSave,
|
||||
}: RuleCreateDialogProps) {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [categoryId, setCategoryId] = useState<string>("");
|
||||
const [applyToExisting, setApplyToExisting] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Reset form when group changes
|
||||
useEffect(() => {
|
||||
if (group) {
|
||||
setKeyword(group.suggestedKeyword);
|
||||
setCategoryId("");
|
||||
setApplyToExisting(true);
|
||||
}
|
||||
}, [group]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Check if keyword already exists in any category
|
||||
const existingCategory = useMemo(() => {
|
||||
if (!keyword) return null;
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
return categories.find((c) =>
|
||||
c.keywords.some((k) => k.toLowerCase() === lowerKeyword)
|
||||
);
|
||||
}, [keyword, categories]);
|
||||
|
||||
const selectedCategory = categories.find((c) => c.id === categoryId);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!keyword || !categoryId || !group) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onSave({
|
||||
keyword,
|
||||
categoryId,
|
||||
applyToExisting,
|
||||
transactionIds: group.transactions.map((t) => t.id),
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to create rule:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!group) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer une règle de catégorisation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Associez un mot-clé à une catégorie pour catégoriser automatiquement
|
||||
les transactions similaires.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Group info */}
|
||||
<div className="p-3 rounded-lg bg-muted/50 border border-border">
|
||||
<div className="text-sm font-medium text-foreground mb-1">
|
||||
{group.displayName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{group.transactions.length} transaction
|
||||
{group.transactions.length > 1 ? "s" : ""} seront catégorisées
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyword input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keyword">Mot-clé de détection</Label>
|
||||
<div className="relative">
|
||||
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="keyword"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="ex: spotify, carrefour, sncf..."
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Les transactions contenant ce mot-clé seront automatiquement
|
||||
catégorisées.
|
||||
</p>
|
||||
{existingCategory && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span>
|
||||
Ce mot-clé existe déjà dans "{existingCategory.name}"
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category select */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Catégorie</Label>
|
||||
<Select value={categoryId} onValueChange={setCategoryId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionner une catégorie" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parentCategories.map((parent) => (
|
||||
<div key={parent.id}>
|
||||
<SelectItem value={parent.id} className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<CategoryIcon
|
||||
icon={parent.icon}
|
||||
color={parent.color}
|
||||
size={16}
|
||||
/>
|
||||
<span>{parent.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{childrenByParent[parent.id]?.map((child) => (
|
||||
<SelectItem key={child.id} value={child.id}>
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<CategoryIcon
|
||||
icon={child.icon}
|
||||
color={child.color}
|
||||
size={16}
|
||||
/>
|
||||
<span>{child.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedCategory && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Mots-clés actuels:
|
||||
</span>
|
||||
{selectedCategory.keywords.length > 0 ? (
|
||||
selectedCategory.keywords.map((k, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">
|
||||
{k}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
Aucun
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Apply to existing checkbox */}
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="apply-existing"
|
||||
checked={applyToExisting}
|
||||
onCheckedChange={(checked) =>
|
||||
setApplyToExisting(checked as boolean)
|
||||
}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="apply-existing"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Appliquer aux transactions existantes
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Catégoriser immédiatement les {group.transactions.length}{" "}
|
||||
transaction
|
||||
{group.transactions.length > 1 ? "s" : ""} de ce groupe
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{keyword && categoryId && (
|
||||
<div className="p-3 rounded-lg bg-success/10 border border-success/20">
|
||||
<div className="flex items-center gap-2 text-sm text-success">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<span>
|
||||
Le mot-clé "<strong>{keyword}</strong>" sera ajouté à la
|
||||
catégorie "<strong>{selectedCategory?.name}</strong>"
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!keyword || !categoryId || isLoading}
|
||||
>
|
||||
{isLoading ? "Création..." : "Créer la règle"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user