refactor: standardize quotation marks across all files and improve code consistency

This commit is contained in:
Julien Froidefond
2025-11-27 11:40:30 +01:00
parent cc1e8c20a6
commit b2efade4d5
107 changed files with 9471 additions and 5952 deletions

View File

@@ -1,43 +1,68 @@
"use client"
"use client";
import { useState } from "react"
import { Sidebar } from "@/components/dashboard/sidebar"
import { useBankingData } from "@/lib/hooks"
import { updateAccount, deleteAccount } from "@/lib/store-db"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { MoreVertical, Pencil, Trash2, Building2, CreditCard, Wallet, PiggyBank, RefreshCw } from "lucide-react"
import type { Account } from "@/lib/types"
import { cn } from "@/lib/utils"
import { useState } from "react";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useBankingData } from "@/lib/hooks";
import { updateAccount, deleteAccount } from "@/lib/store-db";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
MoreVertical,
Pencil,
Trash2,
Building2,
CreditCard,
Wallet,
PiggyBank,
RefreshCw,
} from "lucide-react";
import type { Account } from "@/lib/types";
import { cn } from "@/lib/utils";
const accountTypeIcons = {
CHECKING: Wallet,
SAVINGS: PiggyBank,
CREDIT_CARD: CreditCard,
OTHER: Building2,
}
};
const accountTypeLabels = {
CHECKING: "Compte courant",
SAVINGS: "Épargne",
CREDIT_CARD: "Carte de crédit",
OTHER: "Autre",
}
};
export default function AccountsPage() {
const { data, isLoading, refresh, update } = useBankingData()
const [editingAccount, setEditingAccount] = useState<Account | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const { data, isLoading, refresh, update } = useBankingData();
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [formData, setFormData] = useState({
name: "",
type: "CHECKING" as Account["type"],
folderId: "folder-root",
})
});
if (isLoading || !data) {
return (
@@ -47,28 +72,28 @@ export default function AccountsPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
)
);
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(amount)
}
}).format(amount);
};
const handleEdit = (account: Account) => {
setEditingAccount(account)
setEditingAccount(account);
setFormData({
name: account.name,
type: account.type,
folderId: account.folderId || "folder-root",
})
setIsDialogOpen(true)
}
});
setIsDialogOpen(true);
};
const handleSave = async () => {
if (!editingAccount) return
if (!editingAccount) return;
try {
const updatedAccount = {
@@ -76,34 +101,34 @@ export default function AccountsPage() {
name: formData.name,
type: formData.type,
folderId: formData.folderId,
}
await updateAccount(updatedAccount)
refresh()
setIsDialogOpen(false)
setEditingAccount(null)
};
await updateAccount(updatedAccount);
refresh();
setIsDialogOpen(false);
setEditingAccount(null);
} catch (error) {
console.error("Error updating account:", error)
alert("Erreur lors de la mise à jour du compte")
console.error("Error updating account:", error);
alert("Erreur lors de la mise à jour du compte");
}
}
};
const handleDelete = async (accountId: string) => {
if (!confirm("Supprimer ce compte et toutes ses transactions ?")) return
if (!confirm("Supprimer ce compte et toutes ses transactions ?")) return;
try {
await deleteAccount(accountId)
refresh()
await deleteAccount(accountId);
refresh();
} catch (error) {
console.error("Error deleting account:", error)
alert("Erreur lors de la suppression du compte")
console.error("Error deleting account:", error);
alert("Erreur lors de la suppression du compte");
}
}
};
const getTransactionCount = (accountId: string) => {
return data.transactions.filter((t) => t.accountId === accountId).length
}
return data.transactions.filter((t) => t.accountId === accountId).length;
};
const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0)
const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0);
return (
<div className="flex h-screen bg-background">
@@ -113,11 +138,18 @@ export default function AccountsPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Comptes</h1>
<p className="text-muted-foreground">Gérez vos comptes bancaires</p>
<p className="text-muted-foreground">
Gérez vos comptes bancaires
</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">Solde total</p>
<p className={cn("text-2xl font-bold", totalBalance >= 0 ? "text-emerald-600" : "text-red-600")}>
<p
className={cn(
"text-2xl font-bold",
totalBalance >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(totalBalance)}
</p>
</div>
@@ -129,15 +161,18 @@ export default function AccountsPage() {
<Building2 className="w-16 h-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Aucun compte</h3>
<p className="text-muted-foreground text-center mb-4">
Importez un fichier OFX depuis le tableau de bord pour ajouter votre premier compte.
Importez un fichier OFX depuis le tableau de bord pour ajouter
votre premier compte.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data.accounts.map((account) => {
const Icon = accountTypeIcons[account.type]
const folder = data.folders.find((f) => f.id === account.folderId)
const Icon = accountTypeIcons[account.type];
const folder = data.folders.find(
(f) => f.id === account.folderId,
);
return (
<Card key={account.id} className="relative">
@@ -148,22 +183,35 @@ export default function AccountsPage() {
<Icon className="w-5 h-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{account.name}</CardTitle>
<p className="text-xs text-muted-foreground">{accountTypeLabels[account.type]}</p>
<CardTitle className="text-base">
{account.name}
</CardTitle>
<p className="text-xs text-muted-foreground">
{accountTypeLabels[account.type]}
</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(account)}>
<DropdownMenuItem
onClick={() => handleEdit(account)}
>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(account.id)} className="text-red-600">
<DropdownMenuItem
onClick={() => handleDelete(account.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
@@ -175,23 +223,30 @@ export default function AccountsPage() {
<div
className={cn(
"text-2xl font-bold mb-2",
account.balance >= 0 ? "text-emerald-600" : "text-red-600",
account.balance >= 0
? "text-emerald-600"
: "text-red-600",
)}
>
{formatCurrency(account.balance)}
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{getTransactionCount(account.id)} transactions</span>
<span>
{getTransactionCount(account.id)} transactions
</span>
{folder && <span>{folder.name}</span>}
</div>
{account.lastImport && (
<p className="text-xs text-muted-foreground mt-2">
Dernier import: {new Date(account.lastImport).toLocaleDateString("fr-FR")}
Dernier import:{" "}
{new Date(account.lastImport).toLocaleDateString(
"fr-FR",
)}
</p>
)}
</CardContent>
</Card>
)
);
})}
</div>
)}
@@ -206,13 +261,20 @@ export default function AccountsPage() {
<div className="space-y-4">
<div className="space-y-2">
<Label>Nom du compte</Label>
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} />
<Input
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Type de compte</Label>
<Select
value={formData.type}
onValueChange={(v) => setFormData({ ...formData, type: v as Account["type"] })}
onValueChange={(v) =>
setFormData({ ...formData, type: v as Account["type"] })
}
>
<SelectTrigger>
<SelectValue />
@@ -228,7 +290,10 @@ export default function AccountsPage() {
</div>
<div className="space-y-2">
<Label>Dossier</Label>
<Select value={formData.folderId} onValueChange={(v) => setFormData({ ...formData, folderId: v })}>
<Select
value={formData.folderId}
onValueChange={(v) => setFormData({ ...formData, folderId: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -251,5 +316,5 @@ export default function AccountsPage() {
</DialogContent>
</Dialog>
</div>
)
);
}

View File

@@ -1,42 +1,54 @@
import { NextResponse } from "next/server"
import { accountService } from "@/services/account.service"
import type { Account } from "@/lib/types"
import { NextResponse } from "next/server";
import { accountService } from "@/services/account.service";
import type { Account } from "@/lib/types";
export async function POST(request: Request) {
try {
const data: Omit<Account, "id"> = await request.json()
const created = await accountService.create(data)
return NextResponse.json(created)
const data: Omit<Account, "id"> = await request.json();
const created = await accountService.create(data);
return NextResponse.json(created);
} catch (error) {
console.error("Error creating account:", error)
return NextResponse.json({ error: "Failed to create account" }, { status: 500 })
console.error("Error creating account:", error);
return NextResponse.json(
{ error: "Failed to create account" },
{ status: 500 },
);
}
}
export async function PUT(request: Request) {
try {
const account: Account = await request.json()
const updated = await accountService.update(account.id, account)
return NextResponse.json(updated)
const account: Account = await request.json();
const updated = await accountService.update(account.id, account);
return NextResponse.json(updated);
} catch (error) {
console.error("Error updating account:", error)
return NextResponse.json({ error: "Failed to update account" }, { status: 500 })
console.error("Error updating account:", error);
return NextResponse.json(
{ error: "Failed to update account" },
{ status: 500 },
);
}
}
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url)
const id = searchParams.get("id")
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "Account ID is required" }, { status: 400 })
return NextResponse.json(
{ error: "Account ID is required" },
{ status: 400 },
);
}
await accountService.delete(id)
return NextResponse.json({ success: true })
await accountService.delete(id);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting account:", error)
return NextResponse.json({ error: "Failed to delete account" }, { status: 500 })
console.error("Error deleting account:", error);
return NextResponse.json(
{ error: "Failed to delete account" },
{ status: 500 },
);
}
}

View File

@@ -1,42 +1,54 @@
import { NextResponse } from "next/server"
import { categoryService } from "@/services/category.service"
import type { Category } from "@/lib/types"
import { NextResponse } from "next/server";
import { categoryService } from "@/services/category.service";
import type { Category } from "@/lib/types";
export async function POST(request: Request) {
try {
const data: Omit<Category, "id"> = await request.json()
const created = await categoryService.create(data)
return NextResponse.json(created)
const data: Omit<Category, "id"> = await request.json();
const created = await categoryService.create(data);
return NextResponse.json(created);
} catch (error) {
console.error("Error creating category:", error)
return NextResponse.json({ error: "Failed to create category" }, { status: 500 })
console.error("Error creating category:", error);
return NextResponse.json(
{ error: "Failed to create category" },
{ status: 500 },
);
}
}
export async function PUT(request: Request) {
try {
const category: Category = await request.json()
const updated = await categoryService.update(category.id, category)
return NextResponse.json(updated)
const category: Category = await request.json();
const updated = await categoryService.update(category.id, category);
return NextResponse.json(updated);
} catch (error) {
console.error("Error updating category:", error)
return NextResponse.json({ error: "Failed to update category" }, { status: 500 })
console.error("Error updating category:", error);
return NextResponse.json(
{ error: "Failed to update category" },
{ status: 500 },
);
}
}
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url)
const id = searchParams.get("id")
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "Category ID is required" }, { status: 400 })
return NextResponse.json(
{ error: "Category ID is required" },
{ status: 400 },
);
}
await categoryService.delete(id)
return NextResponse.json({ success: true })
await categoryService.delete(id);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting category:", error)
return NextResponse.json({ error: "Failed to delete category" }, { status: 500 })
console.error("Error deleting category:", error);
return NextResponse.json(
{ error: "Failed to delete category" },
{ status: 500 },
);
}
}

