refactor: standardize quotation marks across all files and improve code consistency
This commit is contained in:
@@ -1,43 +1,68 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState } from "react"
|
||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||
import { useBankingData } from "@/lib/hooks"
|
||||
import { updateAccount, deleteAccount } from "@/lib/store-db"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { MoreVertical, Pencil, Trash2, Building2, CreditCard, Wallet, PiggyBank, RefreshCw } from "lucide-react"
|
||||
import type { Account } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useState } from "react";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useBankingData } from "@/lib/hooks";
|
||||
import { updateAccount, deleteAccount } from "@/lib/store-db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Building2,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
PiggyBank,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { Account } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const accountTypeIcons = {
|
||||
CHECKING: Wallet,
|
||||
SAVINGS: PiggyBank,
|
||||
CREDIT_CARD: CreditCard,
|
||||
OTHER: Building2,
|
||||
}
|
||||
};
|
||||
|
||||
const accountTypeLabels = {
|
||||
CHECKING: "Compte courant",
|
||||
SAVINGS: "Épargne",
|
||||
CREDIT_CARD: "Carte de crédit",
|
||||
OTHER: "Autre",
|
||||
}
|
||||
};
|
||||
|
||||
export default function AccountsPage() {
|
||||
const { data, isLoading, refresh, update } = useBankingData()
|
||||
const [editingAccount, setEditingAccount] = useState<Account | null>(null)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const { data, isLoading, refresh, update } = useBankingData();
|
||||
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
type: "CHECKING" as Account["type"],
|
||||
folderId: "folder-root",
|
||||
})
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
@@ -47,28 +72,28 @@ export default function AccountsPage() {
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(amount)
|
||||
}
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const handleEdit = (account: Account) => {
|
||||
setEditingAccount(account)
|
||||
setEditingAccount(account);
|
||||
setFormData({
|
||||
name: account.name,
|
||||
type: account.type,
|
||||
folderId: account.folderId || "folder-root",
|
||||
})
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingAccount) return
|
||||
if (!editingAccount) return;
|
||||
|
||||
try {
|
||||
const updatedAccount = {
|
||||
@@ -76,34 +101,34 @@ export default function AccountsPage() {
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
folderId: formData.folderId,
|
||||
}
|
||||
await updateAccount(updatedAccount)
|
||||
refresh()
|
||||
setIsDialogOpen(false)
|
||||
setEditingAccount(null)
|
||||
};
|
||||
await updateAccount(updatedAccount);
|
||||
refresh();
|
||||
setIsDialogOpen(false);
|
||||
setEditingAccount(null);
|
||||
} catch (error) {
|
||||
console.error("Error updating account:", error)
|
||||
alert("Erreur lors de la mise à jour du compte")
|
||||
console.error("Error updating account:", error);
|
||||
alert("Erreur lors de la mise à jour du compte");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (accountId: string) => {
|
||||
if (!confirm("Supprimer ce compte et toutes ses transactions ?")) return
|
||||
if (!confirm("Supprimer ce compte et toutes ses transactions ?")) return;
|
||||
|
||||
try {
|
||||
await deleteAccount(accountId)
|
||||
refresh()
|
||||
await deleteAccount(accountId);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
console.error("Error deleting account:", error)
|
||||
alert("Erreur lors de la suppression du compte")
|
||||
console.error("Error deleting account:", error);
|
||||
alert("Erreur lors de la suppression du compte");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getTransactionCount = (accountId: string) => {
|
||||
return data.transactions.filter((t) => t.accountId === accountId).length
|
||||
}
|
||||
return data.transactions.filter((t) => t.accountId === accountId).length;
|
||||
};
|
||||
|
||||
const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0)
|
||||
const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
@@ -113,11 +138,18 @@ export default function AccountsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Comptes</h1>
|
||||
<p className="text-muted-foreground">Gérez vos comptes bancaires</p>
|
||||
<p className="text-muted-foreground">
|
||||
Gérez vos comptes bancaires
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">Solde total</p>
|
||||
<p className={cn("text-2xl font-bold", totalBalance >= 0 ? "text-emerald-600" : "text-red-600")}>
|
||||
<p
|
||||
className={cn(
|
||||
"text-2xl font-bold",
|
||||
totalBalance >= 0 ? "text-emerald-600" : "text-red-600",
|
||||
)}
|
||||
>
|
||||
{formatCurrency(totalBalance)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -129,15 +161,18 @@ export default function AccountsPage() {
|
||||
<Building2 className="w-16 h-16 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Aucun compte</h3>
|
||||
<p className="text-muted-foreground text-center mb-4">
|
||||
Importez un fichier OFX depuis le tableau de bord pour ajouter votre premier compte.
|
||||
Importez un fichier OFX depuis le tableau de bord pour ajouter
|
||||
votre premier compte.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data.accounts.map((account) => {
|
||||
const Icon = accountTypeIcons[account.type]
|
||||
const folder = data.folders.find((f) => f.id === account.folderId)
|
||||
const Icon = accountTypeIcons[account.type];
|
||||
const folder = data.folders.find(
|
||||
(f) => f.id === account.folderId,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={account.id} className="relative">
|
||||
@@ -148,22 +183,35 @@ export default function AccountsPage() {
|
||||
<Icon className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{account.name}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{accountTypeLabels[account.type]}</p>
|
||||
<CardTitle className="text-base">
|
||||
{account.name}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{accountTypeLabels[account.type]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(account)}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleEdit(account)}
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(account.id)} className="text-red-600">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(account.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</DropdownMenuItem>
|
||||
@@ -175,23 +223,30 @@ export default function AccountsPage() {
|
||||
<div
|
||||
className={cn(
|
||||
"text-2xl font-bold mb-2",
|
||||
account.balance >= 0 ? "text-emerald-600" : "text-red-600",
|
||||
account.balance >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-red-600",
|
||||
)}
|
||||
>
|
||||
{formatCurrency(account.balance)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{getTransactionCount(account.id)} transactions</span>
|
||||
<span>
|
||||
{getTransactionCount(account.id)} transactions
|
||||
</span>
|
||||
{folder && <span>{folder.name}</span>}
|
||||
</div>
|
||||
{account.lastImport && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Dernier import: {new Date(account.lastImport).toLocaleDateString("fr-FR")}
|
||||
Dernier import:{" "}
|
||||
{new Date(account.lastImport).toLocaleDateString(
|
||||
"fr-FR",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -206,13 +261,20 @@ export default function AccountsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nom du compte</Label>
|
||||
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} />
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Type de compte</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(v) => setFormData({ ...formData, type: v as Account["type"] })}
|
||||
onValueChange={(v) =>
|
||||
setFormData({ ...formData, type: v as Account["type"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -228,7 +290,10 @@ export default function AccountsPage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Dossier</Label>
|
||||
<Select value={formData.folderId} onValueChange={(v) => setFormData({ ...formData, folderId: v })}>
|
||||
<Select
|
||||
value={formData.folderId}
|
||||
onValueChange={(v) => setFormData({ ...formData, folderId: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -251,5 +316,5 @@ export default function AccountsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,54 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { accountService } from "@/services/account.service"
|
||||
import type { Account } from "@/lib/types"
|
||||
import { NextResponse } from "next/server";
|
||||
import { accountService } from "@/services/account.service";
|
||||
import type { Account } from "@/lib/types";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const data: Omit<Account, "id"> = await request.json()
|
||||
const created = await accountService.create(data)
|
||||
return NextResponse.json(created)
|
||||
const data: Omit<Account, "id"> = await request.json();
|
||||
const created = await accountService.create(data);
|
||||
return NextResponse.json(created);
|
||||
} catch (error) {
|
||||
console.error("Error creating account:", error)
|
||||
return NextResponse.json({ error: "Failed to create account" }, { status: 500 })
|
||||
console.error("Error creating account:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create account" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const account: Account = await request.json()
|
||||
const updated = await accountService.update(account.id, account)
|
||||
return NextResponse.json(updated)
|
||||
const account: Account = await request.json();
|
||||
const updated = await accountService.update(account.id, account);
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error("Error updating account:", error)
|
||||
return NextResponse.json({ error: "Failed to update account" }, { status: 500 })
|
||||
console.error("Error updating account:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update account" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get("id")
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Account ID is required" }, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{ error: "Account ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await accountService.delete(id)
|
||||
return NextResponse.json({ success: true })
|
||||
await accountService.delete(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting account:", error)
|
||||
return NextResponse.json({ error: "Failed to delete account" }, { status: 500 })
|
||||
console.error("Error deleting account:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete account" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,54 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { categoryService } from "@/services/category.service"
|
||||
import type { Category } from "@/lib/types"
|
||||
import { NextResponse } from "next/server";
|
||||
import { categoryService } from "@/services/category.service";
|
||||
import type { Category } from "@/lib/types";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const data: Omit<Category, "id"> = await request.json()
|
||||
const created = await categoryService.create(data)
|
||||
return NextResponse.json(created)
|
||||
const data: Omit<Category, "id"> = await request.json();
|
||||
const created = await categoryService.create(data);
|
||||
return NextResponse.json(created);
|
||||
} catch (error) {
|
||||
console.error("Error creating category:", error)
|
||||
return NextResponse.json({ error: "Failed to create category" }, { status: 500 })
|
||||
console.error("Error creating category:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create category" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const category: Category = await request.json()
|
||||
const updated = await categoryService.update(category.id, category)
|
||||
return NextResponse.json(updated)
|
||||
const category: Category = await request.json();
|
||||
const updated = await categoryService.update(category.id, category);
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error("Error updating category:", error)
|
||||
return NextResponse.json({ error: "Failed to update category" }, { status: 500 })
|
||||
console.error("Error updating category:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update category" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get("id")
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Category ID is required" }, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{ error: "Category ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await categoryService.delete(id)
|
||||
return NextResponse.json({ success: true })
|
||||
await categoryService.delete(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting category:", error)
|
||||
return NextResponse.json({ error: "Failed to delete category" }, { status: 500 })
|
||||
console.error("Error deleting category:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete category" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,57 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { folderService, FolderNotFoundError } from "@/services/folder.service"
|
||||
import type { Folder } from "@/lib/types"
|
||||
import { NextResponse } from "next/server";
|
||||
import { folderService, FolderNotFoundError } from "@/services/folder.service";
|
||||
import type { Folder } from "@/lib/types";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const data: Omit<Folder, "id"> = await request.json()
|
||||
const created = await folderService.create(data)
|
||||
return NextResponse.json(created)
|
||||
const data: Omit<Folder, "id"> = await request.json();
|
||||
const created = await folderService.create(data);
|
||||
return NextResponse.json(created);
|
||||
} catch (error) {
|
||||
console.error("Error creating folder:", error)
|
||||
return NextResponse.json({ error: "Failed to create folder" }, { status: 500 })
|
||||
console.error("Error creating folder:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create folder" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const folder: Folder = await request.json()
|
||||
const updated = await folderService.update(folder.id, folder)
|
||||
return NextResponse.json(updated)
|
||||
const folder: Folder = await request.json();
|
||||
const updated = await folderService.update(folder.id, folder);
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error("Error updating folder:", error)
|
||||
return NextResponse.json({ error: "Failed to update folder" }, { status: 500 })
|
||||
console.error("Error updating folder:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update folder" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get("id")
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Folder ID is required" }, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{ error: "Folder ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await folderService.delete(id)
|
||||
return NextResponse.json({ success: true })
|
||||
await folderService.delete(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof FolderNotFoundError) {
|
||||
return NextResponse.json({ error: "Folder not found" }, { status: 404 })
|
||||
return NextResponse.json({ error: "Folder not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Error deleting folder:", error)
|
||||
return NextResponse.json({ error: "Failed to delete folder" }, { status: 500 })
|
||||
console.error("Error deleting folder:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete folder" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { bankingService } from "@/services/banking.service"
|
||||
import { NextResponse } from "next/server";
|
||||
import { bankingService } from "@/services/banking.service";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await bankingService.getAllData()
|
||||
return NextResponse.json(data)
|
||||
const data = await bankingService.getAllData();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching banking data:", error)
|
||||
return NextResponse.json({ error: "Failed to fetch data" }, { status: 500 })
|
||||
console.error("Error fetching banking data:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch data" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
@@ -10,15 +10,17 @@ export async function POST() {
|
||||
data: {
|
||||
categoryId: null,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
count: result.count,
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error clearing categories:", error)
|
||||
return NextResponse.json({ error: "Failed to clear categories" }, { status: 500 })
|
||||
console.error("Error clearing categories:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to clear categories" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +1,57 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { transactionService } from "@/services/transaction.service"
|
||||
import type { Transaction } from "@/lib/types"
|
||||
import { NextResponse } from "next/server";
|
||||
import { transactionService } from "@/services/transaction.service";
|
||||
import type { Transaction } from "@/lib/types";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const transactions: Transaction[] = await request.json()
|
||||
const result = await transactionService.createMany(transactions)
|
||||
return NextResponse.json(result)
|
||||
const transactions: Transaction[] = await request.json();
|
||||
const result = await transactionService.createMany(transactions);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error("Error creating transactions:", error)
|
||||
return NextResponse.json({ error: "Failed to create transactions" }, { status: 500 })
|
||||
console.error("Error creating transactions:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create transactions" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const transaction: Transaction = await request.json()
|
||||
const updated = await transactionService.update(transaction.id, transaction)
|
||||
return NextResponse.json(updated)
|
||||
const transaction: Transaction = await request.json();
|
||||
const updated = await transactionService.update(
|
||||
transaction.id,
|
||||
transaction,
|
||||
);
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error("Error updating transaction:", error)
|
||||
return NextResponse.json({ error: "Failed to update transaction" }, { status: 500 })
|
||||
console.error("Error updating transaction:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update transaction" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get("id")
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Transaction ID is required" }, { status: 400 })
|
||||
return NextResponse.json(
|
||||
{ error: "Transaction ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await transactionService.delete(id)
|
||||
return NextResponse.json({ success: true })
|
||||
await transactionService.delete(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting transaction:", error)
|
||||
return NextResponse.json({ error: "Failed to delete transaction" }, { status: 500 })
|
||||
console.error("Error deleting transaction:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete transaction" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +1,134 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||
import { useBankingData } from "@/lib/hooks"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Plus, MoreVertical, Pencil, Trash2, RefreshCw, X, ChevronDown, ChevronRight, ChevronsUpDown, Search } from "lucide-react"
|
||||
import { CategoryIcon } from "@/components/ui/category-icon"
|
||||
import { autoCategorize, addCategory, updateCategory, deleteCategory } from "@/lib/store-db"
|
||||
import type { Category } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useState, useMemo } from "react";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useBankingData } from "@/lib/hooks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||
import {
|
||||
autoCategorize,
|
||||
addCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
} from "@/lib/store-db";
|
||||
import type { Category } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const categoryColors = [
|
||||
"#22c55e", "#3b82f6", "#f59e0b", "#ec4899", "#ef4444",
|
||||
"#8b5cf6", "#06b6d4", "#84cc16", "#f97316", "#6366f1",
|
||||
"#14b8a6", "#f43f5e", "#64748b", "#0891b2", "#dc2626",
|
||||
]
|
||||
"#22c55e",
|
||||
"#3b82f6",
|
||||
"#f59e0b",
|
||||
"#ec4899",
|
||||
"#ef4444",
|
||||
"#8b5cf6",
|
||||
"#06b6d4",
|
||||
"#84cc16",
|
||||
"#f97316",
|
||||
"#6366f1",
|
||||
"#14b8a6",
|
||||
"#f43f5e",
|
||||
"#64748b",
|
||||
"#0891b2",
|
||||
"#dc2626",
|
||||
];
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const { data, isLoading, refresh } = useBankingData()
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [expandedParents, setExpandedParents] = useState<Set<string>>(new Set())
|
||||
const { data, isLoading, refresh } = useBankingData();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||
const [expandedParents, setExpandedParents] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
color: "#22c55e",
|
||||
keywords: [] as string[],
|
||||
parentId: null as string | null,
|
||||
})
|
||||
const [newKeyword, setNewKeyword] = useState("")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
});
|
||||
const [newKeyword, setNewKeyword] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Organiser les catégories par parent
|
||||
const { parentCategories, childrenByParent, orphanCategories } = useMemo(() => {
|
||||
if (!data?.categories) return { parentCategories: [], childrenByParent: {}, orphanCategories: [] }
|
||||
const { parentCategories, childrenByParent, orphanCategories } =
|
||||
useMemo(() => {
|
||||
if (!data?.categories)
|
||||
return {
|
||||
parentCategories: [],
|
||||
childrenByParent: {},
|
||||
orphanCategories: [],
|
||||
};
|
||||
|
||||
const parents = data.categories.filter((c) => c.parentId === null)
|
||||
const children: Record<string, Category[]> = {}
|
||||
const orphans: Category[] = []
|
||||
const parents = data.categories.filter((c) => c.parentId === null);
|
||||
const children: Record<string, Category[]> = {};
|
||||
const orphans: Category[] = [];
|
||||
|
||||
// Grouper les enfants par parent
|
||||
data.categories
|
||||
.filter((c) => c.parentId !== null)
|
||||
.forEach((child) => {
|
||||
const parentExists = parents.some((p) => p.id === child.parentId)
|
||||
if (parentExists) {
|
||||
if (!children[child.parentId!]) {
|
||||
children[child.parentId!] = []
|
||||
// Grouper les enfants par parent
|
||||
data.categories
|
||||
.filter((c) => c.parentId !== null)
|
||||
.forEach((child) => {
|
||||
const parentExists = parents.some((p) => p.id === child.parentId);
|
||||
if (parentExists) {
|
||||
if (!children[child.parentId!]) {
|
||||
children[child.parentId!] = [];
|
||||
}
|
||||
children[child.parentId!].push(child);
|
||||
} else {
|
||||
orphans.push(child);
|
||||
}
|
||||
children[child.parentId!].push(child)
|
||||
} else {
|
||||
orphans.push(child)
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
parentCategories: parents,
|
||||
childrenByParent: children,
|
||||
orphanCategories: orphans,
|
||||
}
|
||||
}, [data?.categories])
|
||||
return {
|
||||
parentCategories: parents,
|
||||
childrenByParent: children,
|
||||
orphanCategories: orphans,
|
||||
};
|
||||
}, [data?.categories]);
|
||||
|
||||
// Initialiser tous les parents comme ouverts
|
||||
useState(() => {
|
||||
if (parentCategories.length > 0 && expandedParents.size === 0) {
|
||||
setExpandedParents(new Set(parentCategories.map((p) => p.id)))
|
||||
setExpandedParents(new Set(parentCategories.map((p) => p.id)));
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
@@ -82,62 +138,75 @@ export default function CategoriesPage() {
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(amount)
|
||||
}
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getCategoryStats = (categoryId: string, includeChildren = false) => {
|
||||
let categoryIds = [categoryId]
|
||||
|
||||
let categoryIds = [categoryId];
|
||||
|
||||
if (includeChildren && childrenByParent[categoryId]) {
|
||||
categoryIds = [...categoryIds, ...childrenByParent[categoryId].map((c) => c.id)]
|
||||
categoryIds = [
|
||||
...categoryIds,
|
||||
...childrenByParent[categoryId].map((c) => c.id),
|
||||
];
|
||||
}
|
||||
|
||||
const categoryTransactions = data.transactions.filter((t) => categoryIds.includes(t.categoryId || ""))
|
||||
const total = categoryTransactions.reduce((sum, t) => sum + Math.abs(t.amount), 0)
|
||||
const count = categoryTransactions.length
|
||||
return { total, count }
|
||||
}
|
||||
const categoryTransactions = data.transactions.filter((t) =>
|
||||
categoryIds.includes(t.categoryId || ""),
|
||||
);
|
||||
const total = categoryTransactions.reduce(
|
||||
(sum, t) => sum + Math.abs(t.amount),
|
||||
0,
|
||||
);
|
||||
const count = categoryTransactions.length;
|
||||
return { total, count };
|
||||
};
|
||||
|
||||
const toggleExpanded = (parentId: string) => {
|
||||
const newExpanded = new Set(expandedParents)
|
||||
const newExpanded = new Set(expandedParents);
|
||||
if (newExpanded.has(parentId)) {
|
||||
newExpanded.delete(parentId)
|
||||
newExpanded.delete(parentId);
|
||||
} else {
|
||||
newExpanded.add(parentId)
|
||||
newExpanded.add(parentId);
|
||||
}
|
||||
setExpandedParents(newExpanded)
|
||||
}
|
||||
setExpandedParents(newExpanded);
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
setExpandedParents(new Set(parentCategories.map((p) => p.id)))
|
||||
}
|
||||
setExpandedParents(new Set(parentCategories.map((p) => p.id)));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedParents(new Set())
|
||||
}
|
||||
setExpandedParents(new Set());
|
||||
};
|
||||
|
||||
const allExpanded = parentCategories.length > 0 && expandedParents.size === parentCategories.length
|
||||
const allExpanded =
|
||||
parentCategories.length > 0 &&
|
||||
expandedParents.size === parentCategories.length;
|
||||
|
||||
const handleNewCategory = (parentId: string | null = null) => {
|
||||
setEditingCategory(null)
|
||||
setFormData({ name: "", color: "#22c55e", keywords: [], parentId })
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
setEditingCategory(null);
|
||||
setFormData({ name: "", color: "#22c55e", keywords: [], parentId });
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (category: Category) => {
|
||||
setEditingCategory(category)
|
||||
setEditingCategory(category);
|
||||
setFormData({
|
||||
name: category.name,
|
||||
color: category.color,
|
||||
keywords: [...category.keywords],
|
||||
parentId: category.parentId,
|
||||
})
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
@@ -148,7 +217,7 @@ export default function CategoriesPage() {
|
||||
color: formData.color,
|
||||
keywords: formData.keywords,
|
||||
parentId: formData.parentId,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
await addCategory({
|
||||
name: formData.name,
|
||||
@@ -156,78 +225,88 @@ export default function CategoriesPage() {
|
||||
keywords: formData.keywords,
|
||||
icon: "tag",
|
||||
parentId: formData.parentId,
|
||||
})
|
||||
});
|
||||
}
|
||||
refresh()
|
||||
setIsDialogOpen(false)
|
||||
refresh();
|
||||
setIsDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Error saving category:", error)
|
||||
alert("Erreur lors de la sauvegarde de la catégorie")
|
||||
console.error("Error saving category:", error);
|
||||
alert("Erreur lors de la sauvegarde de la catégorie");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (categoryId: string) => {
|
||||
const hasChildren = childrenByParent[categoryId]?.length > 0
|
||||
const hasChildren = childrenByParent[categoryId]?.length > 0;
|
||||
const message = hasChildren
|
||||
? "Cette catégorie a des sous-catégories. Supprimer quand même ?"
|
||||
: "Supprimer cette catégorie ?"
|
||||
|
||||
if (!confirm(message)) return
|
||||
: "Supprimer cette catégorie ?";
|
||||
|
||||
if (!confirm(message)) return;
|
||||
|
||||
try {
|
||||
await deleteCategory(categoryId)
|
||||
refresh()
|
||||
await deleteCategory(categoryId);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
console.error("Error deleting category:", error)
|
||||
alert("Erreur lors de la suppression de la catégorie")
|
||||
console.error("Error deleting category:", error);
|
||||
alert("Erreur lors de la suppression de la catégorie");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addKeyword = () => {
|
||||
if (newKeyword.trim() && !formData.keywords.includes(newKeyword.trim().toLowerCase())) {
|
||||
if (
|
||||
newKeyword.trim() &&
|
||||
!formData.keywords.includes(newKeyword.trim().toLowerCase())
|
||||
) {
|
||||
setFormData({
|
||||
...formData,
|
||||
keywords: [...formData.keywords, newKeyword.trim().toLowerCase()],
|
||||
})
|
||||
setNewKeyword("")
|
||||
});
|
||||
setNewKeyword("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeKeyword = (keyword: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
keywords: formData.keywords.filter((k) => k !== keyword),
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const reApplyAutoCategories = async () => {
|
||||
if (!confirm("Recatégoriser automatiquement les transactions non catégorisées ?")) return
|
||||
if (
|
||||
!confirm(
|
||||
"Recatégoriser automatiquement les transactions non catégorisées ?",
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
const { updateTransaction } = await import("@/lib/store-db")
|
||||
const uncategorized = data.transactions.filter((t) => !t.categoryId)
|
||||
const { updateTransaction } = await import("@/lib/store-db");
|
||||
const uncategorized = data.transactions.filter((t) => !t.categoryId);
|
||||
|
||||
for (const transaction of uncategorized) {
|
||||
const categoryId = autoCategorize(
|
||||
transaction.description + " " + (transaction.memo || ""),
|
||||
data.categories
|
||||
)
|
||||
data.categories,
|
||||
);
|
||||
if (categoryId) {
|
||||
await updateTransaction({ ...transaction, categoryId })
|
||||
await updateTransaction({ ...transaction, categoryId });
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
refresh();
|
||||
} catch (error) {
|
||||
console.error("Error re-categorizing:", error)
|
||||
alert("Erreur lors de la recatégorisation")
|
||||
console.error("Error re-categorizing:", error);
|
||||
alert("Erreur lors de la recatégorisation");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const uncategorizedCount = data.transactions.filter((t) => !t.categoryId).length
|
||||
const uncategorizedCount = data.transactions.filter(
|
||||
(t) => !t.categoryId,
|
||||
).length;
|
||||
|
||||
// Composant pour une carte de catégorie enfant
|
||||
const ChildCategoryCard = ({ category }: { category: Category }) => {
|
||||
const stats = getCategoryStats(category.id)
|
||||
const stats = getCategoryStats(category.id);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group">
|
||||
@@ -236,21 +315,34 @@ export default function CategoriesPage() {
|
||||
className="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${category.color}20` }}
|
||||
>
|
||||
<CategoryIcon icon={category.icon} color={category.color} size={12} />
|
||||
<CategoryIcon
|
||||
icon={category.icon}
|
||||
color={category.color}
|
||||
size={12}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm truncate">{category.name}</span>
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
{stats.count} opération{stats.count > 1 ? "s" : ""} • {formatCurrency(stats.total)}
|
||||
{stats.count} opération{stats.count > 1 ? "s" : ""} •{" "}
|
||||
{formatCurrency(stats.total)}
|
||||
</span>
|
||||
{category.keywords.length > 0 && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 h-4 shrink-0"
|
||||
>
|
||||
{category.keywords.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleEdit(category)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleEdit(category)}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -263,8 +355,8 @@ export default function CategoriesPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
@@ -277,7 +369,8 @@ export default function CategoriesPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Catégories</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{parentCategories.length} catégories principales •{" "}
|
||||
{data.categories.length - parentCategories.length} sous-catégories
|
||||
{data.categories.length - parentCategories.length}{" "}
|
||||
sous-catégories
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -326,110 +419,141 @@ export default function CategoriesPage() {
|
||||
<div className="space-y-1">
|
||||
{parentCategories
|
||||
.filter((parent) => {
|
||||
if (!searchQuery.trim()) return true
|
||||
const query = searchQuery.toLowerCase()
|
||||
if (!searchQuery.trim()) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
// Afficher si le parent matche
|
||||
if (parent.name.toLowerCase().includes(query)) return true
|
||||
if (parent.keywords.some((k) => k.toLowerCase().includes(query))) return true
|
||||
if (parent.name.toLowerCase().includes(query)) return true;
|
||||
if (
|
||||
parent.keywords.some((k) => k.toLowerCase().includes(query))
|
||||
)
|
||||
return true;
|
||||
// Ou si un enfant matche
|
||||
const children = childrenByParent[parent.id] || []
|
||||
const children = childrenByParent[parent.id] || [];
|
||||
return children.some(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(query) ||
|
||||
c.keywords.some((k) => k.toLowerCase().includes(query))
|
||||
)
|
||||
c.keywords.some((k) => k.toLowerCase().includes(query)),
|
||||
);
|
||||
})
|
||||
.map((parent) => {
|
||||
const allChildren = childrenByParent[parent.id] || []
|
||||
// Filtrer les enfants aussi si recherche active
|
||||
const children = searchQuery.trim()
|
||||
? allChildren.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.keywords.some((k) => k.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
||||
// Garder tous les enfants si le parent matche
|
||||
parent.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: allChildren
|
||||
const stats = getCategoryStats(parent.id, true)
|
||||
const isExpanded = expandedParents.has(parent.id) || (searchQuery.trim() !== "" && children.length > 0)
|
||||
const allChildren = childrenByParent[parent.id] || [];
|
||||
// Filtrer les enfants aussi si recherche active
|
||||
const children = searchQuery.trim()
|
||||
? allChildren.filter(
|
||||
(c) =>
|
||||
c.name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()) ||
|
||||
c.keywords.some((k) =>
|
||||
k.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
) ||
|
||||
// Garder tous les enfants si le parent matche
|
||||
parent.name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: allChildren;
|
||||
const stats = getCategoryStats(parent.id, true);
|
||||
const isExpanded =
|
||||
expandedParents.has(parent.id) ||
|
||||
(searchQuery.trim() !== "" && children.length > 0);
|
||||
|
||||
return (
|
||||
<div key={parent.id} className="border rounded-lg bg-card">
|
||||
<Collapsible open={isExpanded} onOpenChange={() => toggleExpanded(parent.id)}>
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${parent.color}20` }}
|
||||
>
|
||||
<CategoryIcon icon={parent.icon} color={parent.color} size={14} />
|
||||
</div>
|
||||
<span className="font-medium text-sm truncate">{parent.name}</span>
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
{children.length} • {stats.count} opération{stats.count > 1 ? "s" : ""} • {formatCurrency(stats.total)}
|
||||
</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleNewCategory(parent.id)
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(parent)}>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(parent.id)}
|
||||
className="text-red-600"
|
||||
return (
|
||||
<div key={parent.id} className="border rounded-lg bg-card">
|
||||
<Collapsible
|
||||
open={isExpanded}
|
||||
onOpenChange={() => toggleExpanded(parent.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${parent.color}20` }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<CategoryIcon
|
||||
icon={parent.icon}
|
||||
color={parent.color}
|
||||
size={14}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium text-sm truncate">
|
||||
{parent.name}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
{children.length} • {stats.count} opération
|
||||
{stats.count > 1 ? "s" : ""} •{" "}
|
||||
{formatCurrency(stats.total)}
|
||||
</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
{children.length > 0 ? (
|
||||
<div className="px-3 pb-2 space-y-1 ml-6 border-l-2 border-muted ml-5">
|
||||
{children.map((child) => (
|
||||
<ChildCategoryCard key={child.id} category={child} />
|
||||
))}
|
||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNewCategory(parent.id);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleEdit(parent)}
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(parent.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 pb-2 ml-11 text-xs text-muted-foreground italic">
|
||||
Aucune sous-catégorie
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
{children.length > 0 ? (
|
||||
<div className="px-3 pb-2 space-y-1 ml-6 border-l-2 border-muted ml-5">
|
||||
{children.map((child) => (
|
||||
<ChildCategoryCard
|
||||
key={child.id}
|
||||
category={child}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 pb-2 ml-11 text-xs text-muted-foreground italic">
|
||||
Aucune sous-catégorie
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Catégories orphelines (sans parent valide) */}
|
||||
{orphanCategories.length > 0 && (
|
||||
@@ -465,14 +589,19 @@ export default function CategoriesPage() {
|
||||
<Select
|
||||
value={formData.parentId || "none"}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, parentId: value === "none" ? null : value })
|
||||
setFormData({
|
||||
...formData,
|
||||
parentId: value === "none" ? null : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Aucune (catégorie principale)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Aucune (catégorie principale)</SelectItem>
|
||||
<SelectItem value="none">
|
||||
Aucune (catégorie principale)
|
||||
</SelectItem>
|
||||
{parentCategories
|
||||
.filter((p) => p.id !== editingCategory?.id)
|
||||
.map((parent) => (
|
||||
@@ -495,7 +624,9 @@ export default function CategoriesPage() {
|
||||
<Label>Nom</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
placeholder="Ex: Alimentation"
|
||||
/>
|
||||
</div>
|
||||
@@ -510,7 +641,8 @@ export default function CategoriesPage() {
|
||||
onClick={() => setFormData({ ...formData, color })}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full transition-transform",
|
||||
formData.color === color && "ring-2 ring-offset-2 ring-primary scale-110"
|
||||
formData.color === color &&
|
||||
"ring-2 ring-offset-2 ring-primary scale-110",
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
@@ -526,7 +658,9 @@ export default function CategoriesPage() {
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
placeholder="Ajouter un mot-clé"
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addKeyword())}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && (e.preventDefault(), addKeyword())
|
||||
}
|
||||
/>
|
||||
<Button type="button" onClick={addKeyword} size="icon">
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -557,5 +691,5 @@ export default function CategoriesPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState } from "react"
|
||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||
import { useBankingData } from "@/lib/hooks"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { useState } from "react";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useBankingData } from "@/lib/hooks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Plus,
|
||||
MoreVertical,
|
||||
@@ -21,10 +37,15 @@ import {
|
||||
ChevronDown,
|
||||
Building2,
|
||||
RefreshCw,
|
||||
} from "lucide-react"
|
||||
import { addFolder, updateFolder, deleteFolder, updateAccount } from "@/lib/store-db"
|
||||
import type { Folder as FolderType, Account } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
} from "lucide-react";
|
||||
import {
|
||||
addFolder,
|
||||
updateFolder,
|
||||
deleteFolder,
|
||||
updateAccount,
|
||||
} from "@/lib/store-db";
|
||||
import type { Folder as FolderType, Account } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const folderColors = [
|
||||
{ value: "#6366f1", label: "Indigo" },
|
||||
@@ -33,17 +54,17 @@ const folderColors = [
|
||||
{ value: "#ec4899", label: "Rose" },
|
||||
{ value: "#3b82f6", label: "Bleu" },
|
||||
{ value: "#ef4444", label: "Rouge" },
|
||||
]
|
||||
];
|
||||
|
||||
interface FolderTreeItemProps {
|
||||
folder: FolderType
|
||||
accounts: Account[]
|
||||
allFolders: FolderType[]
|
||||
level: number
|
||||
onEdit: (folder: FolderType) => void
|
||||
onDelete: (folderId: string) => void
|
||||
onEditAccount: (account: Account) => void
|
||||
formatCurrency: (amount: number) => string
|
||||
folder: FolderType;
|
||||
accounts: Account[];
|
||||
allFolders: FolderType[];
|
||||
level: number;
|
||||
onEdit: (folder: FolderType) => void;
|
||||
onDelete: (folderId: string) => void;
|
||||
onEditAccount: (account: Account) => void;
|
||||
formatCurrency: (amount: number) => string;
|
||||
}
|
||||
|
||||
function FolderTreeItem({
|
||||
@@ -56,20 +77,27 @@ function FolderTreeItem({
|
||||
onEditAccount,
|
||||
formatCurrency,
|
||||
}: FolderTreeItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
// Pour le dossier "Mes Comptes" (folder-root), inclure aussi les comptes sans dossier
|
||||
const folderAccounts = accounts.filter((a) =>
|
||||
a.folderId === folder.id || (folder.id === "folder-root" && a.folderId === null)
|
||||
)
|
||||
const childFolders = allFolders.filter((f) => f.parentId === folder.id)
|
||||
const hasChildren = childFolders.length > 0 || folderAccounts.length > 0
|
||||
const folderAccounts = accounts.filter(
|
||||
(a) =>
|
||||
a.folderId === folder.id ||
|
||||
(folder.id === "folder-root" && a.folderId === null),
|
||||
);
|
||||
const childFolders = allFolders.filter((f) => f.parentId === folder.id);
|
||||
const hasChildren = childFolders.length > 0 || folderAccounts.length > 0;
|
||||
|
||||
const folderTotal = folderAccounts.reduce((sum, a) => sum + a.balance, 0)
|
||||
const folderTotal = folderAccounts.reduce((sum, a) => sum + a.balance, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={cn("flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group", level > 0 && "ml-6")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
|
||||
level > 0 && "ml-6",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
@@ -101,7 +129,10 @@ function FolderTreeItem({
|
||||
|
||||
{folderAccounts.length > 0 && (
|
||||
<span
|
||||
className={cn("text-sm font-semibold tabular-nums", folderTotal >= 0 ? "text-emerald-600" : "text-red-600")}
|
||||
className={cn(
|
||||
"text-sm font-semibold tabular-nums",
|
||||
folderTotal >= 0 ? "text-emerald-600" : "text-red-600",
|
||||
)}
|
||||
>
|
||||
{formatCurrency(folderTotal)}
|
||||
</span>
|
||||
@@ -109,7 +140,11 @@ function FolderTreeItem({
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -119,7 +154,10 @@ function FolderTreeItem({
|
||||
Modifier
|
||||
</DropdownMenuItem>
|
||||
{folder.id !== "folder-root" && (
|
||||
<DropdownMenuItem onClick={() => onDelete(folder.id)} className="text-red-600">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(folder.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</DropdownMenuItem>
|
||||
@@ -131,15 +169,26 @@ function FolderTreeItem({
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{folderAccounts.map((account) => (
|
||||
<div key={account.id} className={cn("flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group", "ml-12")}>
|
||||
<div
|
||||
key={account.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
|
||||
"ml-12",
|
||||
)}
|
||||
>
|
||||
<Building2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="flex-1 text-sm">{account.name}</span>
|
||||
<span className={cn("text-sm tabular-nums", account.balance >= 0 ? "text-emerald-600" : "text-red-600")}>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm tabular-nums",
|
||||
account.balance >= 0 ? "text-emerald-600" : "text-red-600",
|
||||
)}
|
||||
>
|
||||
{formatCurrency(account.balance)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => onEditAccount(account)}
|
||||
>
|
||||
@@ -164,7 +213,7 @@ function FolderTreeItem({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const accountTypeLabels = {
|
||||
@@ -172,26 +221,26 @@ const accountTypeLabels = {
|
||||
SAVINGS: "Épargne",
|
||||
CREDIT_CARD: "Carte de crédit",
|
||||
OTHER: "Autre",
|
||||
}
|
||||
};
|
||||
|
||||
export default function FoldersPage() {
|
||||
const { data, isLoading, refresh } = useBankingData()
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [editingFolder, setEditingFolder] = useState<FolderType | null>(null)
|
||||
const { data, isLoading, refresh } = useBankingData();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingFolder, setEditingFolder] = useState<FolderType | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
parentId: "folder-root" as string | null,
|
||||
color: "#6366f1",
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
// Account editing state
|
||||
const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false)
|
||||
const [editingAccount, setEditingAccount] = useState<Account | null>(null)
|
||||
const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false);
|
||||
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
||||
const [accountFormData, setAccountFormData] = useState({
|
||||
name: "",
|
||||
type: "CHECKING" as Account["type"],
|
||||
folderId: "folder-root",
|
||||
})
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
@@ -201,36 +250,37 @@ export default function FoldersPage() {
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(amount)
|
||||
}
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const rootFolders = data.folders.filter((f) => f.parentId === null)
|
||||
const rootFolders = data.folders.filter((f) => f.parentId === null);
|
||||
|
||||
const handleNewFolder = () => {
|
||||
setEditingFolder(null)
|
||||
setFormData({ name: "", parentId: "folder-root", color: "#6366f1" })
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
setEditingFolder(null);
|
||||
setFormData({ name: "", parentId: "folder-root", color: "#6366f1" });
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (folder: FolderType) => {
|
||||
setEditingFolder(folder)
|
||||
setEditingFolder(folder);
|
||||
setFormData({
|
||||
name: folder.name,
|
||||
parentId: folder.parentId || "folder-root",
|
||||
color: folder.color,
|
||||
})
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const parentId = formData.parentId === "folder-root" ? null : formData.parentId
|
||||
const parentId =
|
||||
formData.parentId === "folder-root" ? null : formData.parentId;
|
||||
|
||||
try {
|
||||
if (editingFolder) {
|
||||
@@ -239,63 +289,71 @@ export default function FoldersPage() {
|
||||
name: formData.name,
|
||||
parentId,
|
||||
color: formData.color,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
await addFolder({
|
||||
name: formData.name,
|
||||
parentId,
|
||||
color: formData.color,
|
||||
icon: "folder",
|
||||
})
|
||||
});
|
||||
}
|
||||
refresh()
|
||||
setIsDialogOpen(false)
|
||||
refresh();
|
||||
setIsDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Error saving folder:", error)
|
||||
alert("Erreur lors de la sauvegarde du dossier")
|
||||
console.error("Error saving folder:", error);
|
||||
alert("Erreur lors de la sauvegarde du dossier");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (folderId: string) => {
|
||||
if (!confirm("Supprimer ce dossier ? Les comptes seront déplacés à la racine.")) return
|
||||
if (
|
||||
!confirm(
|
||||
"Supprimer ce dossier ? Les comptes seront déplacés à la racine.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
await deleteFolder(folderId)
|
||||
refresh()
|
||||
await deleteFolder(folderId);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
console.error("Error deleting folder:", error)
|
||||
alert("Erreur lors de la suppression du dossier")
|
||||
console.error("Error deleting folder:", error);
|
||||
alert("Erreur lors de la suppression du dossier");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditAccount = (account: Account) => {
|
||||
setEditingAccount(account)
|
||||
setEditingAccount(account);
|
||||
setAccountFormData({
|
||||
name: account.name,
|
||||
type: account.type,
|
||||
folderId: account.folderId || "folder-root",
|
||||
})
|
||||
setIsAccountDialogOpen(true)
|
||||
}
|
||||
});
|
||||
setIsAccountDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveAccount = async () => {
|
||||
if (!editingAccount) return
|
||||
if (!editingAccount) return;
|
||||
|
||||
try {
|
||||
await updateAccount({
|
||||
...editingAccount,
|
||||
name: accountFormData.name,
|
||||
type: accountFormData.type,
|
||||
folderId: accountFormData.folderId === "folder-root" ? null : accountFormData.folderId,
|
||||
})
|
||||
refresh()
|
||||
setIsAccountDialogOpen(false)
|
||||
setEditingAccount(null)
|
||||
folderId:
|
||||
accountFormData.folderId === "folder-root"
|
||||
? null
|
||||
: accountFormData.folderId,
|
||||
});
|
||||
refresh();
|
||||
setIsAccountDialogOpen(false);
|
||||
setEditingAccount(null);
|
||||
} catch (error) {
|
||||
console.error("Error updating account:", error)
|
||||
alert("Erreur lors de la mise à jour du compte")
|
||||
console.error("Error updating account:", error);
|
||||
alert("Erreur lors de la mise à jour du compte");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
@@ -304,8 +362,12 @@ export default function FoldersPage() {
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Organisation</h1>
|
||||
<p className="text-muted-foreground">Organisez vos comptes en dossiers</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
Organisation
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Organisez vos comptes en dossiers
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleNewFolder}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
@@ -341,14 +403,18 @@ export default function FoldersPage() {
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingFolder ? "Modifier le dossier" : "Nouveau dossier"}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{editingFolder ? "Modifier le dossier" : "Nouveau dossier"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nom du dossier</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
placeholder="Ex: Comptes personnels"
|
||||
/>
|
||||
</div>
|
||||
@@ -356,7 +422,12 @@ export default function FoldersPage() {
|
||||
<Label>Dossier parent</Label>
|
||||
<Select
|
||||
value={formData.parentId || "root"}
|
||||
onValueChange={(v) => setFormData({ ...formData, parentId: v === "root" ? null : v })}
|
||||
onValueChange={(v) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
parentId: v === "root" ? null : v,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -382,7 +453,8 @@ export default function FoldersPage() {
|
||||
onClick={() => setFormData({ ...formData, color: value })}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full transition-transform",
|
||||
formData.color === value && "ring-2 ring-offset-2 ring-primary scale-110",
|
||||
formData.color === value &&
|
||||
"ring-2 ring-offset-2 ring-primary scale-110",
|
||||
)}
|
||||
style={{ backgroundColor: value }}
|
||||
/>
|
||||
@@ -409,16 +481,26 @@ export default function FoldersPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nom du compte</Label>
|
||||
<Input
|
||||
value={accountFormData.name}
|
||||
onChange={(e) => setAccountFormData({ ...accountFormData, name: e.target.value })}
|
||||
<Input
|
||||
value={accountFormData.name}
|
||||
onChange={(e) =>
|
||||
setAccountFormData({
|
||||
...accountFormData,
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Type de compte</Label>
|
||||
<Select
|
||||
value={accountFormData.type}
|
||||
onValueChange={(v) => setAccountFormData({ ...accountFormData, type: v as Account["type"] })}
|
||||
onValueChange={(v) =>
|
||||
setAccountFormData({
|
||||
...accountFormData,
|
||||
type: v as Account["type"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -434,9 +516,11 @@ export default function FoldersPage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Dossier</Label>
|
||||
<Select
|
||||
value={accountFormData.folderId}
|
||||
onValueChange={(v) => setAccountFormData({ ...accountFormData, folderId: v })}
|
||||
<Select
|
||||
value={accountFormData.folderId}
|
||||
onValueChange={(v) =>
|
||||
setAccountFormData({ ...accountFormData, folderId: v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -451,7 +535,10 @@ export default function FoldersPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsAccountDialogOpen(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsAccountDialogOpen(false)}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button onClick={handleSaveAccount}>Enregistrer</Button>
|
||||
@@ -460,5 +547,5 @@ export default function FoldersPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -75,8 +75,8 @@
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'Geist', 'Geist Fallback';
|
||||
--font-mono: 'Geist Mono', 'Geist Mono Fallback';
|
||||
--font-sans: "Geist", "Geist Fallback";
|
||||
--font-mono: "Geist Mono", "Geist Mono Fallback";
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { Geist, Geist_Mono } from "next/font/google"
|
||||
import "./globals.css"
|
||||
import type React from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const _geist = Geist({ subsets: ["latin"] })
|
||||
const _geistMono = Geist_Mono({ subsets: ["latin"] })
|
||||
const _geist = Geist({ subsets: ["latin"] });
|
||||
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "FinTrack - Gestion de compte bancaire",
|
||||
description: "Application de gestion personnelle de comptes bancaires avec import OFX",
|
||||
generator: 'v0.app'
|
||||
}
|
||||
description:
|
||||
"Application de gestion personnelle de comptes bancaires avec import OFX",
|
||||
generator: "v0.app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="fr">
|
||||
<body className="font-sans antialiased">{children}</body>
|
||||
</html>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
34
app/page.tsx
34
app/page.tsx
@@ -1,17 +1,17 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||
import { OverviewCards } from "@/components/dashboard/overview-cards"
|
||||
import { RecentTransactions } from "@/components/dashboard/recent-transactions"
|
||||
import { AccountsSummary } from "@/components/dashboard/accounts-summary"
|
||||
import { CategoryBreakdown } from "@/components/dashboard/category-breakdown"
|
||||
import { OFXImportDialog } from "@/components/import/ofx-import-dialog"
|
||||
import { useBankingData } from "@/lib/hooks"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Upload, RefreshCw } from "lucide-react"
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { OverviewCards } from "@/components/dashboard/overview-cards";
|
||||
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
|
||||
import { AccountsSummary } from "@/components/dashboard/accounts-summary";
|
||||
import { CategoryBreakdown } from "@/components/dashboard/category-breakdown";
|
||||
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
||||
import { useBankingData } from "@/lib/hooks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Upload, RefreshCw } from "lucide-react";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data, isLoading, refresh } = useBankingData()
|
||||
const { data, isLoading, refresh } = useBankingData();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
@@ -21,7 +21,7 @@ export default function DashboardPage() {
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -31,8 +31,12 @@ export default function DashboardPage() {
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Tableau de bord</h1>
|
||||
<p className="text-muted-foreground">Vue d'ensemble de vos finances</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
Tableau de bord
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Vue d'ensemble de vos finances
|
||||
</p>
|
||||
</div>
|
||||
<OFXImportDialog onImportComplete={refresh}>
|
||||
<Button>
|
||||
@@ -54,5 +58,5 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState } from "react"
|
||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||
import { useBankingData } from "@/lib/hooks"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useState } from "react";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useBankingData } from "@/lib/hooks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -15,13 +21,21 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Download, Trash2, Upload, RefreshCw, Database, FileJson, Tags } from "lucide-react"
|
||||
import type { BankingData } from "@/lib/types"
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
Upload,
|
||||
RefreshCw,
|
||||
Database,
|
||||
FileJson,
|
||||
Tags,
|
||||
} from "lucide-react";
|
||||
import type { BankingData } from "@/lib/types";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data, isLoading, refresh, update } = useBankingData()
|
||||
const [importing, setImporting] = useState(false)
|
||||
const { data, isLoading, refresh, update } = useBankingData();
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
@@ -31,72 +45,80 @@ export default function SettingsPage() {
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const exportData = () => {
|
||||
const dataStr = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([dataStr], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `fintrack-backup-${new Date().toISOString().split("T")[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
const dataStr = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([dataStr], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `fintrack-backup-${new Date().toISOString().split("T")[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const importData = () => {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = ".json"
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".json";
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setImporting(true)
|
||||
setImporting(true);
|
||||
try {
|
||||
const content = await file.text()
|
||||
const importedData = JSON.parse(content) as BankingData
|
||||
const content = await file.text();
|
||||
const importedData = JSON.parse(content) as BankingData;
|
||||
|
||||
// Validate structure
|
||||
if (!importedData.accounts || !importedData.transactions || !importedData.categories || !importedData.folders) {
|
||||
alert("Format de fichier invalide")
|
||||
return
|
||||
if (
|
||||
!importedData.accounts ||
|
||||
!importedData.transactions ||
|
||||
!importedData.categories ||
|
||||
!importedData.folders
|
||||
) {
|
||||
alert("Format de fichier invalide");
|
||||
return;
|
||||
}
|
||||
|
||||
update(importedData)
|
||||
alert("Données importées avec succès")
|
||||
update(importedData);
|
||||
alert("Données importées avec succès");
|
||||
} catch (error) {
|
||||
alert("Erreur lors de l'import")
|
||||
alert("Erreur lors de l'import");
|
||||
} finally {
|
||||
setImporting(false)
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const resetData = () => {
|
||||
localStorage.removeItem("banking-app-data")
|
||||
window.location.reload()
|
||||
}
|
||||
localStorage.removeItem("banking-app-data");
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const clearAllCategories = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/banking/transactions/clear-categories", {
|
||||
method: "POST",
|
||||
})
|
||||
if (!response.ok) throw new Error("Erreur")
|
||||
refresh()
|
||||
alert("Catégories supprimées de toutes les opérations")
|
||||
const response = await fetch(
|
||||
"/api/banking/transactions/clear-categories",
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error("Erreur");
|
||||
refresh();
|
||||
alert("Catégories supprimées de toutes les opérations");
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
alert("Erreur lors de la suppression des catégories")
|
||||
console.error(error);
|
||||
alert("Erreur lors de la suppression des catégories");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const categorizedCount = data.transactions.filter((t) => t.categoryId).length
|
||||
const categorizedCount = data.transactions.filter((t) => t.categoryId).length;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
@@ -105,7 +127,9 @@ export default function SettingsPage() {
|
||||
<div className="p-6 space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Paramètres</h1>
|
||||
<p className="text-muted-foreground">Gérez vos données et préférences</p>
|
||||
<p className="text-muted-foreground">
|
||||
Gérez vos données et préférences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@@ -114,25 +138,37 @@ export default function SettingsPage() {
|
||||
<Database className="w-5 h-5" />
|
||||
Données
|
||||
</CardTitle>
|
||||
<CardDescription>Exportez ou importez vos données pour les sauvegarder ou les transférer</CardDescription>
|
||||
<CardDescription>
|
||||
Exportez ou importez vos données pour les sauvegarder ou les
|
||||
transférer
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">Statistiques</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.accounts.length} comptes, {data.transactions.length} transactions, {data.categories.length}{" "}
|
||||
catégories
|
||||
{data.accounts.length} comptes, {data.transactions.length}{" "}
|
||||
transactions, {data.categories.length} catégories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={exportData} variant="outline" className="flex-1 bg-transparent">
|
||||
<Button
|
||||
onClick={exportData}
|
||||
variant="outline"
|
||||
className="flex-1 bg-transparent"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exporter (JSON)
|
||||
</Button>
|
||||
<Button onClick={importData} variant="outline" className="flex-1 bg-transparent" disabled={importing}>
|
||||
<Button
|
||||
onClick={importData}
|
||||
variant="outline"
|
||||
className="flex-1 bg-transparent"
|
||||
disabled={importing}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{importing ? "Import..." : "Importer"}
|
||||
</Button>
|
||||
@@ -146,31 +182,45 @@ export default function SettingsPage() {
|
||||
<Trash2 className="w-5 h-5" />
|
||||
Zone dangereuse
|
||||
</CardTitle>
|
||||
<CardDescription>Actions irréversibles - procédez avec prudence</CardDescription>
|
||||
<CardDescription>
|
||||
Actions irréversibles - procédez avec prudence
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Supprimer catégories des opérations */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start border-orange-300 text-orange-700 hover:bg-orange-50">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start border-orange-300 text-orange-700 hover:bg-orange-50"
|
||||
>
|
||||
<Tags className="w-4 h-4 mr-2" />
|
||||
Supprimer les catégories des opérations
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{categorizedCount} opération{categorizedCount > 1 ? "s" : ""} catégorisée{categorizedCount > 1 ? "s" : ""}
|
||||
{categorizedCount} opération
|
||||
{categorizedCount > 1 ? "s" : ""} catégorisée
|
||||
{categorizedCount > 1 ? "s" : ""}
|
||||
</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Supprimer toutes les catégories ?</AlertDialogTitle>
|
||||
<AlertDialogTitle>
|
||||
Supprimer toutes les catégories ?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Cette action va retirer la catégorie de {categorizedCount} opération{categorizedCount > 1 ? "s" : ""}.
|
||||
Les catégories elles-mêmes ne seront pas supprimées, seulement leur affectation aux opérations.
|
||||
Cette action va retirer la catégorie de {categorizedCount}{" "}
|
||||
opération{categorizedCount > 1 ? "s" : ""}. Les catégories
|
||||
elles-mêmes ne seront pas supprimées, seulement leur
|
||||
affectation aux opérations.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={clearAllCategories} className="bg-orange-600 hover:bg-orange-700">
|
||||
<AlertDialogAction
|
||||
onClick={clearAllCategories}
|
||||
className="bg-orange-600 hover:bg-orange-700"
|
||||
>
|
||||
Supprimer les affectations
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -180,7 +230,10 @@ export default function SettingsPage() {
|
||||
{/* Réinitialiser toutes les données */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full justify-start">
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Réinitialiser toutes les données
|
||||
</Button>
|
||||
@@ -189,13 +242,17 @@ export default function SettingsPage() {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Cette action supprimera définitivement tous vos comptes, transactions, catégories et dossiers.
|
||||
Cette action est irréversible.
|
||||
Cette action supprimera définitivement tous vos comptes,
|
||||
transactions, catégories et dossiers. Cette action est
|
||||
irréversible.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={resetData} className="bg-red-600 hover:bg-red-700">
|
||||
<AlertDialogAction
|
||||
onClick={resetData}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Supprimer tout
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -210,17 +267,21 @@ export default function SettingsPage() {
|
||||
<FileJson className="w-5 h-5" />
|
||||
Format OFX
|
||||
</CardTitle>
|
||||
<CardDescription>Informations sur l'import de fichiers</CardDescription>
|
||||
<CardDescription>
|
||||
Informations sur l'import de fichiers
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose prose-sm text-muted-foreground">
|
||||
<p>
|
||||
L'application accepte les fichiers au format OFX (Open Financial Exchange) ou QFX. Ces fichiers sont
|
||||
généralement disponibles depuis l'espace client de votre banque.
|
||||
L'application accepte les fichiers au format OFX (Open
|
||||
Financial Exchange) ou QFX. Ces fichiers sont généralement
|
||||
disponibles depuis l'espace client de votre banque.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Lors de l'import, les transactions sont automatiquement catégorisées selon les mots-clés définis. Les
|
||||
doublons sont détectés et ignorés automatiquement.
|
||||
Lors de l'import, les transactions sont automatiquement
|
||||
catégorisées selon les mots-clés définis. Les doublons sont
|
||||
détectés et ignorés automatiquement.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -228,5 +289,5 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||
import { useBankingData } from "@/lib/hooks"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { RefreshCw, TrendingUp, TrendingDown, ArrowRight } from "lucide-react"
|
||||
import { CategoryIcon } from "@/components/ui/category-icon"
|
||||
import { useState, useMemo } from "react";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useBankingData } from "@/lib/hooks";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RefreshCw, TrendingUp, TrendingDown, ArrowRight } from "lucide-react";
|
||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -21,111 +27,130 @@ import {
|
||||
LineChart,
|
||||
Line,
|
||||
Legend,
|
||||
} from "recharts"
|
||||
import { cn } from "@/lib/utils"
|
||||
} from "recharts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Period = "3months" | "6months" | "12months" | "all"
|
||||
type Period = "3months" | "6months" | "12months" | "all";
|
||||
|
||||
export default function StatisticsPage() {
|
||||
const { data, isLoading } = useBankingData()
|
||||
const [period, setPeriod] = useState<Period>("6months")
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>("all")
|
||||
const { data, isLoading } = useBankingData();
|
||||
const [period, setPeriod] = useState<Period>("6months");
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>("all");
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!data) return null
|
||||
if (!data) return null;
|
||||
|
||||
const now = new Date()
|
||||
let startDate: Date
|
||||
const now = new Date();
|
||||
let startDate: Date;
|
||||
|
||||
switch (period) {
|
||||
case "3months":
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 3, 1)
|
||||
break
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 3, 1);
|
||||
break;
|
||||
case "6months":
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 6, 1)
|
||||
break
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
||||
break;
|
||||
case "12months":
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 12, 1)
|
||||
break
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 12, 1);
|
||||
break;
|
||||
default:
|
||||
startDate = new Date(0)
|
||||
startDate = new Date(0);
|
||||
}
|
||||
|
||||
let transactions = data.transactions.filter((t) => new Date(t.date) >= startDate)
|
||||
let transactions = data.transactions.filter(
|
||||
(t) => new Date(t.date) >= startDate,
|
||||
);
|
||||
|
||||
if (selectedAccount !== "all") {
|
||||
transactions = transactions.filter((t) => t.accountId === selectedAccount)
|
||||
transactions = transactions.filter(
|
||||
(t) => t.accountId === selectedAccount,
|
||||
);
|
||||
}
|
||||
|
||||
// Monthly breakdown
|
||||
const monthlyData = new Map<string, { income: number; expenses: number }>()
|
||||
const monthlyData = new Map<string, { income: number; expenses: number }>();
|
||||
transactions.forEach((t) => {
|
||||
const monthKey = t.date.substring(0, 7)
|
||||
const current = monthlyData.get(monthKey) || { income: 0, expenses: 0 }
|
||||
const monthKey = t.date.substring(0, 7);
|
||||
const current = monthlyData.get(monthKey) || { income: 0, expenses: 0 };
|
||||
if (t.amount >= 0) {
|
||||
current.income += t.amount
|
||||
current.income += t.amount;
|
||||
} else {
|
||||
current.expenses += Math.abs(t.amount)
|
||||
current.expenses += Math.abs(t.amount);
|
||||
}
|
||||
monthlyData.set(monthKey, current)
|
||||
})
|
||||
monthlyData.set(monthKey, current);
|
||||
});
|
||||
|
||||
const monthlyChartData = Array.from(monthlyData.entries())
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([month, values]) => ({
|
||||
month: new Date(month + "-01").toLocaleDateString("fr-FR", { month: "short", year: "2-digit" }),
|
||||
month: new Date(month + "-01").toLocaleDateString("fr-FR", {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
}),
|
||||
revenus: Math.round(values.income),
|
||||
depenses: Math.round(values.expenses),
|
||||
solde: Math.round(values.income - values.expenses),
|
||||
}))
|
||||
}));
|
||||
|
||||
// Category breakdown (expenses only)
|
||||
const categoryTotals = new Map<string, number>()
|
||||
const categoryTotals = new Map<string, number>();
|
||||
transactions
|
||||
.filter((t) => t.amount < 0)
|
||||
.forEach((t) => {
|
||||
const catId = t.categoryId || "uncategorized"
|
||||
const current = categoryTotals.get(catId) || 0
|
||||
categoryTotals.set(catId, current + Math.abs(t.amount))
|
||||
})
|
||||
const catId = t.categoryId || "uncategorized";
|
||||
const current = categoryTotals.get(catId) || 0;
|
||||
categoryTotals.set(catId, current + Math.abs(t.amount));
|
||||
});
|
||||
|
||||
const categoryChartData = Array.from(categoryTotals.entries())
|
||||
.map(([categoryId, total]) => {
|
||||
const category = data.categories.find((c) => c.id === categoryId)
|
||||
const category = data.categories.find((c) => c.id === categoryId);
|
||||
return {
|
||||
name: category?.name || "Non catégorisé",
|
||||
value: Math.round(total),
|
||||
color: category?.color || "#94a3b8",
|
||||
}
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 8)
|
||||
.slice(0, 8);
|
||||
|
||||
// Top expenses
|
||||
const topExpenses = transactions
|
||||
.filter((t) => t.amount < 0)
|
||||
.sort((a, b) => a.amount - b.amount)
|
||||
.slice(0, 5)
|
||||
.slice(0, 5);
|
||||
|
||||
// Summary
|
||||
const totalIncome = transactions.filter((t) => t.amount >= 0).reduce((sum, t) => sum + t.amount, 0)
|
||||
const totalExpenses = transactions.filter((t) => t.amount < 0).reduce((sum, t) => sum + Math.abs(t.amount), 0)
|
||||
const avgMonthlyExpenses = monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0
|
||||
const totalIncome = transactions
|
||||
.filter((t) => t.amount >= 0)
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
const totalExpenses = transactions
|
||||
.filter((t) => t.amount < 0)
|
||||
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
|
||||
const avgMonthlyExpenses =
|
||||
monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0;
|
||||
|
||||
// Balance evolution
|
||||
const sortedTransactions = [...transactions].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
const sortedTransactions = [...transactions].sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||
);
|
||||
|
||||
let runningBalance = 0
|
||||
const balanceByDate = new Map<string, number>()
|
||||
let runningBalance = 0;
|
||||
const balanceByDate = new Map<string, number>();
|
||||
sortedTransactions.forEach((t) => {
|
||||
runningBalance += t.amount
|
||||
balanceByDate.set(t.date, runningBalance)
|
||||
})
|
||||
runningBalance += t.amount;
|
||||
balanceByDate.set(t.date, runningBalance);
|
||||
});
|
||||
|
||||
const balanceChartData = Array.from(balanceByDate.entries()).map(([date, balance]) => ({
|
||||
date: new Date(date).toLocaleDateString("fr-FR", { day: "2-digit", month: "short" }),
|
||||
solde: Math.round(balance),
|
||||
}))
|
||||
const balanceChartData = Array.from(balanceByDate.entries()).map(
|
||||
([date, balance]) => ({
|
||||
date: new Date(date).toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}),
|
||||
solde: Math.round(balance),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
monthlyChartData,
|
||||
@@ -136,8 +161,8 @@ export default function StatisticsPage() {
|
||||
avgMonthlyExpenses,
|
||||
balanceChartData,
|
||||
transactionCount: transactions.length,
|
||||
}
|
||||
}, [data, period, selectedAccount])
|
||||
};
|
||||
}, [data, period, selectedAccount]);
|
||||
|
||||
if (isLoading || !data || !stats) {
|
||||
return (
|
||||
@@ -147,15 +172,15 @@ export default function StatisticsPage() {
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(amount)
|
||||
}
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
@@ -164,11 +189,18 @@ export default function StatisticsPage() {
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Statistiques</h1>
|
||||
<p className="text-muted-foreground">Analysez vos dépenses et revenus</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
Statistiques
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Analysez vos dépenses et revenus
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
|
||||
<Select
|
||||
value={selectedAccount}
|
||||
onValueChange={setSelectedAccount}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Compte" />
|
||||
</SelectTrigger>
|
||||
@@ -181,7 +213,10 @@ export default function StatisticsPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={period} onValueChange={(v) => setPeriod(v as Period)}>
|
||||
<Select
|
||||
value={period}
|
||||
onValueChange={(v) => setPeriod(v as Period)}
|
||||
>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Période" />
|
||||
</SelectTrigger>
|
||||
@@ -205,7 +240,9 @@ export default function StatisticsPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-emerald-600">{formatCurrency(stats.totalIncome)}</div>
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
{formatCurrency(stats.totalIncome)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -217,7 +254,9 @@ export default function StatisticsPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{formatCurrency(stats.totalExpenses)}</div>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{formatCurrency(stats.totalExpenses)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -229,19 +268,25 @@ export default function StatisticsPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatCurrency(stats.avgMonthlyExpenses)}</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(stats.avgMonthlyExpenses)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Économies</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Économies
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className={cn(
|
||||
"text-2xl font-bold",
|
||||
stats.totalIncome - stats.totalExpenses >= 0 ? "text-emerald-600" : "text-red-600",
|
||||
stats.totalIncome - stats.totalExpenses >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-red-600",
|
||||
)}
|
||||
>
|
||||
{formatCurrency(stats.totalIncome - stats.totalExpenses)}
|
||||
@@ -262,9 +307,15 @@ export default function StatisticsPage() {
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={stats.monthlyChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<XAxis dataKey="month" className="text-xs" />
|
||||
<YAxis className="text-xs" tickFormatter={(v) => `${v}€`} />
|
||||
<YAxis
|
||||
className="text-xs"
|
||||
tickFormatter={(v) => `${v}€`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
contentStyle={{
|
||||
@@ -274,8 +325,16 @@ export default function StatisticsPage() {
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar dataKey="revenus" fill="#22c55e" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="depenses" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
||||
<Bar
|
||||
dataKey="revenus"
|
||||
fill="#22c55e"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="depenses"
|
||||
fill="#ef4444"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -318,7 +377,13 @@ export default function StatisticsPage() {
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Legend formatter={(value) => <span className="text-sm text-foreground">{value}</span>} />
|
||||
<Legend
|
||||
formatter={(value) => (
|
||||
<span className="text-sm text-foreground">
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -340,9 +405,19 @@ export default function StatisticsPage() {
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={stats.balanceChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="date" className="text-xs" interval="preserveStartEnd" />
|
||||
<YAxis className="text-xs" tickFormatter={(v) => `${v}€`} />
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
className="text-xs"
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
className="text-xs"
|
||||
tickFormatter={(v) => `${v}€`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
contentStyle={{
|
||||
@@ -351,7 +426,13 @@ export default function StatisticsPage() {
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Line type="monotone" dataKey="solde" stroke="#6366f1" strokeWidth={2} dot={false} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="solde"
|
||||
stroke="#6366f1"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -372,24 +453,40 @@ export default function StatisticsPage() {
|
||||
{stats.topExpenses.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{stats.topExpenses.map((expense, index) => {
|
||||
const category = data.categories.find((c) => c.id === expense.categoryId)
|
||||
const category = data.categories.find(
|
||||
(c) => c.id === expense.categoryId,
|
||||
);
|
||||
return (
|
||||
<div key={expense.id} className="flex items-center gap-3">
|
||||
<div
|
||||
key={expense.id}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-sm font-semibold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{expense.description}</p>
|
||||
<p className="font-medium text-sm truncate">
|
||||
{expense.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(expense.date).toLocaleDateString("fr-FR")}
|
||||
{new Date(expense.date).toLocaleDateString(
|
||||
"fr-FR",
|
||||
)}
|
||||
</span>
|
||||
{category && (
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1"
|
||||
style={{ backgroundColor: `${category.color}20`, color: category.color }}
|
||||
style={{
|
||||
backgroundColor: `${category.color}20`,
|
||||
color: category.color,
|
||||
}}
|
||||
>
|
||||
<CategoryIcon icon={category.icon} color={category.color} size={10} />
|
||||
<CategoryIcon
|
||||
icon={category.icon}
|
||||
color={category.color}
|
||||
size={10}
|
||||
/>
|
||||
{category.name}
|
||||
</span>
|
||||
)}
|
||||
@@ -399,7 +496,7 @@ export default function StatisticsPage() {
|
||||
{formatCurrency(expense.amount)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
@@ -413,5 +510,5 @@ export default function StatisticsPage() {
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,91 +1,125 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||
import { useBankingData } from "@/lib/hooks"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { useState, useMemo } from "react";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useBankingData } from "@/lib/hooks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { OFXImportDialog } from "@/components/import/ofx-import-dialog"
|
||||
import { CategoryIcon } from "@/components/ui/category-icon"
|
||||
import { Search, CheckCircle2, Circle, MoreVertical, Tags, Upload, RefreshCw, ArrowUpDown, Check } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||
import {
|
||||
Search,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
MoreVertical,
|
||||
Tags,
|
||||
Upload,
|
||||
RefreshCw,
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SortField = "date" | "amount" | "description"
|
||||
type SortOrder = "asc" | "desc"
|
||||
type SortField = "date" | "amount" | "description";
|
||||
type SortOrder = "asc" | "desc";
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const { data, isLoading, refresh, update } = useBankingData()
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>("all")
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all")
|
||||
const [showReconciled, setShowReconciled] = useState<string>("all")
|
||||
const [sortField, setSortField] = useState<SortField>("date")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc")
|
||||
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(new Set())
|
||||
const { data, isLoading, refresh, update } = useBankingData();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>("all");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
const [showReconciled, setShowReconciled] = useState<string>("all");
|
||||
const [sortField, setSortField] = useState<SortField>("date");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const filteredTransactions = useMemo(() => {
|
||||
if (!data) return []
|
||||
if (!data) return [];
|
||||
|
||||
let transactions = [...data.transactions]
|
||||
let transactions = [...data.transactions];
|
||||
|
||||
// Filter by search
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
const query = searchQuery.toLowerCase();
|
||||
transactions = transactions.filter(
|
||||
(t) => t.description.toLowerCase().includes(query) || t.memo?.toLowerCase().includes(query),
|
||||
)
|
||||
(t) =>
|
||||
t.description.toLowerCase().includes(query) ||
|
||||
t.memo?.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by account
|
||||
if (selectedAccount !== "all") {
|
||||
transactions = transactions.filter((t) => t.accountId === selectedAccount)
|
||||
transactions = transactions.filter(
|
||||
(t) => t.accountId === selectedAccount,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
if (selectedCategory !== "all") {
|
||||
if (selectedCategory === "uncategorized") {
|
||||
transactions = transactions.filter((t) => !t.categoryId)
|
||||
transactions = transactions.filter((t) => !t.categoryId);
|
||||
} else {
|
||||
transactions = transactions.filter((t) => t.categoryId === selectedCategory)
|
||||
transactions = transactions.filter(
|
||||
(t) => t.categoryId === selectedCategory,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by reconciliation status
|
||||
if (showReconciled !== "all") {
|
||||
const isReconciled = showReconciled === "reconciled"
|
||||
transactions = transactions.filter((t) => t.isReconciled === isReconciled)
|
||||
const isReconciled = showReconciled === "reconciled";
|
||||
transactions = transactions.filter(
|
||||
(t) => t.isReconciled === isReconciled,
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
transactions.sort((a, b) => {
|
||||
let comparison = 0
|
||||
let comparison = 0;
|
||||
switch (sortField) {
|
||||
case "date":
|
||||
comparison = new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
break
|
||||
comparison = new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
break;
|
||||
case "amount":
|
||||
comparison = a.amount - b.amount
|
||||
break
|
||||
comparison = a.amount - b.amount;
|
||||
break;
|
||||
case "description":
|
||||
comparison = a.description.localeCompare(b.description)
|
||||
break
|
||||
comparison = a.description.localeCompare(b.description);
|
||||
break;
|
||||
}
|
||||
return sortOrder === "asc" ? comparison : -comparison
|
||||
})
|
||||
return sortOrder === "asc" ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return transactions
|
||||
}, [data, searchQuery, selectedAccount, selectedCategory, showReconciled, sortField, sortOrder])
|
||||
return transactions;
|
||||
}, [
|
||||
data,
|
||||
searchQuery,
|
||||
selectedAccount,
|
||||
selectedCategory,
|
||||
showReconciled,
|
||||
sortField,
|
||||
sortOrder,
|
||||
]);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
@@ -95,78 +129,80 @@ export default function TransactionsPage() {
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(amount)
|
||||
}
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleReconciled = (transactionId: string) => {
|
||||
const updatedTransactions = data.transactions.map((t) =>
|
||||
t.id === transactionId ? { ...t, isReconciled: !t.isReconciled } : t,
|
||||
)
|
||||
update({ ...data, transactions: updatedTransactions })
|
||||
}
|
||||
);
|
||||
update({ ...data, transactions: updatedTransactions });
|
||||
};
|
||||
|
||||
const setCategory = (transactionId: string, categoryId: string | null) => {
|
||||
const updatedTransactions = data.transactions.map((t) => (t.id === transactionId ? { ...t, categoryId } : t))
|
||||
update({ ...data, transactions: updatedTransactions })
|
||||
}
|
||||
const updatedTransactions = data.transactions.map((t) =>
|
||||
t.id === transactionId ? { ...t, categoryId } : t,
|
||||
);
|
||||
update({ ...data, transactions: updatedTransactions });
|
||||
};
|
||||
|
||||
const bulkReconcile = (reconciled: boolean) => {
|
||||
const updatedTransactions = data.transactions.map((t) =>
|
||||
selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t,
|
||||
)
|
||||
update({ ...data, transactions: updatedTransactions })
|
||||
setSelectedTransactions(new Set())
|
||||
}
|
||||
);
|
||||
update({ ...data, transactions: updatedTransactions });
|
||||
setSelectedTransactions(new Set());
|
||||
};
|
||||
|
||||
const bulkSetCategory = (categoryId: string | null) => {
|
||||
const updatedTransactions = data.transactions.map((t) =>
|
||||
selectedTransactions.has(t.id) ? { ...t, categoryId } : t,
|
||||
)
|
||||
update({ ...data, transactions: updatedTransactions })
|
||||
setSelectedTransactions(new Set())
|
||||
}
|
||||
);
|
||||
update({ ...data, transactions: updatedTransactions });
|
||||
setSelectedTransactions(new Set());
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedTransactions.size === filteredTransactions.length) {
|
||||
setSelectedTransactions(new Set())
|
||||
setSelectedTransactions(new Set());
|
||||
} else {
|
||||
setSelectedTransactions(new Set(filteredTransactions.map((t) => t.id)))
|
||||
setSelectedTransactions(new Set(filteredTransactions.map((t) => t.id)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelectTransaction = (id: string) => {
|
||||
const newSelected = new Set(selectedTransactions)
|
||||
const newSelected = new Set(selectedTransactions);
|
||||
if (newSelected.has(id)) {
|
||||
newSelected.delete(id)
|
||||
newSelected.delete(id);
|
||||
} else {
|
||||
newSelected.add(id)
|
||||
newSelected.add(id);
|
||||
}
|
||||
setSelectedTransactions(newSelected)
|
||||
}
|
||||
setSelectedTransactions(newSelected);
|
||||
};
|
||||
|
||||
const getCategory = (categoryId: string | null) => {
|
||||
if (!categoryId) return null
|
||||
return data.categories.find((c) => c.id === categoryId)
|
||||
}
|
||||
if (!categoryId) return null;
|
||||
return data.categories.find((c) => c.id === categoryId);
|
||||
};
|
||||
|
||||
const getAccount = (accountId: string) => {
|
||||
return data.accounts.find((a) => a.id === accountId)
|
||||
}
|
||||
return data.accounts.find((a) => a.id === accountId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
@@ -175,9 +211,12 @@ export default function TransactionsPage() {
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Transactions</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
Transactions
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{filteredTransactions.length} transaction{filteredTransactions.length > 1 ? "s" : ""}
|
||||
{filteredTransactions.length} transaction
|
||||
{filteredTransactions.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<OFXImportDialog onImportComplete={refresh}>
|
||||
@@ -204,7 +243,10 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
|
||||
<Select
|
||||
value={selectedAccount}
|
||||
onValueChange={setSelectedAccount}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Compte" />
|
||||
</SelectTrigger>
|
||||
@@ -218,13 +260,18 @@ export default function TransactionsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||
<Select
|
||||
value={selectedCategory}
|
||||
onValueChange={setSelectedCategory}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Catégorie" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toutes catégories</SelectItem>
|
||||
<SelectItem value="uncategorized">Non catégorisé</SelectItem>
|
||||
<SelectItem value="uncategorized">
|
||||
Non catégorisé
|
||||
</SelectItem>
|
||||
{data.categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
@@ -233,7 +280,10 @@ export default function TransactionsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={showReconciled} onValueChange={setShowReconciled}>
|
||||
<Select
|
||||
value={showReconciled}
|
||||
onValueChange={setShowReconciled}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="Pointage" />
|
||||
</SelectTrigger>
|
||||
@@ -253,13 +303,22 @@ export default function TransactionsPage() {
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedTransactions.size} sélectionnée{selectedTransactions.size > 1 ? "s" : ""}
|
||||
{selectedTransactions.size} sélectionnée
|
||||
{selectedTransactions.size > 1 ? "s" : ""}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={() => bulkReconcile(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => bulkReconcile(true)}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-1" />
|
||||
Pointer
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => bulkReconcile(false)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => bulkReconcile(false)}
|
||||
>
|
||||
<Circle className="w-4 h-4 mr-1" />
|
||||
Dépointer
|
||||
</Button>
|
||||
@@ -271,11 +330,21 @@ export default function TransactionsPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => bulkSetCategory(null)}>Aucune catégorie</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => bulkSetCategory(null)}>
|
||||
Aucune catégorie
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{data.categories.map((cat) => (
|
||||
<DropdownMenuItem key={cat.id} onClick={() => bulkSetCategory(cat.id)}>
|
||||
<CategoryIcon icon={cat.icon} color={cat.color} size={14} className="mr-2" />
|
||||
<DropdownMenuItem
|
||||
key={cat.id}
|
||||
onClick={() => bulkSetCategory(cat.id)}
|
||||
>
|
||||
<CategoryIcon
|
||||
icon={cat.icon}
|
||||
color={cat.color}
|
||||
size={14}
|
||||
className="mr-2"
|
||||
/>
|
||||
{cat.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -291,7 +360,9 @@ export default function TransactionsPage() {
|
||||
<CardContent className="p-0">
|
||||
{filteredTransactions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Aucune transaction trouvée</p>
|
||||
<p className="text-muted-foreground">
|
||||
Aucune transaction trouvée
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -301,7 +372,8 @@ export default function TransactionsPage() {
|
||||
<th className="p-3 text-left">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedTransactions.size === filteredTransactions.length &&
|
||||
selectedTransactions.size ===
|
||||
filteredTransactions.length &&
|
||||
filteredTransactions.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
@@ -311,10 +383,12 @@ export default function TransactionsPage() {
|
||||
<button
|
||||
onClick={() => {
|
||||
if (sortField === "date") {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc",
|
||||
);
|
||||
} else {
|
||||
setSortField("date")
|
||||
setSortOrder("desc")
|
||||
setSortField("date");
|
||||
setSortOrder("desc");
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
@@ -327,10 +401,12 @@ export default function TransactionsPage() {
|
||||
<button
|
||||
onClick={() => {
|
||||
if (sortField === "description") {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc",
|
||||
);
|
||||
} else {
|
||||
setSortField("description")
|
||||
setSortOrder("asc")
|
||||
setSortField("description");
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
@@ -339,16 +415,22 @@ export default function TransactionsPage() {
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="p-3 text-left text-sm font-medium text-muted-foreground">Compte</th>
|
||||
<th className="p-3 text-left text-sm font-medium text-muted-foreground">Catégorie</th>
|
||||
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Compte
|
||||
</th>
|
||||
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Catégorie
|
||||
</th>
|
||||
<th className="p-3 text-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (sortField === "amount") {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc",
|
||||
);
|
||||
} else {
|
||||
setSortField("amount")
|
||||
setSortOrder("desc")
|
||||
setSortField("amount");
|
||||
setSortOrder("desc");
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground ml-auto"
|
||||
@@ -357,35 +439,48 @@ export default function TransactionsPage() {
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="p-3 text-center text-sm font-medium text-muted-foreground">Pointé</th>
|
||||
<th className="p-3 text-center text-sm font-medium text-muted-foreground">
|
||||
Pointé
|
||||
</th>
|
||||
<th className="p-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTransactions.map((transaction) => {
|
||||
const category = getCategory(transaction.categoryId)
|
||||
const account = getAccount(transaction.accountId)
|
||||
const category = getCategory(transaction.categoryId);
|
||||
const account = getAccount(transaction.accountId);
|
||||
|
||||
return (
|
||||
<tr key={transaction.id} className="border-b border-border last:border-0 hover:bg-muted/50">
|
||||
<tr
|
||||
key={transaction.id}
|
||||
className="border-b border-border last:border-0 hover:bg-muted/50"
|
||||
>
|
||||
<td className="p-3">
|
||||
<Checkbox
|
||||
checked={selectedTransactions.has(transaction.id)}
|
||||
onCheckedChange={() => toggleSelectTransaction(transaction.id)}
|
||||
checked={selectedTransactions.has(
|
||||
transaction.id,
|
||||
)}
|
||||
onCheckedChange={() =>
|
||||
toggleSelectTransaction(transaction.id)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{formatDate(transaction.date)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<p className="font-medium text-sm">{transaction.description}</p>
|
||||
<p className="font-medium text-sm">
|
||||
{transaction.description}
|
||||
</p>
|
||||
{transaction.memo && (
|
||||
<p className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{transaction.memo}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">{account?.name || "-"}</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{account?.name || "-"}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -399,26 +494,49 @@ export default function TransactionsPage() {
|
||||
color: category.color,
|
||||
}}
|
||||
>
|
||||
<CategoryIcon icon={category.icon} color={category.color} size={12} />
|
||||
<CategoryIcon
|
||||
icon={category.icon}
|
||||
color={category.color}
|
||||
size={12}
|
||||
/>
|
||||
{category.name}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Non catégorisé
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setCategory(transaction.id, null)}>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setCategory(transaction.id, null)
|
||||
}
|
||||
>
|
||||
Aucune catégorie
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{data.categories.map((cat) => (
|
||||
<DropdownMenuItem key={cat.id} onClick={() => setCategory(transaction.id, cat.id)}>
|
||||
<CategoryIcon icon={cat.icon} color={cat.color} size={14} className="mr-2" />
|
||||
<DropdownMenuItem
|
||||
key={cat.id}
|
||||
onClick={() =>
|
||||
setCategory(transaction.id, cat.id)
|
||||
}
|
||||
>
|
||||
<CategoryIcon
|
||||
icon={cat.icon}
|
||||
color={cat.color}
|
||||
size={14}
|
||||
className="mr-2"
|
||||
/>
|
||||
{cat.name}
|
||||
{transaction.categoryId === cat.id && <Check className="w-4 h-4 ml-auto" />}
|
||||
{transaction.categoryId === cat.id && (
|
||||
<Check className="w-4 h-4 ml-auto" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -427,7 +545,9 @@ export default function TransactionsPage() {
|
||||
<td
|
||||
className={cn(
|
||||
"p-3 text-right font-semibold tabular-nums",
|
||||
transaction.amount >= 0 ? "text-emerald-600" : "text-red-600",
|
||||
transaction.amount >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-red-600",
|
||||
)}
|
||||
>
|
||||
{transaction.amount >= 0 ? "+" : ""}
|
||||
@@ -448,19 +568,29 @@ export default function TransactionsPage() {
|
||||
<td className="p-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => toggleReconciled(transaction.id)}>
|
||||
{transaction.isReconciled ? "Dépointer" : "Pointer"}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toggleReconciled(transaction.id)
|
||||
}
|
||||
>
|
||||
{transaction.isReconciled
|
||||
? "Dépointer"
|
||||
: "Pointer"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -471,5 +601,5 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user