commit e9e44916fd4e9a268c9af6b23118baac2f4173eb Author: Julien Froidefond Date: Thu Nov 27 09:51:18 2025 +0100 chore: init from v0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d397cde --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Dependencies +node_modules +.pnpm-store + +# Next.js +.next +out +build +dist + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Prisma +/lib/generated/prisma +prisma/*.db +prisma/*.db-journal + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# IDE +.idea +.vscode +*.swp +*.swo +.DS_Store + +# Cursor +.cursor + +# Testing +coverage +.nyc_output + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# Vercel +.vercel + +# Misc +*.log +*.local +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..785fa89 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# FinTrack - Gestion de Compte Bancaire + +Application web moderne de gestion personnelle de comptes bancaires avec import de fichiers OFX, catĂ©gorisation automatique des transactions et visualisations statistiques. + +## 🚀 FonctionnalitĂ©s + +- **Gestion de comptes** : Ajout, modification et organisation de vos comptes bancaires (chĂšques, Ă©pargne, cartes de crĂ©dit) +- **Import OFX** : Import automatique de transactions depuis des fichiers OFX exportĂ©s par votre banque +- **CatĂ©gorisation automatique** : CatĂ©gorisation intelligente des transactions basĂ©e sur des mots-clĂ©s +- **Organisation par dossiers** : Structurez vos comptes avec des dossiers personnalisables +- **Gestion de catĂ©gories** : CrĂ©ez et gĂ©rez vos catĂ©gories de dĂ©penses avec couleurs et icĂŽnes +- **Tableau de bord** : Vue d'ensemble avec cartes rĂ©capitulatives, transactions rĂ©centes et rĂ©partition par catĂ©gorie +- **Statistiques** : Visualisations graphiques de vos finances +- **ThĂšme sombre/clair** : Support du mode sombre et clair +- **Stockage local** : Toutes vos donnĂ©es sont stockĂ©es localement dans le navigateur (localStorage) + +## đŸ› ïž Technologies + +- **Framework** : Next.js 16 (App Router) +- **UI** : React 19, TypeScript +- **Styling** : Tailwind CSS 4, shadcn/ui +- **Composants** : Radix UI +- **Graphiques** : Recharts +- **Formulaires** : React Hook Form + Zod +- **ThĂšme** : next-themes + +## 📋 PrĂ©requis + +- Node.js 18+ +- pnpm (recommandĂ©) ou npm/yarn + +## 🔧 Installation + +1. Clonez le dĂ©pĂŽt : +```bash +git clone +cd bank-account-management-app +``` + +2. Installez les dĂ©pendances : +```bash +pnpm install +``` + +3. Lancez le serveur de dĂ©veloppement : +```bash +pnpm dev +``` + +4. Ouvrez [http://localhost:3000](http://localhost:3000) dans votre navigateur + +## 📩 Scripts disponibles + +- `pnpm dev` : Lance le serveur de dĂ©veloppement +- `pnpm build` : Compile l'application pour la production +- `pnpm start` : Lance le serveur de production +- `pnpm lint` : VĂ©rifie le code avec ESLint + +## 📁 Structure du projet + +``` +├── app/ # Pages Next.js (App Router) +│ ├── accounts/ # Page de gestion des comptes +│ ├── categories/ # Page de gestion des catĂ©gories +│ ├── folders/ # Page d'organisation par dossiers +│ ├── settings/ # Page des paramĂštres +│ ├── statistics/ # Page des statistiques +│ └── transactions/ # Page de gestion des transactions +├── components/ # Composants React +│ ├── dashboard/ # Composants du tableau de bord +│ ├── import/ # Composants d'import OFX +│ └── ui/ # Composants UI rĂ©utilisables (shadcn/ui) +├── lib/ # Utilitaires et logique mĂ©tier +│ ├── hooks.ts # Hooks personnalisĂ©s +│ ├── ofx-parser.tsx # Parser de fichiers OFX +│ ├── store.ts # Gestion du stockage local +│ ├── types.ts # Types TypeScript +│ └── utils.ts # Fonctions utilitaires +└── public/ # Assets statiques +``` + +## đŸ’Ÿ Stockage des donnĂ©es + +L'application utilise `localStorage` pour stocker toutes les donnĂ©es localement dans le navigateur. Les donnĂ©es sont sauvegardĂ©es automatiquement Ă  chaque modification. + +**Note** : Les donnĂ©es sont stockĂ©es uniquement dans votre navigateur. Si vous supprimez les donnĂ©es du navigateur ou changez d'appareil, vous devrez rĂ©importer vos fichiers OFX. + +## đŸ“„ Import de fichiers OFX + +1. Exportez vos transactions depuis votre banque au format OFX +2. Cliquez sur "Importer OFX" dans le tableau de bord +3. SĂ©lectionnez votre fichier OFX +4. Les transactions sont automatiquement importĂ©es et catĂ©gorisĂ©es + +L'application dĂ©tecte automatiquement les doublons basĂ©s sur l'ID unique (FITID) de chaque transaction. + +## 🎹 Personnalisation + +### CatĂ©gories par dĂ©faut + +L'application inclut des catĂ©gories prĂ©-configurĂ©es avec des mots-clĂ©s pour la catĂ©gorisation automatique : +- Alimentation +- Transport +- Logement +- Loisirs +- SantĂ© +- Revenus +- Abonnements +- Shopping + +Vous pouvez modifier, ajouter ou supprimer des catĂ©gories selon vos besoins. + +### ThĂšme + +Le thĂšme sombre/clair peut ĂȘtre changĂ© dans les paramĂštres. L'application dĂ©tecte automatiquement les prĂ©fĂ©rences de votre systĂšme. + +## 🔒 SĂ©curitĂ© et confidentialitĂ© + +- **100% local** : Toutes vos donnĂ©es restent dans votre navigateur +- **Aucune connexion externe** : Aucune donnĂ©e n'est envoyĂ©e Ă  des serveurs externes +- **Pas de tracking** : Aucun service d'analytics tiers (sauf Vercel Analytics optionnel) + +## 🚧 DĂ©veloppement + +### Ajouter une nouvelle fonctionnalitĂ© + +1. CrĂ©ez vos composants dans `components/` +2. Ajoutez les types nĂ©cessaires dans `lib/types.ts` +3. Ajoutez la logique mĂ©tier dans `lib/store.ts` +4. CrĂ©ez la page dans `app/` + +### Structure des donnĂ©es + +Les donnĂ©es sont structurĂ©es comme suit : +- **Accounts** : Comptes bancaires avec solde et mĂ©tadonnĂ©es +- **Transactions** : Transactions avec montant, date, description, catĂ©gorie +- **Folders** : Dossiers pour organiser les comptes +- **Categories** : CatĂ©gories avec mots-clĂ©s pour auto-catĂ©gorisation +- **CategoryRules** : RĂšgles avancĂ©es de catĂ©gorisation (futur) + +## 📝 Licence + +Ce projet est privĂ© et destinĂ© Ă  un usage personnel. + +## đŸ€ Contribution + +Ce projet est en dĂ©veloppement actif. Les suggestions et amĂ©liorations sont les bienvenues. + +--- + +DĂ©veloppĂ© avec ❀ en utilisant Next.js et React + diff --git a/app/accounts/page.tsx b/app/accounts/page.tsx new file mode 100644 index 0000000..17f48d6 --- /dev/null +++ b/app/accounts/page.tsx @@ -0,0 +1,255 @@ +"use client" + +import { useState } from "react" +import { Sidebar } from "@/components/dashboard/sidebar" +import { useBankingData } from "@/lib/hooks" +import { updateAccount, deleteAccount } from "@/lib/store-db" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { MoreVertical, Pencil, Trash2, Building2, CreditCard, Wallet, PiggyBank, RefreshCw } from "lucide-react" +import type { Account } from "@/lib/types" +import { cn } from "@/lib/utils" + +const accountTypeIcons = { + CHECKING: Wallet, + SAVINGS: PiggyBank, + CREDIT_CARD: CreditCard, + OTHER: Building2, +} + +const accountTypeLabels = { + CHECKING: "Compte courant", + SAVINGS: "Épargne", + CREDIT_CARD: "Carte de crĂ©dit", + OTHER: "Autre", +} + +export default function AccountsPage() { + const { data, isLoading, refresh, update } = useBankingData() + const [editingAccount, setEditingAccount] = useState(null) + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [formData, setFormData] = useState({ + name: "", + type: "CHECKING" as Account["type"], + folderId: "folder-root", + }) + + if (isLoading || !data) { + return ( +
+ +
+ +
+
+ ) + } + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + }).format(amount) + } + + const handleEdit = (account: Account) => { + setEditingAccount(account) + setFormData({ + name: account.name, + type: account.type, + folderId: account.folderId || "folder-root", + }) + setIsDialogOpen(true) + } + + const handleSave = async () => { + if (!editingAccount) return + + try { + const updatedAccount = { + ...editingAccount, + name: formData.name, + type: formData.type, + folderId: formData.folderId, + } + await updateAccount(updatedAccount) + refresh() + setIsDialogOpen(false) + setEditingAccount(null) + } catch (error) { + console.error("Error updating account:", error) + alert("Erreur lors de la mise Ă  jour du compte") + } + } + + const handleDelete = async (accountId: string) => { + if (!confirm("Supprimer ce compte et toutes ses transactions ?")) return + + try { + await deleteAccount(accountId) + refresh() + } catch (error) { + console.error("Error deleting account:", error) + alert("Erreur lors de la suppression du compte") + } + } + + const getTransactionCount = (accountId: string) => { + return data.transactions.filter((t) => t.accountId === accountId).length + } + + const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0) + + return ( +
+ +
+
+
+
+

Comptes

+

Gérez vos comptes bancaires

+
+
+

Solde total

+

= 0 ? "text-emerald-600" : "text-red-600")}> + {formatCurrency(totalBalance)} +