View File

@@ -1,45 +1,57 @@
import { NextResponse } from "next/server"
import { folderService, FolderNotFoundError } from "@/services/folder.service"
import type { Folder } from "@/lib/types"
import { NextResponse } from "next/server";
import { folderService, FolderNotFoundError } from "@/services/folder.service";
import type { Folder } from "@/lib/types";
export async function POST(request: Request) {
try {
const data: Omit<Folder, "id"> = await request.json()
const created = await folderService.create(data)
return NextResponse.json(created)
const data: Omit<Folder, "id"> = await request.json();
const created = await folderService.create(data);
return NextResponse.json(created);
} catch (error) {
console.error("Error creating folder:", error)
return NextResponse.json({ error: "Failed to create folder" }, { status: 500 })
console.error("Error creating folder:", error);
return NextResponse.json(
{ error: "Failed to create folder" },
{ status: 500 },
);
}
}
export async function PUT(request: Request) {
try {
const folder: Folder = await request.json()
const updated = await folderService.update(folder.id, folder)
return NextResponse.json(updated)
const folder: Folder = await request.json();
const updated = await folderService.update(folder.id, folder);
return NextResponse.json(updated);
} catch (error) {
console.error("Error updating folder:", error)
return NextResponse.json({ error: "Failed to update folder" }, { status: 500 })
console.error("Error updating folder:", error);
return NextResponse.json(
{ error: "Failed to update folder" },
{ status: 500 },
);
}
}
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url)
const id = searchParams.get("id")
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "Folder ID is required" }, { status: 400 })
return NextResponse.json(
{ error: "Folder ID is required" },
{ status: 400 },
);
}
await folderService.delete(id)
return NextResponse.json({ success: true })
await folderService.delete(id);
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof FolderNotFoundError) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 })
return NextResponse.json({ error: "Folder not found" }, { status: 404 });
}
console.error("Error deleting folder:", error)
return NextResponse.json({ error: "Failed to delete folder" }, { status: 500 })
console.error("Error deleting folder:", error);
return NextResponse.json(
{ error: "Failed to delete folder" },
{ status: 500 },
);
}
}

View File

@@ -1,12 +1,15 @@
import { NextResponse } from "next/server"
import { bankingService } from "@/services/banking.service"
import { NextResponse } from "next/server";
import { bankingService } from "@/services/banking.service";
export async function GET() {
try {
const data = await bankingService.getAllData()
return NextResponse.json(data)
const data = await bankingService.getAllData();
return NextResponse.json(data);
} catch (error) {
console.error("Error fetching banking data:", error)
return NextResponse.json({ error: "Failed to fetch data" }, { status: 500 })
console.error("Error fetching banking data:", error);
return NextResponse.json(
{ error: "Failed to fetch data" },
{ status: 500 },
);
}
}

View File

@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST() {
try {
@@ -10,15 +10,17 @@ export async function POST() {
data: {
categoryId: null,
},
})
});
return NextResponse.json({
success: true,
count: result.count,
})
});
} catch (error) {
console.error("Error clearing categories:", error)
return NextResponse.json({ error: "Failed to clear categories" }, { status: 500 })
console.error("Error clearing categories:", error);
return NextResponse.json(
{ error: "Failed to clear categories" },
{ status: 500 },
);
}
}

View File

@@ -1,42 +1,57 @@
import { NextResponse } from "next/server"
import { transactionService } from "@/services/transaction.service"
import type { Transaction } from "@/lib/types"
import { NextResponse } from "next/server";
import { transactionService } from "@/services/transaction.service";
import type { Transaction } from "@/lib/types";
export async function POST(request: Request) {
try {
const transactions: Transaction[] = await request.json()
const result = await transactionService.createMany(transactions)
return NextResponse.json(result)
const transactions: Transaction[] = await request.json();
const result = await transactionService.createMany(transactions);
return NextResponse.json(result);
} catch (error) {
console.error("Error creating transactions:", error)
return NextResponse.json({ error: "Failed to create transactions" }, { status: 500 })
console.error("Error creating transactions:", error);
return NextResponse.json(
{ error: "Failed to create transactions" },
{ status: 500 },
);
}
}
export async function PUT(request: Request) {
try {
const transaction: Transaction = await request.json()
const updated = await transactionService.update(transaction.id, transaction)
return NextResponse.json(updated)
const transaction: Transaction = await request.json();
const updated = await transactionService.update(
transaction.id,
transaction,
);
return NextResponse.json(updated);
} catch (error) {
console.error("Error updating transaction:", error)
return NextResponse.json({ error: "Failed to update transaction" }, { status: 500 })
console.error("Error updating transaction:", error);
return NextResponse.json(
{ error: "Failed to update transaction" },
{ status: 500 },
);
}
}
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url)
const id = searchParams.get("id")
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "Transaction ID is required" }, { status: 400 })
return NextResponse.json(
{ error: "Transaction ID is required" },
{ status: 400 },
);
}
await transactionService.delete(id)
return NextResponse.json({ success: true })
await transactionService.delete(id);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting transaction:", error)
return NextResponse.json({ error: "Failed to delete transaction" }, { status: 500 })
console.error("Error deleting transaction:", error);
return NextResponse.json(
{ error: "Failed to delete transaction" },
{ status: 500 },
);
}
}

View File

