chore: init from v0
This commit is contained in:
255
app/accounts/page.tsx
Normal file
255
app/accounts/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
app/api/banking/accounts/route.ts
Normal file
74
app/api/banking/accounts/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
||||
79
app/api/banking/categories/route.ts
Normal file
79
app/api/banking/categories/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
||||
94
app/api/banking/folders/route.ts
Normal file
94
app/api/banking/folders/route.ts
Normal 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
79
app/api/banking/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
||||
101
app/api/banking/transactions/route.ts
Normal file
101
app/api/banking/transactions/route.ts
Normal 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
318
app/categories/page.tsx
Normal 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
345
app/folders/page.tsx
Normal 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
125
app/globals.css
Normal 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
25
app/layout.tsx
Normal 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
58
app/page.tsx
Normal 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
187
app/settings/page.tsx
Normal 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
415
app/statistics/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
app/transactions/loading.tsx
Normal file
3
app/transactions/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
475
app/transactions/page.tsx
Normal file
475
app/transactions/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user