+
+
+ + {data.accounts.length === 0 ? ( + + + +

Aucun compte

+

+ Importez un fichier OFX depuis le tableau de bord pour ajouter votre premier compte. +

+
+
+ ) : ( +
+ {data.accounts.map((account) => { + const Icon = accountTypeIcons[account.type] + const folder = data.folders.find((f) => f.id === account.folderId) + + return ( + + +
+
+
+ +
+
+ {account.name} +

{accountTypeLabels[account.type]}

+
+
+ + + + + + handleEdit(account)}> + + Modifier + + handleDelete(account.id)} className="text-red-600"> + + Supprimer + + + +
+
+ +
= 0 ? "text-emerald-600" : "text-red-600", + )} + > + {formatCurrency(account.balance)} +
+
+ {getTransactionCount(account.id)} transactions + {folder && {folder.name}} +
+ {account.lastImport && ( +

+ Dernier import: {new Date(account.lastImport).toLocaleDateString("fr-FR")} +

+ )} +
+
+ ) + })} +
+ )} +
+
+ + + + + Modifier le compte + +
+
+ + setFormData({ ...formData, name: e.target.value })} /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ ) +} diff --git a/app/api/banking/accounts/route.ts b/app/api/banking/accounts/route.ts new file mode 100644 index 0000000..14e463d --- /dev/null +++ b/app/api/banking/accounts/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import type { Account } from "@/lib/types" + +export async function POST(request: Request) { + try { + const account: Omit = await request.json() + + const created = await prisma.account.create({ + data: { + name: account.name, + bankId: account.bankId, + accountNumber: account.accountNumber, + type: account.type, + folderId: account.folderId, + balance: account.balance, + currency: account.currency, + lastImport: account.lastImport, + }, + }) + + return NextResponse.json(created) + } catch (error) { + console.error("Error creating account:", error) + return NextResponse.json({ error: "Failed to create account" }, { status: 500 }) + } +} + +export async function PUT(request: Request) { + try { + const account: Account = await request.json() + + const updated = await prisma.account.update({ + where: { id: account.id }, + data: { + name: account.name, + bankId: account.bankId, + accountNumber: account.accountNumber, + type: account.type, + folderId: account.folderId, + balance: account.balance, + currency: account.currency, + lastImport: account.lastImport, + }, + }) + + return NextResponse.json(updated) + } catch (error) { + console.error("Error updating account:", error) + return NextResponse.json({ error: "Failed to update account" }, { status: 500 }) + } +} + +export async function DELETE(request: Request) { + try { + const { searchParams } = new URL(request.url) + const id = searchParams.get("id") + + if (!id) { + return NextResponse.json({ error: "Account ID is required" }, { status: 400 }) + } + + // Transactions will be deleted automatically due to onDelete: Cascade + await prisma.account.delete({ + where: { id }, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Error deleting account:", error) + return NextResponse.json({ error: "Failed to delete account" }, { status: 500 }) + } +} + diff --git a/app/api/banking/categories/route.ts b/app/api/banking/categories/route.ts new file mode 100644 index 0000000..13d251c --- /dev/null +++ b/app/api/banking/categories/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import type { Category } from "@/lib/types" + +export async function POST(request: Request) { + try { + const category: Omit = await request.json() + + const created = await prisma.category.create({ + data: { + name: category.name, + color: category.color, + icon: category.icon, + keywords: JSON.stringify(category.keywords), + parentId: category.parentId, + }, + }) + + return NextResponse.json({ + ...created, + keywords: JSON.parse(created.keywords), + }) + } catch (error) { + console.error("Error creating category:", error) + return NextResponse.json({ error: "Failed to create category" }, { status: 500 }) + } +} + +export async function PUT(request: Request) { + try { + const category: Category = await request.json() + + const updated = await prisma.category.update({ + where: { id: category.id }, + data: { + name: category.name, + color: category.color, + icon: category.icon, + keywords: JSON.stringify(category.keywords), + parentId: category.parentId, + }, + }) + + return NextResponse.json({ + ...updated, + keywords: JSON.parse(updated.keywords), + }) + } catch (error) { + console.error("Error updating category:", error) + return NextResponse.json({ error: "Failed to update category" }, { status: 500 }) + } +} + +export async function DELETE(request: Request) { + try { + const { searchParams } = new URL(request.url) + const id = searchParams.get("id") + + if (!id) { + return NextResponse.json({ error: "Category ID is required" }, { status: 400 }) + } + + // Remove category from transactions (set to null) + await prisma.transaction.updateMany({ + where: { categoryId: id }, + data: { categoryId: null }, + }) + + await prisma.category.delete({ + where: { id }, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Error deleting category:", error) + return NextResponse.json({ error: "Failed to delete category" }, { status: 500 }) + } +} + diff --git a/app/api/banking/folders/route.ts b/app/api/banking/folders/route.ts new file mode 100644 index 0000000..e1f91f6 --- /dev/null +++ b/app/api/banking/folders/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import type { Folder } from "@/lib/types" + +export async function POST(request: Request) { + try { + const folder: Omit = await request.json() + + const created = await prisma.folder.create({ + data: { + name: folder.name, + parentId: folder.parentId, + color: folder.color, + icon: folder.icon, + }, + }) + + return NextResponse.json(created) + } catch (error) { + console.error("Error creating folder:", error) + return NextResponse.json({ error: "Failed to create folder" }, { status: 500 }) + } +} + +export async function PUT(request: Request) { + try { + const folder: Folder = await request.json() + + const updated = await prisma.folder.update({ + where: { id: folder.id }, + data: { + name: folder.name, + parentId: folder.parentId, + color: folder.color, + icon: folder.icon, + }, + }) + + return NextResponse.json(updated) + } catch (error) { + console.error("Error updating folder:", error) + return NextResponse.json({ error: "Failed to update folder" }, { status: 500 }) + } +} + +export async function DELETE(request: Request) { + try { + const { searchParams } = new URL(request.url) + const id = searchParams.get("id") + + if (!id) { + return NextResponse.json({ error: "Folder ID is required" }, { status: 400 }) + } + + const folder = await prisma.folder.findUnique({ + where: { id }, + include: { children: true }, + }) + + if (!folder) { + return NextResponse.json({ error: "Folder not found" }, { status: 404 }) + } + + // Move accounts to root (null) + await prisma.account.updateMany({ + where: { folderId: id }, + data: { folderId: null }, + }) + + // Move subfolders to parent + if (folder.parentId) { + await prisma.folder.updateMany({ + where: { parentId: id }, + data: { parentId: folder.parentId }, + }) + } else { + // If no parent, move to null (root) + await prisma.folder.updateMany({ + where: { parentId: id }, + data: { parentId: null }, + }) + } + + await prisma.folder.delete({ + where: { id }, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Error deleting folder:", error) + return NextResponse.json({ error: "Failed to delete folder" }, { status: 500 }) + } +} + diff --git a/app/api/banking/route.ts b/app/api/banking/route.ts new file mode 100644 index 0000000..406281b --- /dev/null +++ b/app/api/banking/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import type { BankingData } from "@/lib/types" + +export async function GET() { + try { + const [accounts, transactions, folders, categories, categoryRules] = await Promise.all([ + prisma.account.findMany({ + include: { + folder: true, + }, + }), + prisma.transaction.findMany({ + include: { + account: true, + category: true, + }, + }), + prisma.folder.findMany(), + prisma.category.findMany(), + prisma.categoryRule.findMany(), + ]) + + // Transform Prisma models to match our types + const data: BankingData = { + accounts: accounts.map((a) => ({ + id: a.id, + name: a.name, + bankId: a.bankId, + accountNumber: a.accountNumber, + type: a.type as "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER", + folderId: a.folderId, + balance: a.balance, + currency: a.currency, + lastImport: a.lastImport, + })), + transactions: transactions.map((t) => ({ + id: t.id, + accountId: t.accountId, + date: t.date, + amount: t.amount, + description: t.description, + type: t.type as "DEBIT" | "CREDIT", + categoryId: t.categoryId, + isReconciled: t.isReconciled, + fitId: t.fitId, + memo: t.memo ?? undefined, + checkNum: t.checkNum ?? undefined, + })), + folders: folders.map((f) => ({ + id: f.id, + name: f.name, + parentId: f.parentId, + color: f.color, + icon: f.icon, + })), + categories: categories.map((c) => ({ + id: c.id, + name: c.name, + color: c.color, + icon: c.icon, + keywords: JSON.parse(c.keywords) as string[], + parentId: c.parentId, + })), + categoryRules: categoryRules.map((r) => ({ + id: r.id, + categoryId: r.categoryId, + pattern: r.pattern, + isRegex: r.isRegex, + })), + } + + return NextResponse.json(data) + } catch (error) { + console.error("Error fetching banking data:", error) + return NextResponse.json({ error: "Failed to fetch data" }, { status: 500 }) + } +} + diff --git a/app/api/banking/transactions/route.ts b/app/api/banking/transactions/route.ts new file mode 100644 index 0000000..e3bca1d --- /dev/null +++ b/app/api/banking/transactions/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import type { Transaction } from "@/lib/types" + +export async function POST(request: Request) { + try { + const transactions: Transaction[] = await request.json() + + // Filter out duplicates based on fitId + const existingTransactions = await prisma.transaction.findMany({ + where: { + accountId: { in: transactions.map((t) => t.accountId) }, + fitId: { in: transactions.map((t) => t.fitId) }, + }, + select: { + accountId: true, + fitId: true, + }, + }) + + const existingSet = new Set( + existingTransactions.map((t) => `${t.accountId}-${t.fitId}`), + ) + + const newTransactions = transactions.filter( + (t) => !existingSet.has(`${t.accountId}-${t.fitId}`), + ) + + if (newTransactions.length === 0) { + return NextResponse.json({ count: 0, transactions: [] }) + } + + const created = await prisma.transaction.createMany({ + data: newTransactions.map((t) => ({ + accountId: t.accountId, + date: t.date, + amount: t.amount, + description: t.description, + type: t.type, + categoryId: t.categoryId, + isReconciled: t.isReconciled, + fitId: t.fitId, + memo: t.memo, + checkNum: t.checkNum, + })), + }) + + return NextResponse.json({ count: created.count, transactions: newTransactions }) + } catch (error) { + console.error("Error creating transactions:", error) + return NextResponse.json({ error: "Failed to create transactions" }, { status: 500 }) + } +} + +export async function PUT(request: Request) { + try { + const transaction: Transaction = await request.json() + + const updated = await prisma.transaction.update({ + where: { id: transaction.id }, + data: { + accountId: transaction.accountId, + date: transaction.date, + amount: transaction.amount, + description: transaction.description, + type: transaction.type, + categoryId: transaction.categoryId, + isReconciled: transaction.isReconciled, + fitId: transaction.fitId, + memo: transaction.memo, + checkNum: transaction.checkNum, + }, + }) + + return NextResponse.json(updated) + } catch (error) { + console.error("Error updating transaction:", error) + return NextResponse.json({ error: "Failed to update transaction" }, { status: 500 }) + } +} + +export async function DELETE(request: Request) { + try { + const { searchParams } = new URL(request.url) + const id = searchParams.get("id") + + if (!id) { + return NextResponse.json({ error: "Transaction ID is required" }, { status: 400 }) + } + + await prisma.transaction.delete({ + where: { id }, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Error deleting transaction:", error) + return NextResponse.json({ error: "Failed to delete transaction" }, { status: 500 }) + } +} + diff --git a/app/categories/page.tsx b/app/categories/page.tsx new file mode 100644 index 0000000..7abbb05 --- /dev/null +++ b/app/categories/page.tsx @@ -0,0 +1,318 @@ +"use client" + +import { useState } from "react" +import { Sidebar } from "@/components/dashboard/sidebar" +import { useBankingData } from "@/lib/hooks" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { Plus, MoreVertical, Pencil, Trash2, Tag, RefreshCw, X } from "lucide-react" +import { generateId, autoCategorize, addCategory, updateCategory, deleteCategory } from "@/lib/store-db" +import type { Category } from "@/lib/types" +import { cn } from "@/lib/utils" + +const categoryColors = [ + "#22c55e", + "#3b82f6", + "#f59e0b", + "#ec4899", + "#ef4444", + "#8b5cf6", + "#06b6d4", + "#84cc16", + "#f97316", + "#6366f1", +] + +export default function CategoriesPage() { + const { data, isLoading, refresh } = useBankingData() + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [editingCategory, setEditingCategory] = useState(null) + const [formData, setFormData] = useState({ + name: "", + color: "#22c55e", + keywords: [] as string[], + }) + const [newKeyword, setNewKeyword] = useState("") + + if (isLoading || !data) { + return ( +
+ +
+ +
+
+ ) + } + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + }).format(amount) + } + + const getCategoryStats = (categoryId: string) => { + const categoryTransactions = data.transactions.filter((t) => t.categoryId === categoryId) + const total = categoryTransactions.reduce((sum, t) => sum + Math.abs(t.amount), 0) + const count = categoryTransactions.length + return { total, count } + } + + const handleNewCategory = () => { + setEditingCategory(null) + setFormData({ name: "", color: "#22c55e", keywords: [] }) + setIsDialogOpen(true) + } + + const handleEdit = (category: Category) => { + setEditingCategory(category) + setFormData({ + name: category.name, + color: category.color, + keywords: [...category.keywords], + }) + setIsDialogOpen(true) + } + + const handleSave = async () => { + try { + if (editingCategory) { + await updateCategory({ + ...editingCategory, + name: formData.name, + color: formData.color, + keywords: formData.keywords, + }) + } else { + await addCategory({ + name: formData.name, + color: formData.color, + keywords: formData.keywords, + icon: "tag", + parentId: null, + }) + } + refresh() + setIsDialogOpen(false) + } catch (error) { + console.error("Error saving category:", error) + alert("Erreur lors de la sauvegarde de la catégorie") + } + } + + const handleDelete = async (categoryId: string) => { + if (!confirm("Supprimer cette catégorie ?")) return + + try { + await deleteCategory(categoryId) + refresh() + } catch (error) { + console.error("Error deleting category:", error) + alert("Erreur lors de la suppression de la catégorie") + } + } + + const addKeyword = () => { + if (newKeyword.trim() && !formData.keywords.includes(newKeyword.trim().toLowerCase())) { + setFormData({ + ...formData, + keywords: [...formData.keywords, newKeyword.trim().toLowerCase()], + }) + setNewKeyword("") + } + } + + const removeKeyword = (keyword: string) => { + setFormData({ + ...formData, + keywords: formData.keywords.filter((k) => k !== keyword), + }) + } + + const reApplyAutoCategories = async () => { + if (!confirm("Recatégoriser automatiquement les transactions non catégorisées ?")) return + + try { + const { updateTransaction } = await import("@/lib/store-db") + const uncategorized = data.transactions.filter((t) => !t.categoryId) + + for (const transaction of uncategorized) { + const categoryId = autoCategorize(transaction.description + " " + (transaction.memo || ""), data.categories) + if (categoryId) { + await updateTransaction({ ...transaction, categoryId }) + } + } + refresh() + } catch (error) { + console.error("Error re-categorizing:", error) + alert("Erreur lors de la recatégorisation") + } + } + + const uncategorizedCount = data.transactions.filter((t) => !t.categoryId).length + + return ( +
+ +
+
+
+
+

Catégories

+

+ Gérez vos catégories et mots-clés pour la catégorisation automatique +

+
+
+ {uncategorizedCount > 0 && ( + + )} + +
+
+ +
+ {data.categories.map((category) => { + const stats = getCategoryStats(category.id) + + return ( + + +
+
+
+ +
+
+ {category.name} +

+ {stats.count} transaction{stats.count > 1 ? "s" : ""} +

+
+
+ + + + + + handleEdit(category)}> + + Modifier + + handleDelete(category.id)} className="text-red-600"> + + Supprimer + + + +
+
+ +
{formatCurrency(stats.total)}
+
+ {category.keywords.slice(0, 5).map((keyword) => ( + + {keyword} + + ))} + {category.keywords.length > 5 && ( + + +{category.keywords.length - 5} + + )} +
+
+
+ ) + })} +
+
+
+ + + + + {editingCategory ? "Modifier la catégorie" : "Nouvelle catégorie"} + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="Ex: Alimentation" + /> +
+ +
+ +
+ {categoryColors.map((color) => ( +
+
+ +
+ +
+ setNewKeyword(e.target.value)} + placeholder="Ajouter un mot-clé" + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addKeyword())} + /> + +
+
+ {formData.keywords.map((keyword) => ( + + {keyword} + + + ))} +
+
+ +
+ + +
+
+
+
+
+ ) +} diff --git a/app/folders/page.tsx b/app/folders/page.tsx new file mode 100644 index 0000000..7c24bc0 --- /dev/null +++ b/app/folders/page.tsx @@ -0,0 +1,345 @@ +"use client" + +import { useState } from "react" +import { Sidebar } from "@/components/dashboard/sidebar" +import { useBankingData } from "@/lib/hooks" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { + Plus, + MoreVertical, + Pencil, + Trash2, + Folder, + FolderOpen, + ChevronRight, + ChevronDown, + Building2, + RefreshCw, +} from "lucide-react" +import { generateId, addFolder, updateFolder, deleteFolder } from "@/lib/store-db" +import type { Folder as FolderType, Account } from "@/lib/types" +import { cn } from "@/lib/utils" + +const folderColors = [ + { value: "#6366f1", label: "Indigo" }, + { value: "#22c55e", label: "Vert" }, + { value: "#f59e0b", label: "Orange" }, + { value: "#ec4899", label: "Rose" }, + { value: "#3b82f6", label: "Bleu" }, + { value: "#ef4444", label: "Rouge" }, +] + +interface FolderTreeItemProps { + folder: FolderType + accounts: Account[] + allFolders: FolderType[] + level: number + onEdit: (folder: FolderType) => void + onDelete: (folderId: string) => void + formatCurrency: (amount: number) => string +} + +function FolderTreeItem({ + folder, + accounts, + allFolders, + level, + onEdit, + onDelete, + formatCurrency, +}: FolderTreeItemProps) { + const [isExpanded, setIsExpanded] = useState(true) + + const folderAccounts = accounts.filter((a) => a.folderId === folder.id) + const childFolders = allFolders.filter((f) => f.parentId === folder.id) + const hasChildren = childFolders.length > 0 || folderAccounts.length > 0 + + const folderTotal = folderAccounts.reduce((sum, a) => sum + a.balance, 0) + + return ( +
+
0 && "ml-6")}> + + +
+ {isExpanded ? ( + + ) : ( + + )} +
+ + {folder.name} + + {folderAccounts.length > 0 && ( + = 0 ? "text-emerald-600" : "text-red-600")} + > + {formatCurrency(folderTotal)} + + )} + + + + + + + onEdit(folder)}> + + Modifier + + {folder.id !== "folder-root" && ( + onDelete(folder.id)} className="text-red-600"> + + Supprimer + + )} + + +
+ + {isExpanded && ( +
+ {folderAccounts.map((account) => ( +
+ + {account.name} + = 0 ? "text-emerald-600" : "text-red-600")}> + {formatCurrency(account.balance)} + +
+ ))} + + {childFolders.map((child) => ( + + ))} +
+ )} +
+ ) +} + +export default function FoldersPage() { + const { data, isLoading, refresh } = useBankingData() + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [editingFolder, setEditingFolder] = useState(null) + const [formData, setFormData] = useState({ + name: "", + parentId: "folder-root" as string | null, + color: "#6366f1", + }) + + if (isLoading || !data) { + return ( +
+ +
+ +
+
+ ) + } + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + }).format(amount) + } + + const rootFolders = data.folders.filter((f) => f.parentId === null) + + const handleNewFolder = () => { + setEditingFolder(null) + setFormData({ name: "", parentId: "folder-root", color: "#6366f1" }) + setIsDialogOpen(true) + } + + const handleEdit = (folder: FolderType) => { + setEditingFolder(folder) + setFormData({ + name: folder.name, + parentId: folder.parentId || "folder-root", + color: folder.color, + }) + setIsDialogOpen(true) + } + + const handleSave = async () => { + const parentId = formData.parentId === "folder-root" ? null : formData.parentId + + try { + if (editingFolder) { + await updateFolder({ + ...editingFolder, + name: formData.name, + parentId, + color: formData.color, + }) + } else { + await addFolder({ + name: formData.name, + parentId, + color: formData.color, + icon: "folder", + }) + } + refresh() + setIsDialogOpen(false) + } catch (error) { + console.error("Error saving folder:", error) + alert("Erreur lors de la sauvegarde du dossier") + } + } + + const handleDelete = async (folderId: string) => { + if (!confirm("Supprimer ce dossier ? Les comptes seront déplacés à la racine.")) return + + try { + await deleteFolder(folderId) + refresh() + } catch (error) { + console.error("Error deleting folder:", error) + alert("Erreur lors de la suppression du dossier") + } + } + + return ( +
+ +
+
+
+
+

Organisation

+

Organisez vos comptes en dossiers

+
+ +
+ + + + Arborescence des comptes + + +
+ {rootFolders.map((folder) => ( + + ))} +
+
+
+
+
+ + + + + {editingFolder ? "Modifier le dossier" : "Nouveau dossier"} + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="Ex: Comptes personnels" + /> +
+
+ + +
+
+ +
+ {folderColors.map(({ value }) => ( +
+
+
+ + +
+
+
+
+
+ ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..dc2aea1 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,125 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --font-sans: 'Geist', 'Geist Fallback'; + --font-mono: 'Geist Mono', 'Geist Mono Fallback'; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..7291846 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,25 @@ +import type React from "react" +import type { Metadata } from "next" +import { Geist, Geist_Mono } from "next/font/google" +import "./globals.css" + +const _geist = Geist({ subsets: ["latin"] }) +const _geistMono = Geist_Mono({ subsets: ["latin"] }) + +export const metadata: Metadata = { + title: "FinTrack - Gestion de compte bancaire", + description: "Application de gestion personnelle de comptes bancaires avec import OFX", + generator: 'v0.app' +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + {children} + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..8cd3c20 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,58 @@ +"use client" + +import { Sidebar } from "@/components/dashboard/sidebar" +import { OverviewCards } from "@/components/dashboard/overview-cards" +import { RecentTransactions } from "@/components/dashboard/recent-transactions" +import { AccountsSummary } from "@/components/dashboard/accounts-summary" +import { CategoryBreakdown } from "@/components/dashboard/category-breakdown" +import { OFXImportDialog } from "@/components/import/ofx-import-dialog" +import { useBankingData } from "@/lib/hooks" +import { Button } from "@/components/ui/button" +import { Upload, RefreshCw } from "lucide-react" + +export default function DashboardPage() { + const { data, isLoading, refresh } = useBankingData() + + if (isLoading || !data) { + return ( +
+ +
+ +
+
+ ) + } + + return ( +
+ +
+
+
+
+

Tableau de bord

+

Vue d'ensemble de vos finances

+
+ + + +
+ + + +
+ +
+ + +
+
+
+
+
+ ) +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..48ba1ad --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,187 @@ +"use client" + +import { useState } from "react" +import { Sidebar } from "@/components/dashboard/sidebar" +import { useBankingData } from "@/lib/hooks" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Download, Trash2, Upload, RefreshCw, Database, FileJson } from "lucide-react" +import type { BankingData } from "@/lib/types" + +export default function SettingsPage() { + const { data, isLoading, refresh, update } = useBankingData() + const [importing, setImporting] = useState(false) + + if (isLoading || !data) { + return ( +
+ +
+ +
+
+ ) + } + + const exportData = () => { + const dataStr = JSON.stringify(data, null, 2) + const blob = new Blob([dataStr], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `fintrack-backup-${new Date().toISOString().split("T")[0]}.json` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + + const importData = () => { + const input = document.createElement("input") + input.type = "file" + input.accept = ".json" + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0] + if (!file) return + + setImporting(true) + try { + const content = await file.text() + const importedData = JSON.parse(content) as BankingData + + // Validate structure + if (!importedData.accounts || !importedData.transactions || !importedData.categories || !importedData.folders) { + alert("Format de fichier invalide") + return + } + + update(importedData) + alert("Données importées avec succÚs") + } catch (error) { + alert("Erreur lors de l'import") + } finally { + setImporting(false) + } + } + input.click() + } + + const resetData = () => { + localStorage.removeItem("banking-app-data") + window.location.reload() + } + + return ( +
+ +
+
+
+

ParamĂštres

+

Gérez vos données et préférences

+
+ + + + + + Données + + Exportez ou importez vos données pour les sauvegarder ou les transférer + + +
+
+

Statistiques

+

+ {data.accounts.length} comptes, {data.transactions.length} transactions, {data.categories.length}{" "} + catégories +

+
+
+ +
+ + +
+
+
+ + + + + + Zone dangereuse + + Actions irrĂ©versibles - procĂ©dez avec prudence + + + + + + + + + Êtes-vous sĂ»r ? + + Cette action supprimera dĂ©finitivement tous vos comptes, transactions, catĂ©gories et dossiers. + Cette action est irrĂ©versible. + + + + Annuler + + Supprimer tout + + + + + + + + + + + + Format OFX + + Informations sur l'import de fichiers + + +
+

+ 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. +

+

+ 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. +

+
+
+
+
+
+
+ ) +} diff --git a/app/statistics/page.tsx b/app/statistics/page.tsx new file mode 100644 index 0000000..90cde2c --- /dev/null +++ b/app/statistics/page.tsx @@ -0,0 +1,415 @@ +"use client" + +import { useState, useMemo } from "react" +import { Sidebar } from "@/components/dashboard/sidebar" +import { useBankingData } from "@/lib/hooks" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { RefreshCw, TrendingUp, TrendingDown, ArrowRight } from "lucide-react" +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + PieChart, + Pie, + Cell, + LineChart, + Line, + Legend, +} from "recharts" +import { cn } from "@/lib/utils" + +type Period = "3months" | "6months" | "12months" | "all" + +export default function StatisticsPage() { + const { data, isLoading } = useBankingData() + const [period, setPeriod] = useState("6months") + const [selectedAccount, setSelectedAccount] = useState("all") + + const stats = useMemo(() => { + if (!data) return null + + const now = new Date() + let startDate: Date + + switch (period) { + case "3months": + startDate = new Date(now.getFullYear(), now.getMonth() - 3, 1) + break + case "6months": + startDate = new Date(now.getFullYear(), now.getMonth() - 6, 1) + break + case "12months": + startDate = new Date(now.getFullYear(), now.getMonth() - 12, 1) + break + default: + startDate = new Date(0) + } + + let transactions = data.transactions.filter((t) => new Date(t.date) >= startDate) + + if (selectedAccount !== "all") { + transactions = transactions.filter((t) => t.accountId === selectedAccount) + } + + // Monthly breakdown + const monthlyData = new Map() + transactions.forEach((t) => { + const monthKey = t.date.substring(0, 7) + const current = monthlyData.get(monthKey) || { income: 0, expenses: 0 } + if (t.amount >= 0) { + current.income += t.amount + } else { + current.expenses += Math.abs(t.amount) + } + monthlyData.set(monthKey, current) + }) + + const monthlyChartData = Array.from(monthlyData.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([month, values]) => ({ + month: new Date(month + "-01").toLocaleDateString("fr-FR", { month: "short", year: "2-digit" }), + revenus: Math.round(values.income), + depenses: Math.round(values.expenses), + solde: Math.round(values.income - values.expenses), + })) + + // Category breakdown (expenses only) + const categoryTotals = new Map() + transactions + .filter((t) => t.amount < 0) + .forEach((t) => { + const catId = t.categoryId || "uncategorized" + const current = categoryTotals.get(catId) || 0 + categoryTotals.set(catId, current + Math.abs(t.amount)) + }) + + const categoryChartData = Array.from(categoryTotals.entries()) + .map(([categoryId, total]) => { + const category = data.categories.find((c) => c.id === categoryId) + return { + name: category?.name || "Non catégorisé", + value: Math.round(total), + color: category?.color || "#94a3b8", + } + }) + .sort((a, b) => b.value - a.value) + .slice(0, 8) + + // Top expenses + const topExpenses = transactions + .filter((t) => t.amount < 0) + .sort((a, b) => a.amount - b.amount) + .slice(0, 5) + + // Summary + const totalIncome = transactions.filter((t) => t.amount >= 0).reduce((sum, t) => sum + t.amount, 0) + const totalExpenses = transactions.filter((t) => t.amount < 0).reduce((sum, t) => sum + Math.abs(t.amount), 0) + const avgMonthlyExpenses = monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0 + + // Balance evolution + const sortedTransactions = [...transactions].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + + let runningBalance = 0 + const balanceByDate = new Map() + sortedTransactions.forEach((t) => { + runningBalance += t.amount + balanceByDate.set(t.date, runningBalance) + }) + + const balanceChartData = Array.from(balanceByDate.entries()).map(([date, balance]) => ({ + date: new Date(date).toLocaleDateString("fr-FR", { day: "2-digit", month: "short" }), + solde: Math.round(balance), + })) + + return { + monthlyChartData, + categoryChartData, + topExpenses, + totalIncome, + totalExpenses, + avgMonthlyExpenses, + balanceChartData, + transactionCount: transactions.length, + } + }, [data, period, selectedAccount]) + + if (isLoading || !data || !stats) { + return ( +
+ +
+ +
+
+ ) + } + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + }).format(amount) + } + + return ( +
+ +
+
+
+
+

Statistiques

+

Analysez vos dépenses et revenus

+
+
+ + +
+
+ + {/* Summary Cards */} +
+ + + + + Total Revenus + + + +
{formatCurrency(stats.totalIncome)}
+
+
+ + + + + + Total Dépenses + + + +
{formatCurrency(stats.totalExpenses)}
+
+
+ + + + + + Moyenne mensuelle + + + +
{formatCurrency(stats.avgMonthlyExpenses)}
+
+
+ + + + Économies + + +
= 0 ? "text-emerald-600" : "text-red-600", + )} + > + {formatCurrency(stats.totalIncome - stats.totalExpenses)} +
+
+
+
+ + {/* Charts */} +
+ {/* Monthly Income vs Expenses */} + + + Revenus vs Dépenses par mois + + + {stats.monthlyChartData.length > 0 ? ( +
+ + + + + `${v}€`} /> + formatCurrency(value)} + contentStyle={{ + backgroundColor: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: "8px", + }} + /> + + + + + +
+ ) : ( +
+ Pas de données pour cette période +
+ )} +
+
+ + {/* Category Breakdown */} + + + Répartition par catégorie + + + {stats.categoryChartData.length > 0 ? ( +
+ + + + {stats.categoryChartData.map((entry, index) => ( + + ))} + + formatCurrency(value)} + contentStyle={{ + backgroundColor: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: "8px", + }} + /> + {value}} /> + + +
+ ) : ( +
+ Pas de données pour cette période +
+ )} +
+
+ + {/* Balance Evolution */} + + + Évolution du solde + + + {stats.balanceChartData.length > 0 ? ( +
+ + + + + `${v}€`} /> + formatCurrency(value)} + contentStyle={{ + backgroundColor: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: "8px", + }} + /> + + + +
+ ) : ( +
+ Pas de données pour cette période +
+ )} +
+
+ + {/* Top Expenses */} + + + Top 5 dépenses + + + {stats.topExpenses.length > 0 ? ( +
+ {stats.topExpenses.map((expense, index) => { + const category = data.categories.find((c) => c.id === expense.categoryId) + return ( +
+
+ {index + 1} +
+
+

{expense.description}

+
+ + {new Date(expense.date).toLocaleDateString("fr-FR")} + + {category && ( + + {category.name} + + )} +
+
+
+ {formatCurrency(expense.amount)} +
+
+ ) + })} +
+ ) : ( +
+ Pas de dépenses pour cette période +
+ )} +
+
+
+
+
+
+ ) +} diff --git a/app/transactions/loading.tsx b/app/transactions/loading.tsx new file mode 100644 index 0000000..f15322a --- /dev/null +++ b/app/transactions/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return null +} diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx new file mode 100644 index 0000000..ffb9813 --- /dev/null +++ b/app/transactions/page.tsx @@ -0,0 +1,475 @@ +"use client" + +import { useState, useMemo } from "react" +import { Sidebar } from "@/components/dashboard/sidebar" +import { useBankingData } from "@/lib/hooks" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu" +import { OFXImportDialog } from "@/components/import/ofx-import-dialog" +import { Search, CheckCircle2, Circle, MoreVertical, Tags, Upload, RefreshCw, ArrowUpDown, Check } from "lucide-react" +import { cn } from "@/lib/utils" + +type SortField = "date" | "amount" | "description" +type SortOrder = "asc" | "desc" + +export default function TransactionsPage() { + const { data, isLoading, refresh, update } = useBankingData() + const [searchQuery, setSearchQuery] = useState("") + const [selectedAccount, setSelectedAccount] = useState("all") + const [selectedCategory, setSelectedCategory] = useState("all") + const [showReconciled, setShowReconciled] = useState("all") + const [sortField, setSortField] = useState("date") + const [sortOrder, setSortOrder] = useState("desc") + const [selectedTransactions, setSelectedTransactions] = useState>(new Set()) + + const filteredTransactions = useMemo(() => { + if (!data) return [] + + let transactions = [...data.transactions] + + // Filter by search + if (searchQuery) { + const query = searchQuery.toLowerCase() + transactions = transactions.filter( + (t) => t.description.toLowerCase().includes(query) || t.memo?.toLowerCase().includes(query), + ) + } + + // Filter by account + if (selectedAccount !== "all") { + transactions = transactions.filter((t) => t.accountId === selectedAccount) + } + + // Filter by category + if (selectedCategory !== "all") { + if (selectedCategory === "uncategorized") { + transactions = transactions.filter((t) => !t.categoryId) + } else { + transactions = transactions.filter((t) => t.categoryId === selectedCategory) + } + } + + // Filter by reconciliation status + if (showReconciled !== "all") { + const isReconciled = showReconciled === "reconciled" + transactions = transactions.filter((t) => t.isReconciled === isReconciled) + } + + // Sort + transactions.sort((a, b) => { + let comparison = 0 + switch (sortField) { + case "date": + comparison = new Date(a.date).getTime() - new Date(b.date).getTime() + break + case "amount": + comparison = a.amount - b.amount + break + case "description": + comparison = a.description.localeCompare(b.description) + break + } + return sortOrder === "asc" ? comparison : -comparison + }) + + return transactions + }, [data, searchQuery, selectedAccount, selectedCategory, showReconciled, sortField, sortOrder]) + + if (isLoading || !data) { + return ( +
+ +
+ +
+
+ ) + } + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + }).format(amount) + } + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "short", + year: "numeric", + }) + } + + const toggleReconciled = (transactionId: string) => { + const updatedTransactions = data.transactions.map((t) => + t.id === transactionId ? { ...t, isReconciled: !t.isReconciled } : t, + ) + update({ ...data, transactions: updatedTransactions }) + } + + const setCategory = (transactionId: string, categoryId: string | null) => { + const updatedTransactions = data.transactions.map((t) => (t.id === transactionId ? { ...t, categoryId } : t)) + update({ ...data, transactions: updatedTransactions }) + } + + const bulkReconcile = (reconciled: boolean) => { + const updatedTransactions = data.transactions.map((t) => + selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t, + ) + update({ ...data, transactions: updatedTransactions }) + setSelectedTransactions(new Set()) + } + + const bulkSetCategory = (categoryId: string | null) => { + const updatedTransactions = data.transactions.map((t) => + selectedTransactions.has(t.id) ? { ...t, categoryId } : t, + ) + update({ ...data, transactions: updatedTransactions }) + setSelectedTransactions(new Set()) + } + + const toggleSelectAll = () => { + if (selectedTransactions.size === filteredTransactions.length) { + setSelectedTransactions(new Set()) + } else { + setSelectedTransactions(new Set(filteredTransactions.map((t) => t.id))) + } + } + + const toggleSelectTransaction = (id: string) => { + const newSelected = new Set(selectedTransactions) + if (newSelected.has(id)) { + newSelected.delete(id) + } else { + newSelected.add(id) + } + setSelectedTransactions(newSelected) + } + + const getCategory = (categoryId: string | null) => { + if (!categoryId) return null + return data.categories.find((c) => c.id === categoryId) + } + + const getAccount = (accountId: string) => { + return data.accounts.find((a) => a.id === accountId) + } + + return ( +
+ +
+
+
+
+

Transactions

+

+ {filteredTransactions.length} transaction{filteredTransactions.length > 1 ? "s" : ""} +

+
+ + + +
+ + {/* Filters */} + + +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+
+ + + + + + +
+
+
+ + {/* Bulk actions */} + {selectedTransactions.size > 0 && ( + + +
+ + {selectedTransactions.size} sélectionnée{selectedTransactions.size > 1 ? "s" : ""} + + + + + + + + + bulkSetCategory(null)}>Aucune catégorie + + {data.categories.map((cat) => ( + bulkSetCategory(cat.id)}> +
+ {cat.name} + + ))} + + +
+ + + )} + + {/* Transactions list */} + + + {filteredTransactions.length === 0 ? ( +
+

Aucune transaction trouvée

+
+ ) : ( +
+ + + + + + + + + + + + + + + {filteredTransactions.map((transaction) => { + const category = getCategory(transaction.categoryId) + const account = getAccount(transaction.accountId) + + return ( + + + + + + + + + + + ) + })} + +
+ 0 + } + onCheckedChange={toggleSelectAll} + /> + + + + + CompteCatégorie + + Pointé
+ toggleSelectTransaction(transaction.id)} + /> + + {formatDate(transaction.date)} + +