@@ -1,78 +1,134 @@
"use client"
"use client";
import { useState, useMemo } from "react"
import { Sidebar } from "@/components/dashboard/sidebar"
import { useBankingData } from "@/lib/hooks"
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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Plus, MoreVertical, Pencil, Trash2, RefreshCw, X, ChevronDown, ChevronRight, ChevronsUpDown, Search } from "lucide-react"
import { CategoryIcon } from "@/components/ui/category-icon"
import { autoCategorize, addCategory, updateCategory, deleteCategory } from "@/lib/store-db"
import type { Category } from "@/lib/types"
import { cn } from "@/lib/utils"
import { useState, useMemo } from "react";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useBankingData } from "@/lib/hooks";
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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Plus,
MoreVertical,
Pencil,
Trash2,
RefreshCw,
X,
ChevronDown,
ChevronRight,
ChevronsUpDown,
Search,
} from "lucide-react";
import { CategoryIcon } from "@/components/ui/category-icon";
import {
autoCategorize,
addCategory,
updateCategory,
deleteCategory,
} from "@/lib/store-db";
import type { Category } from "@/lib/types";
import { cn } from "@/lib/utils";
const categoryColors = [
"#22c55e", "#3b82f6", "#f59e0b", "#ec4899", "#ef4444",
"#8b5cf6", "#06b6d4", "#84cc16", "#f97316", "#6366f1",
"#14b8a6", "#f43f5e", "#64748b", "#0891b2", "#dc2626",
]
"#22c55e",
"#3b82f6",
"#f59e0b",
"#ec4899",
"#ef4444",
"#8b5cf6",
"#06b6d4",
"#84cc16",
"#f97316",
"#6366f1",
"#14b8a6",
"#f43f5e",
"#64748b",
"#0891b2",
"#dc2626",
];
export default function CategoriesPage() {
const { data, isLoading, refresh } = useBankingData()
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [expandedParents, setExpandedParents] = useState<Set<string>>(new Set())
const { data, isLoading, refresh } = useBankingData();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [expandedParents, setExpandedParents] = useState<Set<string>>(
new Set(),
);
const [formData, setFormData] = useState({
name: "",
color: "#22c55e",
keywords: [] as string[],
parentId: null as string | null,
})
const [newKeyword, setNewKeyword] = useState("")
const [searchQuery, setSearchQuery] = useState("")
});
const [newKeyword, setNewKeyword] = useState("");
const [searchQuery, setSearchQuery] = useState("");
// Organiser les catégories par parent
const { parentCategories, childrenByParent, orphanCategories } = useMemo(() => {
if (!data?.categories) return { parentCategories: [], childrenByParent: {}, orphanCategories: [] }
const { parentCategories, childrenByParent, orphanCategories } =
useMemo(() => {
if (!data?.categories)
return {
parentCategories: [],
childrenByParent: {},
orphanCategories: [],
};
const parents = data.categories.filter((c) => c.parentId === null)
const children: Record<string, Category[]> = {}
const orphans: Category[] = []
const parents = data.categories.filter((c) => c.parentId === null);
const children: Record<string, Category[]> = {};
const orphans: Category[] = [];
// Grouper les enfants par parent
data.categories
.filter((c) => c.parentId !== null)
.forEach((child) => {
const parentExists = parents.some((p) => p.id === child.parentId)
if (parentExists) {
if (!children[child.parentId!]) {
children[child.parentId!] = []
// Grouper les enfants par parent
data.categories
.filter((c) => c.parentId !== null)
.forEach((child) => {
const parentExists = parents.some((p) => p.id === child.parentId);
if (parentExists) {
if (!children[child.parentId!]) {
children[child.parentId!] = [];
}
children[child.parentId!].push(child);
} else {
orphans.push(child);
}
children[child.parentId!].push(child)
} else {
orphans.push(child)
}
})
});
return {
parentCategories: parents,
childrenByParent: children,
orphanCategories: orphans,
}
}, [data?.categories])
return {
parentCategories: parents,
childrenByParent: children,
orphanCategories: orphans,
};
}, [data?.categories]);
// Initialiser tous les parents comme ouverts
useState(() => {
if (parentCategories.length > 0 && expandedParents.size === 0) {
setExpandedParents(new Set(parentCategories.map((p) => p.id)))
setExpandedParents(new Set(parentCategories.map((p) => p.id)));
}
})
});
if (isLoading || !data) {
return (
@@ -82,62 +138,75 @@ export default function CategoriesPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
)
);
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(amount)
}
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(amount);
};
const getCategoryStats = (categoryId: string, includeChildren = false) => {
let categoryIds = [categoryId]
let categoryIds = [categoryId];
if (includeChildren && childrenByParent[categoryId]) {
categoryIds = [...categoryIds, ...childrenByParent[categoryId].map((c) => c.id)]
categoryIds = [
...categoryIds,
...childrenByParent[categoryId].map((c) => c.id),
];
}
const categoryTransactions = data.transactions.filter((t) => categoryIds.includes(t.categoryId || ""))
const total = categoryTransactions.reduce((sum, t) => sum + Math.abs(t.amount), 0)
const count = categoryTransactions.length
return { total, count }
}
const categoryTransactions = data.transactions.filter((t) =>
categoryIds.includes(t.categoryId || ""),
);
const total = categoryTransactions.reduce(
(sum, t) => sum + Math.abs(t.amount),
0,
);
const count = categoryTransactions.length;
return { total, count };
};
const toggleExpanded = (parentId: string) => {
const newExpanded = new Set(expandedParents)
const newExpanded = new Set(expandedParents);
if (newExpanded.has(parentId)) {
newExpanded.delete(parentId)
newExpanded.delete(parentId);
} else {
newExpanded.add(parentId)
newExpanded.add(parentId);
}
setExpandedParents(newExpanded)
}
setExpandedParents(newExpanded);
};
const expandAll = () => {
setExpandedParents(new Set(parentCategories.map((p) => p.id)))
}
setExpandedParents(new Set(parentCategories.map((p) => p.id)));
};
const collapseAll = () => {
setExpandedParents(new Set())
}
setExpandedParents(new Set());
};
const allExpanded = parentCategories.length > 0 && expandedParents.size === parentCategories.length
const allExpanded =
parentCategories.length > 0 &&
expandedParents.size === parentCategories.length;
const handleNewCategory = (parentId: string | null = null) => {
setEditingCategory(null)
setFormData({ name: "", color: "#22c55e", keywords: [], parentId })
setIsDialogOpen(true)
}
setEditingCategory(null);
setFormData({ name: "", color: "#22c55e", keywords: [], parentId });
setIsDialogOpen(true);
};
const handleEdit = (category: Category) => {
setEditingCategory(category)
setEditingCategory(category);
setFormData({
name: category.name,
color: category.color,
keywords: [...category.keywords],
parentId: category.parentId,
})
setIsDialogOpen(true)
}
});
setIsDialogOpen(true);
};
const handleSave = async () => {
try {
@@ -148,7 +217,7 @@ export default function CategoriesPage() {
color: formData.color,
keywords: formData.keywords,
parentId: formData.parentId,
})
});
} else {
await addCategory({
name: formData.name,
@@ -156,78 +225,88 @@ export default function CategoriesPage() {
keywords: formData.keywords,
icon: "tag",
parentId: formData.parentId,
})
});
}
refresh()
setIsDialogOpen(false)
refresh();
setIsDialogOpen(false);
} catch (error) {
console.error("Error saving category:", error)
alert("Erreur lors de la sauvegarde de la catégorie")
console.error("Error saving category:", error);
alert("Erreur lors de la sauvegarde de la catégorie");
}
}
};
const handleDelete = async (categoryId: string) => {
const hasChildren = childrenByParent[categoryId]?.length > 0
const hasChildren = childrenByParent[categoryId]?.length > 0;
const message = hasChildren
? "Cette catégorie a des sous-catégories. Supprimer quand même ?"
: "Supprimer cette catégorie ?"
if (!confirm(message)) return
: "Supprimer cette catégorie ?";
if (!confirm(message)) return;
try {
await deleteCategory(categoryId)
refresh()
await deleteCategory(categoryId);
refresh();
} catch (error) {
console.error("Error deleting category:", error)
alert("Erreur lors de la suppression de la catégorie")
console.error("Error deleting category:", error);
alert("Erreur lors de la suppression de la catégorie");
}
}
};
const addKeyword = () => {
if (newKeyword.trim() && !formData.keywords.includes(newKeyword.trim().toLowerCase())) {
if (
newKeyword.trim() &&
!formData.keywords.includes(newKeyword.trim().toLowerCase())
) {
setFormData({
...formData,
keywords: [...formData.keywords, newKeyword.trim().toLowerCase()],
})
setNewKeyword("")
});
setNewKeyword("");
}
}
};
const removeKeyword = (keyword: string) => {
setFormData({
...formData,
keywords: formData.keywords.filter((k) => k !== keyword),
})
}
});
};
const reApplyAutoCategories = async () => {
if (!confirm("Recatégoriser automatiquement les transactions non catégorisées ?")) return
if (
!confirm(
"Recatégoriser automatiquement les transactions non catégorisées ?",
)
)
return;
try {
const { updateTransaction } = await import("@/lib/store-db")
const uncategorized = data.transactions.filter((t) => !t.categoryId)
const { updateTransaction } = await import("@/lib/store-db");
const uncategorized = data.transactions.filter((t) => !t.categoryId);
for (const transaction of uncategorized) {
const categoryId = autoCategorize(
transaction.description + " " + (transaction.memo || ""),
data.categories
)
data.categories,
);
if (categoryId) {
await updateTransaction({ ...transaction, categoryId })
await updateTransaction({ ...transaction, categoryId });
}
}
refresh()
refresh();
} catch (error) {
console.error("Error re-categorizing:", error)
alert("Erreur lors de la recatégorisation")
console.error("Error re-categorizing:", error);
alert("Erreur lors de la recatégorisation");
}
}
};
const uncategorizedCount = data.transactions.filter((t) => !t.categoryId).length
const uncategorizedCount = data.transactions.filter(
(t) => !t.categoryId,
).length;
// Composant pour une carte de catégorie enfant
const ChildCategoryCard = ({ category }: { category: Category }) => {
const stats = getCategoryStats(category.id)
const stats = getCategoryStats(category.id);
return (
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group">
@@ -236,21 +315,34 @@ export default function CategoriesPage() {
className="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: `${category.color}20` }}
>
<CategoryIcon icon={category.icon} color={category.color} size={12} />
<CategoryIcon
icon={category.icon}
color={category.color}
size={12}
/>
</div>
<span className="text-sm truncate">{category.name}</span>
<span className="text-sm text-muted-foreground shrink-0">
{stats.count} opération{stats.count > 1 ? "s" : ""} {formatCurrency(stats.total)}
{stats.count} opération{stats.count > 1 ? "s" : ""} {" "}
{formatCurrency(stats.total)}
</span>
{category.keywords.length > 0 && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4 shrink-0"
>
{category.keywords.length}
</Badge>
)}
</div>
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleEdit(category)}>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleEdit(category)}
>
<Pencil className="w-3 h-3" />
</Button>
<Button
@@ -263,8 +355,8 @@ export default function CategoriesPage() {
</Button>
</div>
</div>
)
}
);
};
return (
<div className="flex h-screen bg-background">
@@ -277,7 +369,8 @@ export default function CategoriesPage() {
<h1 className="text-2xl font-bold text-foreground">Catégories</h1>
<p className="text-muted-foreground">
{parentCategories.length} catégories principales {" "}
{data.categories.length - parentCategories.length} sous-catégories
{data.categories.length - parentCategories.length}{" "}
sous-catégories
</p>
</div>
<div className="flex gap-2">
@@ -326,110 +419,141 @@ export default function CategoriesPage() {
<div className="space-y-1">
{parentCategories
.filter((parent) => {
if (!searchQuery.trim()) return true
const query = searchQuery.toLowerCase()
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
// Afficher si le parent matche
if (parent.name.toLowerCase().includes(query)) return true
if (parent.keywords.some((k) => k.toLowerCase().includes(query))) return true
if (parent.name.toLowerCase().includes(query)) return true;
if (
parent.keywords.some((k) => k.toLowerCase().includes(query))
)
return true;
// Ou si un enfant matche
const children = childrenByParent[parent.id] || []
const children = childrenByParent[parent.id] || [];
return children.some(
(c) =>
c.name.toLowerCase().includes(query) ||
c.keywords.some((k) => k.toLowerCase().includes(query))
)
c.keywords.some((k) => k.toLowerCase().includes(query)),
);
})
.map((parent) => {
const allChildren = childrenByParent[parent.id] || []
// Filtrer les enfants aussi si recherche active
const children = searchQuery.trim()
? allChildren.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.keywords.some((k) => k.toLowerCase().includes(searchQuery.toLowerCase())) ||
// Garder tous les enfants si le parent matche
parent.name.toLowerCase().includes(searchQuery.toLowerCase())
)
: allChildren
const stats = getCategoryStats(parent.id, true)
const isExpanded = expandedParents.has(parent.id) || (searchQuery.trim() !== "" && children.length > 0)
const allChildren = childrenByParent[parent.id] || [];
// Filtrer les enfants aussi si recherche active
const children = searchQuery.trim()
? allChildren.filter(
(c) =>
c.name
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
c.keywords.some((k) =>
k.toLowerCase().includes(searchQuery.toLowerCase()),
) ||
// Garder tous les enfants si le parent matche
parent.name
.toLowerCase()
.includes(searchQuery.toLowerCase()),
)
: allChildren;
const stats = getCategoryStats(parent.id, true);
const isExpanded =
expandedParents.has(parent.id) ||
(searchQuery.trim() !== "" && children.length > 0);
return (
<div key={parent.id} className="border rounded-lg bg-card">
<Collapsible open={isExpanded} onOpenChange={() => toggleExpanded(parent.id)}>
<div className="flex items-center justify-between px-3 py-2">
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
)}
<div
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: `${parent.color}20` }}
>
<CategoryIcon icon={parent.icon} color={parent.color} size={14} />
</div>
<span className="font-medium text-sm truncate">{parent.name}</span>
<span className="text-sm text-muted-foreground shrink-0">
{children.length} {stats.count} opération{stats.count > 1 ? "s" : ""} {formatCurrency(stats.total)}
</span>
</button>
</CollapsibleTrigger>
<div className="flex items-center gap-1 shrink-0 ml-2">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation()
handleNewCategory(parent.id)
}}
>
<Plus className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(parent)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(parent.id)}
className="text-red-600"
return (
<div key={parent.id} className="border rounded-lg bg-card">
<Collapsible
open={isExpanded}
onOpenChange={() => toggleExpanded(parent.id)}
>
<div className="flex items-center justify-between px-3 py-2">
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
)}
<div
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: `${parent.color}20` }}
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<CategoryIcon
icon={parent.icon}
color={parent.color}
size={14}
/>
</div>
<span className="font-medium text-sm truncate">
{parent.name}
</span>
<span className="text-sm text-muted-foreground shrink-0">
{children.length} {stats.count} opération
{stats.count > 1 ? "s" : ""} {" "}
{formatCurrency(stats.total)}
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
{children.length > 0 ? (
<div className="px-3 pb-2 space-y-1 ml-6 border-l-2 border-muted ml-5">
{children.map((child) => (
<ChildCategoryCard key={child.id} category={child} />
))}
<div className="flex items-center gap-1 shrink-0 ml-2">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
handleNewCategory(parent.id);
}}
>
<Plus className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleEdit(parent)}
>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(parent.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<div className="px-3 pb-2 ml-11 text-xs text-muted-foreground italic">
Aucune sous-catégorie
</div>
)}
</CollapsibleContent>
</Collapsible>
</div>
)
})}
</div>
<CollapsibleContent>
{children.length > 0 ? (
<div className="px-3 pb-2 space-y-1 ml-6 border-l-2 border-muted ml-5">
{children.map((child) => (
<ChildCategoryCard
key={child.id}
category={child}
/>
))}
</div>
) : (
<div className="px-3 pb-2 ml-11 text-xs text-muted-foreground italic">
Aucune sous-catégorie
</div>
)}
</CollapsibleContent>
</Collapsible>
</div>
);
})}
{/* Catégories orphelines (sans parent valide) */}
{orphanCategories.length > 0 && (
@@ -465,14 +589,19 @@ export default function CategoriesPage() {
<Select
value={formData.parentId || "none"}
onValueChange={(value) =>
setFormData({ ...formData, parentId: value === "none" ? null : value })
setFormData({
...formData,
parentId: value === "none" ? null : value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Aucune (catégorie principale)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Aucune (catégorie principale)</SelectItem>
<SelectItem value="none">
Aucune (catégorie principale)
</SelectItem>
{parentCategories
.filter((p) => p.id !== editingCategory?.id)
.map((parent) => (
@@ -495,7 +624,9 @@ export default function CategoriesPage() {
<Label>Nom</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="Ex: Alimentation"
/>
</div>
@@ -510,7 +641,8 @@ export default function CategoriesPage() {
onClick={() => setFormData({ ...formData, color })}
className={cn(
"w-8 h-8 rounded-full transition-transform",
formData.color === color && "ring-2 ring-offset-2 ring-primary scale-110"
formData.color === color &&
"ring-2 ring-offset-2 ring-primary scale-110",
)}
style={{ backgroundColor: color }}
/>
@@ -526,7 +658,9 @@ export default function CategoriesPage() {
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder="Ajouter un mot-clé"
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addKeyword())}
onKeyDown={(e) =>
e.key === "Enter" && (e.preventDefault(), addKeyword())
}
/>
<Button type="button" onClick={addKeyword} size="icon">
<Plus className="w-4 h-4" />
@@ -557,5 +691,5 @@ export default function CategoriesPage() {
</DialogContent>
</Dialog>
</div>
)
);
}

View File

@@ -1,15 +1,31 @@
"use client"
"use client";
import { useState } from "react"
import { Sidebar } from "@/components/dashboard/sidebar"
import { useBankingData } from "@/lib/hooks"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { useState } from "react";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Plus,
MoreVertical,
@@ -21,10 +37,15 @@ import {
ChevronDown,
Building2,
RefreshCw,
} from "lucide-react"
import { addFolder, updateFolder, deleteFolder, updateAccount } from "@/lib/store-db"
import type { Folder as FolderType, Account } from "@/lib/types"
import { cn } from "@/lib/utils"
} from "lucide-react";
import {
addFolder,
updateFolder,
deleteFolder,
updateAccount,
} from "@/lib/store-db";
import type { Folder as FolderType, Account } from "@/lib/types";
import { cn } from "@/lib/utils";
const folderColors = [
{ value: "#6366f1", label: "Indigo" },
@@ -33,17 +54,17 @@ const folderColors = [
{ value: "#ec4899", label: "Rose" },
{ value: "#3b82f6", label: "Bleu" },
{ value: "#ef4444", label: "Rouge" },
]
];
interface FolderTreeItemProps {
folder: FolderType
accounts: Account[]
allFolders: FolderType[]
level: number
onEdit: (folder: FolderType) => void
onDelete: (folderId: string) => void
onEditAccount: (account: Account) => void
formatCurrency: (amount: number) => string
folder: FolderType;
accounts: Account[];
allFolders: FolderType[];
level: number;
onEdit: (folder: FolderType) => void;
onDelete: (folderId: string) => void;
onEditAccount: (account: Account) => void;
formatCurrency: (amount: number) => string;
}
function FolderTreeItem({
@@ -56,20 +77,27 @@ function FolderTreeItem({
onEditAccount,
formatCurrency,
}: FolderTreeItemProps) {
const [isExpanded, setIsExpanded] = useState(true)
const [isExpanded, setIsExpanded] = useState(true);
// Pour le dossier "Mes Comptes" (folder-root), inclure aussi les comptes sans dossier
const folderAccounts = accounts.filter((a) =>
a.folderId === folder.id || (folder.id === "folder-root" && a.folderId === null)
)
const childFolders = allFolders.filter((f) => f.parentId === folder.id)
const hasChildren = childFolders.length > 0 || folderAccounts.length > 0
const folderAccounts = accounts.filter(
(a) =>
a.folderId === folder.id ||
(folder.id === "folder-root" && a.folderId === null),
);
const childFolders = allFolders.filter((f) => f.parentId === folder.id);
const hasChildren = childFolders.length > 0 || folderAccounts.length > 0;
const folderTotal = folderAccounts.reduce((sum, a) => sum + a.balance, 0)
const folderTotal = folderAccounts.reduce((sum, a) => sum + a.balance, 0);
return (
<div>
<div className={cn("flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group", level > 0 && "ml-6")}>
<div
className={cn(
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
level > 0 && "ml-6",
)}
>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-muted rounded"
@@ -101,7 +129,10 @@ function FolderTreeItem({
{folderAccounts.length > 0 && (
<span
className={cn("text-sm font-semibold tabular-nums", folderTotal >= 0 ? "text-emerald-600" : "text-red-600")}
className={cn(
"text-sm font-semibold tabular-nums",
folderTotal >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(folderTotal)}
</span>
@@ -109,7 +140,11 @@ function FolderTreeItem({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100"
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
@@ -119,7 +154,10 @@ function FolderTreeItem({
Modifier
</DropdownMenuItem>
{folder.id !== "folder-root" && (
<DropdownMenuItem onClick={() => onDelete(folder.id)} className="text-red-600">
<DropdownMenuItem
onClick={() => onDelete(folder.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
@@ -131,15 +169,26 @@ function FolderTreeItem({
{isExpanded && (
<div>
{folderAccounts.map((account) => (
<div key={account.id} className={cn("flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group", "ml-12")}>
<div
key={account.id}
className={cn(
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
"ml-12",
)}
>
<Building2 className="w-4 h-4 text-muted-foreground" />
<span className="flex-1 text-sm">{account.name}</span>
<span className={cn("text-sm tabular-nums", account.balance >= 0 ? "text-emerald-600" : "text-red-600")}>
<span
className={cn(
"text-sm tabular-nums",
account.balance >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(account.balance)}
</span>
<Button
variant="ghost"
size="icon"
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100"
onClick={() => onEditAccount(account)}
>
@@ -164,7 +213,7 @@ function FolderTreeItem({
</div>
)}
</div>
)
);
}
const accountTypeLabels = {
@@ -172,26 +221,26 @@ const accountTypeLabels = {
SAVINGS: "Épargne",
CREDIT_CARD: "Carte de crédit",
OTHER: "Autre",
}
};
export default function FoldersPage() {
const { data, isLoading, refresh } = useBankingData()
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingFolder, setEditingFolder] = useState<FolderType | null>(null)
const { data, isLoading, refresh } = useBankingData();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingFolder, setEditingFolder] = useState<FolderType | null>(null);
const [formData, setFormData] = useState({
name: "",
parentId: "folder-root" as string | null,
color: "#6366f1",
})
});
// Account editing state
const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false)
const [editingAccount, setEditingAccount] = useState<Account | null>(null)
const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false);
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
const [accountFormData, setAccountFormData] = useState({
name: "",
type: "CHECKING" as Account["type"],
folderId: "folder-root",
})
});
if (isLoading || !data) {
return (
@@ -201,36 +250,37 @@ export default function FoldersPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
)
);
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(amount)
}
}).format(amount);
};
const rootFolders = data.folders.filter((f) => f.parentId === null)
const rootFolders = data.folders.filter((f) => f.parentId === null);
const handleNewFolder = () => {
setEditingFolder(null)
setFormData({ name: "", parentId: "folder-root", color: "#6366f1" })
setIsDialogOpen(true)
}
setEditingFolder(null);
setFormData({ name: "", parentId: "folder-root", color: "#6366f1" });
setIsDialogOpen(true);
};
const handleEdit = (folder: FolderType) => {
setEditingFolder(folder)
setEditingFolder(folder);
setFormData({
name: folder.name,
parentId: folder.parentId || "folder-root",
color: folder.color,
})
setIsDialogOpen(true)
}
});
setIsDialogOpen(true);
};
const handleSave = async () => {
const parentId = formData.parentId === "folder-root" ? null : formData.parentId
const parentId =
formData.parentId === "folder-root" ? null : formData.parentId;
try {
if (editingFolder) {
@@ -239,63 +289,71 @@ export default function FoldersPage() {
name: formData.name,
parentId,
color: formData.color,
})
});
} else {
await addFolder({
name: formData.name,
parentId,
color: formData.color,
icon: "folder",
})
});
}
refresh()
setIsDialogOpen(false)
refresh();
setIsDialogOpen(false);
} catch (error) {
console.error("Error saving folder:", error)
alert("Erreur lors de la sauvegarde du dossier")
console.error("Error saving folder:", error);
alert("Erreur lors de la sauvegarde du dossier");
}
}
};
const handleDelete = async (folderId: string) => {
if (!confirm("Supprimer ce dossier ? Les comptes seront déplacés à la racine.")) return
if (
!confirm(
"Supprimer ce dossier ? Les comptes seront déplacés à la racine.",
)
)
return;
try {
await deleteFolder(folderId)
refresh()
await deleteFolder(folderId);
refresh();
} catch (error) {
console.error("Error deleting folder:", error)
alert("Erreur lors de la suppression du dossier")
console.error("Error deleting folder:", error);
alert("Erreur lors de la suppression du dossier");
}
}
};
const handleEditAccount = (account: Account) => {
setEditingAccount(account)
setEditingAccount(account);
setAccountFormData({
name: account.name,
type: account.type,
folderId: account.folderId || "folder-root",
})
setIsAccountDialogOpen(true)
}
});
setIsAccountDialogOpen(true);
};
const handleSaveAccount = async () => {
if (!editingAccount) return
if (!editingAccount) return;
try {
await updateAccount({
...editingAccount,
name: accountFormData.name,
type: accountFormData.type,
folderId: accountFormData.folderId === "folder-root" ? null : accountFormData.folderId,
})
refresh()
setIsAccountDialogOpen(false)
setEditingAccount(null)
folderId:
accountFormData.folderId === "folder-root"
? null
: accountFormData.folderId,
});
refresh();
setIsAccountDialogOpen(false);
setEditingAccount(null);
} catch (error) {
console.error("Error updating account:", error)
alert("Erreur lors de la mise à jour du compte")
console.error("Error updating account:", error);
alert("Erreur lors de la mise à jour du compte");
}
}
};
return (
<div className="flex h-screen bg-background">
@@ -304,8 +362,12 @@ export default function FoldersPage() {
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Organisation</h1>
<p className="text-muted-foreground">Organisez vos comptes en dossiers</p>
<h1 className="text-2xl font-bold text-foreground">
Organisation
</h1>
<p className="text-muted-foreground">
Organisez vos comptes en dossiers
</p>
</div>
<Button onClick={handleNewFolder}>
<Plus className="w-4 h-4 mr-2" />
@@ -341,14 +403,18 @@ export default function FoldersPage() {
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingFolder ? "Modifier le dossier" : "Nouveau dossier"}</DialogTitle>
<DialogTitle>
{editingFolder ? "Modifier le dossier" : "Nouveau dossier"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Nom du dossier</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="Ex: Comptes personnels"
/>
</div>
@@ -356,7 +422,12 @@ export default function FoldersPage() {
<Label>Dossier parent</Label>
<Select
value={formData.parentId || "root"}
onValueChange={(v) => setFormData({ ...formData, parentId: v === "root" ? null : v })}
onValueChange={(v) =>
setFormData({
...formData,
parentId: v === "root" ? null : v,
})
}
>
<SelectTrigger>
<SelectValue />
@@ -382,7 +453,8 @@ export default function FoldersPage() {
onClick={() => setFormData({ ...formData, color: value })}
className={cn(
"w-8 h-8 rounded-full transition-transform",
formData.color === value && "ring-2 ring-offset-2 ring-primary scale-110",
formData.color === value &&
"ring-2 ring-offset-2 ring-primary scale-110",
)}
style={{ backgroundColor: value }}
/>
@@ -409,16 +481,26 @@ export default function FoldersPage() {
<div className="space-y-4">
<div className="space-y-2">
<Label>Nom du compte</Label>
<Input
value={accountFormData.name}
onChange={(e) => setAccountFormData({ ...accountFormData, name: e.target.value })}
<Input
value={accountFormData.name}
onChange={(e) =>
setAccountFormData({
...accountFormData,
name: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label>Type de compte</Label>
<Select
value={accountFormData.type}
onValueChange={(v) => setAccountFormData({ ...accountFormData, type: v as Account["type"] })}
onValueChange={(v) =>
setAccountFormData({
...accountFormData,
type: v as Account["type"],
})
}
>
<SelectTrigger>
<SelectValue />
@@ -434,9 +516,11 @@ export default function FoldersPage() {
</div>
<div className="space-y-2">
<Label>Dossier</Label>
<Select
value={accountFormData.folderId}
onValueChange={(v) => setAccountFormData({ ...accountFormData, folderId: v })}
<Select
value={accountFormData.folderId}
onValueChange={(v) =>
setAccountFormData({ ...accountFormData, folderId: v })
}
>
<SelectTrigger>
<SelectValue />
@@ -451,7 +535,10 @@ export default function FoldersPage() {
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsAccountDialogOpen(false)}>
<Button
variant="outline"
onClick={() => setIsAccountDialogOpen(false)}
>
Annuler
</Button>
<Button onClick={handleSaveAccount}>Enregistrer</Button>
@@ -460,5 +547,5 @@ export default function FoldersPage() {
</DialogContent>
</Dialog>
</div>
)
);
}

View File

@@ -1,5 +1,5 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@@ -75,8 +75,8 @@
}
@theme inline {
--font-sans: 'Geist', 'Geist Fallback';
--font-mono: 'Geist Mono', 'Geist Mono Fallback';
--font-sans: "Geist", "Geist Fallback";
--font-mono: "Geist Mono", "Geist Mono Fallback";
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);

View File

@@ -1,25 +1,26 @@
import type React from "react"
import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import "./globals.css"
import type React from "react";
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const _geist = Geist({ subsets: ["latin"] })
const _geistMono = Geist_Mono({ subsets: ["latin"] })
const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "FinTrack - Gestion de compte bancaire",
description: "Application de gestion personnelle de comptes bancaires avec import OFX",
generator: 'v0.app'
}
description:
"Application de gestion personnelle de comptes bancaires avec import OFX",
generator: "v0.app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
children: React.ReactNode;
}>) {
return (
<html lang="fr">
<body className="font-sans antialiased">{children}</body>
</html>
)
);
}

View File

@@ -1,17 +1,17 @@
"use client"
"use client";
import { Sidebar } from "@/components/dashboard/sidebar"
import { OverviewCards } from "@/components/dashboard/overview-cards"
import { RecentTransactions } from "@/components/dashboard/recent-transactions"
import { AccountsSummary } from "@/components/dashboard/accounts-summary"
import { CategoryBreakdown } from "@/components/dashboard/category-breakdown"
import { OFXImportDialog } from "@/components/import/ofx-import-dialog"
import { useBankingData } from "@/lib/hooks"
import { Button } from "@/components/ui/button"
import { Upload, RefreshCw } from "lucide-react"
import { Sidebar } from "@/components/dashboard/sidebar";
import { OverviewCards } from "@/components/dashboard/overview-cards";
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
import { AccountsSummary } from "@/components/dashboard/accounts-summary";
import { CategoryBreakdown } from "@/components/dashboard/category-breakdown";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button";
import { Upload, RefreshCw } from "lucide-react";
export default function DashboardPage() {
const { data, isLoading, refresh } = useBankingData()
const { data, isLoading, refresh } = useBankingData();
if (isLoading || !data) {
return (
@@ -21,7 +21,7 @@ export default function DashboardPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
)
);
}
return (
@@ -31,8 +31,12 @@ export default function DashboardPage() {
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Tableau de bord</h1>
<p className="text-muted-foreground">Vue d'ensemble de vos finances</p>
<h1 className="text-2xl font-bold text-foreground">
Tableau de bord
</h1>
<p className="text-muted-foreground">
Vue d'ensemble de vos finances
</p>
</div>
<OFXImportDialog onImportComplete={refresh}>
<Button>
@@ -54,5 +58,5 @@ export default function DashboardPage() {
</div>
</main>
</div>
)
);
}

View File

@@ -1,10 +1,16 @@
"use client"
"use client";
import { useState } from "react"
import { Sidebar } from "@/components/dashboard/sidebar"
import { useBankingData } from "@/lib/hooks"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useState } from "react";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
@@ -15,13 +21,21 @@ import {
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Download, Trash2, Upload, RefreshCw, Database, FileJson, Tags } from "lucide-react"
import type { BankingData } from "@/lib/types"
} from "@/components/ui/alert-dialog";
import {
Download,
Trash2,
Upload,
RefreshCw,
Database,
FileJson,
Tags,
} from "lucide-react";
import type { BankingData } from "@/lib/types";
export default function SettingsPage() {
const { data, isLoading, refresh, update } = useBankingData()
const [importing, setImporting] = useState(false)
const { data, isLoading, refresh, update } = useBankingData();
const [importing, setImporting] = useState(false);
if (isLoading || !data) {
return (
@@ -31,72 +45,80 @@ export default function SettingsPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
)
);
}
const exportData = () => {
const dataStr = JSON.stringify(data, null, 2)
const blob = new Blob([dataStr], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `fintrack-backup-${new Date().toISOString().split("T")[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const dataStr = JSON.stringify(data, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `fintrack-backup-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const importData = () => {
const input = document.createElement("input")
input.type = "file"
input.accept = ".json"
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
setImporting(true)
setImporting(true);
try {
const content = await file.text()
const importedData = JSON.parse(content) as BankingData
const content = await file.text();
const importedData = JSON.parse(content) as BankingData;
// Validate structure
if (!importedData.accounts || !importedData.transactions || !importedData.categories || !importedData.folders) {
alert("Format de fichier invalide")
return
if (
!importedData.accounts ||
!importedData.transactions ||
!importedData.categories ||
!importedData.folders
) {
alert("Format de fichier invalide");
return;
}
update(importedData)
alert("Données importées avec succès")
update(importedData);
alert("Données importées avec succès");
} catch (error) {
alert("Erreur lors de l'import")
alert("Erreur lors de l'import");
} finally {
setImporting(false)
setImporting(false);
}
}
input.click()
}
};
input.click();
};
const resetData = () => {
localStorage.removeItem("banking-app-data")
window.location.reload()
}
localStorage.removeItem("banking-app-data");
window.location.reload();
};
const clearAllCategories = async () => {
try {
const response = await fetch("/api/banking/transactions/clear-categories", {
method: "POST",
})
if (!response.ok) throw new Error("Erreur")
refresh()
alert("Catégories supprimées de toutes les opérations")
const response = await fetch(
"/api/banking/transactions/clear-categories",
{
method: "POST",
},
);
if (!response.ok) throw new Error("Erreur");
refresh();
alert("Catégories supprimées de toutes les opérations");
} catch (error) {
console.error(error)
alert("Erreur lors de la suppression des catégories")
console.error(error);
alert("Erreur lors de la suppression des catégories");
}
}
};
const categorizedCount = data.transactions.filter((t) => t.categoryId).length
const categorizedCount = data.transactions.filter((t) => t.categoryId).length;
return (
<div className="flex h-screen bg-background">
@@ -105,7 +127,9 @@ export default function SettingsPage() {
<div className="p-6 space-y-6 max-w-2xl">
<div>
<h1 className="text-2xl font-bold text-foreground">Paramètres</h1>
<p className="text-muted-foreground">Gérez vos données et préférences</p>
<p className="text-muted-foreground">
Gérez vos données et préférences
</p>
</div>
<Card>
@@ -114,25 +138,37 @@ export default function SettingsPage() {
<Database className="w-5 h-5" />
Données
</CardTitle>
<CardDescription>Exportez ou importez vos données pour les sauvegarder ou les transférer</CardDescription>
<CardDescription>
Exportez ou importez vos données pour les sauvegarder ou les
transférer
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div>
<p className="font-medium">Statistiques</p>
<p className="text-sm text-muted-foreground">
{data.accounts.length} comptes, {data.transactions.length} transactions, {data.categories.length}{" "}
catégories
{data.accounts.length} comptes, {data.transactions.length}{" "}
transactions, {data.categories.length} catégories
</p>
</div>
</div>
<div className="flex gap-2">
<Button onClick={exportData} variant="outline" className="flex-1 bg-transparent">
<Button
onClick={exportData}
variant="outline"
className="flex-1 bg-transparent"
>
<Download className="w-4 h-4 mr-2" />
Exporter (JSON)
</Button>
<Button onClick={importData} variant="outline" className="flex-1 bg-transparent" disabled={importing}>
<Button
onClick={importData}
variant="outline"
className="flex-1 bg-transparent"
disabled={importing}
>
<Upload className="w-4 h-4 mr-2" />
{importing ? "Import..." : "Importer"}
</Button>
@@ -146,31 +182,45 @@ export default function SettingsPage() {
<Trash2 className="w-5 h-5" />
Zone dangereuse
</CardTitle>
<CardDescription>Actions irréversibles - procédez avec prudence</CardDescription>
<CardDescription>
Actions irréversibles - procédez avec prudence
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* Supprimer catégories des opérations */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-full justify-start border-orange-300 text-orange-700 hover:bg-orange-50">
<Button
variant="outline"
className="w-full justify-start border-orange-300 text-orange-700 hover:bg-orange-50"
>
<Tags className="w-4 h-4 mr-2" />
Supprimer les catégories des opérations
<span className="ml-auto text-xs text-muted-foreground">
{categorizedCount} opération{categorizedCount > 1 ? "s" : ""} catégorisée{categorizedCount > 1 ? "s" : ""}
{categorizedCount} opération
{categorizedCount > 1 ? "s" : ""} catégorisée
{categorizedCount > 1 ? "s" : ""}
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer toutes les catégories ?</AlertDialogTitle>
<AlertDialogTitle>
Supprimer toutes les catégories ?
</AlertDialogTitle>
<AlertDialogDescription>
Cette action va retirer la catégorie de {categorizedCount} opération{categorizedCount > 1 ? "s" : ""}.
Les catégories elles-mêmes ne seront pas supprimées, seulement leur affectation aux opérations.
Cette action va retirer la catégorie de {categorizedCount}{" "}
opération{categorizedCount > 1 ? "s" : ""}. Les catégories
elles-mêmes ne seront pas supprimées, seulement leur
affectation aux opérations.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={clearAllCategories} className="bg-orange-600 hover:bg-orange-700">
<AlertDialogAction
onClick={clearAllCategories}
className="bg-orange-600 hover:bg-orange-700"
>
Supprimer les affectations
</AlertDialogAction>
</AlertDialogFooter>
@@ -180,7 +230,10 @@ export default function SettingsPage() {
{/* Réinitialiser toutes les données */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full justify-start">
<Button
variant="destructive"
className="w-full justify-start"
>
<Trash2 className="w-4 h-4 mr-2" />
Réinitialiser toutes les données
</Button>
@@ -189,13 +242,17 @@ export default function SettingsPage() {
<AlertDialogHeader>
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action supprimera définitivement tous vos comptes, transactions, catégories et dossiers.
Cette action est irréversible.
Cette action supprimera définitivement tous vos comptes,
transactions, catégories et dossiers. Cette action est
irréversible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={resetData} className="bg-red-600 hover:bg-red-700">
<AlertDialogAction
onClick={resetData}
className="bg-red-600 hover:bg-red-700"
>
Supprimer tout
</AlertDialogAction>
</AlertDialogFooter>
@@ -210,17 +267,21 @@ export default function SettingsPage() {
<FileJson className="w-5 h-5" />
Format OFX
</CardTitle>
<CardDescription>Informations sur l'import de fichiers</CardDescription>
<CardDescription>
Informations sur l'import de fichiers
</CardDescription>
</CardHeader>
<CardContent>
<div className="prose prose-sm text-muted-foreground">
<p>
L'application accepte les fichiers au format OFX (Open Financial Exchange) ou QFX. Ces fichiers sont
généralement disponibles depuis l'espace client de votre banque.
L'application accepte les fichiers au format OFX (Open
Financial Exchange) ou QFX. Ces fichiers sont généralement
disponibles depuis l'espace client de votre banque.
</p>
<p className="mt-2">
Lors de l'import, les transactions sont automatiquement catégorisées selon les mots-clés définis. Les
doublons sont détectés et ignorés automatiquement.
Lors de l'import, les transactions sont automatiquement
catégorisées selon les mots-clés définis. Les doublons sont
détectés et ignorés automatiquement.
</p>
</div>
</CardContent>
@@ -228,5 +289,5 @@ export default function SettingsPage() {
</div>
</main>
</div>
)
);
}

View File

@@ -1,12 +1,18 @@
"use client"
"use client";
import { useState, useMemo } from "react"
import { Sidebar } from "@/components/dashboard/sidebar"
import { useBankingData } from "@/lib/hooks"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { RefreshCw, TrendingUp, TrendingDown, ArrowRight } from "lucide-react"
import { CategoryIcon } from "@/components/ui/category-icon"
import { useState, useMemo } from "react";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useBankingData } from "@/lib/hooks";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RefreshCw, TrendingUp, TrendingDown, ArrowRight } from "lucide-react";
import { CategoryIcon } from "@/components/ui/category-icon";
import {
BarChart,
Bar,
@@ -21,111 +27,130 @@ import {
LineChart,
Line,
Legend,
} from "recharts"
import { cn } from "@/lib/utils"
} from "recharts";
import { cn } from "@/lib/utils";
type Period = "3months" | "6months" | "12months" | "all"
type Period = "3months" | "6months" | "12months" | "all";
export default function StatisticsPage() {
const { data, isLoading } = useBankingData()
const [period, setPeriod] = useState<Period>("6months")
const [selectedAccount, setSelectedAccount] = useState<string>("all")
const { data, isLoading } = useBankingData();
const [period, setPeriod] = useState<Period>("6months");
const [selectedAccount, setSelectedAccount] = useState<string>("all");
const stats = useMemo(() => {
if (!data) return null
if (!data) return null;
const now = new Date()
let startDate: Date
const now = new Date();
let startDate: Date;
switch (period) {
case "3months":
startDate = new Date(now.getFullYear(), now.getMonth() - 3, 1)
break
startDate = new Date(now.getFullYear(), now.getMonth() - 3, 1);
break;
case "6months":
startDate = new Date(now.getFullYear(), now.getMonth() - 6, 1)
break
startDate = new Date(now.getFullYear(), now.getMonth() - 6, 1);
break;
case "12months":
startDate = new Date(now.getFullYear(), now.getMonth() - 12, 1)
break
startDate = new Date(now.getFullYear(), now.getMonth() - 12, 1);
break;
default:
startDate = new Date(0)
startDate = new Date(0);
}
let transactions = data.transactions.filter((t) => new Date(t.date) >= startDate)
let transactions = data.transactions.filter(
(t) => new Date(t.date) >= startDate,
);
if (selectedAccount !== "all") {
transactions = transactions.filter((t) => t.accountId === selectedAccount)
transactions = transactions.filter(
(t) => t.accountId === selectedAccount,
);
}
// Monthly breakdown
const monthlyData = new Map<string, { income: number; expenses: number }>()
const monthlyData = new Map<string, { income: number; expenses: number }>();
transactions.forEach((t) => {
const monthKey = t.date.substring(0, 7)
const current = monthlyData.get(monthKey) || { income: 0, expenses: 0 }
const monthKey = t.date.substring(0, 7);
const current = monthlyData.get(monthKey) || { income: 0, expenses: 0 };
if (t.amount >= 0) {
current.income += t.amount
current.income += t.amount;
} else {
current.expenses += Math.abs(t.amount)
current.expenses += Math.abs(t.amount);
}
monthlyData.set(monthKey, current)
})
monthlyData.set(monthKey, current);
});
const monthlyChartData = Array.from(monthlyData.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([month, values]) => ({
month: new Date(month + "-01").toLocaleDateString("fr-FR", { month: "short", year: "2-digit" }),
month: new Date(month + "-01").toLocaleDateString("fr-FR", {
month: "short",
year: "2-digit",
}),
revenus: Math.round(values.income),
depenses: Math.round(values.expenses),
solde: Math.round(values.income - values.expenses),
}))
}));
// Category breakdown (expenses only)
const categoryTotals = new Map<string, number>()
const categoryTotals = new Map<string, number>();
transactions
.filter((t) => t.amount < 0)
.forEach((t) => {
const catId = t.categoryId || "uncategorized"
const current = categoryTotals.get(catId) || 0
categoryTotals.set(catId, current + Math.abs(t.amount))
})
const catId = t.categoryId || "uncategorized";
const current = categoryTotals.get(catId) || 0;
categoryTotals.set(catId, current + Math.abs(t.amount));
});
const categoryChartData = Array.from(categoryTotals.entries())
.map(([categoryId, total]) => {
const category = data.categories.find((c) => c.id === categoryId)
const category = data.categories.find((c) => c.id === categoryId);
return {
name: category?.name || "Non catégorisé",
value: Math.round(total),
color: category?.color || "#94a3b8",
}
};
})
.sort((a, b) => b.value - a.value)
.slice(0, 8)
.slice(0, 8);
// Top expenses
const topExpenses = transactions
.filter((t) => t.amount < 0)
.sort((a, b) => a.amount - b.amount)
.slice(0, 5)
.slice(0, 5);
// Summary
const totalIncome = transactions.filter((t) => t.amount >= 0).reduce((sum, t) => sum + t.amount, 0)
const totalExpenses = transactions.filter((t) => t.amount < 0).reduce((sum, t) => sum + Math.abs(t.amount), 0)
const avgMonthlyExpenses = monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0
const totalIncome = transactions
.filter((t) => t.amount >= 0)
.reduce((sum, t) => sum + t.amount, 0);
const totalExpenses = transactions
.filter((t) => t.amount < 0)
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
const avgMonthlyExpenses =
monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0;
// Balance evolution
const sortedTransactions = [...transactions].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
const sortedTransactions = [...transactions].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
);
let runningBalance = 0
const balanceByDate = new Map<string, number>()
let runningBalance = 0;
const balanceByDate = new Map<string, number>();
sortedTransactions.forEach((t) => {
runningBalance += t.amount
balanceByDate.set(t.date, runningBalance)
})
runningBalance += t.amount;
balanceByDate.set(t.date, runningBalance);
});
const balanceChartData = Array.from(balanceByDate.entries()).map(([date, balance]) => ({
date: new Date(date).toLocaleDateString("fr-FR", { day: "2-digit", month: "short" }),
solde: Math.round(balance),
}))
const balanceChartData = Array.from(balanceByDate.entries()).map(
([date, balance]) => ({
date: new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
}),
solde: Math.round(balance),
}),
);
return {
monthlyChartData,
@@ -136,8 +161,8 @@ export default function StatisticsPage() {
avgMonthlyExpenses,
balanceChartData,
transactionCount: transactions.length,
}
}, [data, period, selectedAccount])
};
}, [data, period, selectedAccount]);
if (isLoading || !data || !stats) {
return (
@@ -147,15 +172,15 @@ export default function StatisticsPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
)
);
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(amount)
}
}).format(amount);
};
return (
<div className="flex h-screen bg-background">
@@ -164,11 +189,18 @@ export default function StatisticsPage() {
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Statistiques</h1>
<p className="text-muted-foreground">Analysez vos dépenses et revenus</p>
<h1 className="text-2xl font-bold text-foreground">
Statistiques
</h1>
<p className="text-muted-foreground">
Analysez vos dépenses et revenus
</p>
</div>
<div className="flex gap-2">
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
<Select
value={selectedAccount}
onValueChange={setSelectedAccount}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Compte" />
</SelectTrigger>
@@ -181,7 +213,10 @@ export default function StatisticsPage() {
))}
</SelectContent>
</Select>
<Select value={period} onValueChange={(v) => setPeriod(v as Period)}>
<Select
value={period}
onValueChange={(v) => setPeriod(v as Period)}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Période" />
</SelectTrigger>
@@ -205,7 +240,9 @@ export default function StatisticsPage() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-600">{formatCurrency(stats.totalIncome)}</div>
<div className="text-2xl font-bold text-emerald-600">
{formatCurrency(stats.totalIncome)}
</div>
</CardContent>
</Card>
@@ -217,7 +254,9 @@ export default function StatisticsPage() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{formatCurrency(stats.totalExpenses)}</div>
<div className="text-2xl font-bold text-red-600">
{formatCurrency(stats.totalExpenses)}
</div>
</CardContent>
</Card>
@@ -229,19 +268,25 @@ export default function StatisticsPage() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(stats.avgMonthlyExpenses)}</div>
<div className="text-2xl font-bold">
{formatCurrency(stats.avgMonthlyExpenses)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Économies</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">
Économies
</CardTitle>
</CardHeader>
<CardContent>
<div
className={cn(
"text-2xl font-bold",
stats.totalIncome - stats.totalExpenses >= 0 ? "text-emerald-600" : "text-red-600",
stats.totalIncome - stats.totalExpenses >= 0
? "text-emerald-600"
: "text-red-600",
)}
>
{formatCurrency(stats.totalIncome - stats.totalExpenses)}
@@ -262,9 +307,15 @@ export default function StatisticsPage() {
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stats.monthlyChartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<XAxis dataKey="month" className="text-xs" />
<YAxis className="text-xs" tickFormatter={(v) => `${v}`} />
<YAxis
className="text-xs"
tickFormatter={(v) => `${v}`}
/>
<Tooltip
formatter={(value: number) => formatCurrency(value)}
contentStyle={{
@@ -274,8 +325,16 @@ export default function StatisticsPage() {
}}
/>
<Legend />
<Bar dataKey="revenus" fill="#22c55e" radius={[4, 4, 0, 0]} />
<Bar dataKey="depenses" fill="#ef4444" radius={[4, 4, 0, 0]} />
<Bar
dataKey="revenus"
fill="#22c55e"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="depenses"
fill="#ef4444"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
@@ -318,7 +377,13 @@ export default function StatisticsPage() {
borderRadius: "8px",
}}
/>
<Legend formatter={(value) => <span className="text-sm text-foreground">{value}</span>} />
<Legend
formatter={(value) => (
<span className="text-sm text-foreground">
{value}
</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
@@ -340,9 +405,19 @@ export default function StatisticsPage() {
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={stats.balanceChartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="date" className="text-xs" interval="preserveStartEnd" />
<YAxis className="text-xs" tickFormatter={(v) => `${v}`} />
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<XAxis
dataKey="date"
className="text-xs"
interval="preserveStartEnd"
/>
<YAxis
className="text-xs"
tickFormatter={(v) => `${v}`}
/>
<Tooltip
formatter={(value: number) => formatCurrency(value)}
contentStyle={{
@@ -351,7 +426,13 @@ export default function StatisticsPage() {
borderRadius: "8px",
}}
/>
<Line type="monotone" dataKey="solde" stroke="#6366f1" strokeWidth={2} dot={false} />
<Line
type="monotone"
dataKey="solde"
stroke="#6366f1"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
@@ -372,24 +453,40 @@ export default function StatisticsPage() {
{stats.topExpenses.length > 0 ? (
<div className="space-y-4">
{stats.topExpenses.map((expense, index) => {
const category = data.categories.find((c) => c.id === expense.categoryId)
const category = data.categories.find(
(c) => c.id === expense.categoryId,
);
return (
<div key={expense.id} className="flex items-center gap-3">
<div
key={expense.id}
className="flex items-center gap-3"
>
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-sm font-semibold">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{expense.description}</p>
<p className="font-medium text-sm truncate">
{expense.description}
</p>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{new Date(expense.date).toLocaleDateString("fr-FR")}
{new Date(expense.date).toLocaleDateString(
"fr-FR",
)}
</span>
{category && (
<span
className="text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1"
style={{ backgroundColor: `${category.color}20`, color: category.color }}
style={{
backgroundColor: `${category.color}20`,
color: category.color,
}}
>
<CategoryIcon icon={category.icon} color={category.color} size={10} />
<CategoryIcon
icon={category.icon}
color={category.color}
size={10}
/>
{category.name}
</span>
)}
@@ -399,7 +496,7 @@ export default function StatisticsPage() {
{formatCurrency(expense.amount)}
</div>
</div>
)
);
})}
</div>
) : (
@@ -413,5 +510,5 @@ export default function StatisticsPage() {
</div>
</main>
</div>
)
);
}

View File

@@ -1,3 +1,3 @@
export default function Loading() {
return null
return null;
}

View File

@@ -1,91 +1,125 @@
"use client"
"use client";
import { useState, useMemo } from "react"
import { Sidebar } from "@/components/dashboard/sidebar"
import { useBankingData } from "@/lib/hooks"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useState, useMemo } from "react";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import { OFXImportDialog } from "@/components/import/ofx-import-dialog"
import { CategoryIcon } from "@/components/ui/category-icon"
import { Search, CheckCircle2, Circle, MoreVertical, Tags, Upload, RefreshCw, ArrowUpDown, Check } from "lucide-react"
import { cn } from "@/lib/utils"
} from "@/components/ui/dropdown-menu";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import { CategoryIcon } from "@/components/ui/category-icon";
import {
Search,
CheckCircle2,
Circle,
MoreVertical,
Tags,
Upload,
RefreshCw,
ArrowUpDown,
Check,
} from "lucide-react";
import { cn } from "@/lib/utils";
type SortField = "date" | "amount" | "description"
type SortOrder = "asc" | "desc"
type SortField = "date" | "amount" | "description";
type SortOrder = "asc" | "desc";
export default function TransactionsPage() {
const { data, isLoading, refresh, update } = useBankingData()
const [searchQuery, setSearchQuery] = useState("")
const [selectedAccount, setSelectedAccount] = useState<string>("all")
const [selectedCategory, setSelectedCategory] = useState<string>("all")
const [showReconciled, setShowReconciled] = useState<string>("all")
const [sortField, setSortField] = useState<SortField>("date")
const [sortOrder, setSortOrder] = useState<SortOrder>("desc")
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(new Set())
const { data, isLoading, refresh, update } = useBankingData();
const [searchQuery, setSearchQuery] = useState("");
const [selectedAccount, setSelectedAccount] = useState<string>("all");
const [selectedCategory, setSelectedCategory] = useState<string>("all");
const [showReconciled, setShowReconciled] = useState<string>("all");
const [sortField, setSortField] = useState<SortField>("date");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
new Set(),
);
const filteredTransactions = useMemo(() => {
if (!data) return []
if (!data) return [];
let transactions = [...data.transactions]
let transactions = [...data.transactions];
// Filter by search
if (searchQuery) {
const query = searchQuery.toLowerCase()
const query = searchQuery.toLowerCase();
transactions = transactions.filter(
(t) => t.description.toLowerCase().includes(query) || t.memo?.toLowerCase().includes(query),
)
(t) =>
t.description.toLowerCase().includes(query) ||
t.memo?.toLowerCase().includes(query),
);
}
// Filter by account
if (selectedAccount !== "all") {
transactions = transactions.filter((t) => t.accountId === selectedAccount)
transactions = transactions.filter(
(t) => t.accountId === selectedAccount,
);
}
// Filter by category
if (selectedCategory !== "all") {
if (selectedCategory === "uncategorized") {
transactions = transactions.filter((t) => !t.categoryId)
transactions = transactions.filter((t) => !t.categoryId);
} else {
transactions = transactions.filter((t) => t.categoryId === selectedCategory)
transactions = transactions.filter(
(t) => t.categoryId === selectedCategory,
);
}
}
// Filter by reconciliation status
if (showReconciled !== "all") {
const isReconciled = showReconciled === "reconciled"
transactions = transactions.filter((t) => t.isReconciled === isReconciled)
const isReconciled = showReconciled === "reconciled";
transactions = transactions.filter(
(t) => t.isReconciled === isReconciled,
);
}
// Sort
transactions.sort((a, b) => {
let comparison = 0
let comparison = 0;
switch (sortField) {
case "date":
comparison = new Date(a.date).getTime() - new Date(b.date).getTime()
break
comparison = new Date(a.date).getTime() - new Date(b.date).getTime();
break;
case "amount":
comparison = a.amount - b.amount
break
comparison = a.amount - b.amount;
break;
case "description":
comparison = a.description.localeCompare(b.description)
break
comparison = a.description.localeCompare(b.description);
break;
}
return sortOrder === "asc" ? comparison : -comparison
})
return sortOrder === "asc" ? comparison : -comparison;
});
return transactions
}, [data, searchQuery, selectedAccount, selectedCategory, showReconciled, sortField, sortOrder])
return transactions;
}, [
data,
searchQuery,
selectedAccount,
selectedCategory,
showReconciled,
sortField,
sortOrder,
]);
if (isLoading || !data) {
return (
@@ -95,78 +129,80 @@ export default function TransactionsPage() {
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
)
);
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(amount)
}
}).format(amount);
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
})
}
});
};
const toggleReconciled = (transactionId: string) => {
const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? { ...t, isReconciled: !t.isReconciled } : t,
)
update({ ...data, transactions: updatedTransactions })
}
);
update({ ...data, transactions: updatedTransactions });
};
const setCategory = (transactionId: string, categoryId: string | null) => {
const updatedTransactions = data.transactions.map((t) => (t.id === transactionId ? { ...t, categoryId } : t))
update({ ...data, transactions: updatedTransactions })
}
const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? { ...t, categoryId } : t,
);
update({ ...data, transactions: updatedTransactions });
};
const bulkReconcile = (reconciled: boolean) => {
const updatedTransactions = data.transactions.map((t) =>
selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t,
)
update({ ...data, transactions: updatedTransactions })
setSelectedTransactions(new Set())
}
);
update({ ...data, transactions: updatedTransactions });
setSelectedTransactions(new Set());
};
const bulkSetCategory = (categoryId: string | null) => {
const updatedTransactions = data.transactions.map((t) =>
selectedTransactions.has(t.id) ? { ...t, categoryId } : t,
)
update({ ...data, transactions: updatedTransactions })
setSelectedTransactions(new Set())
}
);
update({ ...data, transactions: updatedTransactions });
setSelectedTransactions(new Set());
};
const toggleSelectAll = () => {
if (selectedTransactions.size === filteredTransactions.length) {
setSelectedTransactions(new Set())
setSelectedTransactions(new Set());
} else {
setSelectedTransactions(new Set(filteredTransactions.map((t) => t.id)))
setSelectedTransactions(new Set(filteredTransactions.map((t) => t.id)));
}
}
};
const toggleSelectTransaction = (id: string) => {
const newSelected = new Set(selectedTransactions)
const newSelected = new Set(selectedTransactions);
if (newSelected.has(id)) {
newSelected.delete(id)
newSelected.delete(id);
} else {
newSelected.add(id)
newSelected.add(id);
}
setSelectedTransactions(newSelected)
}
setSelectedTransactions(newSelected);
};
const getCategory = (categoryId: string | null) => {
if (!categoryId) return null
return data.categories.find((c) => c.id === categoryId)
}
if (!categoryId) return null;
return data.categories.find((c) => c.id === categoryId);
};
const getAccount = (accountId: string) => {
return data.accounts.find((a) => a.id === accountId)
}
return data.accounts.find((a) => a.id === accountId);
};
return (
<div className="flex h-screen bg-background">
@@ -175,9 +211,12 @@ export default function TransactionsPage() {
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Transactions</h1>
<h1 className="text-2xl font-bold text-foreground">
Transactions
</h1>
<p className="text-muted-foreground">
{filteredTransactions.length} transaction{filteredTransactions.length > 1 ? "s" : ""}
{filteredTransactions.length} transaction
{filteredTransactions.length > 1 ? "s" : ""}
</p>
</div>
<OFXImportDialog onImportComplete={refresh}>
@@ -204,7 +243,10 @@ export default function TransactionsPage() {
</div>
</div>
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
<Select
value={selectedAccount}
onValueChange={setSelectedAccount}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Compte" />
</SelectTrigger>
@@ -218,13 +260,18 @@ export default function TransactionsPage() {
</SelectContent>
</Select>
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
<Select
value={selectedCategory}
onValueChange={setSelectedCategory}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Catégorie" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes catégories</SelectItem>
<SelectItem value="uncategorized">Non catégorisé</SelectItem>
<SelectItem value="uncategorized">
Non catégorisé
</SelectItem>
{data.categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
@@ -233,7 +280,10 @@ export default function TransactionsPage() {
</SelectContent>
</Select>
<Select value={showReconciled} onValueChange={setShowReconciled}>
<Select
value={showReconciled}
onValueChange={setShowReconciled}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Pointage" />
</SelectTrigger>
@@ -253,13 +303,22 @@ export default function TransactionsPage() {
<CardContent className="py-3">
<div className="flex items-center gap-4">
<span className="text-sm font-medium">
{selectedTransactions.size} sélectionnée{selectedTransactions.size > 1 ? "s" : ""}
{selectedTransactions.size} sélectionnée
{selectedTransactions.size > 1 ? "s" : ""}
</span>
<Button size="sm" variant="outline" onClick={() => bulkReconcile(true)}>
<Button
size="sm"
variant="outline"
onClick={() => bulkReconcile(true)}
>
<CheckCircle2 className="w-4 h-4 mr-1" />
Pointer
</Button>
<Button size="sm" variant="outline" onClick={() => bulkReconcile(false)}>
<Button
size="sm"
variant="outline"
onClick={() => bulkReconcile(false)}
>
<Circle className="w-4 h-4 mr-1" />
Dépointer
</Button>
@@ -271,11 +330,21 @@ export default function TransactionsPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => bulkSetCategory(null)}>Aucune catégorie</DropdownMenuItem>
<DropdownMenuItem onClick={() => bulkSetCategory(null)}>
Aucune catégorie
</DropdownMenuItem>
<DropdownMenuSeparator />
{data.categories.map((cat) => (
<DropdownMenuItem key={cat.id} onClick={() => bulkSetCategory(cat.id)}>
<CategoryIcon icon={cat.icon} color={cat.color} size={14} className="mr-2" />
<DropdownMenuItem
key={cat.id}
onClick={() => bulkSetCategory(cat.id)}
>
<CategoryIcon
icon={cat.icon}
color={cat.color}
size={14}
className="mr-2"
/>
{cat.name}
</DropdownMenuItem>
))}
@@ -291,7 +360,9 @@ export default function TransactionsPage() {
<CardContent className="p-0">
{filteredTransactions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">Aucune transaction trouvée</p>
<p className="text-muted-foreground">
Aucune transaction trouvée
</p>
</div>
) : (
<div className="overflow-x-auto">
@@ -301,7 +372,8 @@ export default function TransactionsPage() {
<th className="p-3 text-left">
<Checkbox
checked={
selectedTransactions.size === filteredTransactions.length &&
selectedTransactions.size ===
filteredTransactions.length &&
filteredTransactions.length > 0
}
onCheckedChange={toggleSelectAll}
@@ -311,10 +383,12 @@ export default function TransactionsPage() {
<button
onClick={() => {
if (sortField === "date") {
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortField("date")
setSortOrder("desc")
setSortField("date");
setSortOrder("desc");
}
}}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
@@ -327,10 +401,12 @@ export default function TransactionsPage() {
<button
onClick={() => {
if (sortField === "description") {
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortField("description")
setSortOrder("asc")
setSortField("description");
setSortOrder("asc");
}
}}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
@@ -339,16 +415,22 @@ export default function TransactionsPage() {
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="p-3 text-left text-sm font-medium text-muted-foreground">Compte</th>
<th className="p-3 text-left text-sm font-medium text-muted-foreground">Catégorie</th>
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
Compte
</th>
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
Catégorie
</th>
<th className="p-3 text-right">
<button
onClick={() => {
if (sortField === "amount") {
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortField("amount")
setSortOrder("desc")
setSortField("amount");
setSortOrder("desc");
}
}}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground ml-auto"
@@ -357,35 +439,48 @@ export default function TransactionsPage() {
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="p-3 text-center text-sm font-medium text-muted-foreground">Pointé</th>
<th className="p-3 text-center text-sm font-medium text-muted-foreground">
Pointé
</th>
<th className="p-3"></th>
</tr>
</thead>
<tbody>
{filteredTransactions.map((transaction) => {
const category = getCategory(transaction.categoryId)
const account = getAccount(transaction.accountId)
const category = getCategory(transaction.categoryId);
const account = getAccount(transaction.accountId);
return (
<tr key={transaction.id} className="border-b border-border last:border-0 hover:bg-muted/50">
<tr
key={transaction.id}
className="border-b border-border last:border-0 hover:bg-muted/50"
>
<td className="p-3">
<Checkbox
checked={selectedTransactions.has(transaction.id)}
onCheckedChange={() => toggleSelectTransaction(transaction.id)}
checked={selectedTransactions.has(
transaction.id,
)}
onCheckedChange={() =>
toggleSelectTransaction(transaction.id)
}
/>
</td>
<td className="p-3 text-sm text-muted-foreground whitespace-nowrap">
{formatDate(transaction.date)}
</td>
<td className="p-3">
<p className="font-medium text-sm">{transaction.description}</p>
<p className="font-medium text-sm">
{transaction.description}
</p>
{transaction.memo && (
<p className="text-xs text-muted-foreground truncate max-w-[300px]">
{transaction.memo}
</p>
)}
</td>
<td className="p-3 text-sm text-muted-foreground">{account?.name || "-"}</td>
<td className="p-3 text-sm text-muted-foreground">
{account?.name || "-"}
</td>
<td className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -399,26 +494,49 @@ export default function TransactionsPage() {
color: category.color,
}}
>
<CategoryIcon icon={category.icon} color={category.color} size={12} />
<CategoryIcon
icon={category.icon}
color={category.color}
size={12}
/>
{category.name}
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
<Badge
variant="outline"
className="text-muted-foreground"
>
Non catégorisé
</Badge>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setCategory(transaction.id, null)}>
<DropdownMenuItem
onClick={() =>
setCategory(transaction.id, null)
}
>
Aucune catégorie
</DropdownMenuItem>
<DropdownMenuSeparator />
{data.categories.map((cat) => (
<DropdownMenuItem key={cat.id} onClick={() => setCategory(transaction.id, cat.id)}>
<CategoryIcon icon={cat.icon} color={cat.color} size={14} className="mr-2" />
<DropdownMenuItem
key={cat.id}
onClick={() =>
setCategory(transaction.id, cat.id)
}
>
<CategoryIcon
icon={cat.icon}
color={cat.color}
size={14}
className="mr-2"
/>
{cat.name}
{transaction.categoryId === cat.id && <Check className="w-4 h-4 ml-auto" />}
{transaction.categoryId === cat.id && (
<Check className="w-4 h-4 ml-auto" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -427,7 +545,9 @@ export default function TransactionsPage() {
<td
className={cn(
"p-3 text-right font-semibold tabular-nums",
transaction.amount >= 0 ? "text-emerald-600" : "text-red-600",
transaction.amount >= 0
? "text-emerald-600"
: "text-red-600",
)}
>
{transaction.amount >= 0 ? "+" : ""}
@@ -448,19 +568,29 @@ export default function TransactionsPage() {
<td className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => toggleReconciled(transaction.id)}>
{transaction.isReconciled ? "Dépointer" : "Pointer"}
<DropdownMenuItem
onClick={() =>
toggleReconciled(transaction.id)
}
>
{transaction.isReconciled
? "Dépointer"
: "Pointer"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
)
);
})}
</tbody>
</table>
@@ -471,5 +601,5 @@ export default function TransactionsPage() {
</div>
</main>
</div>
)
);
}