695 lines
26 KiB
TypeScript
695 lines
26 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo, useEffect } from "react";
|
|
import { useSearchParams } from "next/navigation";
|
|
import { Sidebar } from "@/components/dashboard/sidebar";
|
|
import { useBankingData } from "@/lib/hooks";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuSeparator,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
|
import { 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 SortOrder = "asc" | "desc";
|
|
|
|
export default function TransactionsPage() {
|
|
const searchParams = useSearchParams();
|
|
const { data, isLoading, refresh, update } = useBankingData();
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [selectedAccount, setSelectedAccount] = useState<string>("all");
|
|
|
|
// Initialize account filter from URL params
|
|
useEffect(() => {
|
|
const accountId = searchParams.get("accountId");
|
|
if (accountId) {
|
|
setSelectedAccount(accountId);
|
|
}
|
|
}, [searchParams]);
|
|
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
|
const [showReconciled, setShowReconciled] = useState<string>("all");
|
|
const [sortField, setSortField] = useState<SortField>("date");
|
|
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
|
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
|
|
const filteredTransactions = useMemo(() => {
|
|
if (!data) return [];
|
|
|
|
let transactions = [...data.transactions];
|
|
|
|
// Filter by search
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
transactions = transactions.filter(
|
|
(t) =>
|
|
t.description.toLowerCase().includes(query) ||
|
|
t.memo?.toLowerCase().includes(query),
|
|
);
|
|
}
|
|
|
|
// Filter by account
|
|
if (selectedAccount !== "all") {
|
|
transactions = transactions.filter(
|
|
(t) => t.accountId === selectedAccount,
|
|
);
|
|
}
|
|
|
|
// Filter by category
|
|
if (selectedCategory !== "all") {
|
|
if (selectedCategory === "uncategorized") {
|
|
transactions = transactions.filter((t) => !t.categoryId);
|
|
} else {
|
|
transactions = transactions.filter(
|
|
(t) => t.categoryId === selectedCategory,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Filter by reconciliation status
|
|
if (showReconciled !== "all") {
|
|
const isReconciled = showReconciled === "reconciled";
|
|
transactions = transactions.filter(
|
|
(t) => t.isReconciled === isReconciled,
|
|
);
|
|
}
|
|
|
|
// Sort
|
|
transactions.sort((a, b) => {
|
|
let comparison = 0;
|
|
switch (sortField) {
|
|
case "date":
|
|
comparison = new Date(a.date).getTime() - new Date(b.date).getTime();
|
|
break;
|
|
case "amount":
|
|
comparison = a.amount - b.amount;
|
|
break;
|
|
case "description":
|
|
comparison = a.description.localeCompare(b.description);
|
|
break;
|
|
}
|
|
return sortOrder === "asc" ? comparison : -comparison;
|
|
});
|
|
|
|
return transactions;
|
|
}, [
|
|
data,
|
|
searchQuery,
|
|
selectedAccount,
|
|
selectedCategory,
|
|
showReconciled,
|
|
sortField,
|
|
sortOrder,
|
|
]);
|
|
|
|
if (isLoading || !data) {
|
|
return (
|
|
<div className="flex h-screen">
|
|
<Sidebar />
|
|
<main className="flex-1 flex items-center justify-center">
|
|
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const formatCurrency = (amount: number) => {
|
|
return new Intl.NumberFormat("fr-FR", {
|
|
style: "currency",
|
|
currency: "EUR",
|
|
}).format(amount);
|
|
};
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleDateString("fr-FR", {
|
|
day: "2-digit",
|
|
month: "short",
|
|
year: "numeric",
|
|
});
|
|
};
|
|
|
|
const toggleReconciled = async (transactionId: string) => {
|
|
const transaction = data.transactions.find((t) => t.id === transactionId);
|
|
if (!transaction) return;
|
|
|
|
const updatedTransaction = { ...transaction, isReconciled: !transaction.isReconciled };
|
|
|
|
// Optimistic update
|
|
const updatedTransactions = data.transactions.map((t) =>
|
|
t.id === transactionId ? updatedTransaction : t,
|
|
);
|
|
update({ ...data, transactions: updatedTransactions });
|
|
|
|
// Persist to database
|
|
try {
|
|
await fetch("/api/banking/transactions", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(updatedTransaction),
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to update transaction:", error);
|
|
// Revert on error
|
|
refresh();
|
|
}
|
|
};
|
|
|
|
const setCategory = async (transactionId: string, categoryId: string | null) => {
|
|
const transaction = data.transactions.find((t) => t.id === transactionId);
|
|
if (!transaction) return;
|
|
|
|
const updatedTransaction = { ...transaction, categoryId };
|
|
|
|
// Optimistic update
|
|
const updatedTransactions = data.transactions.map((t) =>
|
|
t.id === transactionId ? updatedTransaction : t,
|
|
);
|
|
update({ ...data, transactions: updatedTransactions });
|
|
|
|
// Persist to database
|
|
try {
|
|
await fetch("/api/banking/transactions", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(updatedTransaction),
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to update transaction:", error);
|
|
refresh();
|
|
}
|
|
};
|
|
|
|
const bulkReconcile = async (reconciled: boolean) => {
|
|
const transactionsToUpdate = data.transactions.filter((t) =>
|
|
selectedTransactions.has(t.id)
|
|
);
|
|
|
|
// Optimistic update
|
|
const updatedTransactions = data.transactions.map((t) =>
|
|
selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t,
|
|
);
|
|
update({ ...data, transactions: updatedTransactions });
|
|
setSelectedTransactions(new Set());
|
|
|
|
// Persist to database
|
|
try {
|
|
await Promise.all(
|
|
transactionsToUpdate.map((t) =>
|
|
fetch("/api/banking/transactions", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ...t, isReconciled: reconciled }),
|
|
})
|
|
)
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to update transactions:", error);
|
|
refresh();
|
|
}
|
|
};
|
|
|
|
const bulkSetCategory = async (categoryId: string | null) => {
|
|
const transactionsToUpdate = data.transactions.filter((t) =>
|
|
selectedTransactions.has(t.id)
|
|
);
|
|
|
|
// Optimistic update
|
|
const updatedTransactions = data.transactions.map((t) =>
|
|
selectedTransactions.has(t.id) ? { ...t, categoryId } : t,
|
|
);
|
|
update({ ...data, transactions: updatedTransactions });
|
|
setSelectedTransactions(new Set());
|
|
|
|
// Persist to database
|
|
try {
|
|
await Promise.all(
|
|
transactionsToUpdate.map((t) =>
|
|
fetch("/api/banking/transactions", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ...t, categoryId }),
|
|
})
|
|
)
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to update transactions:", error);
|
|
refresh();
|
|
}
|
|
};
|
|
|
|
const toggleSelectAll = () => {
|
|
if (selectedTransactions.size === filteredTransactions.length) {
|
|
setSelectedTransactions(new Set());
|
|
} else {
|
|
setSelectedTransactions(new Set(filteredTransactions.map((t) => t.id)));
|
|
}
|
|
};
|
|
|
|
const toggleSelectTransaction = (id: string) => {
|
|
const newSelected = new Set(selectedTransactions);
|
|
if (newSelected.has(id)) {
|
|
newSelected.delete(id);
|
|
} else {
|
|
newSelected.add(id);
|
|
}
|
|
setSelectedTransactions(newSelected);
|
|
};
|
|
|
|
const getCategory = (categoryId: string | null) => {
|
|
if (!categoryId) return null;
|
|
return data.categories.find((c) => c.id === categoryId);
|
|
};
|
|
|
|
const getAccount = (accountId: string) => {
|
|
return data.accounts.find((a) => a.id === accountId);
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-screen bg-background">
|
|
<Sidebar />
|
|
<main className="flex-1 overflow-auto">
|
|
<div className="p-6 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-foreground">
|
|
Transactions
|
|
</h1>
|
|
<p className="text-muted-foreground">
|
|
{filteredTransactions.length} transaction
|
|
{filteredTransactions.length > 1 ? "s" : ""}
|
|
</p>
|
|
</div>
|
|
<OFXImportDialog onImportComplete={refresh}>
|
|
<Button>
|
|
<Upload className="w-4 h-4 mr-2" />
|
|
Importer OFX
|
|
</Button>
|
|
</OFXImportDialog>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex flex-wrap gap-4">
|
|
<div className="flex-1 min-w-[200px]">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Rechercher..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Select
|
|
value={selectedAccount}
|
|
onValueChange={setSelectedAccount}
|
|
>
|
|
<SelectTrigger className="w-[180px]">
|
|
<SelectValue placeholder="Compte" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Tous les comptes</SelectItem>
|
|
{data.accounts.map((account) => (
|
|
<SelectItem key={account.id} value={account.id}>
|
|
{account.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select
|
|
value={selectedCategory}
|
|
onValueChange={setSelectedCategory}
|
|
>
|
|
<SelectTrigger className="w-[180px]">
|
|
<SelectValue placeholder="Catégorie" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Toutes catégories</SelectItem>
|
|
<SelectItem value="uncategorized">
|
|
Non catégorisé
|
|
</SelectItem>
|
|
{data.categories.map((category) => (
|
|
<SelectItem key={category.id} value={category.id}>
|
|
{category.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select
|
|
value={showReconciled}
|
|
onValueChange={setShowReconciled}
|
|
>
|
|
<SelectTrigger className="w-[160px]">
|
|
<SelectValue placeholder="Pointage" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Tout</SelectItem>
|
|
<SelectItem value="reconciled">Pointées</SelectItem>
|
|
<SelectItem value="not-reconciled">Non pointées</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Bulk actions */}
|
|
{selectedTransactions.size > 0 && (
|
|
<Card className="bg-primary/5 border-primary/20">
|
|
<CardContent className="py-3">
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-sm font-medium">
|
|
{selectedTransactions.size} sélectionnée
|
|
{selectedTransactions.size > 1 ? "s" : ""}
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => bulkReconcile(true)}
|
|
>
|
|
<CheckCircle2 className="w-4 h-4 mr-1" />
|
|
Pointer
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => bulkReconcile(false)}
|
|
>
|
|
<Circle className="w-4 h-4 mr-1" />
|
|
Dépointer
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button size="sm" variant="outline">
|
|
<Tags className="w-4 h-4 mr-1" />
|
|
Catégoriser
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<DropdownMenuItem onClick={() => bulkSetCategory(null)}>
|
|
Aucune catégorie
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
{data.categories.map((cat) => (
|
|
<DropdownMenuItem
|
|
key={cat.id}
|
|
onClick={() => bulkSetCategory(cat.id)}
|
|
>
|
|
<CategoryIcon
|
|
icon={cat.icon}
|
|
color={cat.color}
|
|
size={14}
|
|
className="mr-2"
|
|
/>
|
|
{cat.name}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Transactions list */}
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
{filteredTransactions.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12">
|
|
<p className="text-muted-foreground">
|
|
Aucune transaction trouvée
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className="p-3 text-left">
|
|
<Checkbox
|
|
checked={
|
|
selectedTransactions.size ===
|
|
filteredTransactions.length &&
|
|
filteredTransactions.length > 0
|
|
}
|
|
onCheckedChange={toggleSelectAll}
|
|
/>
|
|
</th>
|
|
<th className="p-3 text-left">
|
|
<button
|
|
onClick={() => {
|
|
if (sortField === "date") {
|
|
setSortOrder(
|
|
sortOrder === "asc" ? "desc" : "asc",
|
|
);
|
|
} else {
|
|
setSortField("date");
|
|
setSortOrder("desc");
|
|
}
|
|
}}
|
|
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
|
|
>
|
|
Date
|
|
<ArrowUpDown className="w-3 h-3" />
|
|
</button>
|
|
</th>
|
|
<th className="p-3 text-left">
|
|
<button
|
|
onClick={() => {
|
|
if (sortField === "description") {
|
|
setSortOrder(
|
|
sortOrder === "asc" ? "desc" : "asc",
|
|
);
|
|
} else {
|
|
setSortField("description");
|
|
setSortOrder("asc");
|
|
}
|
|
}}
|
|
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
|
|
>
|
|
Description
|
|
<ArrowUpDown className="w-3 h-3" />
|
|
</button>
|
|
</th>
|
|
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
|
|
Compte
|
|
</th>
|
|
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
|
|
Catégorie
|
|
</th>
|
|
<th className="p-3 text-right">
|
|
<button
|
|
onClick={() => {
|
|
if (sortField === "amount") {
|
|
setSortOrder(
|
|
sortOrder === "asc" ? "desc" : "asc",
|
|
);
|
|
} else {
|
|
setSortField("amount");
|
|
setSortOrder("desc");
|
|
}
|
|
}}
|
|
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground ml-auto"
|
|
>
|
|
Montant
|
|
<ArrowUpDown className="w-3 h-3" />
|
|
</button>
|
|
</th>
|
|
<th className="p-3 text-center text-sm font-medium text-muted-foreground">
|
|
Pointé
|
|
</th>
|
|
<th className="p-3"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredTransactions.map((transaction) => {
|
|
const category = getCategory(transaction.categoryId);
|
|
const account = getAccount(transaction.accountId);
|
|
|
|
return (
|
|
<tr
|
|
key={transaction.id}
|
|
className="border-b border-border last:border-0 hover:bg-muted/50"
|
|
>
|
|
<td className="p-3">
|
|
<Checkbox
|
|
checked={selectedTransactions.has(
|
|
transaction.id,
|
|
)}
|
|
onCheckedChange={() =>
|
|
toggleSelectTransaction(transaction.id)
|
|
}
|
|
/>
|
|
</td>
|
|
<td className="p-3 text-sm text-muted-foreground whitespace-nowrap">
|
|
{formatDate(transaction.date)}
|
|
</td>
|
|
<td className="p-3">
|
|
<p className="font-medium text-sm">
|
|
{transaction.description}
|
|
</p>
|
|
{transaction.memo && (
|
|
<p className="text-xs text-muted-foreground truncate max-w-[300px]">
|
|
{transaction.memo}
|
|
</p>
|
|
)}
|
|
</td>
|
|
<td className="p-3 text-sm text-muted-foreground">
|
|
{account?.name || "-"}
|
|
</td>
|
|
<td className="p-3">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="flex items-center gap-1 hover:opacity-80">
|
|
{category ? (
|
|
<Badge
|
|
variant="secondary"
|
|
className="gap-1"
|
|
style={{
|
|
backgroundColor: `${category.color}20`,
|
|
color: category.color,
|
|
}}
|
|
>
|
|
<CategoryIcon
|
|
icon={category.icon}
|
|
color={category.color}
|
|
size={12}
|
|
/>
|
|
{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>
|
|
);
|
|
}
|