{transaction.description}

+ {transaction.memo && ( +

+ {transaction.memo} +

+ )} +
{account?.name || "-"} + + + + + + setCategory(transaction.id, null)}> + Aucune catégorie + + + {data.categories.map((cat) => ( + setCategory(transaction.id, cat.id)}> +
+ {cat.name} + {transaction.categoryId === cat.id && } + + ))} + + +
= 0 ? "text-emerald-600" : "text-red-600", + )} + > + {transaction.amount >= 0 ? "+" : ""} + {formatCurrency(transaction.amount)} + + + + + + + + + toggleReconciled(transaction.id)}> + {transaction.isReconciled ? "Dépointer" : "Pointer"} + + + +
+
+ )} +
+
+
+
+
+ ) +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..4ee62ee --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/components/dashboard/accounts-summary.tsx b/components/dashboard/accounts-summary.tsx new file mode 100644 index 0000000..d62a42e --- /dev/null +++ b/components/dashboard/accounts-summary.tsx @@ -0,0 +1,81 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Progress } from "@/components/ui/progress" +import type { BankingData } from "@/lib/types" +import { cn } from "@/lib/utils" +import { Building2 } from "lucide-react" + +interface AccountsSummaryProps { + data: BankingData +} + +export function AccountsSummary({ data }: AccountsSummaryProps) { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + }).format(amount) + } + + const totalPositive = data.accounts.filter((a) => a.balance > 0).reduce((sum, a) => sum + a.balance, 0) + + if (data.accounts.length === 0) { + return ( + + + Mes Comptes + + +
+ +

Aucun compte

+

Importez un fichier OFX pour ajouter un compte

+
+
+
+ ) + } + + return ( + + + Mes Comptes + + +
+ {data.accounts.map((account) => { + const percentage = totalPositive > 0 ? Math.max(0, (account.balance / totalPositive) * 100) : 0 + + return ( +
+
+
+
+ +
+
+

{account.name}

+

+ {account.accountNumber.slice(-4).padStart(account.accountNumber.length, "*")} +

+
+
+ = 0 ? "text-emerald-600" : "text-red-600", + )} + > + {formatCurrency(account.balance)} + +
+ {account.balance > 0 && } +
+ ) + })} +
+
+
+ ) +} diff --git a/components/dashboard/category-breakdown.tsx b/components/dashboard/category-breakdown.tsx new file mode 100644 index 0000000..1434a50 --- /dev/null +++ b/components/dashboard/category-breakdown.tsx @@ -0,0 +1,98 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import type { BankingData } from "@/lib/types" +import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts" + +interface CategoryBreakdownProps { + data: BankingData +} + +export function CategoryBreakdown({ data }: CategoryBreakdownProps) { + // Get current month expenses by category + const thisMonth = new Date() + thisMonth.setDate(1) + const thisMonthStr = thisMonth.toISOString().slice(0, 7) + + const monthExpenses = data.transactions.filter((t) => t.date.startsWith(thisMonthStr) && t.amount < 0) + + const categoryTotals = new Map() + + monthExpenses.forEach((t) => { + const catId = t.categoryId || "uncategorized" + const current = categoryTotals.get(catId) || 0 + categoryTotals.set(catId, current + Math.abs(t.amount)) + }) + + const chartData = Array.from(categoryTotals.entries()) + .map(([categoryId, total]) => { + const category = data.categories.find((c) => c.id === categoryId) + return { + name: category?.name || "Non catégorisé", + value: total, + color: category?.color || "#94a3b8", + } + }) + .sort((a, b) => b.value - a.value) + .slice(0, 6) + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + }).format(value) + } + + if (chartData.length === 0) { + return ( + + + Dépenses par catégorie + + +
+

