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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
||||||
|
import { AccountCard, AccountEditDialog } from "@/components/accounts";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingData } from "@/lib/hooks";
|
||||||
import { updateAccount, deleteAccount } from "@/lib/store-db";
|
import { updateAccount, deleteAccount } from "@/lib/store-db";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Building2 } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
MoreVertical,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Building2,
|
|
||||||
CreditCard,
|
|
||||||
Wallet,
|
|
||||||
PiggyBank,
|
|
||||||
RefreshCw,
|
|
||||||
ExternalLink,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { Account } from "@/lib/types";
|
import type { Account } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
const accountTypeIcons = {
|
|
||||||
CHECKING: Wallet,
|
|
||||||
SAVINGS: PiggyBank,
|
|
||||||
CREDIT_CARD: CreditCard,
|
|
||||||
OTHER: Building2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const accountTypeLabels = {
|
|
||||||
CHECKING: "Compte courant",
|
|
||||||
SAVINGS: "Épargne",
|
|
||||||
CREDIT_CARD: "Carte de crédit",
|
|
||||||
OTHER: "Autre",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AccountsPage() {
|
export default function AccountsPage() {
|
||||||
const { data, isLoading, refresh } = useBankingData();
|
const { data, isLoading, refresh } = useBankingData();
|
||||||
@@ -68,14 +22,7 @@ export default function AccountsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoading || !data) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="flex h-screen">
|
|
||||||
<Sidebar />
|
|
||||||
<main className="flex-1 flex items-center justify-center">
|
|
||||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
@@ -136,217 +83,64 @@ export default function AccountsPage() {
|
|||||||
const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0);
|
const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<PageLayout>
|
||||||
<Sidebar />
|
<PageHeader
|
||||||
<main className="flex-1 overflow-auto">
|
title="Comptes"
|
||||||
<div className="p-6 space-y-6">
|
description="Gérez vos comptes bancaires"
|
||||||
<div className="flex items-center justify-between">
|
rightContent={
|
||||||
<div>
|
<div className="text-right">
|
||||||
<h1 className="text-2xl font-bold text-foreground">Comptes</h1>
|
<p className="text-sm text-muted-foreground">Solde total</p>
|
||||||
<p className="text-muted-foreground">
|
<p
|
||||||
Gérez vos comptes bancaires
|
className={cn(
|
||||||
</p>
|
"text-2xl font-bold",
|
||||||
</div>
|
totalBalance >= 0 ? "text-emerald-600" : "text-red-600"
|
||||||
<div className="text-right">
|
)}
|
||||||
<p className="text-sm text-muted-foreground">Solde total</p>
|
>
|
||||||
<p
|
{formatCurrency(totalBalance)}
|
||||||
className={cn(
|
</p>
|
||||||
"text-2xl font-bold",
|
|
||||||
totalBalance >= 0 ? "text-emerald-600" : "text-red-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatCurrency(totalBalance)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{data.accounts.length === 0 ? (
|
{data.accounts.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<Building2 className="w-16 h-16 text-muted-foreground mb-4" />
|
<Building2 className="w-16 h-16 text-muted-foreground mb-4" />
|
||||||
<h3 className="text-lg font-semibold mb-2">Aucun compte</h3>
|
<h3 className="text-lg font-semibold mb-2">Aucun compte</h3>
|
||||||
<p className="text-muted-foreground text-center mb-4">
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
Importez un fichier OFX depuis le tableau de bord pour ajouter
|
Importez un fichier OFX depuis le tableau de bord pour ajouter
|
||||||
votre premier compte.
|
votre premier compte.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{data.accounts.map((account) => {
|
{data.accounts.map((account) => {
|
||||||
const Icon = accountTypeIcons[account.type];
|
const folder = data.folders.find((f) => f.id === account.folderId);
|
||||||
const folder = data.folders.find(
|
|
||||||
(f) => f.id === account.folderId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={account.id} className="relative">
|
<AccountCard
|
||||||
<CardHeader className="pb-2">
|
key={account.id}
|
||||||
<div className="flex items-start justify-between">
|
account={account}
|
||||||
<div className="flex items-center gap-3">
|
folder={folder}
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
transactionCount={getTransactionCount(account.id)}
|
||||||
<Icon className="w-5 h-5 text-primary" />
|
onEdit={handleEdit}
|
||||||
</div>
|
onDelete={handleDelete}
|
||||||
<div>
|
formatCurrency={formatCurrency}
|
||||||
<CardTitle className="text-base">
|
/>
|
||||||
{account.name}
|
);
|
||||||
</CardTitle>
|
})}
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{accountTypeLabels[account.type]}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleEdit(account)}
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4 mr-2" />
|
|
||||||
Modifier
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDelete(account.id)}
|
|
||||||
className="text-red-600"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
Supprimer
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"text-2xl font-bold mb-2",
|
|
||||||
account.balance >= 0
|
|
||||||
? "text-emerald-600"
|
|
||||||
: "text-red-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatCurrency(account.balance)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
||||||
<Link
|
|
||||||
href={`/transactions?accountId=${account.id}`}
|
|
||||||
className="hover:text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{getTransactionCount(account.id)} transactions
|
|
||||||
</Link>
|
|
||||||
{folder && <span>{folder.name}</span>}
|
|
||||||
</div>
|
|
||||||
{account.lastImport && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
Dernier import:{" "}
|
|
||||||
{new Date(account.lastImport).toLocaleDateString(
|
|
||||||
"fr-FR",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{account.externalUrl && (
|
|
||||||
<a
|
|
||||||
href={account.externalUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-2"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
Accéder au portail banque
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
)}
|
||||||
|
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<AccountEditDialog
|
||||||
<DialogContent>
|
open={isDialogOpen}
|
||||||
<DialogHeader>
|
onOpenChange={setIsDialogOpen}
|
||||||
<DialogTitle>Modifier le compte</DialogTitle>
|
formData={formData}
|
||||||
</DialogHeader>
|
onFormDataChange={setFormData}
|
||||||
<div className="space-y-4">
|
folders={data.folders}
|
||||||
<div className="space-y-2">
|
onSave={handleSave}
|
||||||
<Label>Nom du compte</Label>
|
/>
|
||||||
<Input
|
</PageLayout>
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, name: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Type de compte</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.type}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setFormData({ ...formData, type: v as Account["type"] })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(accountTypeLabels).map(([value, label]) => (
|
|
||||||
<SelectItem key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Dossier</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.folderId}
|
|
||||||
onValueChange={(v) => setFormData({ ...formData, folderId: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{data.folders.map((folder) => (
|
|
||||||
<SelectItem key={folder.id} value={folder.id}>
|
|
||||||
{folder.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
||||||
|
import {
|
||||||
|
CategoryCard,
|
||||||
|
CategoryEditDialog,
|
||||||
|
ParentCategoryRow,
|
||||||
|
CategorySearchBar,
|
||||||
|
} from "@/components/categories";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingData } from "@/lib/hooks";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Plus } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
MoreVertical,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
RefreshCw,
|
|
||||||
X,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
ChevronsUpDown,
|
|
||||||
Search,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
|
||||||
import {
|
import {
|
||||||
autoCategorize,
|
autoCategorize,
|
||||||
addCategory,
|
addCategory,
|
||||||
@@ -51,32 +18,13 @@ import {
|
|||||||
deleteCategory,
|
deleteCategory,
|
||||||
} from "@/lib/store-db";
|
} from "@/lib/store-db";
|
||||||
import type { Category } from "@/lib/types";
|
import type { Category } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const categoryColors = [
|
|
||||||
"#22c55e",
|
|
||||||
"#3b82f6",
|
|
||||||
"#f59e0b",
|
|
||||||
"#ec4899",
|
|
||||||
"#ef4444",
|
|
||||||
"#8b5cf6",
|
|
||||||
"#06b6d4",
|
|
||||||
"#84cc16",
|
|
||||||
"#f97316",
|
|
||||||
"#6366f1",
|
|
||||||
"#14b8a6",
|
|
||||||
"#f43f5e",
|
|
||||||
"#64748b",
|
|
||||||
"#0891b2",
|
|
||||||
"#dc2626",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
const { data, isLoading, refresh } = useBankingData();
|
const { data, isLoading, refresh } = useBankingData();
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||||
const [expandedParents, setExpandedParents] = useState<Set<string>>(
|
const [expandedParents, setExpandedParents] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set()
|
||||||
);
|
);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -84,7 +32,6 @@ export default function CategoriesPage() {
|
|||||||
keywords: [] as string[],
|
keywords: [] as string[],
|
||||||
parentId: null as string | null,
|
parentId: null as string | null,
|
||||||
});
|
});
|
||||||
const [newKeyword, setNewKeyword] = useState("");
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
// Organiser les catégories par parent
|
// Organiser les catégories par parent
|
||||||
@@ -101,7 +48,6 @@ export default function CategoriesPage() {
|
|||||||
const children: Record<string, Category[]> = {};
|
const children: Record<string, Category[]> = {};
|
||||||
const orphans: Category[] = [];
|
const orphans: Category[] = [];
|
||||||
|
|
||||||
// Grouper les enfants par parent
|
|
||||||
data.categories
|
data.categories
|
||||||
.filter((c) => c.parentId !== null)
|
.filter((c) => c.parentId !== null)
|
||||||
.forEach((child) => {
|
.forEach((child) => {
|
||||||
@@ -131,14 +77,7 @@ export default function CategoriesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoading || !data) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="flex h-screen">
|
|
||||||
<Sidebar />
|
|
||||||
<main className="flex-1 flex items-center justify-center">
|
|
||||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
@@ -159,11 +98,11 @@ export default function CategoriesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const categoryTransactions = data.transactions.filter((t) =>
|
const categoryTransactions = data.transactions.filter((t) =>
|
||||||
categoryIds.includes(t.categoryId || ""),
|
categoryIds.includes(t.categoryId || "")
|
||||||
);
|
);
|
||||||
const total = categoryTransactions.reduce(
|
const total = categoryTransactions.reduce(
|
||||||
(sum, t) => sum + Math.abs(t.amount),
|
(sum, t) => sum + Math.abs(t.amount),
|
||||||
0,
|
0
|
||||||
);
|
);
|
||||||
const count = categoryTransactions.length;
|
const count = categoryTransactions.length;
|
||||||
return { total, count };
|
return { total, count };
|
||||||
@@ -252,30 +191,10 @@ export default function CategoriesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addKeyword = () => {
|
|
||||||
if (
|
|
||||||
newKeyword.trim() &&
|
|
||||||
!formData.keywords.includes(newKeyword.trim().toLowerCase())
|
|
||||||
) {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
keywords: [...formData.keywords, newKeyword.trim().toLowerCase()],
|
|
||||||
});
|
|
||||||
setNewKeyword("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeKeyword = (keyword: string) => {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
keywords: formData.keywords.filter((k) => k !== keyword),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const reApplyAutoCategories = async () => {
|
const reApplyAutoCategories = async () => {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
"Recatégoriser automatiquement les transactions non catégorisées ?",
|
"Recatégoriser automatiquement les transactions non catégorisées ?"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
@@ -287,7 +206,7 @@ export default function CategoriesPage() {
|
|||||||
for (const transaction of uncategorized) {
|
for (const transaction of uncategorized) {
|
||||||
const categoryId = autoCategorize(
|
const categoryId = autoCategorize(
|
||||||
transaction.description + " " + (transaction.memo || ""),
|
transaction.description + " " + (transaction.memo || ""),
|
||||||
data.categories,
|
data.categories
|
||||||
);
|
);
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
await updateTransaction({ ...transaction, categoryId });
|
await updateTransaction({ ...transaction, categoryId });
|
||||||
@@ -301,395 +220,118 @@ export default function CategoriesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const uncategorizedCount = data.transactions.filter(
|
const uncategorizedCount = data.transactions.filter(
|
||||||
(t) => !t.categoryId,
|
(t) => !t.categoryId
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
// Composant pour une carte de catégorie enfant
|
// Filtrer les catégories selon la recherche
|
||||||
const ChildCategoryCard = ({ category }: { category: Category }) => {
|
const filteredParentCategories = parentCategories.filter((parent) => {
|
||||||
const stats = getCategoryStats(category.id);
|
if (!searchQuery.trim()) return true;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
return (
|
if (parent.name.toLowerCase().includes(query)) return true;
|
||||||
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group">
|
if (parent.keywords.some((k) => k.toLowerCase().includes(query)))
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
return true;
|
||||||
<div
|
const children = childrenByParent[parent.id] || [];
|
||||||
className="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
|
return children.some(
|
||||||
style={{ backgroundColor: `${category.color}20` }}
|
(c) =>
|
||||||
>
|
c.name.toLowerCase().includes(query) ||
|
||||||
<CategoryIcon
|
c.keywords.some((k) => k.toLowerCase().includes(query))
|
||||||
icon={category.icon}
|
|
||||||
color={category.color}
|
|
||||||
size={12}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm truncate">{category.name}</span>
|
|
||||||
<span className="text-sm text-muted-foreground shrink-0">
|
|
||||||
{stats.count} opération{stats.count > 1 ? "s" : ""} •{" "}
|
|
||||||
{formatCurrency(stats.total)}
|
|
||||||
</span>
|
|
||||||
{category.keywords.length > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-4 shrink-0"
|
|
||||||
>
|
|
||||||
{category.keywords.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={() => handleEdit(category)}
|
|
||||||
>
|
|
||||||
<Pencil className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
|
||||||
onClick={() => handleDelete(category.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<PageLayout>
|
||||||
<Sidebar />
|
<PageHeader
|
||||||
<main className="flex-1 overflow-auto">
|
title="Catégories"
|
||||||
<div className="p-6 space-y-6">
|
description={`${parentCategories.length} catégories principales • ${data.categories.length - parentCategories.length} sous-catégories`}
|
||||||
{/* Header */}
|
actions={
|
||||||
<div className="flex items-center justify-between">
|
<>
|
||||||
<div>
|
{uncategorizedCount > 0 && (
|
||||||
<h1 className="text-2xl font-bold text-foreground">Catégories</h1>
|
<Button variant="outline" onClick={reApplyAutoCategories}>
|
||||||
<p className="text-muted-foreground">
|
Recatégoriser ({uncategorizedCount})
|
||||||
{parentCategories.length} catégories principales •{" "}
|
|
||||||
{data.categories.length - parentCategories.length}{" "}
|
|
||||||
sous-catégories
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{uncategorizedCount > 0 && (
|
|
||||||
<Button variant="outline" onClick={reApplyAutoCategories}>
|
|
||||||
Recatégoriser ({uncategorizedCount})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button onClick={() => handleNewCategory(null)}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Nouvelle catégorie
|
|
||||||
</Button>
|
</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"
|
|
||||||
/>
|
|
||||||
{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) => {
|
|
||||||
const allChildren = childrenByParent[parent.id] || [];
|
|
||||||
// Filtrer les enfants aussi si recherche active
|
|
||||||
const children = searchQuery.trim()
|
|
||||||
? allChildren.filter(
|
|
||||||
(c) =>
|
|
||||||
c.name
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.toLowerCase()) ||
|
|
||||||
c.keywords.some((k) =>
|
|
||||||
k.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
||||||
) ||
|
|
||||||
// Garder tous les enfants si le parent matche
|
|
||||||
parent.name
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.toLowerCase()),
|
|
||||||
)
|
|
||||||
: allChildren;
|
|
||||||
const stats = getCategoryStats(parent.id, true);
|
|
||||||
const isExpanded =
|
|
||||||
expandedParents.has(parent.id) ||
|
|
||||||
(searchQuery.trim() !== "" && children.length > 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={parent.id} className="border rounded-lg bg-card">
|
|
||||||
<Collapsible
|
|
||||||
open={isExpanded}
|
|
||||||
onOpenChange={() => toggleExpanded(parent.id)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-3 py-2">
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
|
|
||||||
style={{ backgroundColor: `${parent.color}20` }}
|
|
||||||
>
|
|
||||||
<CategoryIcon
|
|
||||||
icon={parent.icon}
|
|
||||||
color={parent.color}
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-sm truncate">
|
|
||||||
{parent.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground shrink-0">
|
|
||||||
{children.length} • {stats.count} opération
|
|
||||||
{stats.count > 1 ? "s" : ""} •{" "}
|
|
||||||
{formatCurrency(stats.total)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleNewCategory(parent.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleEdit(parent)}
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4 mr-2" />
|
|
||||||
Modifier
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDelete(parent.id)}
|
|
||||||
className="text-red-600"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
|
||||||
Catégories non classées ({orphanCategories.length})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-2 space-y-1">
|
|
||||||
{orphanCategories.map((category) => (
|
|
||||||
<ChildCategoryCard key={category.id} category={category} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
<Button onClick={() => handleNewCategory(null)}>
|
||||||
</div>
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
</main>
|
Nouvelle catégorie
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Dialog de création/édition */}
|
<CategorySearchBar
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
searchQuery={searchQuery}
|
||||||
<DialogContent className="sm:max-w-md">
|
onSearchChange={setSearchQuery}
|
||||||
<DialogHeader>
|
allExpanded={allExpanded}
|
||||||
<DialogTitle>
|
onToggleAll={allExpanded ? collapseAll : expandAll}
|
||||||
{editingCategory ? "Modifier la catégorie" : "Nouvelle catégorie"}
|
/>
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
<div className="space-y-1">
|
||||||
<div className="space-y-4">
|
{filteredParentCategories.map((parent) => {
|
||||||
{/* Catégorie parente */}
|
const allChildren = childrenByParent[parent.id] || [];
|
||||||
<div className="space-y-2">
|
const children = searchQuery.trim()
|
||||||
<Label>Catégorie parente</Label>
|
? allChildren.filter(
|
||||||
<Select
|
(c) =>
|
||||||
value={formData.parentId || "none"}
|
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
onValueChange={(value) =>
|
c.keywords.some((k) =>
|
||||||
setFormData({
|
k.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
...formData,
|
) ||
|
||||||
parentId: value === "none" ? null : value,
|
parent.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
})
|
)
|
||||||
}
|
: allChildren;
|
||||||
>
|
const stats = getCategoryStats(parent.id, true);
|
||||||
<SelectTrigger>
|
const isExpanded =
|
||||||
<SelectValue placeholder="Aucune (catégorie principale)" />
|
expandedParents.has(parent.id) ||
|
||||||
</SelectTrigger>
|
(searchQuery.trim() !== "" && children.length > 0);
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">
|
return (
|
||||||
Aucune (catégorie principale)
|
<ParentCategoryRow
|
||||||
</SelectItem>
|
key={parent.id}
|
||||||
{parentCategories
|
parent={parent}
|
||||||
.filter((p) => p.id !== editingCategory?.id)
|
children={children}
|
||||||
.map((parent) => (
|
stats={stats}
|
||||||
<SelectItem key={parent.id} value={parent.id}>
|
isExpanded={isExpanded}
|
||||||
<div className="flex items-center gap-2">
|
onToggleExpanded={() => toggleExpanded(parent.id)}
|
||||||
<div
|
formatCurrency={formatCurrency}
|
||||||
className="w-3 h-3 rounded-full"
|
getCategoryStats={(id) => getCategoryStats(id)}
|
||||||
style={{ backgroundColor: parent.color }}
|
onEdit={handleEdit}
|
||||||
/>
|
onDelete={handleDelete}
|
||||||
{parent.name}
|
onNewCategory={handleNewCategory}
|
||||||
</div>
|
/>
|
||||||
</SelectItem>
|
);
|
||||||
))}
|
})}
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
{orphanCategories.length > 0 && (
|
||||||
|
<div className="border rounded-lg bg-card">
|
||||||
|
<div className="px-3 py-2 border-b">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Catégories non classées ({orphanCategories.length})
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
{/* Nom */}
|
{orphanCategories.map((category) => (
|
||||||
<div className="space-y-2">
|
<CategoryCard
|
||||||
<Label>Nom</Label>
|
key={category.id}
|
||||||
<Input
|
category={category}
|
||||||
value={formData.name}
|
stats={getCategoryStats(category.id)}
|
||||||
onChange={(e) =>
|
formatCurrency={formatCurrency}
|
||||||
setFormData({ ...formData, name: e.target.value })
|
onEdit={handleEdit}
|
||||||
}
|
onDelete={handleDelete}
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
)}
|
||||||
</Dialog>
|
</div>
|
||||||
</div>
|
|
||||||
|
<CategoryEditDialog
|
||||||
|
open={isDialogOpen}
|
||||||
|
onOpenChange={setIsDialogOpen}
|
||||||
|
editingCategory={editingCategory}
|
||||||
|
formData={formData}
|
||||||
|
onFormDataChange={setFormData}
|
||||||
|
parentCategories={parentCategories}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
||||||
|
import {
|
||||||
|
FolderTreeItem,
|
||||||
|
FolderEditDialog,
|
||||||
|
AccountFolderDialog,
|
||||||
|
} from "@/components/folders";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingData } from "@/lib/hooks";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Plus } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
MoreVertical,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Folder,
|
|
||||||
FolderOpen,
|
|
||||||
ChevronRight,
|
|
||||||
ChevronDown,
|
|
||||||
Building2,
|
|
||||||
RefreshCw,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
addFolder,
|
addFolder,
|
||||||
updateFolder,
|
updateFolder,
|
||||||
@@ -45,189 +18,6 @@ import {
|
|||||||
updateAccount,
|
updateAccount,
|
||||||
} from "@/lib/store-db";
|
} from "@/lib/store-db";
|
||||||
import type { Folder as FolderType, Account } from "@/lib/types";
|
import type { Folder as FolderType, Account } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
const folderColors = [
|
|
||||||
{ value: "#6366f1", label: "Indigo" },
|
|
||||||
{ value: "#22c55e", label: "Vert" },
|
|
||||||
{ value: "#f59e0b", label: "Orange" },
|
|
||||||
{ value: "#ec4899", label: "Rose" },
|
|
||||||
{ value: "#3b82f6", label: "Bleu" },
|
|
||||||
{ value: "#ef4444", label: "Rouge" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface FolderTreeItemProps {
|
|
||||||
folder: FolderType;
|
|
||||||
accounts: Account[];
|
|
||||||
allFolders: FolderType[];
|
|
||||||
level: number;
|
|
||||||
onEdit: (folder: FolderType) => void;
|
|
||||||
onDelete: (folderId: string) => void;
|
|
||||||
onEditAccount: (account: Account) => void;
|
|
||||||
formatCurrency: (amount: number) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FolderTreeItem({
|
|
||||||
folder,
|
|
||||||
accounts,
|
|
||||||
allFolders,
|
|
||||||
level,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onEditAccount,
|
|
||||||
formatCurrency,
|
|
||||||
}: FolderTreeItemProps) {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
|
||||||
|
|
||||||
// Pour le dossier "Mes Comptes" (folder-root), inclure aussi les comptes sans dossier
|
|
||||||
const folderAccounts = accounts.filter(
|
|
||||||
(a) =>
|
|
||||||
a.folderId === folder.id ||
|
|
||||||
(folder.id === "folder-root" && a.folderId === null),
|
|
||||||
);
|
|
||||||
const childFolders = allFolders.filter((f) => f.parentId === folder.id);
|
|
||||||
const hasChildren = childFolders.length > 0 || folderAccounts.length > 0;
|
|
||||||
|
|
||||||
const folderTotal = folderAccounts.reduce((sum, a) => sum + a.balance, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
|
|
||||||
level > 0 && "ml-6",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
className="p-1 hover:bg-muted rounded"
|
|
||||||
disabled={!hasChildren}
|
|
||||||
>
|
|
||||||
{hasChildren ? (
|
|
||||||
isExpanded ? (
|
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="w-6 h-6 rounded flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: `${folder.color}20` }}
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<FolderOpen className="w-4 h-4" style={{ color: folder.color }} />
|
|
||||||
) : (
|
|
||||||
<Folder className="w-4 h-4" style={{ color: folder.color }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="flex-1 font-medium text-sm">{folder.name}</span>
|
|
||||||
|
|
||||||
{folderAccounts.length > 0 && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-sm font-semibold tabular-nums",
|
|
||||||
folderTotal >= 0 ? "text-emerald-600" : "text-red-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatCurrency(folderTotal)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 opacity-0 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => onEdit(folder)}>
|
|
||||||
<Pencil className="w-4 h-4 mr-2" />
|
|
||||||
Modifier
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{folder.id !== "folder-root" && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onDelete(folder.id)}
|
|
||||||
className="text-red-600"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
Supprimer
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div>
|
|
||||||
{folderAccounts.map((account) => (
|
|
||||||
<div
|
|
||||||
key={account.id}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
|
|
||||||
"ml-12",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Building2 className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<Link
|
|
||||||
href={`/transactions?accountId=${account.id}`}
|
|
||||||
className="flex-1 text-sm hover:text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{account.name}
|
|
||||||
</Link>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-sm tabular-nums",
|
|
||||||
account.balance >= 0 ? "text-emerald-600" : "text-red-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatCurrency(account.balance)}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 opacity-0 group-hover:opacity-100"
|
|
||||||
onClick={() => onEditAccount(account)}
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{childFolders.map((child) => (
|
|
||||||
<FolderTreeItem
|
|
||||||
key={child.id}
|
|
||||||
folder={child}
|
|
||||||
accounts={accounts}
|
|
||||||
allFolders={allFolders}
|
|
||||||
level={level + 1}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onDelete={onDelete}
|
|
||||||
onEditAccount={onEditAccount}
|
|
||||||
formatCurrency={formatCurrency}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const accountTypeLabels = {
|
|
||||||
CHECKING: "Compte courant",
|
|
||||||
SAVINGS: "Épargne",
|
|
||||||
CREDIT_CARD: "Carte de crédit",
|
|
||||||
OTHER: "Autre",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function FoldersPage() {
|
export default function FoldersPage() {
|
||||||
const { data, isLoading, refresh } = useBankingData();
|
const { data, isLoading, refresh } = useBankingData();
|
||||||
@@ -249,14 +39,7 @@ export default function FoldersPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoading || !data) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="flex h-screen">
|
|
||||||
<Sidebar />
|
|
||||||
<main className="flex-1 flex items-center justify-center">
|
|
||||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
@@ -315,7 +98,7 @@ export default function FoldersPage() {
|
|||||||
const handleDelete = async (folderId: string) => {
|
const handleDelete = async (folderId: string) => {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
"Supprimer ce dossier ? Les comptes seront déplacés à la racine.",
|
"Supprimer ce dossier ? Les comptes seront déplacés à la racine."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
@@ -362,196 +145,59 @@ export default function FoldersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<PageLayout>
|
||||||
<Sidebar />
|
<PageHeader
|
||||||
<main className="flex-1 overflow-auto">
|
title="Organisation"
|
||||||
<div className="p-6 space-y-6">
|
description="Organisez vos comptes en dossiers"
|
||||||
<div className="flex items-center justify-between">
|
actions={
|
||||||
<div>
|
<Button onClick={handleNewFolder}>
|
||||||
<h1 className="text-2xl font-bold text-foreground">
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Organisation
|
Nouveau dossier
|
||||||
</h1>
|
</Button>
|
||||||
<p className="text-muted-foreground">
|
}
|
||||||
Organisez vos comptes en dossiers
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleNewFolder}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Nouveau dossier
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Arborescence des comptes</CardTitle>
|
<CardTitle>Arborescence des comptes</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{rootFolders.map((folder) => (
|
{rootFolders.map((folder) => (
|
||||||
<FolderTreeItem
|
<FolderTreeItem
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
folder={folder}
|
folder={folder}
|
||||||
accounts={data.accounts}
|
accounts={data.accounts}
|
||||||
allFolders={data.folders}
|
allFolders={data.folders}
|
||||||
level={0}
|
level={0}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onEditAccount={handleEditAccount}
|
onEditAccount={handleEditAccount}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{editingFolder ? "Modifier le dossier" : "Nouveau dossier"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Nom du dossier</Label>
|
|
||||||
<Input
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, name: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="Ex: Comptes personnels"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Dossier parent</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.parentId || "root"}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
parentId: v === "root" ? null : v,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="root">Racine</SelectItem>
|
|
||||||
{data.folders
|
|
||||||
.filter((f) => f.id !== editingFolder?.id)
|
|
||||||
.map((folder) => (
|
|
||||||
<SelectItem key={folder.id} value={folder.id}>
|
|
||||||
{folder.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Couleur</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{folderColors.map(({ value }) => (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
onClick={() => setFormData({ ...formData, color: value })}
|
|
||||||
className={cn(
|
|
||||||
"w-8 h-8 rounded-full transition-transform",
|
|
||||||
formData.color === value &&
|
|
||||||
"ring-2 ring-offset-2 ring-primary scale-110",
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: value }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} disabled={!formData.name.trim()}>
|
|
||||||
{editingFolder ? "Enregistrer" : "Créer"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</CardContent>
|
||||||
</Dialog>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={isAccountDialogOpen} onOpenChange={setIsAccountDialogOpen}>
|
<FolderEditDialog
|
||||||
<DialogContent>
|
open={isDialogOpen}
|
||||||
<DialogHeader>
|
onOpenChange={setIsDialogOpen}
|
||||||
<DialogTitle>Modifier le compte</DialogTitle>
|
editingFolder={editingFolder}
|
||||||
</DialogHeader>
|
formData={formData}
|
||||||
<div className="space-y-4">
|
onFormDataChange={setFormData}
|
||||||
<div className="space-y-2">
|
folders={data.folders}
|
||||||
<Label>Nom du compte</Label>
|
onSave={handleSave}
|
||||||
<Input
|
/>
|
||||||
value={accountFormData.name}
|
|
||||||
onChange={(e) =>
|
<AccountFolderDialog
|
||||||
setAccountFormData({
|
open={isAccountDialogOpen}
|
||||||
...accountFormData,
|
onOpenChange={setIsAccountDialogOpen}
|
||||||
name: e.target.value,
|
formData={accountFormData}
|
||||||
})
|
onFormDataChange={setAccountFormData}
|
||||||
}
|
folders={data.folders}
|
||||||
/>
|
onSave={handleSaveAccount}
|
||||||
</div>
|
/>
|
||||||
<div className="space-y-2">
|
</PageLayout>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
64
app/page.tsx
64
app/page.tsx
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
||||||
import { OverviewCards } from "@/components/dashboard/overview-cards";
|
import { OverviewCards } from "@/components/dashboard/overview-cards";
|
||||||
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
|
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
|
||||||
import { AccountsSummary } from "@/components/dashboard/accounts-summary";
|
import { AccountsSummary } from "@/components/dashboard/accounts-summary";
|
||||||
@@ -8,55 +8,39 @@ import { CategoryBreakdown } from "@/components/dashboard/category-breakdown";
|
|||||||
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingData } from "@/lib/hooks";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Upload, RefreshCw } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { data, isLoading, refresh } = useBankingData();
|
const { data, isLoading, refresh } = useBankingData();
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoading || !data) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="flex h-screen">
|
|
||||||
<Sidebar />
|
|
||||||
<main className="flex-1 flex items-center justify-center">
|
|
||||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<PageLayout>
|
||||||
<Sidebar />
|
<PageHeader
|
||||||
<main className="flex-1 overflow-auto">
|
title="Tableau de bord"
|
||||||
<div className="p-6 space-y-6">
|
description="Vue d'ensemble de vos finances"
|
||||||
<div className="flex items-center justify-between">
|
actions={
|
||||||
<div>
|
<OFXImportDialog onImportComplete={refresh}>
|
||||||
<h1 className="text-2xl font-bold text-foreground">
|
<Button>
|
||||||
Tableau de bord
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
</h1>
|
Importer OFX
|
||||||
<p className="text-muted-foreground">
|
</Button>
|
||||||
Vue d'ensemble de vos finances
|
</OFXImportDialog>
|
||||||
</p>
|
}
|
||||||
</div>
|
/>
|
||||||
<OFXImportDialog onImportComplete={refresh}>
|
|
||||||
<Button>
|
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
|
||||||
Importer OFX
|
|
||||||
</Button>
|
|
||||||
</OFXImportDialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<OverviewCards data={data} />
|
<OverviewCards data={data} />
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<RecentTransactions data={data} />
|
<RecentTransactions data={data} />
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<AccountsSummary data={data} />
|
<AccountsSummary data={data} />
|
||||||
<CategoryBreakdown data={data} />
|
<CategoryBreakdown data={data} />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
||||||
|
import { DataCard, DangerZoneCard, OFXInfoCard } from "@/components/settings";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingData } from "@/lib/hooks";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import {
|
|
||||||
Download,
|
|
||||||
Trash2,
|
|
||||||
Upload,
|
|
||||||
RefreshCw,
|
|
||||||
Database,
|
|
||||||
FileJson,
|
|
||||||
Tags,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { BankingData } from "@/lib/types";
|
import type { BankingData } from "@/lib/types";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -38,14 +11,7 @@ export default function SettingsPage() {
|
|||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoading || !data) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="flex h-screen">
|
|
||||||
<Sidebar />
|
|
||||||
<main className="flex-1 flex items-center justify-center">
|
|
||||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportData = () => {
|
const exportData = () => {
|
||||||
@@ -74,7 +40,6 @@ export default function SettingsPage() {
|
|||||||
const content = await file.text();
|
const content = await file.text();
|
||||||
const importedData = JSON.parse(content) as BankingData;
|
const importedData = JSON.parse(content) as BankingData;
|
||||||
|
|
||||||
// Validate structure
|
|
||||||
if (
|
if (
|
||||||
!importedData.accounts ||
|
!importedData.accounts ||
|
||||||
!importedData.transactions ||
|
!importedData.transactions ||
|
||||||
@@ -107,7 +72,7 @@ export default function SettingsPage() {
|
|||||||
"/api/banking/transactions/clear-categories",
|
"/api/banking/transactions/clear-categories",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error("Erreur");
|
if (!response.ok) throw new Error("Erreur");
|
||||||
refresh();
|
refresh();
|
||||||
@@ -121,173 +86,28 @@ export default function SettingsPage() {
|
|||||||
const categorizedCount = data.transactions.filter((t) => t.categoryId).length;
|
const categorizedCount = data.transactions.filter((t) => t.categoryId).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<PageLayout>
|
||||||
<Sidebar />
|
<div className="max-w-2xl space-y-6">
|
||||||
<main className="flex-1 overflow-auto">
|
<PageHeader
|
||||||
<div className="p-6 space-y-6 max-w-2xl">
|
title="Paramètres"
|
||||||
<div>
|
description="Gérez vos données et préférences"
|
||||||
<h1 className="text-2xl font-bold text-foreground">Paramètres</h1>
|
/>
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Gérez vos données et préférences
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
<DataCard
|
||||||
<CardHeader>
|
data={data}
|
||||||
<CardTitle className="flex items-center gap-2">
|
importing={importing}
|
||||||
<Database className="w-5 h-5" />
|
onExport={exportData}
|
||||||
Données
|
onImport={importData}
|
||||||
</CardTitle>
|
/>
|
||||||
<CardDescription>
|
|
||||||
Exportez ou importez vos données pour les sauvegarder ou les
|
|
||||||
transférer
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Statistiques</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{data.accounts.length} comptes, {data.transactions.length}{" "}
|
|
||||||
transactions, {data.categories.length} catégories
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<DangerZoneCard
|
||||||
<Button
|
categorizedCount={categorizedCount}
|
||||||
onClick={exportData}
|
onClearCategories={clearAllCategories}
|
||||||
variant="outline"
|
onResetData={resetData}
|
||||||
className="flex-1 bg-transparent"
|
/>
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Exporter (JSON)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={importData}
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1 bg-transparent"
|
|
||||||
disabled={importing}
|
|
||||||
>
|
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
|
||||||
{importing ? "Import..." : "Importer"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-red-200">
|
<OFXInfoCard />
|
||||||
<CardHeader>
|
</div>
|
||||||
<CardTitle className="flex items-center gap-2 text-red-600">
|
</PageLayout>
|
||||||
<Trash2 className="w-5 h-5" />
|
|
||||||
Zone dangereuse
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Actions irréversibles - procédez avec prudence
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{/* Supprimer catégories des opérations */}
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start border-orange-300 text-orange-700 hover:bg-orange-50"
|
|
||||||
>
|
|
||||||
<Tags className="w-4 h-4 mr-2" />
|
|
||||||
Supprimer les catégories des opérations
|
|
||||||
<span className="ml-auto text-xs text-muted-foreground">
|
|
||||||
{categorizedCount} opération
|
|
||||||
{categorizedCount > 1 ? "s" : ""} catégorisée
|
|
||||||
{categorizedCount > 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
Supprimer toutes les catégories ?
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Cette action va retirer la catégorie de {categorizedCount}{" "}
|
|
||||||
opération{categorizedCount > 1 ? "s" : ""}. Les catégories
|
|
||||||
elles-mêmes ne seront pas supprimées, seulement leur
|
|
||||||
affectation aux opérations.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={clearAllCategories}
|
|
||||||
className="bg-orange-600 hover:bg-orange-700"
|
|
||||||
>
|
|
||||||
Supprimer les affectations
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
{/* Réinitialiser toutes les données */}
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
className="w-full justify-start"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
Réinitialiser toutes les données
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Cette action supprimera définitivement tous vos comptes,
|
|
||||||
transactions, catégories et dossiers. Cette action est
|
|
||||||
irréversible.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={resetData}
|
|
||||||
className="bg-red-600 hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Supprimer tout
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<FileJson className="w-5 h-5" />
|
|
||||||
Format OFX
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Informations sur l'import de fichiers
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="prose prose-sm text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
L'application accepte les fichiers au format OFX (Open
|
|
||||||
Financial Exchange) ou QFX. Ces fichiers sont généralement
|
|
||||||
disponibles depuis l'espace client de votre banque.
|
|
||||||
</p>
|
|
||||||
<p className="mt-2">
|
|
||||||
Lors de l'import, les transactions sont automatiquement
|
|
||||||
catégorisées selon les mots-clés définis. Les doublons sont
|
|
||||||
détectés et ignorés automatiquement.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
||||||
|
import {
|
||||||
|
StatsSummaryCards,
|
||||||
|
MonthlyChart,
|
||||||
|
CategoryPieChart,
|
||||||
|
BalanceLineChart,
|
||||||
|
TopExpensesList,
|
||||||
|
} from "@/components/statistics";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingData } from "@/lib/hooks";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -11,24 +17,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { RefreshCw, TrendingUp, TrendingDown, ArrowRight } from "lucide-react";
|
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
|
||||||
import {
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
Legend,
|
|
||||||
} from "recharts";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type Period = "3months" | "6months" | "12months" | "all";
|
type Period = "3months" | "6months" | "12months" | "all";
|
||||||
|
|
||||||
@@ -58,12 +46,12 @@ export default function StatisticsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let transactions = data.transactions.filter(
|
let transactions = data.transactions.filter(
|
||||||
(t) => new Date(t.date) >= startDate,
|
(t) => new Date(t.date) >= startDate
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selectedAccount !== "all") {
|
if (selectedAccount !== "all") {
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
(t) => t.accountId === selectedAccount,
|
(t) => t.accountId === selectedAccount
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +120,7 @@ export default function StatisticsPage() {
|
|||||||
|
|
||||||
// Balance evolution
|
// Balance evolution
|
||||||
const sortedTransactions = [...transactions].sort(
|
const sortedTransactions = [...transactions].sort(
|
||||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
let runningBalance = 0;
|
let runningBalance = 0;
|
||||||
@@ -149,7 +137,7 @@ export default function StatisticsPage() {
|
|||||||
month: "short",
|
month: "short",
|
||||||
}),
|
}),
|
||||||
solde: Math.round(balance),
|
solde: Math.round(balance),
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -164,17 +152,6 @@ export default function StatisticsPage() {
|
|||||||
};
|
};
|
||||||
}, [data, period, selectedAccount]);
|
}, [data, period, selectedAccount]);
|
||||||
|
|
||||||
if (isLoading || !data || !stats) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen">
|
|
||||||
<Sidebar />
|
|
||||||
<main className="flex-1 flex items-center justify-center">
|
|
||||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
@@ -182,333 +159,74 @@ export default function StatisticsPage() {
|
|||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoading || !data || !stats) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<PageLayout>
|
||||||
<Sidebar />
|
<PageHeader
|
||||||
<main className="flex-1 overflow-auto">
|
title="Statistiques"
|
||||||
<div className="p-6 space-y-6">
|
description="Analysez vos dépenses et revenus"
|
||||||
<div className="flex items-center justify-between">
|
actions={
|
||||||
<div>
|
<>
|
||||||
<h1 className="text-2xl font-bold text-foreground">
|
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
|
||||||
Statistiques
|
<SelectTrigger className="w-[180px]">
|
||||||
</h1>
|
<SelectValue placeholder="Compte" />
|
||||||
<p className="text-muted-foreground">
|
</SelectTrigger>
|
||||||
Analysez vos dépenses et revenus
|
<SelectContent>
|
||||||
</p>
|
<SelectItem value="all">Tous les comptes</SelectItem>
|
||||||
</div>
|
{data.accounts.map((account) => (
|
||||||
<div className="flex gap-2">
|
<SelectItem key={account.id} value={account.id}>
|
||||||
<Select
|
{account.name}
|
||||||
value={selectedAccount}
|
</SelectItem>
|
||||||
onValueChange={setSelectedAccount}
|
))}
|
||||||
>
|
</SelectContent>
|
||||||
<SelectTrigger className="w-[180px]">
|
</Select>
|
||||||
<SelectValue placeholder="Compte" />
|
<Select
|
||||||
</SelectTrigger>
|
value={period}
|
||||||
<SelectContent>
|
onValueChange={(v) => setPeriod(v as Period)}
|
||||||
<SelectItem value="all">Tous les comptes</SelectItem>
|
>
|
||||||
{data.accounts.map((account) => (
|
<SelectTrigger className="w-[150px]">
|
||||||
<SelectItem key={account.id} value={account.id}>
|
<SelectValue placeholder="Période" />
|
||||||
{account.name}
|
</SelectTrigger>
|
||||||
</SelectItem>
|
<SelectContent>
|
||||||
))}
|
<SelectItem value="3months">3 mois</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="6months">6 mois</SelectItem>
|
||||||
</Select>
|
<SelectItem value="12months">12 mois</SelectItem>
|
||||||
<Select
|
<SelectItem value="all">Tout</SelectItem>
|
||||||
value={period}
|
</SelectContent>
|
||||||
onValueChange={(v) => setPeriod(v as Period)}
|
</Select>
|
||||||
>
|
</>
|
||||||
<SelectTrigger className="w-[150px]">
|
}
|
||||||
<SelectValue placeholder="Période" />
|
/>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="3months">3 mois</SelectItem>
|
|
||||||
<SelectItem value="6months">6 mois</SelectItem>
|
|
||||||
<SelectItem value="12months">12 mois</SelectItem>
|
|
||||||
<SelectItem value="all">Tout</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary Cards */}
|
<StatsSummaryCards
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
totalIncome={stats.totalIncome}
|
||||||
<Card>
|
totalExpenses={stats.totalExpenses}
|
||||||
<CardHeader className="pb-2">
|
avgMonthlyExpenses={stats.avgMonthlyExpenses}
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
formatCurrency={formatCurrency}
|
||||||
<TrendingUp className="w-4 h-4 text-emerald-600" />
|
/>
|
||||||
Total Revenus
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold text-emerald-600">
|
|
||||||
{formatCurrency(stats.totalIncome)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<CardHeader className="pb-2">
|
<MonthlyChart
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
data={stats.monthlyChartData}
|
||||||
<TrendingDown className="w-4 h-4 text-red-600" />
|
formatCurrency={formatCurrency}
|
||||||
Total Dépenses
|
/>
|
||||||
</CardTitle>
|
<CategoryPieChart
|
||||||
</CardHeader>
|
data={stats.categoryChartData}
|
||||||
<CardContent>
|
formatCurrency={formatCurrency}
|
||||||
<div className="text-2xl font-bold text-red-600">
|
/>
|
||||||
{formatCurrency(stats.totalExpenses)}
|
<BalanceLineChart
|
||||||
</div>
|
data={stats.balanceChartData}
|
||||||
</CardContent>
|
formatCurrency={formatCurrency}
|
||||||
</Card>
|
/>
|
||||||
|
<TopExpensesList
|
||||||
<Card>
|
expenses={stats.topExpenses}
|
||||||
<CardHeader className="pb-2">
|
categories={data.categories}
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
formatCurrency={formatCurrency}
|
||||||
<ArrowRight className="w-4 h-4" />
|
/>
|
||||||
Moyenne mensuelle
|
</div>
|
||||||
</CardTitle>
|
</PageLayout>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatCurrency(stats.avgMonthlyExpenses)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
||||||
Économies
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"text-2xl font-bold",
|
|
||||||
stats.totalIncome - stats.totalExpenses >= 0
|
|
||||||
? "text-emerald-600"
|
|
||||||
: "text-red-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatCurrency(stats.totalIncome - stats.totalExpenses)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Charts */}
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
{/* Monthly Income vs Expenses */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Revenus vs Dépenses par mois</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{stats.monthlyChartData.length > 0 ? (
|
|
||||||
<div className="h-[300px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={stats.monthlyChartData}>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
className="stroke-muted"
|
|
||||||
/>
|
|
||||||
<XAxis dataKey="month" className="text-xs" />
|
|
||||||
<YAxis
|
|
||||||
className="text-xs"
|
|
||||||
tickFormatter={(v) => `${v}€`}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value: number) => formatCurrency(value)}
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "hsl(var(--card))",
|
|
||||||
border: "1px solid hsl(var(--border))",
|
|
||||||
borderRadius: "8px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
<Bar
|
|
||||||
dataKey="revenus"
|
|
||||||
fill="#22c55e"
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="depenses"
|
|
||||||
fill="#ef4444"
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
|
||||||
Pas de données pour cette période
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Category Breakdown */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Répartition par catégorie</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{stats.categoryChartData.length > 0 ? (
|
|
||||||
<div className="h-[300px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={stats.categoryChartData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={60}
|
|
||||||
outerRadius={100}
|
|
||||||
paddingAngle={2}
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{stats.categoryChartData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value: number) => formatCurrency(value)}
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "hsl(var(--card))",
|
|
||||||
border: "1px solid hsl(var(--border))",
|
|
||||||
borderRadius: "8px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
formatter={(value) => (
|
|
||||||
<span className="text-sm text-foreground">
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
|
||||||
Pas de données pour cette période
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Balance Evolution */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Évolution du solde</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{stats.balanceChartData.length > 0 ? (
|
|
||||||
<div className="h-[300px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart data={stats.balanceChartData}>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
className="stroke-muted"
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
className="text-xs"
|
|
||||||
interval="preserveStartEnd"
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
className="text-xs"
|
|
||||||
tickFormatter={(v) => `${v}€`}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value: number) => formatCurrency(value)}
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "hsl(var(--card))",
|
|
||||||
border: "1px solid hsl(var(--border))",
|
|
||||||
borderRadius: "8px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="solde"
|
|
||||||
stroke="#6366f1"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
|
||||||
Pas de données pour cette période
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Top Expenses */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Top 5 dépenses</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{stats.topExpenses.length > 0 ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{stats.topExpenses.map((expense, index) => {
|
|
||||||
const category = data.categories.find(
|
|
||||||
(c) => c.id === expense.categoryId,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={expense.id}
|
|
||||||
className="flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-sm font-semibold">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-medium text-sm truncate">
|
|
||||||
{expense.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{new Date(expense.date).toLocaleDateString(
|
|
||||||
"fr-FR",
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{category && (
|
|
||||||
<span
|
|
||||||
className="text-xs px-1.5 py-0.5 rounded 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,41 +2,16 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useEffect } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
||||||
|
import {
|
||||||
|
TransactionFilters,
|
||||||
|
TransactionBulkActions,
|
||||||
|
TransactionTable,
|
||||||
|
} from "@/components/transactions";
|
||||||
|
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingData } from "@/lib/hooks";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Upload } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
|
||||||
import {
|
|
||||||
Search,
|
|
||||||
CheckCircle2,
|
|
||||||
Circle,
|
|
||||||
MoreVertical,
|
|
||||||
Tags,
|
|
||||||
Upload,
|
|
||||||
RefreshCw,
|
|
||||||
ArrowUpDown,
|
|
||||||
Check,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type SortField = "date" | "amount" | "description";
|
type SortField = "date" | "amount" | "description";
|
||||||
type SortOrder = "asc" | "desc";
|
type SortOrder = "asc" | "desc";
|
||||||
@@ -47,19 +22,19 @@ export default function TransactionsPage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [selectedAccount, setSelectedAccount] = useState<string>("all");
|
const [selectedAccount, setSelectedAccount] = useState<string>("all");
|
||||||
|
|
||||||
// Initialize account filter from URL params
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const accountId = searchParams.get("accountId");
|
const accountId = searchParams.get("accountId");
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
setSelectedAccount(accountId);
|
setSelectedAccount(accountId);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||||
const [showReconciled, setShowReconciled] = useState<string>("all");
|
const [showReconciled, setShowReconciled] = useState<string>("all");
|
||||||
const [sortField, setSortField] = useState<SortField>("date");
|
const [sortField, setSortField] = useState<SortField>("date");
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||||
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
|
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set()
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredTransactions = useMemo(() => {
|
const filteredTransactions = useMemo(() => {
|
||||||
@@ -67,43 +42,38 @@ export default function TransactionsPage() {
|
|||||||
|
|
||||||
let transactions = [...data.transactions];
|
let transactions = [...data.transactions];
|
||||||
|
|
||||||
// Filter by search
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
(t) =>
|
(t) =>
|
||||||
t.description.toLowerCase().includes(query) ||
|
t.description.toLowerCase().includes(query) ||
|
||||||
t.memo?.toLowerCase().includes(query),
|
t.memo?.toLowerCase().includes(query)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by account
|
|
||||||
if (selectedAccount !== "all") {
|
if (selectedAccount !== "all") {
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
(t) => t.accountId === selectedAccount,
|
(t) => t.accountId === selectedAccount
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by category
|
|
||||||
if (selectedCategory !== "all") {
|
if (selectedCategory !== "all") {
|
||||||
if (selectedCategory === "uncategorized") {
|
if (selectedCategory === "uncategorized") {
|
||||||
transactions = transactions.filter((t) => !t.categoryId);
|
transactions = transactions.filter((t) => !t.categoryId);
|
||||||
} else {
|
} else {
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
(t) => t.categoryId === selectedCategory,
|
(t) => t.categoryId === selectedCategory
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by reconciliation status
|
|
||||||
if (showReconciled !== "all") {
|
if (showReconciled !== "all") {
|
||||||
const isReconciled = showReconciled === "reconciled";
|
const isReconciled = showReconciled === "reconciled";
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
(t) => t.isReconciled === isReconciled,
|
(t) => t.isReconciled === isReconciled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort
|
|
||||||
transactions.sort((a, b) => {
|
transactions.sort((a, b) => {
|
||||||
let comparison = 0;
|
let comparison = 0;
|
||||||
switch (sortField) {
|
switch (sortField) {
|
||||||
@@ -132,14 +102,7 @@ export default function TransactionsPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoading || !data) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="flex h-screen">
|
|
||||||
<Sidebar />
|
|
||||||
<main className="flex-1 flex items-center justify-center">
|
|
||||||
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
@@ -161,15 +124,16 @@ export default function TransactionsPage() {
|
|||||||
const transaction = data.transactions.find((t) => t.id === transactionId);
|
const transaction = data.transactions.find((t) => t.id === transactionId);
|
||||||
if (!transaction) return;
|
if (!transaction) return;
|
||||||
|
|
||||||
const updatedTransaction = { ...transaction, isReconciled: !transaction.isReconciled };
|
const updatedTransaction = {
|
||||||
|
...transaction,
|
||||||
// Optimistic update
|
isReconciled: !transaction.isReconciled,
|
||||||
|
};
|
||||||
|
|
||||||
const updatedTransactions = data.transactions.map((t) =>
|
const updatedTransactions = data.transactions.map((t) =>
|
||||||
t.id === transactionId ? updatedTransaction : t,
|
t.id === transactionId ? updatedTransaction : t
|
||||||
);
|
);
|
||||||
update({ ...data, transactions: updatedTransactions });
|
update({ ...data, transactions: updatedTransactions });
|
||||||
|
|
||||||
// Persist to database
|
|
||||||
try {
|
try {
|
||||||
await fetch("/api/banking/transactions", {
|
await fetch("/api/banking/transactions", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -178,24 +142,24 @@ export default function TransactionsPage() {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update transaction:", error);
|
console.error("Failed to update transaction:", error);
|
||||||
// Revert on error
|
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setCategory = async (transactionId: string, categoryId: string | null) => {
|
const setCategory = async (
|
||||||
|
transactionId: string,
|
||||||
|
categoryId: string | null
|
||||||
|
) => {
|
||||||
const transaction = data.transactions.find((t) => t.id === transactionId);
|
const transaction = data.transactions.find((t) => t.id === transactionId);
|
||||||
if (!transaction) return;
|
if (!transaction) return;
|
||||||
|
|
||||||
const updatedTransaction = { ...transaction, categoryId };
|
const updatedTransaction = { ...transaction, categoryId };
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
const updatedTransactions = data.transactions.map((t) =>
|
const updatedTransactions = data.transactions.map((t) =>
|
||||||
t.id === transactionId ? updatedTransaction : t,
|
t.id === transactionId ? updatedTransaction : t
|
||||||
);
|
);
|
||||||
update({ ...data, transactions: updatedTransactions });
|
update({ ...data, transactions: updatedTransactions });
|
||||||
|
|
||||||
// Persist to database
|
|
||||||
try {
|
try {
|
||||||
await fetch("/api/banking/transactions", {
|
await fetch("/api/banking/transactions", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -212,15 +176,13 @@ export default function TransactionsPage() {
|
|||||||
const transactionsToUpdate = data.transactions.filter((t) =>
|
const transactionsToUpdate = data.transactions.filter((t) =>
|
||||||
selectedTransactions.has(t.id)
|
selectedTransactions.has(t.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
const updatedTransactions = data.transactions.map((t) =>
|
const updatedTransactions = data.transactions.map((t) =>
|
||||||
selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t,
|
selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t
|
||||||
);
|
);
|
||||||
update({ ...data, transactions: updatedTransactions });
|
update({ ...data, transactions: updatedTransactions });
|
||||||
setSelectedTransactions(new Set());
|
setSelectedTransactions(new Set());
|
||||||
|
|
||||||
// Persist to database
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
transactionsToUpdate.map((t) =>
|
transactionsToUpdate.map((t) =>
|
||||||
@@ -241,15 +203,13 @@ export default function TransactionsPage() {
|
|||||||
const transactionsToUpdate = data.transactions.filter((t) =>
|
const transactionsToUpdate = data.transactions.filter((t) =>
|
||||||
selectedTransactions.has(t.id)
|
selectedTransactions.has(t.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
const updatedTransactions = data.transactions.map((t) =>
|
const updatedTransactions = data.transactions.map((t) =>
|
||||||
selectedTransactions.has(t.id) ? { ...t, categoryId } : t,
|
selectedTransactions.has(t.id) ? { ...t, categoryId } : t
|
||||||
);
|
);
|
||||||
update({ ...data, transactions: updatedTransactions });
|
update({ ...data, transactions: updatedTransactions });
|
||||||
setSelectedTransactions(new Set());
|
setSelectedTransactions(new Set());
|
||||||
|
|
||||||
// Persist to database
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
transactionsToUpdate.map((t) =>
|
transactionsToUpdate.map((t) =>
|
||||||
@@ -284,411 +244,65 @@ export default function TransactionsPage() {
|
|||||||
setSelectedTransactions(newSelected);
|
setSelectedTransactions(newSelected);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCategory = (categoryId: string | null) => {
|
const handleSortChange = (field: SortField) => {
|
||||||
if (!categoryId) return null;
|
if (sortField === field) {
|
||||||
return data.categories.find((c) => c.id === categoryId);
|
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||||
};
|
} else {
|
||||||
|
setSortField(field);
|
||||||
const getAccount = (accountId: string) => {
|
setSortOrder(field === "date" ? "desc" : "asc");
|
||||||
return data.accounts.find((a) => a.id === accountId);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<PageLayout>
|
||||||
<Sidebar />
|
<PageHeader
|
||||||
<main className="flex-1 overflow-auto">
|
title="Transactions"
|
||||||
<div className="p-6 space-y-6">
|
description={`${filteredTransactions.length} transaction${filteredTransactions.length > 1 ? "s" : ""}`}
|
||||||
<div className="flex items-center justify-between">
|
actions={
|
||||||
<div>
|
<OFXImportDialog onImportComplete={refresh}>
|
||||||
<h1 className="text-2xl font-bold text-foreground">
|
<Button>
|
||||||
Transactions
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
</h1>
|
Importer OFX
|
||||||
<p className="text-muted-foreground">
|
</Button>
|
||||||
{filteredTransactions.length} transaction
|
</OFXImportDialog>
|
||||||
{filteredTransactions.length > 1 ? "s" : ""}
|
}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
<OFXImportDialog onImportComplete={refresh}>
|
|
||||||
<Button>
|
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
|
||||||
Importer OFX
|
|
||||||
</Button>
|
|
||||||
</OFXImportDialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
<TransactionFilters
|
||||||
<Card>
|
searchQuery={searchQuery}
|
||||||
<CardContent className="pt-4">
|
onSearchChange={setSearchQuery}
|
||||||
<div className="flex flex-wrap gap-4">
|
selectedAccount={selectedAccount}
|
||||||
<div className="flex-1 min-w-[200px]">
|
onAccountChange={setSelectedAccount}
|
||||||
<div className="relative">
|
selectedCategory={selectedCategory}
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
onCategoryChange={setSelectedCategory}
|
||||||
<Input
|
showReconciled={showReconciled}
|
||||||
placeholder="Rechercher..."
|
onReconciledChange={setShowReconciled}
|
||||||
value={searchQuery}
|
accounts={data.accounts}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
categories={data.categories}
|
||||||
className="pl-9"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select
|
<TransactionBulkActions
|
||||||
value={selectedAccount}
|
selectedCount={selectedTransactions.size}
|
||||||
onValueChange={setSelectedAccount}
|
categories={data.categories}
|
||||||
>
|
onReconcile={bulkReconcile}
|
||||||
<SelectTrigger className="w-[180px]">
|
onSetCategory={bulkSetCategory}
|
||||||
<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
|
<TransactionTable
|
||||||
value={selectedCategory}
|
transactions={filteredTransactions}
|
||||||
onValueChange={setSelectedCategory}
|
accounts={data.accounts}
|
||||||
>
|
categories={data.categories}
|
||||||
<SelectTrigger className="w-[180px]">
|
selectedTransactions={selectedTransactions}
|
||||||
<SelectValue placeholder="Catégorie" />
|
sortField={sortField}
|
||||||
</SelectTrigger>
|
sortOrder={sortOrder}
|
||||||
<SelectContent>
|
onSortChange={handleSortChange}
|
||||||
<SelectItem value="all">Toutes catégories</SelectItem>
|
onToggleSelectAll={toggleSelectAll}
|
||||||
<SelectItem value="uncategorized">
|
onToggleSelectTransaction={toggleSelectTransaction}
|
||||||
Non catégorisé
|
onToggleReconciled={toggleReconciled}
|
||||||
</SelectItem>
|
onSetCategory={setCategory}
|
||||||
{data.categories.map((category) => (
|
formatCurrency={formatCurrency}
|
||||||
<SelectItem key={category.id} value={category.id}>
|
formatDate={formatDate}
|
||||||
{category.name}
|
/>
|
||||||
</SelectItem>
|
</PageLayout>
|
||||||
))}
|
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
{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"
|
|
||||||
/>
|
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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