feat: add new navigation item for rules in the dashboard sidebar with associated icon
This commit is contained in:
298
app/rules/page.tsx
Normal file
298
app/rules/page.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback } from "react";
|
||||||
|
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
||||||
|
import {
|
||||||
|
RuleGroupCard,
|
||||||
|
RuleCreateDialog,
|
||||||
|
RulesSearchBar,
|
||||||
|
} from "@/components/rules";
|
||||||
|
import { useBankingData } from "@/lib/hooks";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Sparkles, RefreshCw } from "lucide-react";
|
||||||
|
import { updateCategory, autoCategorize, updateTransaction } from "@/lib/store-db";
|
||||||
|
import {
|
||||||
|
normalizeDescription,
|
||||||
|
suggestKeyword,
|
||||||
|
} from "@/components/rules/constants";
|
||||||
|
import type { Transaction, Category } from "@/lib/types";
|
||||||
|
|
||||||
|
interface TransactionGroup {
|
||||||
|
key: string;
|
||||||
|
displayName: string;
|
||||||
|
transactions: Transaction[];
|
||||||
|
totalAmount: number;
|
||||||
|
suggestedKeyword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RulesPage() {
|
||||||
|
const { data, isLoading, refresh } = useBankingData();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [sortBy, setSortBy] = useState<"count" | "amount" | "name">("count");
|
||||||
|
const [filterMinCount, setFilterMinCount] = useState(2);
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<TransactionGroup | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [isAutoCategorizing, setIsAutoCategorizing] = useState(false);
|
||||||
|
|
||||||
|
// Group uncategorized transactions by normalized description
|
||||||
|
const transactionGroups = useMemo(() => {
|
||||||
|
if (!data?.transactions) return [];
|
||||||
|
|
||||||
|
const uncategorized = data.transactions.filter((t) => !t.categoryId);
|
||||||
|
const groups: Record<string, Transaction[]> = {};
|
||||||
|
|
||||||
|
uncategorized.forEach((transaction) => {
|
||||||
|
const key = normalizeDescription(transaction.description);
|
||||||
|
if (!groups[key]) {
|
||||||
|
groups[key] = [];
|
||||||
|
}
|
||||||
|
groups[key].push(transaction);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array with metadata
|
||||||
|
const groupArray: TransactionGroup[] = Object.entries(groups).map(
|
||||||
|
([key, transactions]) => {
|
||||||
|
const descriptions = transactions.map((t) => t.description);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
displayName: transactions[0].description, // Use first transaction's description as display name
|
||||||
|
transactions,
|
||||||
|
totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0),
|
||||||
|
suggestedKeyword: suggestKeyword(descriptions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
let filtered = groupArray;
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filtered = groupArray.filter(
|
||||||
|
(g) =>
|
||||||
|
g.displayName.toLowerCase().includes(query) ||
|
||||||
|
g.key.includes(query) ||
|
||||||
|
g.suggestedKeyword.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by minimum count
|
||||||
|
filtered = filtered.filter((g) => g.transactions.length >= filterMinCount);
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case "count":
|
||||||
|
return b.transactions.length - a.transactions.length;
|
||||||
|
case "amount":
|
||||||
|
return Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
|
||||||
|
case "name":
|
||||||
|
return a.displayName.localeCompare(b.displayName);
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [data?.transactions, searchQuery, sortBy, filterMinCount]);
|
||||||
|
|
||||||
|
const uncategorizedCount = useMemo(() => {
|
||||||
|
if (!data?.transactions) return 0;
|
||||||
|
return data.transactions.filter((t) => !t.categoryId).length;
|
||||||
|
}, [data?.transactions]);
|
||||||
|
|
||||||
|
const formatCurrency = useCallback((amount: number) => {
|
||||||
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(amount);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatDate = useCallback((dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleExpand = useCallback((key: string) => {
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateRule = useCallback((group: TransactionGroup) => {
|
||||||
|
setSelectedGroup(group);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
[data, refresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAutoCategorize = useCallback(async () => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
setIsAutoCategorizing(true);
|
||||||
|
try {
|
||||||
|
const uncategorized = data.transactions.filter((t) => !t.categoryId);
|
||||||
|
let categorizedCount = 0;
|
||||||
|
|
||||||
|
for (const transaction of uncategorized) {
|
||||||
|
const categoryId = autoCategorize(
|
||||||
|
transaction.description + " " + (transaction.memo || ""),
|
||||||
|
data.categories
|
||||||
|
);
|
||||||
|
if (categoryId) {
|
||||||
|
await updateTransaction({ ...transaction, categoryId });
|
||||||
|
categorizedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
alert(`${categorizedCount} transaction(s) catégorisée(s) automatiquement`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error auto-categorizing:", error);
|
||||||
|
alert("Erreur lors de la catégorisation automatique");
|
||||||
|
} finally {
|
||||||
|
setIsAutoCategorizing(false);
|
||||||
|
}
|
||||||
|
}, [data, refresh]);
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader
|
||||||
|
title="Règles de catégorisation"
|
||||||
|
description={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{transactionGroups.length} groupe
|
||||||
|
{transactionGroups.length > 1 ? "s" : ""} de transactions similaires
|
||||||
|
<Badge variant="secondary">{uncategorizedCount} non catégorisées</Badge>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
uncategorizedCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleAutoCategorize}
|
||||||
|
disabled={isAutoCategorizing}
|
||||||
|
>
|
||||||
|
{isAutoCategorizing ? (
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Auto-catégoriser
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RulesSearchBar
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
sortBy={sortBy}
|
||||||
|
onSortChange={setSortBy}
|
||||||
|
filterMinCount={filterMinCount}
|
||||||
|
onFilterMinCountChange={setFilterMinCount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{transactionGroups.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<Sparkles className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
{uncategorizedCount === 0
|
||||||
|
? "Toutes les transactions sont catégorisées !"
|
||||||
|
: "Aucun groupe trouvé"}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-md">
|
||||||
|
{uncategorizedCount === 0
|
||||||
|
? "Continuez à importer des transactions pour voir les suggestions de règles."
|
||||||
|
: filterMinCount > 1
|
||||||
|
? `Essayez de réduire le filtre minimum à ${filterMinCount - 1}+ transactions.`
|
||||||
|
: "Modifiez vos critères de recherche."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{transactionGroups.map((group) => (
|
||||||
|
<RuleGroupCard
|
||||||
|
key={group.key}
|
||||||
|
group={group}
|
||||||
|
isExpanded={expandedGroups.has(group.key)}
|
||||||
|
onToggleExpand={() => toggleExpand(group.key)}
|
||||||
|
onCreateRule={() => handleCreateRule(group)}
|
||||||
|
categories={data.categories}
|
||||||
|
formatCurrency={formatCurrency}
|
||||||
|
formatDate={formatDate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RuleCreateDialog
|
||||||
|
open={isDialogOpen}
|
||||||
|
onOpenChange={setIsDialogOpen}
|
||||||
|
group={selectedGroup}
|
||||||
|
categories={data.categories}
|
||||||
|
onSave={handleSaveRule}
|
||||||
|
/>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Settings,
|
Settings,
|
||||||
|
Wand2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -23,6 +24,7 @@ const navItems = [
|
|||||||
{ href: "/folders", label: "Organisation", icon: FolderTree },
|
{ href: "/folders", label: "Organisation", icon: FolderTree },
|
||||||
{ href: "/transactions", label: "Transactions", icon: Upload },
|
{ href: "/transactions", label: "Transactions", icon: Upload },
|
||||||
{ href: "/categories", label: "Catégories", icon: Tags },
|
{ href: "/categories", label: "Catégories", icon: Tags },
|
||||||
|
{ href: "/rules", label: "Règles", icon: Wand2 },
|
||||||
{ href: "/statistics", label: "Statistiques", icon: BarChart3 },
|
{ href: "/statistics", label: "Statistiques", icon: BarChart3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
95
components/rules/constants.ts
Normal file
95
components/rules/constants.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// Minimum transactions in a group to be displayed
|
||||||
|
export const MIN_GROUP_SIZE = 1;
|
||||||
|
|
||||||
|
// Common words to filter out when suggesting keywords
|
||||||
|
export const STOP_WORDS = [
|
||||||
|
"de",
|
||||||
|
"du",
|
||||||
|
"la",
|
||||||
|
"le",
|
||||||
|
"les",
|
||||||
|
"des",
|
||||||
|
"un",
|
||||||
|
"une",
|
||||||
|
"et",
|
||||||
|
"ou",
|
||||||
|
"par",
|
||||||
|
"pour",
|
||||||
|
"avec",
|
||||||
|
"sur",
|
||||||
|
"dans",
|
||||||
|
"en",
|
||||||
|
"au",
|
||||||
|
"aux",
|
||||||
|
"ce",
|
||||||
|
"cette",
|
||||||
|
"ces",
|
||||||
|
"mon",
|
||||||
|
"ma",
|
||||||
|
"mes",
|
||||||
|
"ton",
|
||||||
|
"ta",
|
||||||
|
"tes",
|
||||||
|
"son",
|
||||||
|
"sa",
|
||||||
|
"ses",
|
||||||
|
"notre",
|
||||||
|
"nos",
|
||||||
|
"votre",
|
||||||
|
"vos",
|
||||||
|
"leur",
|
||||||
|
"leurs",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Function to normalize transaction descriptions for grouping
|
||||||
|
export function normalizeDescription(description: string): string {
|
||||||
|
return description
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\d{2}\/\d{2}\/\d{4}/g, "") // Remove dates
|
||||||
|
.replace(/\d{2}-\d{2}-\d{4}/g, "") // Remove dates
|
||||||
|
.replace(/\d+[.,]\d+/g, "") // Remove amounts
|
||||||
|
.replace(/carte \*+\d+/gi, "CARTE") // Normalize card numbers
|
||||||
|
.replace(/cb\*+\d+/gi, "CB") // Normalize CB numbers
|
||||||
|
.replace(/\s+/g, " ") // Normalize spaces
|
||||||
|
.replace(/[^\w\s]/g, " ") // Remove special chars
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract meaningful keywords from description
|
||||||
|
export function extractKeywords(description: string): string[] {
|
||||||
|
const normalized = normalizeDescription(description);
|
||||||
|
const words = normalized.split(/\s+/);
|
||||||
|
|
||||||
|
return words
|
||||||
|
.filter((word) => word.length > 2)
|
||||||
|
.filter((word) => !STOP_WORDS.includes(word.toLowerCase()))
|
||||||
|
.filter((word) => !/^\d+$/.test(word)); // Remove pure numbers
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggest a keyword based on common patterns in descriptions
|
||||||
|
export function suggestKeyword(descriptions: string[]): string {
|
||||||
|
// Find common substrings
|
||||||
|
const keywords = descriptions.flatMap(extractKeywords);
|
||||||
|
const frequency: Record<string, number> = {};
|
||||||
|
|
||||||
|
keywords.forEach((keyword) => {
|
||||||
|
frequency[keyword] = (frequency[keyword] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the most frequent keyword that appears in most descriptions
|
||||||
|
const sorted = Object.entries(frequency)
|
||||||
|
.filter(([_, count]) => count >= Math.ceil(descriptions.length * 0.5))
|
||||||
|
.sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
|
if (sorted.length > 0) {
|
||||||
|
// Return the longest frequent keyword
|
||||||
|
return sorted.reduce((best, current) =>
|
||||||
|
current[0].length > best[0].length ? current : best
|
||||||
|
)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: first meaningful word from first description
|
||||||
|
const firstKeywords = extractKeywords(descriptions[0]);
|
||||||
|
return firstKeywords[0] || descriptions[0].slice(0, 15);
|
||||||
|
}
|
||||||
|
|
||||||
4
components/rules/index.ts
Normal file
4
components/rules/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { RuleGroupCard } from "./rule-group-card";
|
||||||
|
export { RuleCreateDialog } from "./rule-create-dialog";
|
||||||
|
export { RulesSearchBar } from "./rules-search-bar";
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
157
components/rules/rule-group-card.tsx
Normal file
157
components/rules/rule-group-card.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown, ChevronRight, Plus, Tag } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Transaction, Category } from "@/lib/types";
|
||||||
|
|
||||||
|
interface TransactionGroup {
|
||||||
|
key: string;
|
||||||
|
displayName: string;
|
||||||
|
transactions: Transaction[];
|
||||||
|
totalAmount: number;
|
||||||
|
suggestedKeyword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuleGroupCardProps {
|
||||||
|
group: TransactionGroup;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpand: () => void;
|
||||||
|
onCreateRule: () => void;
|
||||||
|
categories: Category[];
|
||||||
|
formatCurrency: (amount: number) => string;
|
||||||
|
formatDate: (date: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuleGroupCard({
|
||||||
|
group,
|
||||||
|
isExpanded,
|
||||||
|
onToggleExpand,
|
||||||
|
onCreateRule,
|
||||||
|
formatCurrency,
|
||||||
|
formatDate,
|
||||||
|
}: RuleGroupCardProps) {
|
||||||
|
const avgAmount =
|
||||||
|
group.transactions.reduce((sum, t) => sum + t.amount, 0) /
|
||||||
|
group.transactions.length;
|
||||||
|
const isDebit = avgAmount < 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg bg-card overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 p-4 cursor-pointer hover:bg-accent/5 transition-colors"
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-foreground truncate">
|
||||||
|
{group.displayName}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
|
{group.transactions.length} transaction
|
||||||
|
{group.transactions.length > 1 ? "s" : ""}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
|
||||||
|
<Tag className="h-3 w-3" />
|
||||||
|
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{group.suggestedKeyword}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"font-semibold tabular-nums",
|
||||||
|
isDebit ? "text-destructive" : "text-success"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(group.totalAmount)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Moy: {formatCurrency(avgAmount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCreateRule();
|
||||||
|
}}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Créer règle
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded transactions list */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-border bg-muted/30">
|
||||||
|
<div className="max-h-64 overflow-y-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground">
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground">
|
||||||
|
Description
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium text-muted-foreground">
|
||||||
|
Montant
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{group.transactions.map((transaction) => (
|
||||||
|
<tr
|
||||||
|
key={transaction.id}
|
||||||
|
className="border-t border-border/50 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDate(transaction.date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 truncate max-w-md">
|
||||||
|
{transaction.description}
|
||||||
|
{transaction.memo && (
|
||||||
|
<span className="text-muted-foreground ml-2">
|
||||||
|
({transaction.memo})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-right tabular-nums whitespace-nowrap",
|
||||||
|
transaction.amount < 0
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-success"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(transaction.amount)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
78
components/rules/rules-search-bar.tsx
Normal file
78
components/rules/rules-search-bar.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Search, Filter, ArrowUpDown } from "lucide-react";
|
||||||
|
|
||||||
|
interface RulesSearchBarProps {
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
sortBy: "count" | "amount" | "name";
|
||||||
|
onSortChange: (value: "count" | "amount" | "name") => void;
|
||||||
|
filterMinCount: number;
|
||||||
|
onFilterMinCountChange: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RulesSearchBar({
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
sortBy,
|
||||||
|
onSortChange,
|
||||||
|
filterMinCount,
|
||||||
|
onFilterMinCountChange,
|
||||||
|
}: RulesSearchBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Rechercher dans les descriptions..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Select
|
||||||
|
value={sortBy}
|
||||||
|
onValueChange={(v) => onSortChange(v as "count" | "amount" | "name")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<ArrowUpDown className="h-4 w-4 mr-2" />
|
||||||
|
<SelectValue placeholder="Trier par" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="count">Nombre de transactions</SelectItem>
|
||||||
|
<SelectItem value="amount">Montant total</SelectItem>
|
||||||
|
<SelectItem value="name">Nom</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filterMinCount.toString()}
|
||||||
|
onValueChange={(v) => onFilterMinCountChange(parseInt(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
<SelectValue placeholder="Minimum" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1+ transactions</SelectItem>
|
||||||
|
<SelectItem value="2">2+ transactions</SelectItem>
|
||||||
|
<SelectItem value="3">3+ transactions</SelectItem>
|
||||||
|
<SelectItem value="5">5+ transactions</SelectItem>
|
||||||
|
<SelectItem value="10">10+ transactions</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user