Pas de données ce mois-ci

+
+
+
+ ) + } + + return ( + + + Dépenses par catégorie + + +
+ + + + {chartData.map((entry, index) => ( + + ))} + + formatCurrency(value)} + contentStyle={{ + backgroundColor: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + borderRadius: "8px", + }} + /> + {value}} /> + + +
+
+
+ ) +} diff --git a/components/dashboard/overview-cards.tsx b/components/dashboard/overview-cards.tsx new file mode 100644 index 0000000..57d74ae --- /dev/null +++ b/components/dashboard/overview-cards.tsx @@ -0,0 +1,96 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { TrendingUp, TrendingDown, Wallet, CreditCard } from "lucide-react" +import type { BankingData } from "@/lib/types" + +interface OverviewCardsProps { + data: BankingData +} + +export function OverviewCards({ data }: OverviewCardsProps) { + const totalBalance = data.accounts.reduce((sum, acc) => sum + acc.balance, 0) + + const thisMonth = new Date() + thisMonth.setDate(1) + const thisMonthStr = thisMonth.toISOString().slice(0, 7) + + const monthTransactions = data.transactions.filter((t) => t.date.startsWith(thisMonthStr)) + + const income = monthTransactions.filter((t) => t.amount > 0).reduce((sum, t) => sum + t.amount, 0) + + const expenses = monthTransactions.filter((t) => t.amount < 0).reduce((sum, t) => sum + Math.abs(t.amount), 0) + + const reconciled = data.transactions.filter((t) => t.isReconciled).length + const total = data.transactions.length + const reconciledPercent = total > 0 ? Math.round((reconciled / total) * 100) : 0 + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + }).format(amount) + } + + return ( +
+ + + Solde Total + + + +
= 0 ? "text-emerald-600" : "text-red-600")}> + {formatCurrency(totalBalance)} +
+

