chore: init from v0

This commit is contained in:
Julien Froidefond
2025-11-27 09:51:18 +01:00
commit e9e44916fd
109 changed files with 15966 additions and 0 deletions

255
app/accounts/page.tsx Normal file
View File

@@ -0,0 +1,255 @@
"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"
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 [formData, setFormData] = useState({
name: "",
type: "CHECKING" as Account["type"],
folderId: "folder-root",
})
if (isLoading || !data) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<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)
}
const handleEdit = (account: Account) => {
setEditingAccount(account)
setFormData({
name: account.name,
type: account.type,
folderId: account.folderId || "folder-root",
})
setIsDialogOpen(true)
}
const handleSave = async () => {
if (!editingAccount) return
try {
const updatedAccount = {
...editingAccount,
name: formData.name,
type: formData.type,
folderId: formData.folderId,
}
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")
}
}
const handleDelete = async (accountId: string) => {
if (!confirm("Supprimer ce compte et toutes ses transactions ?")) return
try {
await deleteAccount(accountId)
refresh()
} catch (error) {
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
}
const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0)
return (
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-auto">
<div className="p-6 space-y-6">
<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>
</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")}>
{formatCurrency(totalBalance)}
</p>
</div>
</div>
{data.accounts.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<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.
</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)
return (
<Card key={account.id} className="relative">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<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>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(account.id)} className="text-red-600">
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
<div
className={cn(
"text-2xl font-bold mb-2",
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>
{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")}
</p>
)}
</CardContent>
</Card>
)
})}
</div>
)}
</div>
</main>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier le compte</DialogTitle>
</DialogHeader>
<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 })} />
</div>
<div className="space-y-2">
<Label>Type de compte</Label>
<Select
value={formData.type}
onValueChange={(v) => setFormData({ ...formData, type: v as Account["type"] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(accountTypeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Dossier</Label>
<Select value={formData.folderId} onValueChange={(v) => setFormData({ ...formData, folderId: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{data.folders.map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Annuler
</Button>
<Button onClick={handleSave}>Enregistrer</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import type { Account } from "@/lib/types"
export async function POST(request: Request) {
try {
const account: Omit<Account, "id"> = await request.json()
const created = await prisma.account.create({
data: {
name: account.name,
bankId: account.bankId,
accountNumber: account.accountNumber,
type: account.type,
folderId: account.folderId,
balance: account.balance,
currency: account.currency,
lastImport: account.lastImport,
},
})
return NextResponse.json(created)
} catch (error) {
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 prisma.account.update({
where: { id: account.id },
data: {
name: account.name,
bankId: account.bankId,
accountNumber: account.accountNumber,
type: account.type,
folderId: account.folderId,
balance: account.balance,
currency: account.currency,
lastImport: account.lastImport,
},
})
return NextResponse.json(updated)
} catch (error) {
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")
if (!id) {
return NextResponse.json({ error: "Account ID is required" }, { status: 400 })
}
// Transactions will be deleted automatically due to onDelete: Cascade
await prisma.account.delete({
where: { id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("Error deleting account:", error)
return NextResponse.json({ error: "Failed to delete account" }, { status: 500 })
}
}

View File

@@ -0,0 +1,79 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import type { Category } from "@/lib/types"
export async function POST(request: Request) {
try {
const category: Omit<Category, "id"> = await request.json()
const created = await prisma.category.create({
data: {
name: category.name,
color: category.color,
icon: category.icon,
keywords: JSON.stringify(category.keywords),
parentId: category.parentId,
},
})
return NextResponse.json({
...created,
keywords: JSON.parse(created.keywords),
})
} catch (error) {
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 prisma.category.update({
where: { id: category.id },
data: {
name: category.name,
color: category.color,
icon: category.icon,
keywords: JSON.stringify(category.keywords),
parentId: category.parentId,
},
})
return NextResponse.json({
...updated,
keywords: JSON.parse(updated.keywords),
})
} catch (error) {
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")
if (!id) {
return NextResponse.json({ error: "Category ID is required" }, { status: 400 })
}
// Remove category from transactions (set to null)
await prisma.transaction.updateMany({
where: { categoryId: id },
data: { categoryId: null },
})
await prisma.category.delete({
where: { id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("Error deleting category:", error)
return NextResponse.json({ error: "Failed to delete category" }, { status: 500 })
}
}

View File

@@ -0,0 +1,94 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import type { Folder } from "@/lib/types"
export async function POST(request: Request) {
try {
const folder: Omit<Folder, "id"> = await request.json()
const created = await prisma.folder.create({
data: {
name: folder.name,
parentId: folder.parentId,
color: folder.color,
icon: folder.icon,
},
})
return NextResponse.json(created)
} catch (error) {
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 prisma.folder.update({
where: { id: folder.id },
data: {
name: folder.name,
parentId: folder.parentId,
color: folder.color,
icon: folder.icon,
},
})
return NextResponse.json(updated)
} catch (error) {
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")
if (!id) {
return NextResponse.json({ error: "Folder ID is required" }, { status: 400 })
}
const folder = await prisma.folder.findUnique({
where: { id },
include: { children: true },
})
if (!folder) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 })
}
// Move accounts to root (null)
await prisma.account.updateMany({
where: { folderId: id },
data: { folderId: null },
})
// Move subfolders to parent
if (folder.parentId) {
await prisma.folder.updateMany({
where: { parentId: id },
data: { parentId: folder.parentId },
})
} else {
// If no parent, move to null (root)
await prisma.folder.updateMany({
where: { parentId: id },
data: { parentId: null },
})
}
await prisma.folder.delete({
where: { id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("Error deleting folder:", error)
return NextResponse.json({ error: "Failed to delete folder" }, { status: 500 })
}
}

79
app/api/banking/route.ts Normal file
View File

@@ -0,0 +1,79 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import type { BankingData } from "@/lib/types"
export async function GET() {
try {
const [accounts, transactions, folders, categories, categoryRules] = await Promise.all([
prisma.account.findMany({
include: {
folder: true,
},
}),
prisma.transaction.findMany({
include: {
account: true,
category: true,
},
}),
prisma.folder.findMany(),
prisma.category.findMany(),
prisma.categoryRule.findMany(),
])
// Transform Prisma models to match our types
const data: BankingData = {
accounts: accounts.map((a) => ({
id: a.id,
name: a.name,
bankId: a.bankId,
accountNumber: a.accountNumber,
type: a.type as "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER",
folderId: a.folderId,
balance: a.balance,
currency: a.currency,
lastImport: a.lastImport,
})),
transactions: transactions.map((t) => ({
id: t.id,
accountId: t.accountId,
date: t.date,
amount: t.amount,
description: t.description,
type: t.type as "DEBIT" | "CREDIT",
categoryId: t.categoryId,
isReconciled: t.isReconciled,
fitId: t.fitId,
memo: t.memo ?? undefined,
checkNum: t.checkNum ?? undefined,
})),
folders: folders.map((f) => ({
id: f.id,
name: f.name,
parentId: f.parentId,
color: f.color,
icon: f.icon,
})),
categories: categories.map((c) => ({
id: c.id,
name: c.name,
color: c.color,
icon: c.icon,
keywords: JSON.parse(c.keywords) as string[],
parentId: c.parentId,
})),
categoryRules: categoryRules.map((r) => ({
id: r.id,
categoryId: r.categoryId,
pattern: r.pattern,
isRegex: r.isRegex,
})),
}
return NextResponse.json(data)
} catch (error) {
console.error("Error fetching banking data:", error)
return NextResponse.json({ error: "Failed to fetch data" }, { status: 500 })
}
}

View File

@@ -0,0 +1,101 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import type { Transaction } from "@/lib/types"
export async function POST(request: Request) {
try {
const transactions: Transaction[] = await request.json()
// Filter out duplicates based on fitId
const existingTransactions = await prisma.transaction.findMany({
where: {
accountId: { in: transactions.map((t) => t.accountId) },
fitId: { in: transactions.map((t) => t.fitId) },
},
select: {
accountId: true,
fitId: true,
},
})
const existingSet = new Set(
existingTransactions.map((t) => `${t.accountId}-${t.fitId}`),
)
const newTransactions = transactions.filter(
(t) => !existingSet.has(`${t.accountId}-${t.fitId}`),
)
if (newTransactions.length === 0) {
return NextResponse.json({ count: 0, transactions: [] })
}
const created = await prisma.transaction.createMany({
data: newTransactions.map((t) => ({
accountId: t.accountId,
date: t.date,
amount: t.amount,
description: t.description,
type: t.type,
categoryId: t.categoryId,
isReconciled: t.isReconciled,
fitId: t.fitId,
memo: t.memo,
checkNum: t.checkNum,
})),
})
return NextResponse.json({ count: created.count, transactions: newTransactions })
} catch (error) {
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 prisma.transaction.update({
where: { id: transaction.id },
data: {
accountId: transaction.accountId,
date: transaction.date,
amount: transaction.amount,
description: transaction.description,
type: transaction.type,
categoryId: transaction.categoryId,
isReconciled: transaction.isReconciled,
fitId: transaction.fitId,
memo: transaction.memo,
checkNum: transaction.checkNum,
},
})
return NextResponse.json(updated)
} catch (error) {
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")
if (!id) {
return NextResponse.json({ error: "Transaction ID is required" }, { status: 400 })
}
await prisma.transaction.delete({
where: { id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("Error deleting transaction:", error)
return NextResponse.json({ error: "Failed to delete transaction" }, { status: 500 })
}
}

318
app/categories/page.tsx Normal file
View File

@@ -0,0 +1,318 @@
"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 { 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 { Plus, MoreVertical, Pencil, Trash2, Tag, RefreshCw, X } from "lucide-react"
import { generateId, 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",
]
export default function CategoriesPage() {
const { data, isLoading, refresh } = useBankingData()
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [formData, setFormData] = useState({
name: "",
color: "#22c55e",
keywords: [] as string[],
})
const [newKeyword, setNewKeyword] = useState("")
if (isLoading || !data) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<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)
}
const getCategoryStats = (categoryId: string) => {
const categoryTransactions = data.transactions.filter((t) => t.categoryId === categoryId)
const total = categoryTransactions.reduce((sum, t) => sum + Math.abs(t.amount), 0)
const count = categoryTransactions.length
return { total, count }
}
const handleNewCategory = () => {
setEditingCategory(null)
setFormData({ name: "", color: "#22c55e", keywords: [] })
setIsDialogOpen(true)
}
const handleEdit = (category: Category) => {
setEditingCategory(category)
setFormData({
name: category.name,
color: category.color,
keywords: [...category.keywords],
})
setIsDialogOpen(true)
}
const handleSave = async () => {
try {
if (editingCategory) {
await updateCategory({
...editingCategory,
name: formData.name,
color: formData.color,
keywords: formData.keywords,
})
} else {
await addCategory({
name: formData.name,
color: formData.color,
keywords: formData.keywords,
icon: "tag",
parentId: null,
})
}
refresh()
setIsDialogOpen(false)
} catch (error) {
console.error("Error saving category:", error)
alert("Erreur lors de la sauvegarde de la catégorie")
}
}
const handleDelete = async (categoryId: string) => {
if (!confirm("Supprimer cette catégorie ?")) return
try {
await deleteCategory(categoryId)
refresh()
} catch (error) {
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())) {
setFormData({
...formData,
keywords: [...formData.keywords, newKeyword.trim().toLowerCase()],
})
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
try {
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)
if (categoryId) {
await updateTransaction({ ...transaction, categoryId })
}
}
refresh()
} catch (error) {
console.error("Error re-categorizing:", error)
alert("Erreur lors de la recatégorisation")
}
}
const uncategorizedCount = data.transactions.filter((t) => !t.categoryId).length
return (
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-auto">
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Catégories</h1>
<p className="text-muted-foreground">
Gérez vos catégories et mots-clés pour la catégorisation automatique
</p>
</div>
<div className="flex gap-2">
{uncategorizedCount > 0 && (
<Button variant="outline" onClick={reApplyAutoCategories}>
Recatégoriser ({uncategorizedCount})
</Button>
)}
<Button onClick={handleNewCategory}>
<Plus className="w-4 h-4 mr-2" />
Nouvelle catégorie
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data.categories.map((category) => {
const stats = getCategoryStats(category.id)
return (
<Card key={category.id}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center"
style={{ backgroundColor: `${category.color}20` }}
>
<Tag className="w-5 h-5" style={{ color: category.color }} />
</div>
<div>
<CardTitle className="text-base">{category.name}</CardTitle>
<p className="text-xs text-muted-foreground">
{stats.count} transaction{stats.count > 1 ? "s" : ""}
</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(category)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(category.id)} className="text-red-600">
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
<div className="text-lg font-semibold mb-3">{formatCurrency(stats.total)}</div>
<div className="flex flex-wrap gap-1">
{category.keywords.slice(0, 5).map((keyword) => (
<Badge key={keyword} variant="secondary" className="text-xs">
{keyword}
</Badge>
))}
{category.keywords.length > 5 && (
<Badge variant="secondary" className="text-xs">
+{category.keywords.length - 5}
</Badge>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
</main>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editingCategory ? "Modifier la catégorie" : "Nouvelle catégorie"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Nom</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Ex: Alimentation"
/>
</div>
<div className="space-y-2">
<Label>Couleur</Label>
<div className="flex flex-wrap gap-2">
{categoryColors.map((color) => (
<button
key={color}
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",
)}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
<div className="space-y-2">
<Label>Mots-clés pour la catégorisation automatique</Label>
<div className="flex gap-2">
<Input
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder="Ajouter un mot-clé"
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addKeyword())}
/>
<Button type="button" onClick={addKeyword} size="icon">
<Plus className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap gap-1 mt-2">
{formData.keywords.map((keyword) => (
<Badge key={keyword} variant="secondary" className="gap-1">
{keyword}
<button onClick={() => removeKeyword(keyword)}>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Annuler
</Button>
<Button onClick={handleSave} disabled={!formData.name.trim()}>
{editingCategory ? "Enregistrer" : "Créer"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}

345
app/folders/page.tsx Normal file
View File

@@ -0,0 +1,345 @@
"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 {
Plus,
MoreVertical,
Pencil,
Trash2,
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
Building2,
RefreshCw,
} from "lucide-react"
import { generateId, addFolder, updateFolder, deleteFolder } from "@/lib/store-db"
import type { Folder as FolderType, Account } from "@/lib/types"
import { cn } from "@/lib/utils"
const folderColors = [
{ value: "#6366f1", label: "Indigo" },
{ value: "#22c55e", label: "Vert" },
{ value: "#f59e0b", label: "Orange" },
{ 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
formatCurrency: (amount: number) => string
}
function FolderTreeItem({
folder,
accounts,
allFolders,
level,
onEdit,
onDelete,
formatCurrency,
}: FolderTreeItemProps) {
const [isExpanded, setIsExpanded] = useState(true)
const folderAccounts = accounts.filter((a) => a.folderId === folder.id)
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)
return (
<div>
<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"
disabled={!hasChildren}
>
{hasChildren ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)
) : (
<div className="w-4 h-4" />
)}
</button>
<div
className="w-6 h-6 rounded flex items-center justify-center"
style={{ backgroundColor: `${folder.color}20` }}
>
{isExpanded ? (
<FolderOpen className="w-4 h-4" style={{ color: folder.color }} />
) : (
<Folder className="w-4 h-4" style={{ color: folder.color }} />
)}
</div>
<span className="flex-1 font-medium text-sm">{folder.name}</span>
{folderAccounts.length > 0 && (
<span
className={cn("text-sm font-semibold tabular-nums", folderTotal >= 0 ? "text-emerald-600" : "text-red-600")}
>
{formatCurrency(folderTotal)}
</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(folder)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
{folder.id !== "folder-root" && (
<DropdownMenuItem onClick={() => onDelete(folder.id)} className="text-red-600">
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{isExpanded && (
<div>
{folderAccounts.map((account) => (
<div key={account.id} className={cn("flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50", "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")}>
{formatCurrency(account.balance)}
</span>
</div>
))}
{childFolders.map((child) => (
<FolderTreeItem
key={child.id}
folder={child}
accounts={accounts}
allFolders={allFolders}
level={level + 1}
onEdit={onEdit}
onDelete={onDelete}
formatCurrency={formatCurrency}
/>
))}
</div>
)}
</div>
)
}
export default function FoldersPage() {
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",
})
if (isLoading || !data) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<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)
}
const rootFolders = data.folders.filter((f) => f.parentId === null)
const handleNewFolder = () => {
setEditingFolder(null)
setFormData({ name: "", parentId: "folder-root", color: "#6366f1" })
setIsDialogOpen(true)
}
const handleEdit = (folder: FolderType) => {
setEditingFolder(folder)
setFormData({
name: folder.name,
parentId: folder.parentId || "folder-root",
color: folder.color,
})
setIsDialogOpen(true)
}
const handleSave = async () => {
const parentId = formData.parentId === "folder-root" ? null : formData.parentId
try {
if (editingFolder) {
await updateFolder({
...editingFolder,
name: formData.name,
parentId,
color: formData.color,
})
} else {
await addFolder({
name: formData.name,
parentId,
color: formData.color,
icon: "folder",
})
}
refresh()
setIsDialogOpen(false)
} catch (error) {
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
try {
await deleteFolder(folderId)
refresh()
} catch (error) {
console.error("Error deleting folder:", error)
alert("Erreur lors de la suppression du dossier")
}
}
return (
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-auto">
<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>
</div>
<Button onClick={handleNewFolder}>
<Plus className="w-4 h-4 mr-2" />
Nouveau dossier
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Arborescence des comptes</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{rootFolders.map((folder) => (
<FolderTreeItem
key={folder.id}
folder={folder}
accounts={data.accounts}
allFolders={data.folders}
level={0}
onEdit={handleEdit}
onDelete={handleDelete}
formatCurrency={formatCurrency}
/>
))}
</div>
</CardContent>
</Card>
</div>
</main>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<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 })}
placeholder="Ex: Comptes personnels"
/>
</div>
<div className="space-y-2">
<Label>Dossier parent</Label>
<Select
value={formData.parentId || "root"}
onValueChange={(v) => setFormData({ ...formData, parentId: v === "root" ? null : v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="root">Racine</SelectItem>
{data.folders
.filter((f) => f.id !== editingFolder?.id)
.map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Couleur</Label>
<div className="flex gap-2">
{folderColors.map(({ value }) => (
<button
key={value}
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",
)}
style={{ backgroundColor: value }}
/>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Annuler
</Button>
<Button onClick={handleSave} disabled={!formData.name.trim()}>
{editingFolder ? "Enregistrer" : "Créer"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}

125
app/globals.css Normal file
View File

@@ -0,0 +1,125 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--font-sans: 'Geist', 'Geist Fallback';
--font-mono: 'Geist Mono', 'Geist Mono Fallback';
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

25
app/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
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"] })
export const metadata: Metadata = {
title: "FinTrack - Gestion de compte bancaire",
description: "Application de gestion personnelle de comptes bancaires avec import OFX",
generator: 'v0.app'
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="fr">
<body className="font-sans antialiased">{children}</body>
</html>
)
}

58
app/page.tsx Normal file
View File

@@ -0,0 +1,58 @@
"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"
export default function DashboardPage() {
const { data, isLoading, refresh } = useBankingData()
if (isLoading || !data) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
)
}
return (
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-auto">
<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>
</div>
<OFXImportDialog onImportComplete={refresh}>
<Button>
<Upload className="w-4 h-4 mr-2" />
Importer OFX
</Button>
</OFXImportDialog>
</div>
<OverviewCards data={data} />
<div className="grid gap-6 lg:grid-cols-2">
<RecentTransactions data={data} />
<div className="space-y-6">
<AccountsSummary data={data} />
<CategoryBreakdown data={data} />
</div>
</div>
</div>
</main>
</div>
)
}

187
app/settings/page.tsx Normal file
View File

@@ -0,0 +1,187 @@
"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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Download, Trash2, Upload, RefreshCw, Database, FileJson } from "lucide-react"
import type { BankingData } from "@/lib/types"
export default function SettingsPage() {
const { data, isLoading, refresh, update } = useBankingData()
const [importing, setImporting] = useState(false)
if (isLoading || !data) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<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 importData = () => {
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
setImporting(true)
try {
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
}
update(importedData)
alert("Données importées avec succès")
} catch (error) {
alert("Erreur lors de l'import")
} finally {
setImporting(false)
}
}
input.click()
}
const resetData = () => {
localStorage.removeItem("banking-app-data")
window.location.reload()
}
return (
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-auto">
<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>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5" />
Données
</CardTitle>
<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
</p>
</div>
</div>
<div className="flex gap-2">
<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}>
<Upload className="w-4 h-4 mr-2" />
{importing ? "Import..." : "Importer"}
</Button>
</div>
</CardContent>
</Card>
<Card className="border-red-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<Trash2 className="w-5 h-5" />
Zone dangereuse
</CardTitle>
<CardDescription>Actions irréversibles - procédez avec prudence</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="w-4 h-4 mr-2" />
Réinitialiser toutes les données
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={resetData} className="bg-red-600 hover:bg-red-700">
Supprimer tout
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileJson className="w-5 h-5" />
Format OFX
</CardTitle>
<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.
</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.
</p>
</div>
</CardContent>
</Card>
</div>
</main>
</div>
)
}

415
app/statistics/page.tsx Normal file
View File

@@ -0,0 +1,415 @@
"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 {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
LineChart,
Line,
Legend,
} from "recharts"
import { cn } from "@/lib/utils"
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 stats = useMemo(() => {
if (!data) return null
const now = new Date()
let startDate: Date
switch (period) {
case "3months":
startDate = new Date(now.getFullYear(), now.getMonth() - 3, 1)
break
case "6months":
startDate = new Date(now.getFullYear(), now.getMonth() - 6, 1)
break
case "12months":
startDate = new Date(now.getFullYear(), now.getMonth() - 12, 1)
break
default:
startDate = new Date(0)
}
let transactions = data.transactions.filter((t) => new Date(t.date) >= startDate)
if (selectedAccount !== "all") {
transactions = transactions.filter((t) => t.accountId === selectedAccount)
}
// Monthly breakdown
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 }
if (t.amount >= 0) {
current.income += t.amount
} else {
current.expenses += Math.abs(t.amount)
}
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" }),
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>()
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 categoryChartData = Array.from(categoryTotals.entries())
.map(([categoryId, total]) => {
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)
// Top expenses
const topExpenses = transactions
.filter((t) => t.amount < 0)
.sort((a, b) => a.amount - b.amount)
.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
// Balance evolution
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>()
sortedTransactions.forEach((t) => {
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),
}))
return {
monthlyChartData,
categoryChartData,
topExpenses,
totalIncome,
totalExpenses,
avgMonthlyExpenses,
balanceChartData,
transactionCount: transactions.length,
}
}, [data, period, selectedAccount])
if (isLoading || !data || !stats) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<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 (
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-auto">
<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>
</div>
<div className="flex gap-2">
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Compte" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les comptes</SelectItem>
{data.accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={period} onValueChange={(v) => setPeriod(v as Period)}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Période" />
</SelectTrigger>
<SelectContent>
<SelectItem value="3months">3 mois</SelectItem>
<SelectItem value="6months">6 mois</SelectItem>
<SelectItem value="12months">12 mois</SelectItem>
<SelectItem value="all">Tout</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-emerald-600" />
Total Revenus
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-600">{formatCurrency(stats.totalIncome)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-red-600" />
Total Dépenses
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{formatCurrency(stats.totalExpenses)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<ArrowRight className="w-4 h-4" />
Moyenne mensuelle
</CardTitle>
</CardHeader>
<CardContent>
<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>
</CardHeader>
<CardContent>
<div
className={cn(
"text-2xl font-bold",
stats.totalIncome - stats.totalExpenses >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(stats.totalIncome - stats.totalExpenses)}
</div>
</CardContent>
</Card>
</div>
{/* Charts */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Monthly Income vs Expenses */}
<Card>
<CardHeader>
<CardTitle>Revenus vs Dépenses par mois</CardTitle>
</CardHeader>
<CardContent>
{stats.monthlyChartData.length > 0 ? (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stats.monthlyChartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="month" className="text-xs" />
<YAxis className="text-xs" tickFormatter={(v) => `${v}`} />
<Tooltip
formatter={(value: number) => formatCurrency(value)}
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}}
/>
<Legend />
<Bar dataKey="revenus" fill="#22c55e" radius={[4, 4, 0, 0]} />
<Bar dataKey="depenses" fill="#ef4444" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
Pas de données pour cette période
</div>
)}
</CardContent>
</Card>
{/* Category Breakdown */}
<Card>
<CardHeader>
<CardTitle>Répartition par catégorie</CardTitle>
</CardHeader>
<CardContent>
{stats.categoryChartData.length > 0 ? (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={stats.categoryChartData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
dataKey="value"
>
{stats.categoryChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value: number) => formatCurrency(value)}
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}}
/>
<Legend formatter={(value) => <span className="text-sm text-foreground">{value}</span>} />
</PieChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
Pas de données pour cette période
</div>
)}
</CardContent>
</Card>
{/* Balance Evolution */}
<Card>
<CardHeader>
<CardTitle>Évolution du solde</CardTitle>
</CardHeader>
<CardContent>
{stats.balanceChartData.length > 0 ? (
<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}`} />
<Tooltip
formatter={(value: number) => formatCurrency(value)}
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}}
/>
<Line type="monotone" dataKey="solde" stroke="#6366f1" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
Pas de données pour cette période
</div>
)}
</CardContent>
</Card>
{/* Top Expenses */}
<Card>
<CardHeader>
<CardTitle>Top 5 dépenses</CardTitle>
</CardHeader>
<CardContent>
{stats.topExpenses.length > 0 ? (
<div className="space-y-4">
{stats.topExpenses.map((expense, index) => {
const category = data.categories.find((c) => c.id === expense.categoryId)
return (
<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>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{new Date(expense.date).toLocaleDateString("fr-FR")}
</span>
{category && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{ backgroundColor: `${category.color}20`, color: category.color }}
>
{category.name}
</span>
)}
</div>
</div>
<div className="text-red-600 font-semibold tabular-nums">
{formatCurrency(expense.amount)}
</div>
</div>
)
})}
</div>
) : (
<div className="h-[200px] flex items-center justify-center text-muted-foreground">
Pas de dépenses pour cette période
</div>
)}
</CardContent>
</Card>
</div>
</div>
</main>
</div>
)
}

View File

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

475
app/transactions/page.tsx Normal file
View File

@@ -0,0 +1,475 @@
"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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import { OFXImportDialog } from "@/components/import/ofx-import-dialog"
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"
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 filteredTransactions = useMemo(() => {
if (!data) return []
let transactions = [...data.transactions]
// Filter by search
if (searchQuery) {
const query = searchQuery.toLowerCase()
transactions = transactions.filter(
(t) => t.description.toLowerCase().includes(query) || t.memo?.toLowerCase().includes(query),
)
}
// Filter by account
if (selectedAccount !== "all") {
transactions = transactions.filter((t) => t.accountId === selectedAccount)
}
// Filter by category
if (selectedCategory !== "all") {
if (selectedCategory === "uncategorized") {
transactions = transactions.filter((t) => !t.categoryId)
} else {
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)
}
// Sort
transactions.sort((a, b) => {
let comparison = 0
switch (sortField) {
case "date":
comparison = new Date(a.date).getTime() - new Date(b.date).getTime()
break
case "amount":
comparison = a.amount - b.amount
break
case "description":
comparison = a.description.localeCompare(b.description)
break
}
return sortOrder === "asc" ? comparison : -comparison
})
return transactions
}, [data, searchQuery, selectedAccount, selectedCategory, showReconciled, sortField, sortOrder])
if (isLoading || !data) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<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)
}
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 })
}
const setCategory = (transactionId: string, categoryId: string | null) => {
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())
}
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())
}
const toggleSelectAll = () => {
if (selectedTransactions.size === filteredTransactions.length) {
setSelectedTransactions(new Set())
} else {
setSelectedTransactions(new Set(filteredTransactions.map((t) => t.id)))
}
}
const toggleSelectTransaction = (id: string) => {
const newSelected = new Set(selectedTransactions)
if (newSelected.has(id)) {
newSelected.delete(id)
} else {
newSelected.add(id)
}
setSelectedTransactions(newSelected)
}
const getCategory = (categoryId: string | null) => {
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 (
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-auto">
<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>
<p className="text-muted-foreground">
{filteredTransactions.length} transaction{filteredTransactions.length > 1 ? "s" : ""}
</p>
</div>
<OFXImportDialog onImportComplete={refresh}>
<Button>
<Upload className="w-4 h-4 mr-2" />
Importer OFX
</Button>
</OFXImportDialog>
</div>
{/* Filters */}
<Card>
<CardContent className="pt-4">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Rechercher..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Compte" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les comptes</SelectItem>
{data.accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.name}
</SelectItem>
))}
</SelectContent>
</Select>
<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>
{data.categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={showReconciled} onValueChange={setShowReconciled}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Pointage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tout</SelectItem>
<SelectItem value="reconciled">Pointées</SelectItem>
<SelectItem value="not-reconciled">Non pointées</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Bulk actions */}
{selectedTransactions.size > 0 && (
<Card className="bg-primary/5 border-primary/20">
<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" : ""}
</span>
<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)}>
<Circle className="w-4 h-4 mr-1" />
Dépointer
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<Tags className="w-4 h-4 mr-1" />
Catégoriser
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => bulkSetCategory(null)}>Aucune catégorie</DropdownMenuItem>
<DropdownMenuSeparator />
{data.categories.map((cat) => (
<DropdownMenuItem key={cat.id} onClick={() => bulkSetCategory(cat.id)}>
<div className="w-3 h-3 rounded-full mr-2" style={{ backgroundColor: cat.color }} />
{cat.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
)}
{/* Transactions list */}
<Card>
<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>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="p-3 text-left">
<Checkbox
checked={
selectedTransactions.size === filteredTransactions.length &&
filteredTransactions.length > 0
}
onCheckedChange={toggleSelectAll}
/>
</th>
<th className="p-3 text-left">
<button
onClick={() => {
if (sortField === "date") {
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
} else {
setSortField("date")
setSortOrder("desc")
}
}}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
>
Date
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="p-3 text-left">
<button
onClick={() => {
if (sortField === "description") {
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
} else {
setSortField("description")
setSortOrder("asc")
}
}}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
>
Description
<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-right">
<button
onClick={() => {
if (sortField === "amount") {
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
} else {
setSortField("amount")
setSortOrder("desc")
}
}}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground ml-auto"
>
Montant
<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"></th>
</tr>
</thead>
<tbody>
{filteredTransactions.map((transaction) => {
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">
<td className="p-3">
<Checkbox
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>
{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">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 hover:opacity-80">
{category ? (
<Badge
variant="secondary"
style={{
backgroundColor: `${category.color}20`,
color: category.color,
}}
>
{category.name}
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
Non catégorisé
</Badge>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<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)}>
<div
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: cat.color }}
/>
{cat.name}
{transaction.categoryId === cat.id && <Check className="w-4 h-4 ml-auto" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</td>
<td
className={cn(
"p-3 text-right font-semibold tabular-nums",
transaction.amount >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{transaction.amount >= 0 ? "+" : ""}
{formatCurrency(transaction.amount)}
</td>
<td className="p-3 text-center">
<button
onClick={() => toggleReconciled(transaction.id)}
className="p-1 hover:bg-muted rounded"
>
{transaction.isReconciled ? (
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
) : (
<Circle className="w-5 h-5 text-muted-foreground" />
)}
</button>
</td>
<td className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</main>
</div>
)
}