feat: refactor dashboard and account pages to utilize new layout components, enhancing structure and loading states

This commit is contained in:
Julien Froidefond
2025-11-27 12:44:44 +01:00
parent e469656e0d
commit 88937579e2
40 changed files with 2781 additions and 2226 deletions

View File

@@ -1,60 +1,14 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Sidebar } from "@/components/dashboard/sidebar"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import { AccountCard, AccountEditDialog } from "@/components/accounts";
import { useBankingData } from "@/lib/hooks"; import { useBankingData } from "@/lib/hooks";
import { updateAccount, deleteAccount } from "@/lib/store-db"; import { updateAccount, deleteAccount } from "@/lib/store-db";
import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Building2 } from "lucide-react";
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,
ExternalLink,
} from "lucide-react";
import type { Account } from "@/lib/types"; import type { Account } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Link from "next/link";
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() { export default function AccountsPage() {
const { data, isLoading, refresh } = useBankingData(); const { data, isLoading, refresh } = useBankingData();
@@ -68,14 +22,7 @@ export default function AccountsPage() {
}); });
if (isLoading || !data) { if (isLoading || !data) {
return ( return <LoadingState />;
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
);
} }
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
@@ -136,29 +83,24 @@ export default function AccountsPage() {
const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0); const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0);
return ( return (
<div className="flex h-screen bg-background"> <PageLayout>
<Sidebar /> <PageHeader
<main className="flex-1 overflow-auto"> title="Comptes"
<div className="p-6 space-y-6"> description="Gérez vos comptes bancaires"
<div className="flex items-center justify-between"> rightContent={
<div>
<h1 className="text-2xl font-bold text-foreground">Comptes</h1>
<p className="text-muted-foreground">
Gérez vos comptes bancaires
</p>
</div>
<div className="text-right"> <div className="text-right">
<p className="text-sm text-muted-foreground">Solde total</p> <p className="text-sm text-muted-foreground">Solde total</p>
<p <p
className={cn( className={cn(
"text-2xl font-bold", "text-2xl font-bold",
totalBalance >= 0 ? "text-emerald-600" : "text-red-600", totalBalance >= 0 ? "text-emerald-600" : "text-red-600"
)} )}
> >
{formatCurrency(totalBalance)} {formatCurrency(totalBalance)}
</p> </p>
</div> </div>
</div> }
/>
{data.accounts.length === 0 ? ( {data.accounts.length === 0 ? (
<Card> <Card>
@@ -174,179 +116,31 @@ export default function AccountsPage() {
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data.accounts.map((account) => { {data.accounts.map((account) => {
const Icon = accountTypeIcons[account.type]; const folder = data.folders.find((f) => f.id === account.folderId);
const folder = data.folders.find(
(f) => f.id === account.folderId,
);
return ( return (
<Card key={account.id} className="relative"> <AccountCard
<CardHeader className="pb-2"> key={account.id}
<div className="flex items-start justify-between"> account={account}
<div className="flex items-center gap-3"> folder={folder}
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center"> transactionCount={getTransactionCount(account.id)}
<Icon className="w-5 h-5 text-primary" /> onEdit={handleEdit}
</div> onDelete={handleDelete}
<div> formatCurrency={formatCurrency}
<CardTitle className="text-base"> />
{account.name}
</CardTitle>
<p className="text-xs text-muted-foreground">
{accountTypeLabels[account.type]}
</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleEdit(account)}
>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(account.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
<div
className={cn(
"text-2xl font-bold mb-2",
account.balance >= 0
? "text-emerald-600"
: "text-red-600",
)}
>
{formatCurrency(account.balance)}
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<Link
href={`/transactions?accountId=${account.id}`}
className="hover:text-primary hover:underline"
>
{getTransactionCount(account.id)} transactions
</Link>
{folder && <span>{folder.name}</span>}
</div>
{account.lastImport && (
<p className="text-xs text-muted-foreground mt-2">
Dernier import:{" "}
{new Date(account.lastImport).toLocaleDateString(
"fr-FR",
)}
</p>
)}
{account.externalUrl && (
<a
href={account.externalUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-2"
>
<ExternalLink className="w-3 h-3" />
Accéder au portail banque
</a>
)}
</CardContent>
</Card>
); );
})} })}
</div> </div>
)} )}
</div>
</main>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <AccountEditDialog
<DialogContent> open={isDialogOpen}
<DialogHeader> onOpenChange={setIsDialogOpen}
<DialogTitle>Modifier le compte</DialogTitle> formData={formData}
</DialogHeader> onFormDataChange={setFormData}
<div className="space-y-4"> folders={data.folders}
<div className="space-y-2"> onSave={handleSave}
<Label>Nom du compte</Label>
<Input
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
/> />
</div> </PageLayout>
<div className="space-y-2">
<Label>Type de compte</Label>
<Select
value={formData.type}
onValueChange={(v) =>
setFormData({ ...formData, type: v as Account["type"] })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(accountTypeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Dossier</Label>
<Select
value={formData.folderId}
onValueChange={(v) => setFormData({ ...formData, folderId: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{data.folders.map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Lien externe (portail banque)</Label>
<Input
value={formData.externalUrl}
onChange={(e) =>
setFormData({ ...formData, externalUrl: e.target.value })
}
placeholder="https://..."
/>
<p className="text-xs text-muted-foreground">
URL personnalisée vers le portail de votre banque
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Annuler
</Button>
<Button onClick={handleSave}>Enregistrer</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
); );
} }

View File

@@ -1,49 +1,16 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { Sidebar } from "@/components/dashboard/sidebar"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import {
CategoryCard,
CategoryEditDialog,
ParentCategoryRow,
CategorySearchBar,
} from "@/components/categories";
import { useBankingData } from "@/lib/hooks"; import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Plus } from "lucide-react";
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 { import {
autoCategorize, autoCategorize,
addCategory, addCategory,
@@ -51,32 +18,13 @@ import {
deleteCategory, deleteCategory,
} from "@/lib/store-db"; } from "@/lib/store-db";
import type { Category } from "@/lib/types"; 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",
];
export default function CategoriesPage() { export default function CategoriesPage() {
const { data, isLoading, refresh } = useBankingData(); const { data, isLoading, refresh } = useBankingData();
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null); const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [expandedParents, setExpandedParents] = useState<Set<string>>( const [expandedParents, setExpandedParents] = useState<Set<string>>(
new Set(), new Set()
); );
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
@@ -84,7 +32,6 @@ export default function CategoriesPage() {
keywords: [] as string[], keywords: [] as string[],
parentId: null as string | null, parentId: null as string | null,
}); });
const [newKeyword, setNewKeyword] = useState("");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
// Organiser les catégories par parent // Organiser les catégories par parent
@@ -101,7 +48,6 @@ export default function CategoriesPage() {
const children: Record<string, Category[]> = {}; const children: Record<string, Category[]> = {};
const orphans: Category[] = []; const orphans: Category[] = [];
// Grouper les enfants par parent
data.categories data.categories
.filter((c) => c.parentId !== null) .filter((c) => c.parentId !== null)
.forEach((child) => { .forEach((child) => {
@@ -131,14 +77,7 @@ export default function CategoriesPage() {
}); });
if (isLoading || !data) { if (isLoading || !data) {
return ( return <LoadingState />;
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
);
} }
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
@@ -159,11 +98,11 @@ export default function CategoriesPage() {
} }
const categoryTransactions = data.transactions.filter((t) => const categoryTransactions = data.transactions.filter((t) =>
categoryIds.includes(t.categoryId || ""), categoryIds.includes(t.categoryId || "")
); );
const total = categoryTransactions.reduce( const total = categoryTransactions.reduce(
(sum, t) => sum + Math.abs(t.amount), (sum, t) => sum + Math.abs(t.amount),
0, 0
); );
const count = categoryTransactions.length; const count = categoryTransactions.length;
return { total, count }; return { total, count };
@@ -252,30 +191,10 @@ export default function CategoriesPage() {
} }
}; };
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 () => { const reApplyAutoCategories = async () => {
if ( if (
!confirm( !confirm(
"Recatégoriser automatiquement les transactions non catégorisées ?", "Recatégoriser automatiquement les transactions non catégorisées ?"
) )
) )
return; return;
@@ -287,7 +206,7 @@ export default function CategoriesPage() {
for (const transaction of uncategorized) { for (const transaction of uncategorized) {
const categoryId = autoCategorize( const categoryId = autoCategorize(
transaction.description + " " + (transaction.memo || ""), transaction.description + " " + (transaction.memo || ""),
data.categories, data.categories
); );
if (categoryId) { if (categoryId) {
await updateTransaction({ ...transaction, categoryId }); await updateTransaction({ ...transaction, categoryId });
@@ -301,79 +220,31 @@ export default function CategoriesPage() {
}; };
const uncategorizedCount = data.transactions.filter( const uncategorizedCount = data.transactions.filter(
(t) => !t.categoryId, (t) => !t.categoryId
).length; ).length;
// Composant pour une carte de catégorie enfant // Filtrer les catégories selon la recherche
const ChildCategoryCard = ({ category }: { category: Category }) => { const filteredParentCategories = parentCategories.filter((parent) => {
const stats = getCategoryStats(category.id); if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return ( if (parent.name.toLowerCase().includes(query)) return true;
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group"> if (parent.keywords.some((k) => k.toLowerCase().includes(query)))
<div className="flex items-center gap-2 min-w-0 flex-1"> return true;
<div const children = childrenByParent[parent.id] || [];
className="w-5 h-5 rounded-full flex items-center justify-center shrink-0" return children.some(
style={{ backgroundColor: `${category.color}20` }} (c) =>
> c.name.toLowerCase().includes(query) ||
<CategoryIcon c.keywords.some((k) => k.toLowerCase().includes(query))
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)}
</span>
{category.keywords.length > 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)}
>
<Pencil className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => handleDelete(category.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
); );
}; });
return ( return (
<div className="flex h-screen bg-background"> <PageLayout>
<Sidebar /> <PageHeader
<main className="flex-1 overflow-auto"> title="Catégories"
<div className="p-6 space-y-6"> description={`${parentCategories.length} catégories principales • ${data.categories.length - parentCategories.length} sous-catégories`}
{/* Header */} actions={
<div className="flex items-center justify-between"> <>
<div>
<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
</p>
</div>
<div className="flex gap-2">
{uncategorizedCount > 0 && ( {uncategorizedCount > 0 && (
<Button variant="outline" onClick={reApplyAutoCategories}> <Button variant="outline" onClick={reApplyAutoCategories}>
Recatégoriser ({uncategorizedCount}) Recatégoriser ({uncategorizedCount})
@@ -383,74 +254,28 @@ export default function CategoriesPage() {
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Nouvelle catégorie Nouvelle catégorie
</Button> </Button>
</div> </>
</div> }
/>
{/* Barre de recherche et contrôles */}
<div className="flex items-center gap-3"> <CategorySearchBar
<div className="relative flex-1 max-w-sm"> searchQuery={searchQuery}
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> onSearchChange={setSearchQuery}
<Input allExpanded={allExpanded}
placeholder="Rechercher une catégorie ou un mot-clé..." onToggleAll={allExpanded ? collapseAll : expandAll}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/> />
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={allExpanded ? collapseAll : expandAll}
>
<ChevronsUpDown className="w-4 h-4 mr-2" />
{allExpanded ? "Tout replier" : "Tout déplier"}
</Button>
</div>
{/* Liste des catégories par parent */}
<div className="space-y-1"> <div className="space-y-1">
{parentCategories {filteredParentCategories.map((parent) => {
.filter((parent) => {
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;
// Ou si un enfant matche
const children = childrenByParent[parent.id] || [];
return children.some(
(c) =>
c.name.toLowerCase().includes(query) ||
c.keywords.some((k) => k.toLowerCase().includes(query)),
);
})
.map((parent) => {
const allChildren = childrenByParent[parent.id] || []; const allChildren = childrenByParent[parent.id] || [];
// Filtrer les enfants aussi si recherche active
const children = searchQuery.trim() const children = searchQuery.trim()
? allChildren.filter( ? allChildren.filter(
(c) => (c) =>
c.name c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
c.keywords.some((k) => c.keywords.some((k) =>
k.toLowerCase().includes(searchQuery.toLowerCase()), k.toLowerCase().includes(searchQuery.toLowerCase())
) || ) ||
// Garder tous les enfants si le parent matche parent.name.toLowerCase().includes(searchQuery.toLowerCase())
parent.name
.toLowerCase()
.includes(searchQuery.toLowerCase()),
) )
: allChildren; : allChildren;
const stats = getCategoryStats(parent.id, true); const stats = getCategoryStats(parent.id, true);
@@ -459,103 +284,22 @@ export default function CategoriesPage() {
(searchQuery.trim() !== "" && children.length > 0); (searchQuery.trim() !== "" && children.length > 0);
return ( return (
<div key={parent.id} className="border rounded-lg bg-card"> <ParentCategoryRow
<Collapsible key={parent.id}
open={isExpanded} parent={parent}
onOpenChange={() => toggleExpanded(parent.id)} children={children}
> stats={stats}
<div className="flex items-center justify-between px-3 py-2"> isExpanded={isExpanded}
<CollapsibleTrigger asChild> onToggleExpanded={() => toggleExpanded(parent.id)}
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0"> formatCurrency={formatCurrency}
{isExpanded ? ( getCategoryStats={(id) => getCategoryStats(id)}
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" /> onEdit={handleEdit}
) : ( onDelete={handleDelete}
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" /> onNewCategory={handleNewCategory}
)}
<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"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</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 && ( {orphanCategories.length > 0 && (
<div className="border rounded-lg bg-card"> <div className="border rounded-lg bg-card">
<div className="px-3 py-2 border-b"> <div className="px-3 py-2 border-b">
@@ -565,131 +309,29 @@ export default function CategoriesPage() {
</div> </div>
<div className="p-2 space-y-1"> <div className="p-2 space-y-1">
{orphanCategories.map((category) => ( {orphanCategories.map((category) => (
<ChildCategoryCard key={category.id} category={category} /> <CategoryCard
key={category.id}
category={category}
stats={getCategoryStats(category.id)}
formatCurrency={formatCurrency}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))} ))}
</div> </div>
</div> </div>
)} )}
</div> </div>
</div>
</main>
{/* Dialog de création/édition */} <CategoryEditDialog
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> open={isDialogOpen}
<DialogContent className="sm:max-w-md"> onOpenChange={setIsDialogOpen}
<DialogHeader> editingCategory={editingCategory}
<DialogTitle> formData={formData}
{editingCategory ? "Modifier la catégorie" : "Nouvelle catégorie"} onFormDataChange={setFormData}
</DialogTitle> parentCategories={parentCategories}
</DialogHeader> onSave={handleSave}
<div className="space-y-4">
{/* Catégorie parente */}
<div className="space-y-2">
<Label>Catégorie parente</Label>
<Select
value={formData.parentId || "none"}
onValueChange={(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>
{parentCategories
.filter((p) => p.id !== editingCategory?.id)
.map((parent) => (
<SelectItem key={parent.id} value={parent.id}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: parent.color }}
/> />
{parent.name} </PageLayout>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Nom */}
<div className="space-y-2">
<Label>Nom</Label>
<Input
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="Ex: Alimentation"
/>
</div>
{/* Couleur */}
<div className="space-y-2">
<Label>Couleur</Label>
<div className="flex flex-wrap gap-2">
{categoryColors.map((color) => (
<button
key={color}
onClick={() => setFormData({ ...formData, color })}
className={cn(
"w-8 h-8 rounded-full transition-transform",
formData.color === color &&
"ring-2 ring-offset-2 ring-primary scale-110",
)}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
{/* Mots-clés */}
<div className="space-y-2">
<Label>Mots-clés pour la catégorisation automatique</Label>
<div className="flex gap-2">
<Input
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder="Ajouter un mot-clé"
onKeyDown={(e) =>
e.key === "Enter" && (e.preventDefault(), addKeyword())
}
/>
<Button type="button" onClick={addKeyword} size="icon">
<Plus className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap gap-1 mt-2 max-h-32 overflow-y-auto">
{formData.keywords.map((keyword) => (
<Badge key={keyword} variant="secondary" className="gap-1">
{keyword}
<button onClick={() => removeKeyword(keyword)}>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Annuler
</Button>
<Button onClick={handleSave} disabled={!formData.name.trim()}>
{editingCategory ? "Enregistrer" : "Créer"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
); );
} }

View File

@@ -1,43 +1,16 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Sidebar } from "@/components/dashboard/sidebar"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import {
FolderTreeItem,
FolderEditDialog,
AccountFolderDialog,
} from "@/components/folders";
import { useBankingData } from "@/lib/hooks"; import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Plus } from "lucide-react";
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 { import {
addFolder, addFolder,
updateFolder, updateFolder,
@@ -45,189 +18,6 @@ import {
updateAccount, updateAccount,
} from "@/lib/store-db"; } from "@/lib/store-db";
import type { Folder as FolderType, Account } from "@/lib/types"; import type { Folder as FolderType, Account } from "@/lib/types";
import { cn } from "@/lib/utils";
import Link from "next/link";
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;
onEditAccount: (account: Account) => void;
formatCurrency: (amount: number) => string;
}
function FolderTreeItem({
folder,
accounts,
allFolders,
level,
onEdit,
onDelete,
onEditAccount,
formatCurrency,
}: FolderTreeItemProps) {
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 folderTotal = folderAccounts.reduce((sum, a) => sum + a.balance, 0);
return (
<div>
<div
className={cn(
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
level > 0 && "ml-6",
)}
>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-muted rounded"
disabled={!hasChildren}
>
{hasChildren ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)
) : (
<div className="w-4 h-4" />
)}
</button>
<div
className="w-6 h-6 rounded flex items-center justify-center"
style={{ backgroundColor: `${folder.color}20` }}
>
{isExpanded ? (
<FolderOpen className="w-4 h-4" style={{ color: folder.color }} />
) : (
<Folder className="w-4 h-4" style={{ color: folder.color }} />
)}
</div>
<span className="flex-1 font-medium text-sm">{folder.name}</span>
{folderAccounts.length > 0 && (
<span
className={cn(
"text-sm font-semibold tabular-nums",
folderTotal >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(folderTotal)}
</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100"
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(folder)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
{folder.id !== "folder-root" && (
<DropdownMenuItem
onClick={() => onDelete(folder.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{isExpanded && (
<div>
{folderAccounts.map((account) => (
<div
key={account.id}
className={cn(
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
"ml-12",
)}
>
<Building2 className="w-4 h-4 text-muted-foreground" />
<Link
href={`/transactions?accountId=${account.id}`}
className="flex-1 text-sm hover:text-primary hover:underline"
>
{account.name}
</Link>
<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"
className="h-7 w-7 opacity-0 group-hover:opacity-100"
onClick={() => onEditAccount(account)}
>
<Pencil className="w-4 h-4" />
</Button>
</div>
))}
{childFolders.map((child) => (
<FolderTreeItem
key={child.id}
folder={child}
accounts={accounts}
allFolders={allFolders}
level={level + 1}
onEdit={onEdit}
onDelete={onDelete}
onEditAccount={onEditAccount}
formatCurrency={formatCurrency}
/>
))}
</div>
)}
</div>
);
}
const accountTypeLabels = {
CHECKING: "Compte courant",
SAVINGS: "Épargne",
CREDIT_CARD: "Carte de crédit",
OTHER: "Autre",
};
export default function FoldersPage() { export default function FoldersPage() {
const { data, isLoading, refresh } = useBankingData(); const { data, isLoading, refresh } = useBankingData();
@@ -249,14 +39,7 @@ export default function FoldersPage() {
}); });
if (isLoading || !data) { if (isLoading || !data) {
return ( return <LoadingState />;
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
);
} }
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
@@ -315,7 +98,7 @@ export default function FoldersPage() {
const handleDelete = async (folderId: string) => { const handleDelete = async (folderId: string) => {
if ( if (
!confirm( !confirm(
"Supprimer ce dossier ? Les comptes seront déplacés à la racine.", "Supprimer ce dossier ? Les comptes seront déplacés à la racine."
) )
) )
return; return;
@@ -362,24 +145,17 @@ export default function FoldersPage() {
}; };
return ( return (
<div className="flex h-screen bg-background"> <PageLayout>
<Sidebar /> <PageHeader
<main className="flex-1 overflow-auto"> title="Organisation"
<div className="p-6 space-y-6"> description="Organisez vos comptes en dossiers"
<div className="flex items-center justify-between"> actions={
<div>
<h1 className="text-2xl font-bold text-foreground">
Organisation
</h1>
<p className="text-muted-foreground">
Organisez vos comptes en dossiers
</p>
</div>
<Button onClick={handleNewFolder}> <Button onClick={handleNewFolder}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Nouveau dossier Nouveau dossier
</Button> </Button>
</div> }
/>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -403,155 +179,25 @@ export default function FoldersPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div>
</main>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <FolderEditDialog
<DialogContent> open={isDialogOpen}
<DialogHeader> onOpenChange={setIsDialogOpen}
<DialogTitle> editingFolder={editingFolder}
{editingFolder ? "Modifier le dossier" : "Nouveau dossier"} formData={formData}
</DialogTitle> onFormDataChange={setFormData}
</DialogHeader> folders={data.folders}
<div className="space-y-4"> onSave={handleSave}
<div className="space-y-2">
<Label>Nom du dossier</Label>
<Input
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="Ex: Comptes personnels"
/> />
</div>
<div className="space-y-2">
<Label>Dossier parent</Label>
<Select
value={formData.parentId || "root"}
onValueChange={(v) =>
setFormData({
...formData,
parentId: v === "root" ? null : v,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="root">Racine</SelectItem>
{data.folders
.filter((f) => f.id !== editingFolder?.id)
.map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Couleur</Label>
<div className="flex gap-2">
{folderColors.map(({ value }) => (
<button
key={value}
onClick={() => setFormData({ ...formData, color: value })}
className={cn(
"w-8 h-8 rounded-full transition-transform",
formData.color === value &&
"ring-2 ring-offset-2 ring-primary scale-110",
)}
style={{ backgroundColor: value }}
/>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Annuler
</Button>
<Button onClick={handleSave} disabled={!formData.name.trim()}>
{editingFolder ? "Enregistrer" : "Créer"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={isAccountDialogOpen} onOpenChange={setIsAccountDialogOpen}> <AccountFolderDialog
<DialogContent> open={isAccountDialogOpen}
<DialogHeader> onOpenChange={setIsAccountDialogOpen}
<DialogTitle>Modifier le compte</DialogTitle> formData={accountFormData}
</DialogHeader> onFormDataChange={setAccountFormData}
<div className="space-y-4"> folders={data.folders}
<div className="space-y-2"> onSave={handleSaveAccount}
<Label>Nom du compte</Label>
<Input
value={accountFormData.name}
onChange={(e) =>
setAccountFormData({
...accountFormData,
name: e.target.value,
})
}
/> />
</div> </PageLayout>
<div className="space-y-2">
<Label>Type de compte</Label>
<Select
value={accountFormData.type}
onValueChange={(v) =>
setAccountFormData({
...accountFormData,
type: v as Account["type"],
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(accountTypeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Dossier</Label>
<Select
value={accountFormData.folderId}
onValueChange={(v) =>
setAccountFormData({ ...accountFormData, folderId: v })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{data.folders.map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsAccountDialogOpen(false)}
>
Annuler
</Button>
<Button onClick={handleSaveAccount}>Enregistrer</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
); );
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Sidebar } from "@/components/dashboard/sidebar"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import { OverviewCards } from "@/components/dashboard/overview-cards"; import { OverviewCards } from "@/components/dashboard/overview-cards";
import { RecentTransactions } from "@/components/dashboard/recent-transactions"; import { RecentTransactions } from "@/components/dashboard/recent-transactions";
import { AccountsSummary } from "@/components/dashboard/accounts-summary"; import { AccountsSummary } from "@/components/dashboard/accounts-summary";
@@ -8,43 +8,29 @@ import { CategoryBreakdown } from "@/components/dashboard/category-breakdown";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog"; import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import { useBankingData } from "@/lib/hooks"; import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Upload, RefreshCw } from "lucide-react"; import { Upload } from "lucide-react";
export default function DashboardPage() { export default function DashboardPage() {
const { data, isLoading, refresh } = useBankingData(); const { data, isLoading, refresh } = useBankingData();
if (isLoading || !data) { if (isLoading || !data) {
return ( return <LoadingState />;
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
);
} }
return ( return (
<div className="flex h-screen bg-background"> <PageLayout>
<Sidebar /> <PageHeader
<main className="flex-1 overflow-auto"> title="Tableau de bord"
<div className="p-6 space-y-6"> description="Vue d'ensemble de vos finances"
<div className="flex items-center justify-between"> actions={
<div>
<h1 className="text-2xl font-bold text-foreground">
Tableau de bord
</h1>
<p className="text-muted-foreground">
Vue d'ensemble de vos finances
</p>
</div>
<OFXImportDialog onImportComplete={refresh}> <OFXImportDialog onImportComplete={refresh}>
<Button> <Button>
<Upload className="w-4 h-4 mr-2" /> <Upload className="w-4 h-4 mr-2" />
Importer OFX Importer OFX
</Button> </Button>
</OFXImportDialog> </OFXImportDialog>
</div> }
/>
<OverviewCards data={data} /> <OverviewCards data={data} />
@@ -55,8 +41,6 @@ export default function DashboardPage() {
<CategoryBreakdown data={data} /> <CategoryBreakdown data={data} />
</div> </div>
</div> </div>
</div> </PageLayout>
</main>
</div>
); );
} }

View File

@@ -1,36 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Sidebar } from "@/components/dashboard/sidebar"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import { DataCard, DangerZoneCard, OFXInfoCard } from "@/components/settings";
import { useBankingData } from "@/lib/hooks"; 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,
Tags,
} from "lucide-react";
import type { BankingData } from "@/lib/types"; import type { BankingData } from "@/lib/types";
export default function SettingsPage() { export default function SettingsPage() {
@@ -38,14 +11,7 @@ export default function SettingsPage() {
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
if (isLoading || !data) { if (isLoading || !data) {
return ( return <LoadingState />;
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
);
} }
const exportData = () => { const exportData = () => {
@@ -74,7 +40,6 @@ export default function SettingsPage() {
const content = await file.text(); const content = await file.text();
const importedData = JSON.parse(content) as BankingData; const importedData = JSON.parse(content) as BankingData;
// Validate structure
if ( if (
!importedData.accounts || !importedData.accounts ||
!importedData.transactions || !importedData.transactions ||
@@ -107,7 +72,7 @@ export default function SettingsPage() {
"/api/banking/transactions/clear-categories", "/api/banking/transactions/clear-categories",
{ {
method: "POST", method: "POST",
}, }
); );
if (!response.ok) throw new Error("Erreur"); if (!response.ok) throw new Error("Erreur");
refresh(); refresh();
@@ -121,173 +86,28 @@ export default function SettingsPage() {
const categorizedCount = data.transactions.filter((t) => t.categoryId).length; const categorizedCount = data.transactions.filter((t) => t.categoryId).length;
return ( return (
<div className="flex h-screen bg-background"> <PageLayout>
<Sidebar /> <div className="max-w-2xl space-y-6">
<main className="flex-1 overflow-auto"> <PageHeader
<div className="p-6 space-y-6 max-w-2xl"> title="Paramètres"
<div> description="Gérez vos données et préférences"
<h1 className="text-2xl font-bold text-foreground">Paramètres</h1> />
<p className="text-muted-foreground">
Gérez vos données et préférences
</p>
</div>
<Card> <DataCard
<CardHeader> data={data}
<CardTitle className="flex items-center gap-2"> importing={importing}
<Database className="w-5 h-5" /> onExport={exportData}
Données onImport={importData}
</CardTitle> />
<CardDescription>
Exportez ou importez vos données pour les sauvegarder ou les
transférer
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div>
<p className="font-medium">Statistiques</p>
<p className="text-sm text-muted-foreground">
{data.accounts.length} comptes, {data.transactions.length}{" "}
transactions, {data.categories.length} catégories
</p>
</div>
</div>
<div className="flex gap-2"> <DangerZoneCard
<Button categorizedCount={categorizedCount}
onClick={exportData} onClearCategories={clearAllCategories}
variant="outline" onResetData={resetData}
className="flex-1 bg-transparent" />
>
<Download className="w-4 h-4 mr-2" />
Exporter (JSON)
</Button>
<Button
onClick={importData}
variant="outline"
className="flex-1 bg-transparent"
disabled={importing}
>
<Upload className="w-4 h-4 mr-2" />
{importing ? "Import..." : "Importer"}
</Button>
</div>
</CardContent>
</Card>
<Card className="border-red-200"> <OFXInfoCard />
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<Trash2 className="w-5 h-5" />
Zone dangereuse
</CardTitle>
<CardDescription>
Actions irréversibles - procédez avec prudence
</CardDescription>
</CardHeader>
<CardContent 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"
>
<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" : ""}
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={clearAllCategories}
className="bg-orange-600 hover:bg-orange-700"
>
Supprimer les affectations
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Réinitialiser toutes les données */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
className="w-full justify-start"
>
<Trash2 className="w-4 h-4 mr-2" />
Réinitialiser toutes les données
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action supprimera définitivement tous vos comptes,
transactions, catégories et dossiers. Cette action est
irréversible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={resetData}
className="bg-red-600 hover:bg-red-700"
>
Supprimer tout
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileJson className="w-5 h-5" />
Format OFX
</CardTitle>
<CardDescription>
Informations sur l'import de fichiers
</CardDescription>
</CardHeader>
<CardContent>
<div className="prose prose-sm text-muted-foreground">
<p>
L'application accepte les fichiers au format OFX (Open
Financial Exchange) ou QFX. Ces fichiers sont généralement
disponibles depuis l'espace client de votre banque.
</p>
<p className="mt-2">
Lors de l'import, les transactions sont automatiquement
catégorisées selon les mots-clés définis. Les doublons sont
détectés et ignorés automatiquement.
</p>
</div>
</CardContent>
</Card>
</div>
</main>
</div> </div>
</PageLayout>
); );
} }

View File

@@ -1,9 +1,15 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { Sidebar } from "@/components/dashboard/sidebar"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import {
StatsSummaryCards,
MonthlyChart,
CategoryPieChart,
BalanceLineChart,
TopExpensesList,
} from "@/components/statistics";
import { useBankingData } from "@/lib/hooks"; import { useBankingData } from "@/lib/hooks";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -11,24 +17,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { RefreshCw, TrendingUp, TrendingDown, ArrowRight } from "lucide-react";
import { CategoryIcon } from "@/components/ui/category-icon";
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"; type Period = "3months" | "6months" | "12months" | "all";
@@ -58,12 +46,12 @@ export default function StatisticsPage() {
} }
let transactions = data.transactions.filter( let transactions = data.transactions.filter(
(t) => new Date(t.date) >= startDate, (t) => new Date(t.date) >= startDate
); );
if (selectedAccount !== "all") { if (selectedAccount !== "all") {
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.accountId === selectedAccount, (t) => t.accountId === selectedAccount
); );
} }
@@ -132,7 +120,7 @@ export default function StatisticsPage() {
// Balance evolution // Balance evolution
const sortedTransactions = [...transactions].sort( const sortedTransactions = [...transactions].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
); );
let runningBalance = 0; let runningBalance = 0;
@@ -149,7 +137,7 @@ export default function StatisticsPage() {
month: "short", month: "short",
}), }),
solde: Math.round(balance), solde: Math.round(balance),
}), })
); );
return { return {
@@ -164,17 +152,6 @@ export default function StatisticsPage() {
}; };
}, [data, period, selectedAccount]); }, [data, period, selectedAccount]);
if (isLoading || !data || !stats) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
);
}
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
style: "currency", style: "currency",
@@ -182,25 +159,18 @@ export default function StatisticsPage() {
}).format(amount); }).format(amount);
}; };
if (isLoading || !data || !stats) {
return <LoadingState />;
}
return ( return (
<div className="flex h-screen bg-background"> <PageLayout>
<Sidebar /> <PageHeader
<main className="flex-1 overflow-auto"> title="Statistiques"
<div className="p-6 space-y-6"> description="Analysez vos dépenses et revenus"
<div className="flex items-center justify-between"> actions={
<div> <>
<h1 className="text-2xl font-bold text-foreground"> <Select value={selectedAccount} onValueChange={setSelectedAccount}>
Statistiques
</h1>
<p className="text-muted-foreground">
Analysez vos dépenses et revenus
</p>
</div>
<div className="flex gap-2">
<Select
value={selectedAccount}
onValueChange={setSelectedAccount}
>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="Compte" /> <SelectValue placeholder="Compte" />
</SelectTrigger> </SelectTrigger>
@@ -227,288 +197,36 @@ export default function StatisticsPage() {
<SelectItem value="all">Tout</SelectItem> <SelectItem value="all">Tout</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </>
</div> }
/>
{/* Summary Cards */} <StatsSummaryCards
<div className="grid gap-4 md:grid-cols-4"> totalIncome={stats.totalIncome}
<Card> totalExpenses={stats.totalExpenses}
<CardHeader className="pb-2"> avgMonthlyExpenses={stats.avgMonthlyExpenses}
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2"> formatCurrency={formatCurrency}
<TrendingUp className="w-4 h-4 text-emerald-600" /> />
Total Revenus
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-600">
{formatCurrency(stats.totalIncome)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-red-600" />
Total Dépenses
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{formatCurrency(stats.totalExpenses)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<ArrowRight className="w-4 h-4" />
Moyenne mensuelle
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(stats.avgMonthlyExpenses)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Économies
</CardTitle>
</CardHeader>
<CardContent>
<div
className={cn(
"text-2xl font-bold",
stats.totalIncome - stats.totalExpenses >= 0
? "text-emerald-600"
: "text-red-600",
)}
>
{formatCurrency(stats.totalIncome - stats.totalExpenses)}
</div>
</CardContent>
</Card>
</div>
{/* Charts */}
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
{/* Monthly Income vs Expenses */} <MonthlyChart
<Card> data={stats.monthlyChartData}
<CardHeader> formatCurrency={formatCurrency}
<CardTitle>Revenus vs Dépenses par mois</CardTitle>
</CardHeader>
<CardContent>
{stats.monthlyChartData.length > 0 ? (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stats.monthlyChartData}>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/> />
<XAxis dataKey="month" className="text-xs" /> <CategoryPieChart
<YAxis
className="text-xs"
tickFormatter={(v) => `${v}`}
/>
<Tooltip
formatter={(value: number) => formatCurrency(value)}
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}}
/>
<Legend />
<Bar
dataKey="revenus"
fill="#22c55e"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="depenses"
fill="#ef4444"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
Pas de données pour cette période
</div>
)}
</CardContent>
</Card>
{/* Category Breakdown */}
<Card>
<CardHeader>
<CardTitle>Répartition par catégorie</CardTitle>
</CardHeader>
<CardContent>
{stats.categoryChartData.length > 0 ? (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={stats.categoryChartData} data={stats.categoryChartData}
cx="50%" formatCurrency={formatCurrency}
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
dataKey="value"
>
{stats.categoryChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value: number) => formatCurrency(value)}
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}}
/> />
<Legend <BalanceLineChart
formatter={(value) => ( data={stats.balanceChartData}
<span className="text-sm text-foreground"> formatCurrency={formatCurrency}
{value}
</span>
)}
/> />
</PieChart> <TopExpensesList
</ResponsiveContainer> expenses={stats.topExpenses}
</div> categories={data.categories}
) : ( formatCurrency={formatCurrency}
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
Pas de données pour cette période
</div>
)}
</CardContent>
</Card>
{/* Balance Evolution */}
<Card>
<CardHeader>
<CardTitle>Évolution du solde</CardTitle>
</CardHeader>
<CardContent>
{stats.balanceChartData.length > 0 ? (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={stats.balanceChartData}>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/> />
<XAxis
dataKey="date"
className="text-xs"
interval="preserveStartEnd"
/>
<YAxis
className="text-xs"
tickFormatter={(v) => `${v}`}
/>
<Tooltip
formatter={(value: number) => formatCurrency(value)}
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}}
/>
<Line
type="monotone"
dataKey="solde"
stroke="#6366f1"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
Pas de données pour cette période
</div>
)}
</CardContent>
</Card>
{/* Top Expenses */}
<Card>
<CardHeader>
<CardTitle>Top 5 dépenses</CardTitle>
</CardHeader>
<CardContent>
{stats.topExpenses.length > 0 ? (
<div className="space-y-4">
{stats.topExpenses.map((expense, index) => {
const category = data.categories.find(
(c) => c.id === expense.categoryId,
);
return (
<div
key={expense.id}
className="flex items-center gap-3"
>
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-sm font-semibold">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
{expense.description}
</p>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{new Date(expense.date).toLocaleDateString(
"fr-FR",
)}
</span>
{category && (
<span
className="text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1"
style={{
backgroundColor: `${category.color}20`,
color: category.color,
}}
>
<CategoryIcon
icon={category.icon}
color={category.color}
size={10}
/>
{category.name}
</span>
)}
</div>
</div>
<div className="text-red-600 font-semibold tabular-nums">
{formatCurrency(expense.amount)}
</div>
</div>
);
})}
</div>
) : (
<div className="h-[200px] flex items-center justify-center text-muted-foreground">
Pas de dépenses pour cette période
</div>
)}
</CardContent>
</Card>
</div>
</div>
</main>
</div> </div>
</PageLayout>
); );
} }

View File

@@ -2,41 +2,16 @@
import { useState, useMemo, useEffect } from "react"; import { useState, useMemo, useEffect } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Sidebar } from "@/components/dashboard/sidebar"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import {
TransactionFilters,
TransactionBulkActions,
TransactionTable,
} from "@/components/transactions";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import { useBankingData } from "@/lib/hooks"; import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Upload } from "lucide-react";
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";
type SortField = "date" | "amount" | "description"; type SortField = "date" | "amount" | "description";
type SortOrder = "asc" | "desc"; type SortOrder = "asc" | "desc";
@@ -47,19 +22,19 @@ export default function TransactionsPage() {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selectedAccount, setSelectedAccount] = useState<string>("all"); const [selectedAccount, setSelectedAccount] = useState<string>("all");
// Initialize account filter from URL params
useEffect(() => { useEffect(() => {
const accountId = searchParams.get("accountId"); const accountId = searchParams.get("accountId");
if (accountId) { if (accountId) {
setSelectedAccount(accountId); setSelectedAccount(accountId);
} }
}, [searchParams]); }, [searchParams]);
const [selectedCategory, setSelectedCategory] = useState<string>("all"); const [selectedCategory, setSelectedCategory] = useState<string>("all");
const [showReconciled, setShowReconciled] = useState<string>("all"); const [showReconciled, setShowReconciled] = useState<string>("all");
const [sortField, setSortField] = useState<SortField>("date"); const [sortField, setSortField] = useState<SortField>("date");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc"); const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>( const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
new Set(), new Set()
); );
const filteredTransactions = useMemo(() => { const filteredTransactions = useMemo(() => {
@@ -67,43 +42,38 @@ export default function TransactionsPage() {
let transactions = [...data.transactions]; let transactions = [...data.transactions];
// Filter by search
if (searchQuery) { if (searchQuery) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
transactions = transactions.filter( transactions = transactions.filter(
(t) => (t) =>
t.description.toLowerCase().includes(query) || t.description.toLowerCase().includes(query) ||
t.memo?.toLowerCase().includes(query), t.memo?.toLowerCase().includes(query)
); );
} }
// Filter by account
if (selectedAccount !== "all") { if (selectedAccount !== "all") {
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.accountId === selectedAccount, (t) => t.accountId === selectedAccount
); );
} }
// Filter by category
if (selectedCategory !== "all") { if (selectedCategory !== "all") {
if (selectedCategory === "uncategorized") { if (selectedCategory === "uncategorized") {
transactions = transactions.filter((t) => !t.categoryId); transactions = transactions.filter((t) => !t.categoryId);
} else { } else {
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.categoryId === selectedCategory, (t) => t.categoryId === selectedCategory
); );
} }
} }
// Filter by reconciliation status
if (showReconciled !== "all") { if (showReconciled !== "all") {
const isReconciled = showReconciled === "reconciled"; const isReconciled = showReconciled === "reconciled";
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.isReconciled === isReconciled, (t) => t.isReconciled === isReconciled
); );
} }
// Sort
transactions.sort((a, b) => { transactions.sort((a, b) => {
let comparison = 0; let comparison = 0;
switch (sortField) { switch (sortField) {
@@ -132,14 +102,7 @@ export default function TransactionsPage() {
]); ]);
if (isLoading || !data) { if (isLoading || !data) {
return ( return <LoadingState />;
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
);
} }
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
@@ -161,15 +124,16 @@ export default function TransactionsPage() {
const transaction = data.transactions.find((t) => t.id === transactionId); const transaction = data.transactions.find((t) => t.id === transactionId);
if (!transaction) return; if (!transaction) return;
const updatedTransaction = { ...transaction, isReconciled: !transaction.isReconciled }; const updatedTransaction = {
...transaction,
isReconciled: !transaction.isReconciled,
};
// Optimistic update
const updatedTransactions = data.transactions.map((t) => const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t, t.id === transactionId ? updatedTransaction : t
); );
update({ ...data, transactions: updatedTransactions }); update({ ...data, transactions: updatedTransactions });
// Persist to database
try { try {
await fetch("/api/banking/transactions", { await fetch("/api/banking/transactions", {
method: "PUT", method: "PUT",
@@ -178,24 +142,24 @@ export default function TransactionsPage() {
}); });
} catch (error) { } catch (error) {
console.error("Failed to update transaction:", error); console.error("Failed to update transaction:", error);
// Revert on error
refresh(); refresh();
} }
}; };
const setCategory = async (transactionId: string, categoryId: string | null) => { const setCategory = async (
transactionId: string,
categoryId: string | null
) => {
const transaction = data.transactions.find((t) => t.id === transactionId); const transaction = data.transactions.find((t) => t.id === transactionId);
if (!transaction) return; if (!transaction) return;
const updatedTransaction = { ...transaction, categoryId }; const updatedTransaction = { ...transaction, categoryId };
// Optimistic update
const updatedTransactions = data.transactions.map((t) => const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t, t.id === transactionId ? updatedTransaction : t
); );
update({ ...data, transactions: updatedTransactions }); update({ ...data, transactions: updatedTransactions });
// Persist to database
try { try {
await fetch("/api/banking/transactions", { await fetch("/api/banking/transactions", {
method: "PUT", method: "PUT",
@@ -213,14 +177,12 @@ export default function TransactionsPage() {
selectedTransactions.has(t.id) selectedTransactions.has(t.id)
); );
// Optimistic update
const updatedTransactions = data.transactions.map((t) => const updatedTransactions = data.transactions.map((t) =>
selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t, selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t
); );
update({ ...data, transactions: updatedTransactions }); update({ ...data, transactions: updatedTransactions });
setSelectedTransactions(new Set()); setSelectedTransactions(new Set());
// Persist to database
try { try {
await Promise.all( await Promise.all(
transactionsToUpdate.map((t) => transactionsToUpdate.map((t) =>
@@ -242,14 +204,12 @@ export default function TransactionsPage() {
selectedTransactions.has(t.id) selectedTransactions.has(t.id)
); );
// Optimistic update
const updatedTransactions = data.transactions.map((t) => const updatedTransactions = data.transactions.map((t) =>
selectedTransactions.has(t.id) ? { ...t, categoryId } : t, selectedTransactions.has(t.id) ? { ...t, categoryId } : t
); );
update({ ...data, transactions: updatedTransactions }); update({ ...data, transactions: updatedTransactions });
setSelectedTransactions(new Set()); setSelectedTransactions(new Set());
// Persist to database
try { try {
await Promise.all( await Promise.all(
transactionsToUpdate.map((t) => transactionsToUpdate.map((t) =>
@@ -284,411 +244,65 @@ export default function TransactionsPage() {
setSelectedTransactions(newSelected); setSelectedTransactions(newSelected);
}; };
const getCategory = (categoryId: string | null) => { const handleSortChange = (field: SortField) => {
if (!categoryId) return null; if (sortField === field) {
return data.categories.find((c) => c.id === categoryId); setSortOrder(sortOrder === "asc" ? "desc" : "asc");
}; } else {
setSortField(field);
const getAccount = (accountId: string) => { setSortOrder(field === "date" ? "desc" : "asc");
return data.accounts.find((a) => a.id === accountId); }
}; };
return ( return (
<div className="flex h-screen bg-background"> <PageLayout>
<Sidebar /> <PageHeader
<main className="flex-1 overflow-auto"> title="Transactions"
<div className="p-6 space-y-6"> description={`${filteredTransactions.length} transaction${filteredTransactions.length > 1 ? "s" : ""}`}
<div className="flex items-center justify-between"> actions={
<div>
<h1 className="text-2xl font-bold text-foreground">
Transactions
</h1>
<p className="text-muted-foreground">
{filteredTransactions.length} transaction
{filteredTransactions.length > 1 ? "s" : ""}
</p>
</div>
<OFXImportDialog onImportComplete={refresh}> <OFXImportDialog onImportComplete={refresh}>
<Button> <Button>
<Upload className="w-4 h-4 mr-2" /> <Upload className="w-4 h-4 mr-2" />
Importer OFX Importer OFX
</Button> </Button>
</OFXImportDialog> </OFXImportDialog>
</div>
{/* Filters */}
<Card>
<CardContent className="pt-4">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Rechercher..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
<Select
value={selectedAccount}
onValueChange={setSelectedAccount}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Compte" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les comptes</SelectItem>
{data.accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedCategory}
onValueChange={setSelectedCategory}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Catégorie" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes catégories</SelectItem>
<SelectItem value="uncategorized">
Non catégorisé
</SelectItem>
{data.categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={showReconciled}
onValueChange={setShowReconciled}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Pointage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tout</SelectItem>
<SelectItem value="reconciled">Pointées</SelectItem>
<SelectItem value="not-reconciled">Non pointées</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Bulk actions */}
{selectedTransactions.size > 0 && (
<Card className="bg-primary/5 border-primary/20">
<CardContent className="py-3">
<div className="flex items-center gap-4">
<span className="text-sm font-medium">
{selectedTransactions.size} sélectionnée
{selectedTransactions.size > 1 ? "s" : ""}
</span>
<Button
size="sm"
variant="outline"
onClick={() => bulkReconcile(true)}
>
<CheckCircle2 className="w-4 h-4 mr-1" />
Pointer
</Button>
<Button
size="sm"
variant="outline"
onClick={() => bulkReconcile(false)}
>
<Circle className="w-4 h-4 mr-1" />
Dépointer
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<Tags className="w-4 h-4 mr-1" />
Catégoriser
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => bulkSetCategory(null)}>
Aucune catégorie
</DropdownMenuItem>
<DropdownMenuSeparator />
{data.categories.map((cat) => (
<DropdownMenuItem
key={cat.id}
onClick={() => bulkSetCategory(cat.id)}
>
<CategoryIcon
icon={cat.icon}
color={cat.color}
size={14}
className="mr-2"
/>
{cat.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
)}
{/* Transactions list */}
<Card>
<CardContent className="p-0">
{filteredTransactions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">
Aucune transaction trouvée
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="p-3 text-left">
<Checkbox
checked={
selectedTransactions.size ===
filteredTransactions.length &&
filteredTransactions.length > 0
}
onCheckedChange={toggleSelectAll}
/>
</th>
<th className="p-3 text-left">
<button
onClick={() => {
if (sortField === "date") {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortField("date");
setSortOrder("desc");
}
}}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
>
Date
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="p-3 text-left">
<button
onClick={() => {
if (sortField === "description") {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortField("description");
setSortOrder("asc");
}
}}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
>
Description
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
Compte
</th>
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
Catégorie
</th>
<th className="p-3 text-right">
<button
onClick={() => {
if (sortField === "amount") {
setSortOrder(
sortOrder === "asc" ? "desc" : "asc",
);
} else {
setSortField("amount");
setSortOrder("desc");
}
}}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground ml-auto"
>
Montant
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="p-3 text-center text-sm font-medium text-muted-foreground">
Pointé
</th>
<th className="p-3"></th>
</tr>
</thead>
<tbody>
{filteredTransactions.map((transaction) => {
const category = getCategory(transaction.categoryId);
const account = getAccount(transaction.accountId);
return (
<tr
key={transaction.id}
className="border-b border-border last:border-0 hover:bg-muted/50"
>
<td className="p-3">
<Checkbox
checked={selectedTransactions.has(
transaction.id,
)}
onCheckedChange={() =>
toggleSelectTransaction(transaction.id)
} }
/> />
</td>
<td className="p-3 text-sm text-muted-foreground whitespace-nowrap"> <TransactionFilters
{formatDate(transaction.date)} searchQuery={searchQuery}
</td> onSearchChange={setSearchQuery}
<td className="p-3"> selectedAccount={selectedAccount}
<p className="font-medium text-sm"> onAccountChange={setSelectedAccount}
{transaction.description} selectedCategory={selectedCategory}
</p> onCategoryChange={setSelectedCategory}
{transaction.memo && ( showReconciled={showReconciled}
<p className="text-xs text-muted-foreground truncate max-w-[300px]"> onReconciledChange={setShowReconciled}
{transaction.memo} accounts={data.accounts}
</p> categories={data.categories}
)}
</td>
<td className="p-3 text-sm text-muted-foreground">
{account?.name || "-"}
</td>
<td className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 hover:opacity-80">
{category ? (
<Badge
variant="secondary"
className="gap-1"
style={{
backgroundColor: `${category.color}20`,
color: category.color,
}}
>
<CategoryIcon
icon={category.icon}
color={category.color}
size={12}
/> />
{category.name}
</Badge> <TransactionBulkActions
) : ( selectedCount={selectedTransactions.size}
<Badge categories={data.categories}
variant="outline" onReconcile={bulkReconcile}
className="text-muted-foreground" onSetCategory={bulkSetCategory}
>
Non catégorisé
</Badge>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() =>
setCategory(transaction.id, null)
}
>
Aucune catégorie
</DropdownMenuItem>
<DropdownMenuSeparator />
{data.categories.map((cat) => (
<DropdownMenuItem
key={cat.id}
onClick={() =>
setCategory(transaction.id, cat.id)
}
>
<CategoryIcon
icon={cat.icon}
color={cat.color}
size={14}
className="mr-2"
/> />
{cat.name}
{transaction.categoryId === cat.id && ( <TransactionTable
<Check className="w-4 h-4 ml-auto" /> transactions={filteredTransactions}
)} accounts={data.accounts}
</DropdownMenuItem> categories={data.categories}
))} selectedTransactions={selectedTransactions}
</DropdownMenuContent> sortField={sortField}
</DropdownMenu> sortOrder={sortOrder}
</td> onSortChange={handleSortChange}
<td onToggleSelectAll={toggleSelectAll}
className={cn( onToggleSelectTransaction={toggleSelectTransaction}
"p-3 text-right font-semibold tabular-nums", onToggleReconciled={toggleReconciled}
transaction.amount >= 0 onSetCategory={setCategory}
? "text-emerald-600" formatCurrency={formatCurrency}
: "text-red-600", formatDate={formatDate}
)} />
> </PageLayout>
{transaction.amount >= 0 ? "+" : ""}
{formatCurrency(transaction.amount)}
</td>
<td className="p-3 text-center">
<button
onClick={() => toggleReconciled(transaction.id)}
className="p-1 hover:bg-muted rounded"
>
{transaction.isReconciled ? (
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
) : (
<Circle className="w-5 h-5 text-muted-foreground" />
)}
</button>
</td>
<td className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
toggleReconciled(transaction.id)
}
>
{transaction.isReconciled
? "Dépointer"
: "Pointer"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</main>
</div>
); );
} }

View File

@@ -0,0 +1,112 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreVertical, Pencil, Trash2, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
import Link from "next/link";
import type { Account, Folder } from "@/lib/types";
import { accountTypeIcons, accountTypeLabels } from "./constants";
interface AccountCardProps {
account: Account;
folder?: Folder;
transactionCount: number;
onEdit: (account: Account) => void;
onDelete: (accountId: string) => void;
formatCurrency: (amount: number) => string;
}
export function AccountCard({
account,
folder,
transactionCount,
onEdit,
onDelete,
formatCurrency,
}: AccountCardProps) {
const Icon = accountTypeIcons[account.type];
return (
<Card className="relative">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Icon className="w-5 h-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{account.name}</CardTitle>
<p className="text-xs text-muted-foreground">
{accountTypeLabels[account.type]}
</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(account)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(account.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
<div
className={cn(
"text-2xl font-bold mb-2",
account.balance >= 0 ? "text-emerald-600" : "text-red-600"
)}
>
{formatCurrency(account.balance)}
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<Link
href={`/transactions?accountId=${account.id}`}
className="hover:text-primary hover:underline"
>
{transactionCount} transactions
</Link>
{folder && <span>{folder.name}</span>}
</div>
{account.lastImport && (
<p className="text-xs text-muted-foreground mt-2">
Dernier import:{" "}
{new Date(account.lastImport).toLocaleDateString("fr-FR")}
</p>
)}
{account.externalUrl && (
<a
href={account.externalUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-2"
>
<ExternalLink className="w-3 h-3" />
Accéder au portail banque
</a>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { Button } from "@/components/ui/button";
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 type { Account, Folder } from "@/lib/types";
import { accountTypeLabels } from "./constants";
interface AccountFormData {
name: string;
type: Account["type"];
folderId: string;
externalUrl: string;
}
interface AccountEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
formData: AccountFormData;
onFormDataChange: (data: AccountFormData) => void;
folders: Folder[];
onSave: () => void;
}
export function AccountEditDialog({
open,
onOpenChange,
formData,
onFormDataChange,
folders,
onSave,
}: AccountEditDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier le compte</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Nom du compte</Label>
<Input
value={formData.name}
onChange={(e) =>
onFormDataChange({ ...formData, name: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Type de compte</Label>
<Select
value={formData.type}
onValueChange={(v) =>
onFormDataChange({ ...formData, type: v as Account["type"] })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(accountTypeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Dossier</Label>
<Select
value={formData.folderId}
onValueChange={(v) =>
onFormDataChange({ ...formData, folderId: v })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{folders.map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Lien externe (portail banque)</Label>
<Input
value={formData.externalUrl}
onChange={(e) =>
onFormDataChange({ ...formData, externalUrl: e.target.value })
}
placeholder="https://..."
/>
<p className="text-xs text-muted-foreground">
URL personnalisée vers le portail de votre banque
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button onClick={onSave}>Enregistrer</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,16 @@
import { Wallet, PiggyBank, CreditCard, Building2 } from "lucide-react";
export const accountTypeIcons = {
CHECKING: Wallet,
SAVINGS: PiggyBank,
CREDIT_CARD: CreditCard,
OTHER: Building2,
};
export const accountTypeLabels = {
CHECKING: "Compte courant",
SAVINGS: "Épargne",
CREDIT_CARD: "Carte de crédit",
OTHER: "Autre",
};

View File

@@ -0,0 +1,4 @@
export { AccountCard } from "./account-card";
export { AccountEditDialog } from "./account-edit-dialog";
export { accountTypeIcons, accountTypeLabels } from "./constants";

View File

@@ -0,0 +1,73 @@
"use client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { CategoryIcon } from "@/components/ui/category-icon";
import { Pencil, Trash2 } from "lucide-react";
import type { Category } from "@/lib/types";
interface CategoryCardProps {
category: Category;
stats: { total: number; count: number };
formatCurrency: (amount: number) => string;
onEdit: (category: Category) => void;
onDelete: (categoryId: string) => void;
}
export function CategoryCard({
category,
stats,
formatCurrency,
onEdit,
onDelete,
}: CategoryCardProps) {
return (
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div
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}
/>
</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)}
</span>
{category.keywords.length > 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={() => onEdit(category)}
>
<Pencil className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => onDelete(category.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import { useState } from "react";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Category } from "@/lib/types";
import { categoryColors } from "./constants";
interface CategoryFormData {
name: string;
color: string;
keywords: string[];
parentId: string | null;
}
interface CategoryEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editingCategory: Category | null;
formData: CategoryFormData;
onFormDataChange: (data: CategoryFormData) => void;
parentCategories: Category[];
onSave: () => void;
}
export function CategoryEditDialog({
open,
onOpenChange,
editingCategory,
formData,
onFormDataChange,
parentCategories,
onSave,
}: CategoryEditDialogProps) {
const [newKeyword, setNewKeyword] = useState("");
const addKeyword = () => {
if (
newKeyword.trim() &&
!formData.keywords.includes(newKeyword.trim().toLowerCase())
) {
onFormDataChange({
...formData,
keywords: [...formData.keywords, newKeyword.trim().toLowerCase()],
});
setNewKeyword("");
}
};
const removeKeyword = (keyword: string) => {
onFormDataChange({
...formData,
keywords: formData.keywords.filter((k) => k !== keyword),
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{editingCategory ? "Modifier la catégorie" : "Nouvelle catégorie"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Catégorie parente */}
<div className="space-y-2">
<Label>Catégorie parente</Label>
<Select
value={formData.parentId || "none"}
onValueChange={(value) =>
onFormDataChange({
...formData,
parentId: value === "none" ? null : value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Aucune (catégorie principale)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
Aucune (catégorie principale)
</SelectItem>
{parentCategories
.filter((p) => p.id !== editingCategory?.id)
.map((parent) => (
<SelectItem key={parent.id} value={parent.id}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: parent.color }}
/>
{parent.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Nom */}
<div className="space-y-2">
<Label>Nom</Label>
<Input
value={formData.name}
onChange={(e) =>
onFormDataChange({ ...formData, name: e.target.value })
}
placeholder="Ex: Alimentation"
/>
</div>
{/* Couleur */}
<div className="space-y-2">
<Label>Couleur</Label>
<div className="flex flex-wrap gap-2">
{categoryColors.map((color) => (
<button
key={color}
onClick={() => onFormDataChange({ ...formData, color })}
className={cn(
"w-8 h-8 rounded-full transition-transform",
formData.color === color &&
"ring-2 ring-offset-2 ring-primary scale-110"
)}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
{/* Mots-clés */}
<div className="space-y-2">
<Label>Mots-clés pour la catégorisation automatique</Label>
<div className="flex gap-2">
<Input
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder="Ajouter un mot-clé"
onKeyDown={(e) =>
e.key === "Enter" && (e.preventDefault(), addKeyword())
}
/>
<Button type="button" onClick={addKeyword} size="icon">
<Plus className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap gap-1 mt-2 max-h-32 overflow-y-auto">
{formData.keywords.map((keyword) => (
<Badge key={keyword} variant="secondary" className="gap-1">
{keyword}
<button onClick={() => removeKeyword(keyword)}>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button onClick={onSave} disabled={!formData.name.trim()}>
{editingCategory ? "Enregistrer" : "Créer"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Search, X, ChevronsUpDown } from "lucide-react";
interface CategorySearchBarProps {
searchQuery: string;
onSearchChange: (query: string) => void;
allExpanded: boolean;
onToggleAll: () => void;
}
export function CategorySearchBar({
searchQuery,
onSearchChange,
allExpanded,
onToggleAll,
}: CategorySearchBarProps) {
return (
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Rechercher une catégorie ou un mot-clé..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
{searchQuery && (
<button
onClick={() => onSearchChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<Button variant="outline" size="sm" onClick={onToggleAll}>
<ChevronsUpDown className="w-4 h-4 mr-2" />
{allExpanded ? "Tout replier" : "Tout déplier"}
</Button>
</div>
);
}

View File

@@ -0,0 +1,18 @@
export const categoryColors = [
"#22c55e",
"#3b82f6",
"#f59e0b",
"#ec4899",
"#ef4444",
"#8b5cf6",
"#06b6d4",
"#84cc16",
"#f97316",
"#6366f1",
"#14b8a6",
"#f43f5e",
"#64748b",
"#0891b2",
"#dc2626",
];

View File

@@ -0,0 +1,6 @@
export { CategoryCard } from "./category-card";
export { CategoryEditDialog } from "./category-edit-dialog";
export { ParentCategoryRow } from "./parent-category-row";
export { CategorySearchBar } from "./category-search-bar";
export { categoryColors } from "./constants";

View File

@@ -0,0 +1,140 @@
"use client";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { CategoryIcon } from "@/components/ui/category-icon";
import {
Plus,
MoreVertical,
Pencil,
Trash2,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { CategoryCard } from "./category-card";
import type { Category } from "@/lib/types";
interface ParentCategoryRowProps {
parent: Category;
children: Category[];
stats: { total: number; count: number };
isExpanded: boolean;
onToggleExpanded: () => void;
formatCurrency: (amount: number) => string;
getCategoryStats: (categoryId: string) => { total: number; count: number };
onEdit: (category: Category) => void;
onDelete: (categoryId: string) => void;
onNewCategory: (parentId: string) => void;
}
export function ParentCategoryRow({
parent,
children,
stats,
isExpanded,
onToggleExpanded,
formatCurrency,
getCategoryStats,
onEdit,
onDelete,
onNewCategory,
}: ParentCategoryRowProps) {
return (
<div className="border rounded-lg bg-card">
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
<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();
onNewCategory(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={() => onEdit(parent)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(parent.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</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) => (
<CategoryCard
key={child.id}
category={child}
stats={getCategoryStats(child.id)}
formatCurrency={formatCurrency}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
) : (
<div className="px-3 pb-2 ml-11 text-xs text-muted-foreground italic">
Aucune sous-catégorie
</div>
)}
</CollapsibleContent>
</Collapsible>
</div>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { Button } from "@/components/ui/button";
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 type { Account, Folder } from "@/lib/types";
import { accountTypeLabels } from "./constants";
interface AccountFormData {
name: string;
type: Account["type"];
folderId: string;
}
interface AccountFolderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
formData: AccountFormData;
onFormDataChange: (data: AccountFormData) => void;
folders: Folder[];
onSave: () => void;
}
export function AccountFolderDialog({
open,
onOpenChange,
formData,
onFormDataChange,
folders,
onSave,
}: AccountFolderDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier le compte</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Nom du compte</Label>
<Input
value={formData.name}
onChange={(e) =>
onFormDataChange({
...formData,
name: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label>Type de compte</Label>
<Select
value={formData.type}
onValueChange={(v) =>
onFormDataChange({
...formData,
type: v as Account["type"],
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(accountTypeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Dossier</Label>
<Select
value={formData.folderId}
onValueChange={(v) =>
onFormDataChange({ ...formData, folderId: v })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{folders.map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button onClick={onSave}>Enregistrer</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,16 @@
export 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" },
];
export const accountTypeLabels = {
CHECKING: "Compte courant",
SAVINGS: "Épargne",
CREDIT_CARD: "Carte de crédit",
OTHER: "Autre",
};

View File

@@ -0,0 +1,123 @@
"use client";
import { Button } from "@/components/ui/button";
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 { cn } from "@/lib/utils";
import type { Folder } from "@/lib/types";
import { folderColors } from "./constants";
interface FolderFormData {
name: string;
parentId: string | null;
color: string;
}
interface FolderEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editingFolder: Folder | null;
formData: FolderFormData;
onFormDataChange: (data: FolderFormData) => void;
folders: Folder[];
onSave: () => void;
}
export function FolderEditDialog({
open,
onOpenChange,
editingFolder,
formData,
onFormDataChange,
folders,
onSave,
}: FolderEditDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingFolder ? "Modifier le dossier" : "Nouveau dossier"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Nom du dossier</Label>
<Input
value={formData.name}
onChange={(e) =>
onFormDataChange({ ...formData, name: e.target.value })
}
placeholder="Ex: Comptes personnels"
/>
</div>
<div className="space-y-2">
<Label>Dossier parent</Label>
<Select
value={formData.parentId || "root"}
onValueChange={(v) =>
onFormDataChange({
...formData,
parentId: v === "root" ? null : v,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="root">Racine</SelectItem>
{folders
.filter((f) => f.id !== editingFolder?.id)
.map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Couleur</Label>
<div className="flex gap-2">
{folderColors.map(({ value }) => (
<button
key={value}
onClick={() => onFormDataChange({ ...formData, color: value })}
className={cn(
"w-8 h-8 rounded-full transition-transform",
formData.color === value &&
"ring-2 ring-offset-2 ring-primary scale-110"
)}
style={{ backgroundColor: value }}
/>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button onClick={onSave} disabled={!formData.name.trim()}>
{editingFolder ? "Enregistrer" : "Créer"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
MoreVertical,
Pencil,
Trash2,
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
Building2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import Link from "next/link";
import type { Folder as FolderType, Account } from "@/lib/types";
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;
}
export function FolderTreeItem({
folder,
accounts,
allFolders,
level,
onEdit,
onDelete,
onEditAccount,
formatCurrency,
}: FolderTreeItemProps) {
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 folderTotal = folderAccounts.reduce((sum, a) => sum + a.balance, 0);
return (
<div>
<div
className={cn(
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
level > 0 && "ml-6"
)}
>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-muted rounded"
disabled={!hasChildren}
>
{hasChildren ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)
) : (
<div className="w-4 h-4" />
)}
</button>
<div
className="w-6 h-6 rounded flex items-center justify-center"
style={{ backgroundColor: `${folder.color}20` }}
>
{isExpanded ? (
<FolderOpen className="w-4 h-4" style={{ color: folder.color }} />
) : (
<Folder className="w-4 h-4" style={{ color: folder.color }} />
)}
</div>
<span className="flex-1 font-medium text-sm">{folder.name}</span>
{folderAccounts.length > 0 && (
<span
className={cn(
"text-sm font-semibold tabular-nums",
folderTotal >= 0 ? "text-emerald-600" : "text-red-600"
)}
>
{formatCurrency(folderTotal)}
</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100"
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(folder)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
{folder.id !== "folder-root" && (
<DropdownMenuItem
onClick={() => onDelete(folder.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{isExpanded && (
<div>
{folderAccounts.map((account) => (
<div
key={account.id}
className={cn(
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
"ml-12"
)}
>
<Building2 className="w-4 h-4 text-muted-foreground" />
<Link
href={`/transactions?accountId=${account.id}`}
className="flex-1 text-sm hover:text-primary hover:underline"
>
{account.name}
</Link>
<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"
className="h-7 w-7 opacity-0 group-hover:opacity-100"
onClick={() => onEditAccount(account)}
>
<Pencil className="w-4 h-4" />
</Button>
</div>
))}
{childFolders.map((child) => (
<FolderTreeItem
key={child.id}
folder={child}
accounts={accounts}
allFolders={allFolders}
level={level + 1}
onEdit={onEdit}
onDelete={onDelete}
onEditAccount={onEditAccount}
formatCurrency={formatCurrency}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
export { FolderTreeItem } from "./folder-tree-item";
export { FolderEditDialog } from "./folder-edit-dialog";
export { AccountFolderDialog } from "./account-folder-dialog";
export { folderColors, accountTypeLabels } from "./constants";

View File

@@ -0,0 +1,4 @@
export { PageLayout } from "./page-layout";
export { LoadingState } from "./loading-state";
export { PageHeader } from "./page-header";

View File

@@ -0,0 +1,16 @@
"use client";
import { Sidebar } from "@/components/dashboard/sidebar";
import { RefreshCw } from "lucide-react";
export function LoadingState() {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { ReactNode } from "react";
interface PageHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
rightContent?: ReactNode;
}
export function PageHeader({
title,
description,
actions,
rightContent,
}: PageHeaderProps) {
return (
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
{description && (
<p className="text-muted-foreground">{description}</p>
)}
</div>
{rightContent}
{actions && <div className="flex gap-2">{actions}</div>}
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { Sidebar } from "@/components/dashboard/sidebar";
import { ReactNode } from "react";
interface PageLayoutProps {
children: ReactNode;
}
export function PageLayout({ children }: PageLayoutProps) {
return (
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-auto">
<div className="p-6 space-y-6">{children}</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
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 { Trash2, Tags } from "lucide-react";
interface DangerZoneCardProps {
categorizedCount: number;
onClearCategories: () => void;
onResetData: () => void;
}
export function DangerZoneCard({
categorizedCount,
onClearCategories,
onResetData,
}: DangerZoneCardProps) {
return (
<Card className="border-red-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<Trash2 className="w-5 h-5" />
Zone dangereuse
</CardTitle>
<CardDescription>
Actions irréversibles - procédez avec prudence
</CardDescription>
</CardHeader>
<CardContent 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"
>
<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" : ""}
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={onClearCategories}
className="bg-orange-600 hover:bg-orange-700"
>
Supprimer les affectations
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Réinitialiser toutes les données */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full justify-start">
<Trash2 className="w-4 h-4 mr-2" />
Réinitialiser toutes les données
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action supprimera définitivement tous vos comptes,
transactions, catégories et dossiers. Cette action est
irréversible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={onResetData}
className="bg-red-600 hover:bg-red-700"
>
Supprimer tout
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Download, Upload, Database } from "lucide-react";
import type { BankingData } from "@/lib/types";
interface DataCardProps {
data: BankingData;
importing: boolean;
onExport: () => void;
onImport: () => void;
}
export function DataCard({
data,
importing,
onExport,
onImport,
}: DataCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5" />
Données
</CardTitle>
<CardDescription>
Exportez ou importez vos données pour les sauvegarder ou les
transférer
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div>
<p className="font-medium">Statistiques</p>
<p className="text-sm text-muted-foreground">
{data.accounts.length} comptes, {data.transactions.length}{" "}
transactions, {data.categories.length} catégories
</p>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={onExport}
variant="outline"
className="flex-1 bg-transparent"
>
<Download className="w-4 h-4 mr-2" />
Exporter (JSON)
</Button>
<Button
onClick={onImport}
variant="outline"
className="flex-1 bg-transparent"
disabled={importing}
>
<Upload className="w-4 h-4 mr-2" />
{importing ? "Import..." : "Importer"}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,4 @@
export { DataCard } from "./data-card";
export { DangerZoneCard } from "./danger-zone-card";
export { OFXInfoCard } from "./ofx-info-card";

View File

@@ -0,0 +1,41 @@
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { FileJson } from "lucide-react";
export function OFXInfoCard() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileJson className="w-5 h-5" />
Format OFX
</CardTitle>
<CardDescription>
Informations sur l'import de fichiers
</CardDescription>
</CardHeader>
<CardContent>
<div className="prose prose-sm text-muted-foreground">
<p>
L'application accepte les fichiers au format OFX (Open Financial
Exchange) ou QFX. Ces fichiers sont généralement disponibles depuis
l'espace client de votre banque.
</p>
<p className="mt-2">
Lors de l'import, les transactions sont automatiquement
catégorisées selon les mots-clés définis. Les doublons sont détectés
et ignorés automatiquement.
</p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
interface BalanceChartData {
date: string;
solde: number;
}
interface BalanceLineChartProps {
data: BalanceChartData[];
formatCurrency: (amount: number) => string;
}
export function BalanceLineChart({
data,
formatCurrency,
}: BalanceLineChartProps) {
return (
<Card>
<CardHeader>
<CardTitle>Évolution du solde</CardTitle>
</CardHeader>
<CardContent>
{data.length > 0 ? (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
className="text-xs"
interval="preserveStartEnd"
/>
<YAxis className="text-xs" tickFormatter={(v) => `${v}`} />
<Tooltip
formatter={(value: number) => formatCurrency(value)}
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}}
/>
<Line
type="monotone"
dataKey="solde"
stroke="#6366f1"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
Pas de données pour cette période
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
PieChart,
Pie,
Cell,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
interface CategoryChartData {
name: string;
value: number;
color: string;
}
interface CategoryPieChartProps {
data: CategoryChartData[];
formatCurrency: (amount: number) => string;
}
export function CategoryPieChart({
data,
formatCurrency,
}: CategoryPieChartProps) {
return (
<Card>
<CardHeader>
<CardTitle>Répartition par catégorie</CardTitle>
</CardHeader>
<CardContent>
{data.length > 0 ? (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value: number) => formatCurrency(value)}
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}}
/>
<Legend
formatter={(value) => (
<span className="text-sm text-foreground">{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
Pas de données pour cette période
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,6 @@
export { StatsSummaryCards } from "./stats-summary-cards";
export { MonthlyChart } from "./monthly-chart";
export { CategoryPieChart } from "./category-pie-chart";
export { BalanceLineChart } from "./balance-line-chart";
export { TopExpensesList } from "./top-expenses-list";

View File

@@ -0,0 +1,64 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
interface MonthlyChartData {
month: string;
revenus: number;
depenses: number;
solde: number;
}
interface MonthlyChartProps {
data: MonthlyChartData[];
formatCurrency: (amount: number) => string;
}
export function MonthlyChart({ data, formatCurrency }: MonthlyChartProps) {
return (
<Card>
<CardHeader>
<CardTitle>Revenus vs Dépenses par mois</CardTitle>
</CardHeader>
<CardContent>
{data.length > 0 ? (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="month" className="text-xs" />
<YAxis className="text-xs" tickFormatter={(v) => `${v}`} />
<Tooltip
formatter={(value: number) => formatCurrency(value)}
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}}
/>
<Legend />
<Bar dataKey="revenus" fill="#22c55e" radius={[4, 4, 0, 0]} />
<Bar dataKey="depenses" fill="#ef4444" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
Pas de données pour cette période
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
interface StatsSummaryCardsProps {
totalIncome: number;
totalExpenses: number;
avgMonthlyExpenses: number;
formatCurrency: (amount: number) => string;
}
export function StatsSummaryCards({
totalIncome,
totalExpenses,
avgMonthlyExpenses,
formatCurrency,
}: StatsSummaryCardsProps) {
const savings = totalIncome - totalExpenses;
return (
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-emerald-600" />
Total Revenus
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-600">
{formatCurrency(totalIncome)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-red-600" />
Total Dépenses
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{formatCurrency(totalExpenses)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<ArrowRight className="w-4 h-4" />
Moyenne mensuelle
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(avgMonthlyExpenses)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Économies
</CardTitle>
</CardHeader>
<CardContent>
<div
className={cn(
"text-2xl font-bold",
savings >= 0 ? "text-emerald-600" : "text-red-600"
)}
>
{formatCurrency(savings)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CategoryIcon } from "@/components/ui/category-icon";
import type { Transaction, Category } from "@/lib/types";
interface TopExpensesListProps {
expenses: Transaction[];
categories: Category[];
formatCurrency: (amount: number) => string;
}
export function TopExpensesList({
expenses,
categories,
formatCurrency,
}: TopExpensesListProps) {
return (
<Card>
<CardHeader>
<CardTitle>Top 5 dépenses</CardTitle>
</CardHeader>
<CardContent>
{expenses.length > 0 ? (
<div className="space-y-4">
{expenses.map((expense, index) => {
const category = categories.find(
(c) => c.id === expense.categoryId
);
return (
<div key={expense.id} className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-sm font-semibold">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
{expense.description}
</p>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{new Date(expense.date).toLocaleDateString("fr-FR")}
</span>
{category && (
<span
className="text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1"
style={{
backgroundColor: `${category.color}20`,
color: category.color,
}}
>
<CategoryIcon
icon={category.icon}
color={category.color}
size={10}
/>
{category.name}
</span>
)}
</div>
</div>
<div className="text-red-600 font-semibold tabular-nums">
{formatCurrency(expense.amount)}
</div>
</div>
);
})}
</div>
) : (
<div className="h-[200px] flex items-center justify-center text-muted-foreground">
Pas de dépenses pour cette période
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,4 @@
export { TransactionFilters } from "./transaction-filters";
export { TransactionBulkActions } from "./transaction-bulk-actions";
export { TransactionTable } from "./transaction-table";

View File

@@ -0,0 +1,83 @@
"use client";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { CategoryIcon } from "@/components/ui/category-icon";
import { CheckCircle2, Circle, Tags } from "lucide-react";
import type { Category } from "@/lib/types";
interface TransactionBulkActionsProps {
selectedCount: number;
categories: Category[];
onReconcile: (reconciled: boolean) => void;
onSetCategory: (categoryId: string | null) => void;
}
export function TransactionBulkActions({
selectedCount,
categories,
onReconcile,
onSetCategory,
}: TransactionBulkActionsProps) {
if (selectedCount === 0) return null;
return (
<Card className="bg-primary/5 border-primary/20">
<CardContent className="py-3">
<div className="flex items-center gap-4">
<span className="text-sm font-medium">
{selectedCount} sélectionnée{selectedCount > 1 ? "s" : ""}
</span>
<Button size="sm" variant="outline" onClick={() => onReconcile(true)}>
<CheckCircle2 className="w-4 h-4 mr-1" />
Pointer
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onReconcile(false)}
>
<Circle className="w-4 h-4 mr-1" />
Dépointer
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<Tags className="w-4 h-4 mr-1" />
Catégoriser
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => onSetCategory(null)}>
Aucune catégorie
</DropdownMenuItem>
<DropdownMenuSeparator />
{categories.map((cat) => (
<DropdownMenuItem
key={cat.id}
onClick={() => onSetCategory(cat.id)}
>
<CategoryIcon
icon={cat.icon}
color={cat.color}
size={14}
className="mr-2"
/>
{cat.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Search } from "lucide-react";
import type { Account, Category } from "@/lib/types";
interface TransactionFiltersProps {
searchQuery: string;
onSearchChange: (query: string) => void;
selectedAccount: string;
onAccountChange: (account: string) => void;
selectedCategory: string;
onCategoryChange: (category: string) => void;
showReconciled: string;
onReconciledChange: (value: string) => void;
accounts: Account[];
categories: Category[];
}
export function TransactionFilters({
searchQuery,
onSearchChange,
selectedAccount,
onAccountChange,
selectedCategory,
onCategoryChange,
showReconciled,
onReconciledChange,
accounts,
categories,
}: TransactionFiltersProps) {
return (
<Card>
<CardContent className="pt-4">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Rechercher..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
</div>
</div>
<Select value={selectedAccount} onValueChange={onAccountChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Compte" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les comptes</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedCategory} onValueChange={onCategoryChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Catégorie" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes catégories</SelectItem>
<SelectItem value="uncategorized">Non catégorisé</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={showReconciled} onValueChange={onReconciledChange}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Pointage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tout</SelectItem>
<SelectItem value="reconciled">Pointées</SelectItem>
<SelectItem value="not-reconciled">Non pointées</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,277 @@
"use client";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { CategoryIcon } from "@/components/ui/category-icon";
import {
CheckCircle2,
Circle,
MoreVertical,
ArrowUpDown,
Check,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { Transaction, Account, Category } from "@/lib/types";
type SortField = "date" | "amount" | "description";
type SortOrder = "asc" | "desc";
interface TransactionTableProps {
transactions: Transaction[];
accounts: Account[];
categories: Category[];
selectedTransactions: Set<string>;
sortField: SortField;
sortOrder: SortOrder;
onSortChange: (field: SortField) => void;
onToggleSelectAll: () => void;
onToggleSelectTransaction: (id: string) => void;
onToggleReconciled: (id: string) => void;
onSetCategory: (transactionId: string, categoryId: string | null) => void;
formatCurrency: (amount: number) => string;
formatDate: (dateStr: string) => string;
}
export function TransactionTable({
transactions,
accounts,
categories,
selectedTransactions,
sortField,
sortOrder,
onSortChange,
onToggleSelectAll,
onToggleSelectTransaction,
onToggleReconciled,
onSetCategory,
formatCurrency,
formatDate,
}: TransactionTableProps) {
const getCategory = (categoryId: string | null) => {
if (!categoryId) return null;
return categories.find((c) => c.id === categoryId);
};
const getAccount = (accountId: string) => {
return accounts.find((a) => a.id === accountId);
};
return (
<Card>
<CardContent className="p-0">
{transactions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">Aucune transaction trouvée</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="p-3 text-left">
<Checkbox
checked={
selectedTransactions.size === transactions.length &&
transactions.length > 0
}
onCheckedChange={onToggleSelectAll}
/>
</th>
<th className="p-3 text-left">
<button
onClick={() => onSortChange("date")}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
>
Date
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="p-3 text-left">
<button
onClick={() => onSortChange("description")}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
>
Description
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
Compte
</th>
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
Catégorie
</th>
<th className="p-3 text-right">
<button
onClick={() => onSortChange("amount")}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground ml-auto"
>
Montant
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="p-3 text-center text-sm font-medium text-muted-foreground">
Pointé
</th>
<th className="p-3"></th>
</tr>
</thead>
<tbody>
{transactions.map((transaction) => {
const category = getCategory(transaction.categoryId);
const account = getAccount(transaction.accountId);
return (
<tr
key={transaction.id}
className="border-b border-border last:border-0 hover:bg-muted/50"
>
<td className="p-3">
<Checkbox
checked={selectedTransactions.has(transaction.id)}
onCheckedChange={() =>
onToggleSelectTransaction(transaction.id)
}
/>
</td>
<td className="p-3 text-sm text-muted-foreground whitespace-nowrap">
{formatDate(transaction.date)}
</td>
<td className="p-3">
<p className="font-medium text-sm">
{transaction.description}
</p>
{transaction.memo && (
<p className="text-xs text-muted-foreground truncate max-w-[300px]">
{transaction.memo}
</p>
)}
</td>
<td className="p-3 text-sm text-muted-foreground">
{account?.name || "-"}
</td>
<td className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 hover:opacity-80">
{category ? (
<Badge
variant="secondary"
className="gap-1"
style={{
backgroundColor: `${category.color}20`,
color: category.color,
}}
>
<CategoryIcon
icon={category.icon}
color={category.color}
size={12}
/>
{category.name}
</Badge>
) : (
<Badge
variant="outline"
className="text-muted-foreground"
>
Non catégorisé
</Badge>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => onSetCategory(transaction.id, null)}
>
Aucune catégorie
</DropdownMenuItem>
<DropdownMenuSeparator />
{categories.map((cat) => (
<DropdownMenuItem
key={cat.id}
onClick={() =>
onSetCategory(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" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</td>
<td
className={cn(
"p-3 text-right font-semibold tabular-nums",
transaction.amount >= 0
? "text-emerald-600"
: "text-red-600"
)}
>
{transaction.amount >= 0 ? "+" : ""}
{formatCurrency(transaction.amount)}
</td>
<td className="p-3 text-center">
<button
onClick={() => onToggleReconciled(transaction.id)}
className="p-1 hover:bg-muted rounded"
>
{transaction.isReconciled ? (
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
) : (
<Circle className="w-5 h-5 text-muted-foreground" />
)}
</button>
</td>
<td className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => onToggleReconciled(transaction.id)}
>
{transaction.isReconciled
? "Dépointer"
: "Pointer"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}