+ {data.accounts.length} compte{data.accounts.length > 1 ? "s" : ""} +

+
+
+ + + + Revenus du mois + + + +
{formatCurrency(income)}
+

+ {monthTransactions.filter((t) => t.amount > 0).length} opération + {monthTransactions.filter((t) => t.amount > 0).length > 1 ? "s" : ""} +

+
+
+ + + + Dépenses du mois + + + +
{formatCurrency(expenses)}
+

+ {monthTransactions.filter((t) => t.amount < 0).length} opération + {monthTransactions.filter((t) => t.amount < 0).length > 1 ? "s" : ""} +

+
+
+ + + + Pointage + + + +
{reconciledPercent}%
+

+ {reconciled} / {total} opérations pointées +

+
+
+
+ ) +} + +import { cn } from "@/lib/utils" diff --git a/components/dashboard/recent-transactions.tsx b/components/dashboard/recent-transactions.tsx new file mode 100644 index 0000000..0af44a3 --- /dev/null +++ b/components/dashboard/recent-transactions.tsx @@ -0,0 +1,114 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { CheckCircle2, Circle } from "lucide-react" +import type { BankingData } from "@/lib/types" +import { cn } from "@/lib/utils" + +interface RecentTransactionsProps { + data: BankingData +} + +export function RecentTransactions({ data }: RecentTransactionsProps) { + const recentTransactions = [...data.transactions] + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + .slice(0, 10) + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + }).format(amount) + } + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "short", + }) + } + + const getCategory = (categoryId: string | null) => { + if (!categoryId) return null + return data.categories.find((c) => c.id === categoryId) + } + + const getAccount = (accountId: string) => { + return data.accounts.find((a) => a.id === accountId) + } + + if (recentTransactions.length === 0) { + return ( + + + Transactions récentes + + +
+

Aucune transaction

+

Importez un fichier OFX pour commencer

+
+
+
+ ) + } + + return ( + + + Transactions récentes + + +
+ {recentTransactions.map((transaction) => { + const category = getCategory(transaction.categoryId) + const account = getAccount(transaction.accountId) + + return ( +
+
+ {transaction.isReconciled ? ( + + ) : ( + + )} +
+ +
+

{transaction.description}

+
+ {formatDate(transaction.date)} + {account && ‱ {account.name}} + {category && ( + + {category.name} + + )} +
+
+ +
= 0 ? "text-emerald-600" : "text-red-600", + )} + > + {transaction.amount >= 0 ? "+" : ""} + {formatCurrency(transaction.amount)} +
+
+ ) + })} +
+
+
+ ) +} diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx new file mode 100644 index 0000000..d9b60ef --- /dev/null +++ b/components/dashboard/sidebar.tsx @@ -0,0 +1,81 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + LayoutDashboard, + Wallet, + FolderTree, + Tags, + BarChart3, + Upload, + ChevronLeft, + ChevronRight, + Settings, +} from "lucide-react" + +const navItems = [ + { href: "/", label: "Tableau de bord", icon: LayoutDashboard }, + { href: "/accounts", label: "Comptes", icon: Wallet }, + { href: "/folders", label: "Organisation", icon: FolderTree }, + { href: "/transactions", label: "Transactions", icon: Upload }, + { href: "/categories", label: "Catégories", icon: Tags }, + { href: "/statistics", label: "Statistiques", icon: BarChart3 }, +] + +export function Sidebar() { + const pathname = usePathname() + const [collapsed, setCollapsed] = useState(false) + + return ( + + ) +} diff --git a/components/import/ofx-import-dialog.tsx b/components/import/ofx-import-dialog.tsx new file mode 100644 index 0000000..9c8c111 --- /dev/null +++ b/components/import/ofx-import-dialog.tsx @@ -0,0 +1,507 @@ +"use client" + +import type React from "react" + +import { useState, useCallback } from "react" +import { useDropzone } from "react-dropzone" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Progress } from "@/components/ui/progress" +import { Upload, FileText, CheckCircle2, AlertCircle, Loader2 } from "lucide-react" +import { parseOFX } from "@/lib/ofx-parser" +import { loadData, addAccount, updateAccount, addTransactions, generateId, autoCategorize } from "@/lib/store-db" +import type { OFXAccount, Account, Transaction, Folder, BankingData } from "@/lib/types" +import { cn } from "@/lib/utils" + +interface OFXImportDialogProps { + children: React.ReactNode + onImportComplete?: () => void +} + +type ImportStep = "upload" | "configure" | "importing" | "success" | "error" + +interface ImportResult { + fileName: string + accountName: string + transactionsImported: number + isNew: boolean + error?: string +} + +export function OFXImportDialog({ children, onImportComplete }: OFXImportDialogProps) { + const [open, setOpen] = useState(false) + const [step, setStep] = useState("upload") + + // Single file mode + const [parsedData, setParsedData] = useState(null) + const [accountName, setAccountName] = useState("") + const [selectedFolder, setSelectedFolder] = useState("folder-root") + const [folders, setFolders] = useState([]) + const [existingAccountId, setExistingAccountId] = useState(null) + + // Multi-file mode + const [importResults, setImportResults] = useState([]) + const [importProgress, setImportProgress] = useState(0) + const [totalFiles, setTotalFiles] = useState(0) + + const [error, setError] = useState(null) + + // Import a single OFX file directly (for multi-file mode) + const importOFXDirect = async ( + parsed: OFXAccount, + fileName: string, + data: BankingData + ): Promise => { + try { + // Check if account already exists + const existing = data.accounts.find( + (a) => a.accountNumber === parsed.accountId && a.bankId === parsed.bankId + ) + + let accountId: string + let accountName: string + let isNew = false + + if (existing) { + accountId = existing.id + accountName = existing.name + await updateAccount({ + ...existing, + balance: parsed.balance, + lastImport: new Date().toISOString(), + }) + } else { + isNew = true + accountName = `Compte ${parsed.accountId.slice(-4)}` + const newAccount = await addAccount({ + name: accountName, + bankId: parsed.bankId, + accountNumber: parsed.accountId, + type: parsed.accountType as Account["type"], + folderId: null, + balance: parsed.balance, + currency: parsed.currency, + lastImport: new Date().toISOString(), + }) + accountId = newAccount.id + } + + // Add transactions with auto-categorization + const existingFitIds = new Set( + data.transactions.filter((t) => t.accountId === accountId).map((t) => t.fitId) + ) + + const newTransactions: Transaction[] = parsed.transactions + .filter((t) => !existingFitIds.has(t.fitId)) + .map((t) => ({ + id: generateId(), + accountId, + date: t.date, + amount: t.amount, + description: t.name, + type: t.amount >= 0 ? "CREDIT" : "DEBIT", + categoryId: autoCategorize(t.name + " " + (t.memo || ""), data.categories), + isReconciled: false, + fitId: t.fitId, + memo: t.memo, + checkNum: t.checkNum, + })) + + if (newTransactions.length > 0) { + await addTransactions(newTransactions) + } + + return { + fileName, + accountName, + transactionsImported: newTransactions.length, + isNew, + } + } catch (err) { + return { + fileName, + accountName: "Erreur", + transactionsImported: 0, + isNew: false, + error: err instanceof Error ? err.message : "Erreur inconnue", + } + } + } + + const onDrop = useCallback(async (acceptedFiles: File[]) => { + if (acceptedFiles.length === 0) return + + // Multi-file mode: import directly + if (acceptedFiles.length > 1) { + setStep("importing") + setTotalFiles(acceptedFiles.length) + setImportProgress(0) + setImportResults([]) + + const data = await loadData() + const results: ImportResult[] = [] + + for (let i = 0; i < acceptedFiles.length; i++) { + const file = acceptedFiles[i] + const content = await file.text() + const parsed = parseOFX(content) + + if (parsed) { + // Reload data after each import to get updated accounts/transactions + const freshData = i === 0 ? data : await loadData() + const result = await importOFXDirect(parsed, file.name, freshData) + results.push(result) + } else { + results.push({ + fileName: file.name, + accountName: "Erreur", + transactionsImported: 0, + isNew: false, + error: "Format OFX invalide", + }) + } + + setImportProgress(((i + 1) / acceptedFiles.length) * 100) + setImportResults([...results]) + } + + setStep("success") + onImportComplete?.() + return + } + + // Single file mode: show configuration + const file = acceptedFiles[0] + const content = await file.text() + const parsed = parseOFX(content) + + if (parsed) { + setParsedData(parsed) + setAccountName(`Compte ${parsed.accountId.slice(-4)}`) + + try { + const data = await loadData() + setFolders(data.folders) + + const existing = data.accounts.find( + (a) => a.accountNumber === parsed.accountId && a.bankId === parsed.bankId + ) + if (existing) { + setExistingAccountId(existing.id) + setAccountName(existing.name) + setSelectedFolder(existing.folderId || "folder-root") + } + + setStep("configure") + } catch (err) { + console.error("Error loading data:", err) + setError("Erreur lors du chargement des données") + setStep("error") + } + } else { + setError("Impossible de lire le fichier OFX. Vérifiez le format.") + setStep("error") + } + }, [onImportComplete]) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + "application/x-ofx": [".ofx"], + "application/vnd.intu.qfx": [".qfx"], + "text/plain": [".ofx", ".qfx"], + }, + // No maxFiles limit - accept multiple files + }) + + const handleImport = async () => { + if (!parsedData) return + + try { + setStep("importing") + const data = await loadData() + + let accountId: string + + if (existingAccountId) { + accountId = existingAccountId + const existingAccount = data.accounts.find((a) => a.id === existingAccountId) + if (existingAccount) { + await updateAccount({ + ...existingAccount, + name: accountName, + folderId: selectedFolder, + balance: parsedData.balance, + lastImport: new Date().toISOString(), + }) + } + } else { + const newAccount = await addAccount({ + name: accountName, + bankId: parsedData.bankId, + accountNumber: parsedData.accountId, + type: parsedData.accountType as Account["type"], + folderId: selectedFolder, + balance: parsedData.balance, + currency: parsedData.currency, + lastImport: new Date().toISOString(), + }) + accountId = newAccount.id + } + + const existingFitIds = new Set( + data.transactions.filter((t) => t.accountId === accountId).map((t) => t.fitId) + ) + + const newTransactions: Transaction[] = parsedData.transactions + .filter((t) => !existingFitIds.has(t.fitId)) + .map((t) => ({ + id: generateId(), + accountId, + date: t.date, + amount: t.amount, + description: t.name, + type: t.amount >= 0 ? "CREDIT" : "DEBIT", + categoryId: autoCategorize(t.name + " " + (t.memo || ""), data.categories), + isReconciled: false, + fitId: t.fitId, + memo: t.memo, + checkNum: t.checkNum, + })) + + if (newTransactions.length > 0) { + await addTransactions(newTransactions) + } + + setImportResults([{ + fileName: "Import", + accountName, + transactionsImported: newTransactions.length, + isNew: !existingAccountId, + }]) + setStep("success") + onImportComplete?.() + } catch (err) { + console.error("Error importing:", err) + setError("Erreur lors de l'import") + setStep("error") + } + } + + const handleClose = () => { + setOpen(false) + setTimeout(() => { + setStep("upload") + setParsedData(null) + setAccountName("") + setSelectedFolder("folder-root") + setExistingAccountId(null) + setError(null) + setImportResults([]) + setImportProgress(0) + setTotalFiles(0) + }, 200) + } + + const totalTransactions = importResults.reduce((sum, r) => sum + r.transactionsImported, 0) + const successCount = importResults.filter((r) => !r.error).length + const errorCount = importResults.filter((r) => r.error).length + + return ( + { + if (!o) handleClose() + else setOpen(true) + }} + > + {children} + + + + {step === "upload" && "Importer des fichiers OFX"} + {step === "configure" && "Configurer le compte"} + {step === "importing" && "Import en cours..."} + {step === "success" && "Import terminé"} + {step === "error" && "Erreur d'import"} + + + {step === "upload" && "Glissez-déposez vos fichiers OFX ou cliquez pour sélectionner"} + {step === "configure" && "Vérifiez les informations du compte avant l'import"} + {step === "importing" && `Import de ${totalFiles} fichier${totalFiles > 1 ? "s" : ""}...`} + {step === "success" && ( + importResults.length > 1 + ? `${successCount} fichier${successCount > 1 ? "s" : ""} importé${successCount > 1 ? "s" : ""}, ${totalTransactions} transactions` + : `${totalTransactions} nouvelles transactions importées` + )} + {step === "error" && error} + + + + {step === "upload" && ( +
+ + +

+ {isDragActive ? "Déposez les fichiers ici..." : "Fichiers .ofx ou .qfx acceptés"} +

+

+ Un fichier = configuration manuelle ‱ Plusieurs fichiers = import direct +

+
+ )} + + {step === "configure" && parsedData && ( +
+
+ +
+

{parsedData.transactions.length} transactions

+

+ Solde:{" "} + {new Intl.NumberFormat("fr-FR", { style: "currency", currency: parsedData.currency }).format( + parsedData.balance, + )} +

+
+
+ +
+ + setAccountName(e.target.value)} + placeholder="Ex: Compte courant BNP" + /> +
+ +
+ + +
+ + {existingAccountId && ( +

+ Ce compte existe déjà. Les nouvelles transactions seront ajoutées. +

+ )} + +
+ + +
+
+ )} + + {step === "importing" && ( +
+
+ + Import en cours... +
+ {totalFiles > 1 && ( + + )} + {importResults.length > 0 && ( +
+ {importResults.map((result, i) => ( +
+ {result.error ? ( + + ) : ( + + )} + {result.fileName} + {!result.error && ( + + +{result.transactionsImported} + + )} +
+ ))} +
+ )} +
+ )} + + {step === "success" && ( +
+ + + {importResults.length > 1 && ( +
+ {importResults.map((result, i) => ( +
+ {result.error ? ( + + ) : ( + + )} +
+

{result.accountName}

+

{result.fileName}

+
+ {result.error ? ( + {result.error} + ) : ( + + {result.isNew ? "Nouveau" : "Mis à jour"} ‱ +{result.transactionsImported} + + )} +
+ ))} +
+ )} + + {errorCount > 0 && ( +

+ {errorCount} fichier{errorCount > 1 ? "s" : ""} en erreur +

+ )} + +
+ +
+
+ )} + + {step === "error" && ( +
+ + +
+ )} +
+
+ ) +} diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx new file mode 100644 index 0000000..55c2f6e --- /dev/null +++ b/components/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as React from 'react' +import { + ThemeProvider as NextThemesProvider, + type ThemeProviderProps, +} from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..e538a33 --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +'use client' + +import * as React from 'react' +import * as AccordionPrimitive from '@radix-ui/react-accordion' +import { ChevronDownIcon } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180', + className, + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..9704452 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +'use client' + +import * as React from 'react' +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' + +import { cn } from '@/lib/utils' +import { buttonVariants } from '@/components/ui/button' + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..e6751ab --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + { + variants: { + variant: { + default: 'bg-card text-card-foreground', + destructive: + 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..40bb120 --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio' + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..aa98465 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +'use client' + +import * as React from 'react' +import * as AvatarPrimitive from '@radix-ui/react-avatar' + +import { cn } from '@/lib/utils' + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..fc4126b --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', + secondary: + 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', + destructive: + 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<'span'> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : 'span' + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..1750ff2 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { ChevronRight, MoreHorizontal } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { + return