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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,73 @@
"use client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { CategoryIcon } from "@/components/ui/category-icon";
import { Pencil, Trash2 } from "lucide-react";
import type { Category } from "@/lib/types";
interface CategoryCardProps {
category: Category;
stats: { total: number; count: number };
formatCurrency: (amount: number) => string;
onEdit: (category: Category) => void;
onDelete: (categoryId: string) => void;
}
export function CategoryCard({
category,
stats,
formatCurrency,
onEdit,
onDelete,
}: CategoryCardProps) {
return (
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div
className="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: `${category.color}20` }}
>
<CategoryIcon
icon={category.icon}
color={category.color}
size={12}
/>
</div>
<span className="text-sm truncate">{category.name}</span>
<span className="text-sm text-muted-foreground shrink-0">
{stats.count} opération{stats.count > 1 ? "s" : ""} {" "}
{formatCurrency(stats.total)}
</span>
{category.keywords.length > 0 && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4 shrink-0"
>
{category.keywords.length}
</Badge>
)}
</div>
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => onEdit(category)}
>
<Pencil className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => onDelete(category.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,140 @@
"use client";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { CategoryIcon } from "@/components/ui/category-icon";
import {
Plus,
MoreVertical,
Pencil,
Trash2,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { CategoryCard } from "./category-card";
import type { Category } from "@/lib/types";
interface ParentCategoryRowProps {
parent: Category;
children: Category[];
stats: { total: number; count: number };
isExpanded: boolean;
onToggleExpanded: () => void;
formatCurrency: (amount: number) => string;
getCategoryStats: (categoryId: string) => { total: number; count: number };
onEdit: (category: Category) => void;
onDelete: (categoryId: string) => void;
onNewCategory: (parentId: string) => void;
}
export function ParentCategoryRow({
parent,
children,
stats,
isExpanded,
onToggleExpanded,
formatCurrency,
getCategoryStats,
onEdit,
onDelete,
onNewCategory,
}: ParentCategoryRowProps) {
return (
<div className="border rounded-lg bg-card">
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
<div className="flex items-center justify-between px-3 py-2">
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
)}
<div
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: `${parent.color}20` }}
>
<CategoryIcon
icon={parent.icon}
color={parent.color}
size={14}
/>
</div>
<span className="font-medium text-sm truncate">{parent.name}</span>
<span className="text-sm text-muted-foreground shrink-0">
{children.length} {stats.count} opération
{stats.count > 1 ? "s" : ""} {formatCurrency(stats.total)}
</span>
</button>
</CollapsibleTrigger>
<div className="flex items-center gap-1 shrink-0 ml-2">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
onNewCategory(parent.id);
}}
>
<Plus className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(parent)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(parent.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<CollapsibleContent>
{children.length > 0 ? (
<div className="px-3 pb-2 space-y-1 ml-6 border-l-2 border-muted ml-5">
{children.map((child) => (
<CategoryCard
key={child.id}
category={child}
stats={getCategoryStats(child.id)}
formatCurrency={formatCurrency}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
) : (
<div className="px-3 pb-2 ml-11 text-xs text-muted-foreground italic">
Aucune sous-catégorie
</div>
)}
</CollapsibleContent>
</Collapsible>
</div>
);
}

View File

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

View File

@@ -0,0 +1,16 @@
export const folderColors = [
{ value: "#6366f1", label: "Indigo" },
{ value: "#22c55e", label: "Vert" },
{ value: "#f59e0b", label: "Orange" },
{ value: "#ec4899", label: "Rose" },
{ value: "#3b82f6", label: "Bleu" },
{ value: "#ef4444", label: "Rouge" },
];
export const accountTypeLabels = {
CHECKING: "Compte courant",
SAVINGS: "Épargne",
CREDIT_CARD: "Carte de crédit",
OTHER: "Autre",
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,119 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Trash2, Tags } from "lucide-react";
interface DangerZoneCardProps {
categorizedCount: number;
onClearCategories: () => void;
onResetData: () => void;
}
export function DangerZoneCard({
categorizedCount,
onClearCategories,
onResetData,
}: DangerZoneCardProps) {
return (
<Card className="border-red-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<Trash2 className="w-5 h-5" />
Zone dangereuse
</CardTitle>
<CardDescription>
Actions irréversibles - procédez avec prudence
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* Supprimer catégories des opérations */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
className="w-full justify-start border-orange-300 text-orange-700 hover:bg-orange-50"
>
<Tags className="w-4 h-4 mr-2" />
Supprimer les catégories des opérations
<span className="ml-auto text-xs text-muted-foreground">
{categorizedCount} opération
{categorizedCount > 1 ? "s" : ""} catégorisée
{categorizedCount > 1 ? "s" : ""}
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Supprimer toutes les catégories ?
</AlertDialogTitle>
<AlertDialogDescription>
Cette action va retirer la catégorie de {categorizedCount}{" "}
opération{categorizedCount > 1 ? "s" : ""}. Les catégories
elles-mêmes ne seront pas supprimées, seulement leur
affectation aux opérations.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={onClearCategories}
className="bg-orange-600 hover:bg-orange-700"
>
Supprimer les affectations
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Réinitialiser toutes les données */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full justify-start">
<Trash2 className="w-4 h-4 mr-2" />
Réinitialiser toutes les données
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action supprimera définitivement tous vos comptes,
transactions, catégories et dossiers. Cette action est
irréversible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={onResetData}
className="bg-red-600 hover:bg-red-700"
>
Supprimer tout
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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