feat: refactor dashboard and account pages to utilize new layout components, enhancing structure and loading states
This commit is contained in:
@@ -1,60 +1,14 @@
|
||||
"use client";
|
||||
|
||||
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 { updateAccount, deleteAccount } from "@/lib/store-db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Building2,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
PiggyBank,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Building2 } from "lucide-react";
|
||||
import type { Account } from "@/lib/types";
|
||||
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() {
|
||||
const { data, isLoading, refresh } = useBankingData();
|
||||
@@ -68,14 +22,7 @@ export default function AccountsPage() {
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
@@ -136,29 +83,24 @@ export default function AccountsPage() {
|
||||
const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Comptes</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gérez vos comptes bancaires
|
||||
</p>
|
||||
</div>
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="Comptes"
|
||||
description="Gérez vos comptes bancaires"
|
||||
rightContent={
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">Solde total</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-2xl font-bold",
|
||||
totalBalance >= 0 ? "text-emerald-600" : "text-red-600",
|
||||
totalBalance >= 0 ? "text-emerald-600" : "text-red-600"
|
||||
)}
|
||||
>
|
||||
{formatCurrency(totalBalance)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{data.accounts.length === 0 ? (
|
||||
<Card>
|
||||
@@ -174,179 +116,31 @@ export default function AccountsPage() {
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data.accounts.map((account) => {
|
||||
const Icon = accountTypeIcons[account.type];
|
||||
const folder = data.folders.find(
|
||||
(f) => f.id === account.folderId,
|
||||
);
|
||||
const folder = data.folders.find((f) => f.id === account.folderId);
|
||||
|
||||
return (
|
||||
<Card key={account.id} className="relative">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">
|
||||
{account.name}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{accountTypeLabels[account.type]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleEdit(account)}
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(account.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className={cn(
|
||||
"text-2xl font-bold mb-2",
|
||||
account.balance >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-red-600",
|
||||
)}
|
||||
>
|
||||
{formatCurrency(account.balance)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<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>
|
||||
<AccountCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
folder={folder}
|
||||
transactionCount={getTransactionCount(account.id)}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier le compte</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nom du compte</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
<AccountEditDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
formData={formData}
|
||||
onFormDataChange={setFormData}
|
||||
folders={data.folders}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Type de compte</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(v) =>
|
||||
setFormData({ ...formData, type: v as Account["type"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(accountTypeLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Dossier</Label>
|
||||
<Select
|
||||
value={formData.folderId}
|
||||
onValueChange={(v) => setFormData({ ...formData, folderId: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{data.folders.map((folder) => (
|
||||
<SelectItem key={folder.id} value={folder.id}>
|
||||
{folder.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="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>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,16 @@
|
||||
"use client";
|
||||
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||
import { Plus } from "lucide-react";
|
||||
import {
|
||||
autoCategorize,
|
||||
addCategory,
|
||||
@@ -51,32 +18,13 @@ import {
|
||||
deleteCategory,
|
||||
} from "@/lib/store-db";
|
||||
import type { Category } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const categoryColors = [
|
||||
"#22c55e",
|
||||
"#3b82f6",
|
||||
"#f59e0b",
|
||||
"#ec4899",
|
||||
"#ef4444",
|
||||
"#8b5cf6",
|
||||
"#06b6d4",
|
||||
"#84cc16",
|
||||
"#f97316",
|
||||
"#6366f1",
|
||||
"#14b8a6",
|
||||
"#f43f5e",
|
||||
"#64748b",
|
||||
"#0891b2",
|
||||
"#dc2626",
|
||||
];
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const { data, isLoading, refresh } = useBankingData();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||
const [expandedParents, setExpandedParents] = useState<Set<string>>(
|
||||
new Set(),
|
||||
new Set()
|
||||
);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
@@ -84,7 +32,6 @@ export default function CategoriesPage() {
|
||||
keywords: [] as string[],
|
||||
parentId: null as string | null,
|
||||
});
|
||||
const [newKeyword, setNewKeyword] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Organiser les catégories par parent
|
||||
@@ -101,7 +48,6 @@ export default function CategoriesPage() {
|
||||
const children: Record<string, Category[]> = {};
|
||||
const orphans: Category[] = [];
|
||||
|
||||
// Grouper les enfants par parent
|
||||
data.categories
|
||||
.filter((c) => c.parentId !== null)
|
||||
.forEach((child) => {
|
||||
@@ -131,14 +77,7 @@ export default function CategoriesPage() {
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
@@ -159,11 +98,11 @@ export default function CategoriesPage() {
|
||||
}
|
||||
|
||||
const categoryTransactions = data.transactions.filter((t) =>
|
||||
categoryIds.includes(t.categoryId || ""),
|
||||
categoryIds.includes(t.categoryId || "")
|
||||
);
|
||||
const total = categoryTransactions.reduce(
|
||||
(sum, t) => sum + Math.abs(t.amount),
|
||||
0,
|
||||
0
|
||||
);
|
||||
const count = categoryTransactions.length;
|
||||
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 () => {
|
||||
if (
|
||||
!confirm(
|
||||
"Recatégoriser automatiquement les transactions non catégorisées ?",
|
||||
"Recatégoriser automatiquement les transactions non catégorisées ?"
|
||||
)
|
||||
)
|
||||
return;
|
||||
@@ -287,7 +206,7 @@ export default function CategoriesPage() {
|
||||
for (const transaction of uncategorized) {
|
||||
const categoryId = autoCategorize(
|
||||
transaction.description + " " + (transaction.memo || ""),
|
||||
data.categories,
|
||||
data.categories
|
||||
);
|
||||
if (categoryId) {
|
||||
await updateTransaction({ ...transaction, categoryId });
|
||||
@@ -301,79 +220,31 @@ export default function CategoriesPage() {
|
||||
};
|
||||
|
||||
const uncategorizedCount = data.transactions.filter(
|
||||
(t) => !t.categoryId,
|
||||
(t) => !t.categoryId
|
||||
).length;
|
||||
|
||||
// Composant pour une carte de catégorie enfant
|
||||
const ChildCategoryCard = ({ category }: { category: Category }) => {
|
||||
const stats = getCategoryStats(category.id);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group">
|
||||
<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={() => 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>
|
||||
// Filtrer les catégories selon la recherche
|
||||
const filteredParentCategories = parentCategories.filter((parent) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
if (parent.name.toLowerCase().includes(query)) return true;
|
||||
if (parent.keywords.some((k) => k.toLowerCase().includes(query)))
|
||||
return true;
|
||||
const children = childrenByParent[parent.id] || [];
|
||||
return children.some(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(query) ||
|
||||
c.keywords.some((k) => k.toLowerCase().includes(query))
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<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">
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="Catégories"
|
||||
description={`${parentCategories.length} catégories principales • ${data.categories.length - parentCategories.length} sous-catégories`}
|
||||
actions={
|
||||
<>
|
||||
{uncategorizedCount > 0 && (
|
||||
<Button variant="outline" onClick={reApplyAutoCategories}>
|
||||
Recatégoriser ({uncategorizedCount})
|
||||
@@ -383,74 +254,28 @@ export default function CategoriesPage() {
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvelle catégorie
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barre de recherche et contrôles */}
|
||||
<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) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<CategorySearchBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
allExpanded={allExpanded}
|
||||
onToggleAll={allExpanded ? collapseAll : expandAll}
|
||||
/>
|
||||
{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">
|
||||
{parentCategories
|
||||
.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) => {
|
||||
{filteredParentCategories.map((parent) => {
|
||||
const allChildren = childrenByParent[parent.id] || [];
|
||||
// Filtrer les enfants aussi si recherche active
|
||||
const children = searchQuery.trim()
|
||||
? allChildren.filter(
|
||||
(c) =>
|
||||
c.name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()) ||
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
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;
|
||||
const stats = getCategoryStats(parent.id, true);
|
||||
@@ -459,103 +284,22 @@ export default function CategoriesPage() {
|
||||
(searchQuery.trim() !== "" && children.length > 0);
|
||||
|
||||
return (
|
||||
<div key={parent.id} className="border rounded-lg bg-card">
|
||||
<Collapsible
|
||||
open={isExpanded}
|
||||
onOpenChange={() => toggleExpanded(parent.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${parent.color}20` }}
|
||||
>
|
||||
<CategoryIcon
|
||||
icon={parent.icon}
|
||||
color={parent.color}
|
||||
size={14}
|
||||
<ParentCategoryRow
|
||||
key={parent.id}
|
||||
parent={parent}
|
||||
children={children}
|
||||
stats={stats}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={() => toggleExpanded(parent.id)}
|
||||
formatCurrency={formatCurrency}
|
||||
getCategoryStats={(id) => getCategoryStats(id)}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onNewCategory={handleNewCategory}
|
||||
/>
|
||||
</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 && (
|
||||
<div className="border rounded-lg bg-card">
|
||||
<div className="px-3 py-2 border-b">
|
||||
@@ -565,131 +309,29 @@ export default function CategoriesPage() {
|
||||
</div>
|
||||
<div className="p-2 space-y-1">
|
||||
{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>
|
||||
</main>
|
||||
|
||||
{/* Dialog de création/édition */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingCategory ? "Modifier la catégorie" : "Nouvelle catégorie"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* 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 }}
|
||||
<CategoryEditDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
editingCategory={editingCategory}
|
||||
formData={formData}
|
||||
onFormDataChange={setFormData}
|
||||
parentCategories={parentCategories}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
{parent.name}
|
||||
</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>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,16 @@
|
||||
"use client";
|
||||
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Building2,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { Plus } from "lucide-react";
|
||||
import {
|
||||
addFolder,
|
||||
updateFolder,
|
||||
@@ -45,189 +18,6 @@ import {
|
||||
updateAccount,
|
||||
} from "@/lib/store-db";
|
||||
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() {
|
||||
const { data, isLoading, refresh } = useBankingData();
|
||||
@@ -249,14 +39,7 @@ export default function FoldersPage() {
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
@@ -315,7 +98,7 @@ export default function FoldersPage() {
|
||||
const handleDelete = async (folderId: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
"Supprimer ce dossier ? Les comptes seront déplacés à la racine.",
|
||||
"Supprimer ce dossier ? Les comptes seront déplacés à la racine."
|
||||
)
|
||||
)
|
||||
return;
|
||||
@@ -362,24 +145,17 @@ export default function FoldersPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
Organisation
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Organisez vos comptes en dossiers
|
||||
</p>
|
||||
</div>
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="Organisation"
|
||||
description="Organisez vos comptes en dossiers"
|
||||
actions={
|
||||
<Button onClick={handleNewFolder}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouveau dossier
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -403,155 +179,25 @@ export default function FoldersPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingFolder ? "Modifier le dossier" : "Nouveau dossier"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nom du dossier</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
placeholder="Ex: Comptes personnels"
|
||||
<FolderEditDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
editingFolder={editingFolder}
|
||||
formData={formData}
|
||||
onFormDataChange={setFormData}
|
||||
folders={data.folders}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</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}>
|
||||
<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={accountFormData.name}
|
||||
onChange={(e) =>
|
||||
setAccountFormData({
|
||||
...accountFormData,
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
<AccountFolderDialog
|
||||
open={isAccountDialogOpen}
|
||||
onOpenChange={setIsAccountDialogOpen}
|
||||
formData={accountFormData}
|
||||
onFormDataChange={setAccountFormData}
|
||||
folders={data.folders}
|
||||
onSave={handleSaveAccount}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
38
app/page.tsx
38
app/page.tsx
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
||||
import { OverviewCards } from "@/components/dashboard/overview-cards";
|
||||
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
|
||||
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 { useBankingData } from "@/lib/hooks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Upload, RefreshCw } from "lucide-react";
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data, isLoading, refresh } = useBankingData();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
Tableau de bord
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Vue d'ensemble de vos finances
|
||||
</p>
|
||||
</div>
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="Tableau de bord"
|
||||
description="Vue d'ensemble de vos finances"
|
||||
actions={
|
||||
<OFXImportDialog onImportComplete={refresh}>
|
||||
<Button>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Importer OFX
|
||||
</Button>
|
||||
</OFXImportDialog>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<OverviewCards data={data} />
|
||||
|
||||
@@ -55,8 +41,6 @@ export default function DashboardPage() {
|
||||
<CategoryBreakdown data={data} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,9 @@
|
||||
"use client";
|
||||
|
||||
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 { 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";
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -38,14 +11,7 @@ export default function SettingsPage() {
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const exportData = () => {
|
||||
@@ -74,7 +40,6 @@ export default function SettingsPage() {
|
||||
const content = await file.text();
|
||||
const importedData = JSON.parse(content) as BankingData;
|
||||
|
||||
// Validate structure
|
||||
if (
|
||||
!importedData.accounts ||
|
||||
!importedData.transactions ||
|
||||
@@ -107,7 +72,7 @@ export default function SettingsPage() {
|
||||
"/api/banking/transactions/clear-categories",
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw new Error("Erreur");
|
||||
refresh();
|
||||
@@ -121,173 +86,28 @@ export default function SettingsPage() {
|
||||
const categorizedCount = data.transactions.filter((t) => t.categoryId).length;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-6 space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Paramètres</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gérez vos données et préférences
|
||||
</p>
|
||||
</div>
|
||||
<PageLayout>
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<PageHeader
|
||||
title="Paramètres"
|
||||
description="Gérez vos données et préférences"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<DataCard
|
||||
data={data}
|
||||
importing={importing}
|
||||
onExport={exportData}
|
||||
onImport={importData}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={exportData}
|
||||
variant="outline"
|
||||
className="flex-1 bg-transparent"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exporter (JSON)
|
||||
</Button>
|
||||
<Button
|
||||
onClick={importData}
|
||||
variant="outline"
|
||||
className="flex-1 bg-transparent"
|
||||
disabled={importing}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{importing ? "Import..." : "Importer"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DangerZoneCard
|
||||
categorizedCount={categorizedCount}
|
||||
onClearCategories={clearAllCategories}
|
||||
onResetData={resetData}
|
||||
/>
|
||||
|
||||
<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={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>
|
||||
<OFXInfoCard />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -11,24 +17,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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";
|
||||
|
||||
@@ -58,12 +46,12 @@ export default function StatisticsPage() {
|
||||
}
|
||||
|
||||
let transactions = data.transactions.filter(
|
||||
(t) => new Date(t.date) >= startDate,
|
||||
(t) => new Date(t.date) >= startDate
|
||||
);
|
||||
|
||||
if (selectedAccount !== "all") {
|
||||
transactions = transactions.filter(
|
||||
(t) => t.accountId === selectedAccount,
|
||||
(t) => t.accountId === selectedAccount
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,7 +120,7 @@ export default function StatisticsPage() {
|
||||
|
||||
// Balance evolution
|
||||
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;
|
||||
@@ -149,7 +137,7 @@ export default function StatisticsPage() {
|
||||
month: "short",
|
||||
}),
|
||||
solde: Math.round(balance),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -164,17 +152,6 @@ export default function StatisticsPage() {
|
||||
};
|
||||
}, [data, period, selectedAccount]);
|
||||
|
||||
if (isLoading || !data || !stats) {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
@@ -182,25 +159,18 @@ export default function StatisticsPage() {
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (isLoading || !data || !stats) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
Statistiques
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Analysez vos dépenses et revenus
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={selectedAccount}
|
||||
onValueChange={setSelectedAccount}
|
||||
>
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="Statistiques"
|
||||
description="Analysez vos dépenses et revenus"
|
||||
actions={
|
||||
<>
|
||||
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Compte" />
|
||||
</SelectTrigger>
|
||||
@@ -227,288 +197,36 @@ export default function StatisticsPage() {
|
||||
<SelectItem value="all">Tout</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-600" />
|
||||
Total Revenus
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
{formatCurrency(stats.totalIncome)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatsSummaryCards
|
||||
totalIncome={stats.totalIncome}
|
||||
totalExpenses={stats.totalExpenses}
|
||||
avgMonthlyExpenses={stats.avgMonthlyExpenses}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<TrendingDown className="w-4 h-4 text-red-600" />
|
||||
Total Dépenses
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{formatCurrency(stats.totalExpenses)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
Moyenne mensuelle
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(stats.avgMonthlyExpenses)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Économies
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className={cn(
|
||||
"text-2xl font-bold",
|
||||
stats.totalIncome - stats.totalExpenses >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-red-600",
|
||||
)}
|
||||
>
|
||||
{formatCurrency(stats.totalIncome - stats.totalExpenses)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Monthly Income vs Expenses */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Revenus vs Dépenses par mois</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats.monthlyChartData.length > 0 ? (
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={stats.monthlyChartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
<MonthlyChart
|
||||
data={stats.monthlyChartData}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
<XAxis dataKey="month" className="text-xs" />
|
||||
<YAxis
|
||||
className="text-xs"
|
||||
tickFormatter={(v) => `${v}€`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="revenus"
|
||||
fill="#22c55e"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="depenses"
|
||||
fill="#ef4444"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
Pas de données pour cette période
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category Breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Répartition par catégorie</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats.categoryChartData.length > 0 ? (
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
<CategoryPieChart
|
||||
data={stats.categoryChartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{stats.categoryChartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value) => (
|
||||
<span className="text-sm text-foreground">
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
<BalanceLineChart
|
||||
data={stats.balanceChartData}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
Pas de données pour cette période
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Balance Evolution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Évolution du solde</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats.balanceChartData.length > 0 ? (
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={stats.balanceChartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
<TopExpensesList
|
||||
expenses={stats.topExpenses}
|
||||
categories={data.categories}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
<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>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,41 +2,16 @@
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||
import {
|
||||
Search,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
MoreVertical,
|
||||
Tags,
|
||||
Upload,
|
||||
RefreshCw,
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
type SortField = "date" | "amount" | "description";
|
||||
type SortOrder = "asc" | "desc";
|
||||
@@ -47,19 +22,19 @@ export default function TransactionsPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>("all");
|
||||
|
||||
// Initialize account filter from URL params
|
||||
useEffect(() => {
|
||||
const accountId = searchParams.get("accountId");
|
||||
if (accountId) {
|
||||
setSelectedAccount(accountId);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
const [showReconciled, setShowReconciled] = useState<string>("all");
|
||||
const [sortField, setSortField] = useState<SortField>("date");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
|
||||
new Set(),
|
||||
new Set()
|
||||
);
|
||||
|
||||
const filteredTransactions = useMemo(() => {
|
||||
@@ -67,43 +42,38 @@ export default function TransactionsPage() {
|
||||
|
||||
let transactions = [...data.transactions];
|
||||
|
||||
// Filter by search
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
transactions = transactions.filter(
|
||||
(t) =>
|
||||
t.description.toLowerCase().includes(query) ||
|
||||
t.memo?.toLowerCase().includes(query),
|
||||
t.memo?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by account
|
||||
if (selectedAccount !== "all") {
|
||||
transactions = transactions.filter(
|
||||
(t) => t.accountId === selectedAccount,
|
||||
(t) => t.accountId === selectedAccount
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
if (selectedCategory !== "all") {
|
||||
if (selectedCategory === "uncategorized") {
|
||||
transactions = transactions.filter((t) => !t.categoryId);
|
||||
} else {
|
||||
transactions = transactions.filter(
|
||||
(t) => t.categoryId === selectedCategory,
|
||||
(t) => t.categoryId === selectedCategory
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by reconciliation status
|
||||
if (showReconciled !== "all") {
|
||||
const isReconciled = showReconciled === "reconciled";
|
||||
transactions = transactions.filter(
|
||||
(t) => t.isReconciled === isReconciled,
|
||||
(t) => t.isReconciled === isReconciled
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
transactions.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortField) {
|
||||
@@ -132,14 +102,7 @@ export default function TransactionsPage() {
|
||||
]);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
@@ -161,15 +124,16 @@ export default function TransactionsPage() {
|
||||
const transaction = data.transactions.find((t) => t.id === transactionId);
|
||||
if (!transaction) return;
|
||||
|
||||
const updatedTransaction = { ...transaction, isReconciled: !transaction.isReconciled };
|
||||
const updatedTransaction = {
|
||||
...transaction,
|
||||
isReconciled: !transaction.isReconciled,
|
||||
};
|
||||
|
||||
// Optimistic update
|
||||
const updatedTransactions = data.transactions.map((t) =>
|
||||
t.id === transactionId ? updatedTransaction : t,
|
||||
t.id === transactionId ? updatedTransaction : t
|
||||
);
|
||||
update({ ...data, transactions: updatedTransactions });
|
||||
|
||||
// Persist to database
|
||||
try {
|
||||
await fetch("/api/banking/transactions", {
|
||||
method: "PUT",
|
||||
@@ -178,24 +142,24 @@ export default function TransactionsPage() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update transaction:", error);
|
||||
// Revert on error
|
||||
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);
|
||||
if (!transaction) return;
|
||||
|
||||
const updatedTransaction = { ...transaction, categoryId };
|
||||
|
||||
// Optimistic update
|
||||
const updatedTransactions = data.transactions.map((t) =>
|
||||
t.id === transactionId ? updatedTransaction : t,
|
||||
t.id === transactionId ? updatedTransaction : t
|
||||
);
|
||||
update({ ...data, transactions: updatedTransactions });
|
||||
|
||||
// Persist to database
|
||||
try {
|
||||
await fetch("/api/banking/transactions", {
|
||||
method: "PUT",
|
||||
@@ -213,14 +177,12 @@ export default function TransactionsPage() {
|
||||
selectedTransactions.has(t.id)
|
||||
);
|
||||
|
||||
// Optimistic update
|
||||
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 });
|
||||
setSelectedTransactions(new Set());
|
||||
|
||||
// Persist to database
|
||||
try {
|
||||
await Promise.all(
|
||||
transactionsToUpdate.map((t) =>
|
||||
@@ -242,14 +204,12 @@ export default function TransactionsPage() {
|
||||
selectedTransactions.has(t.id)
|
||||
);
|
||||
|
||||
// Optimistic update
|
||||
const updatedTransactions = data.transactions.map((t) =>
|
||||
selectedTransactions.has(t.id) ? { ...t, categoryId } : t,
|
||||
selectedTransactions.has(t.id) ? { ...t, categoryId } : t
|
||||
);
|
||||
update({ ...data, transactions: updatedTransactions });
|
||||
setSelectedTransactions(new Set());
|
||||
|
||||
// Persist to database
|
||||
try {
|
||||
await Promise.all(
|
||||
transactionsToUpdate.map((t) =>
|
||||
@@ -284,411 +244,65 @@ export default function TransactionsPage() {
|
||||
setSelectedTransactions(newSelected);
|
||||
};
|
||||
|
||||
const getCategory = (categoryId: string | null) => {
|
||||
if (!categoryId) return null;
|
||||
return data.categories.find((c) => c.id === categoryId);
|
||||
};
|
||||
|
||||
const getAccount = (accountId: string) => {
|
||||
return data.accounts.find((a) => a.id === accountId);
|
||||
const handleSortChange = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder(field === "date" ? "desc" : "asc");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
Transactions
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{filteredTransactions.length} transaction
|
||||
{filteredTransactions.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="Transactions"
|
||||
description={`${filteredTransactions.length} transaction${filteredTransactions.length > 1 ? "s" : ""}`}
|
||||
actions={
|
||||
<OFXImportDialog onImportComplete={refresh}>
|
||||
<Button>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Importer OFX
|
||||
</Button>
|
||||
</OFXImportDialog>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Rechercher..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={selectedAccount}
|
||||
onValueChange={setSelectedAccount}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Compte" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les comptes</SelectItem>
|
||||
{data.accounts.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={selectedCategory}
|
||||
onValueChange={setSelectedCategory}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Catégorie" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toutes catégories</SelectItem>
|
||||
<SelectItem value="uncategorized">
|
||||
Non catégorisé
|
||||
</SelectItem>
|
||||
{data.categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={showReconciled}
|
||||
onValueChange={setShowReconciled}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="Pointage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tout</SelectItem>
|
||||
<SelectItem value="reconciled">Pointées</SelectItem>
|
||||
<SelectItem value="not-reconciled">Non pointées</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bulk actions */}
|
||||
{selectedTransactions.size > 0 && (
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedTransactions.size} sélectionnée
|
||||
{selectedTransactions.size > 1 ? "s" : ""}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => bulkReconcile(true)}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-1" />
|
||||
Pointer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => bulkReconcile(false)}
|
||||
>
|
||||
<Circle className="w-4 h-4 mr-1" />
|
||||
Dépointer
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<Tags className="w-4 h-4 mr-1" />
|
||||
Catégoriser
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => bulkSetCategory(null)}>
|
||||
Aucune catégorie
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{data.categories.map((cat) => (
|
||||
<DropdownMenuItem
|
||||
key={cat.id}
|
||||
onClick={() => bulkSetCategory(cat.id)}
|
||||
>
|
||||
<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">
|
||||
{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}
|
||||
|
||||
<TransactionFilters
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
selectedAccount={selectedAccount}
|
||||
onAccountChange={setSelectedAccount}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
showReconciled={showReconciled}
|
||||
onReconciledChange={setShowReconciled}
|
||||
accounts={data.accounts}
|
||||
categories={data.categories}
|
||||
/>
|
||||
{category.name}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Non catégorisé
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setCategory(transaction.id, null)
|
||||
}
|
||||
>
|
||||
Aucune catégorie
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{data.categories.map((cat) => (
|
||||
<DropdownMenuItem
|
||||
key={cat.id}
|
||||
onClick={() =>
|
||||
setCategory(transaction.id, cat.id)
|
||||
}
|
||||
>
|
||||
<CategoryIcon
|
||||
icon={cat.icon}
|
||||
color={cat.color}
|
||||
size={14}
|
||||
className="mr-2"
|
||||
|
||||
<TransactionBulkActions
|
||||
selectedCount={selectedTransactions.size}
|
||||
categories={data.categories}
|
||||
onReconcile={bulkReconcile}
|
||||
onSetCategory={bulkSetCategory}
|
||||
/>
|
||||
{cat.name}
|
||||
{transaction.categoryId === cat.id && (
|
||||
<Check className="w-4 h-4 ml-auto" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"p-3 text-right font-semibold tabular-nums",
|
||||
transaction.amount >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-red-600",
|
||||
)}
|
||||
>
|
||||
{transaction.amount >= 0 ? "+" : ""}
|
||||
{formatCurrency(transaction.amount)}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
onClick={() => toggleReconciled(transaction.id)}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
>
|
||||
{transaction.isReconciled ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toggleReconciled(transaction.id)
|
||||
}
|
||||
>
|
||||
{transaction.isReconciled
|
||||
? "Dépointer"
|
||||
: "Pointer"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<TransactionTable
|
||||
transactions={filteredTransactions}
|
||||
accounts={data.accounts}
|
||||
categories={data.categories}
|
||||
selectedTransactions={selectedTransactions}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onToggleSelectTransaction={toggleSelectTransaction}
|
||||
onToggleReconciled={toggleReconciled}
|
||||
onSetCategory={setCategory}
|
||||
formatCurrency={formatCurrency}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
112
components/accounts/account-card.tsx
Normal file
112
components/accounts/account-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
126
components/accounts/account-edit-dialog.tsx
Normal file
126
components/accounts/account-edit-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
16
components/accounts/constants.ts
Normal file
16
components/accounts/constants.ts
Normal 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",
|
||||
};
|
||||
|
||||
4
components/accounts/index.ts
Normal file
4
components/accounts/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { AccountCard } from "./account-card";
|
||||
export { AccountEditDialog } from "./account-edit-dialog";
|
||||
export { accountTypeIcons, accountTypeLabels } from "./constants";
|
||||
|
||||
73
components/categories/category-card.tsx
Normal file
73
components/categories/category-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
192
components/categories/category-edit-dialog.tsx
Normal file
192
components/categories/category-edit-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
46
components/categories/category-search-bar.tsx
Normal file
46
components/categories/category-search-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
18
components/categories/constants.ts
Normal file
18
components/categories/constants.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const categoryColors = [
|
||||
"#22c55e",
|
||||
"#3b82f6",
|
||||
"#f59e0b",
|
||||
"#ec4899",
|
||||
"#ef4444",
|
||||
"#8b5cf6",
|
||||
"#06b6d4",
|
||||
"#84cc16",
|
||||
"#f97316",
|
||||
"#6366f1",
|
||||
"#14b8a6",
|
||||
"#f43f5e",
|
||||
"#64748b",
|
||||
"#0891b2",
|
||||
"#dc2626",
|
||||
];
|
||||
|
||||
6
components/categories/index.ts
Normal file
6
components/categories/index.ts
Normal 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";
|
||||
|
||||
140
components/categories/parent-category-row.tsx
Normal file
140
components/categories/parent-category-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
118
components/folders/account-folder-dialog.tsx
Normal file
118
components/folders/account-folder-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
16
components/folders/constants.ts
Normal file
16
components/folders/constants.ts
Normal 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",
|
||||
};
|
||||
|
||||
123
components/folders/folder-edit-dialog.tsx
Normal file
123
components/folders/folder-edit-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
189
components/folders/folder-tree-item.tsx
Normal file
189
components/folders/folder-tree-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
5
components/folders/index.ts
Normal file
5
components/folders/index.ts
Normal 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";
|
||||
|
||||
4
components/layout/index.ts
Normal file
4
components/layout/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { PageLayout } from "./page-layout";
|
||||
export { LoadingState } from "./loading-state";
|
||||
export { PageHeader } from "./page-header";
|
||||
|
||||
16
components/layout/loading-state.tsx
Normal file
16
components/layout/loading-state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
31
components/layout/page-header.tsx
Normal file
31
components/layout/page-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
20
components/layout/page-layout.tsx
Normal file
20
components/layout/page-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
119
components/settings/danger-zone-card.tsx
Normal file
119
components/settings/danger-zone-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
73
components/settings/data-card.tsx
Normal file
73
components/settings/data-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
4
components/settings/index.ts
Normal file
4
components/settings/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { DataCard } from "./data-card";
|
||||
export { DangerZoneCard } from "./danger-zone-card";
|
||||
export { OFXInfoCard } from "./ofx-info-card";
|
||||
|
||||
41
components/settings/ofx-info-card.tsx
Normal file
41
components/settings/ofx-info-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
72
components/statistics/balance-line-chart.tsx
Normal file
72
components/statistics/balance-line-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
76
components/statistics/category-pie-chart.tsx
Normal file
76
components/statistics/category-pie-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
6
components/statistics/index.ts
Normal file
6
components/statistics/index.ts
Normal 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";
|
||||
|
||||
64
components/statistics/monthly-chart.tsx
Normal file
64
components/statistics/monthly-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
86
components/statistics/stats-summary-cards.tsx
Normal file
86
components/statistics/stats-summary-cards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
77
components/statistics/top-expenses-list.tsx
Normal file
77
components/statistics/top-expenses-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
4
components/transactions/index.ts
Normal file
4
components/transactions/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { TransactionFilters } from "./transaction-filters";
|
||||
export { TransactionBulkActions } from "./transaction-bulk-actions";
|
||||
export { TransactionTable } from "./transaction-table";
|
||||
|
||||
83
components/transactions/transaction-bulk-actions.tsx
Normal file
83
components/transactions/transaction-bulk-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
100
components/transactions/transaction-filters.tsx
Normal file
100
components/transactions/transaction-filters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
277
components/transactions/transaction-table.tsx
Normal file
277
components/transactions/transaction-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user