chore: init from v0
This commit is contained in:
475
app/transactions/page.tsx
Normal file
475
app/transactions/page.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
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 { 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 { data, isLoading, refresh, update } = useBankingData()
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>("all")
|
||||
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 = (transactionId: string) => {
|
||||
const updatedTransactions = data.transactions.map((t) =>
|
||||
t.id === transactionId ? { ...t, isReconciled: !t.isReconciled } : t,
|
||||
)
|
||||
update({ ...data, transactions: updatedTransactions })
|
||||
}
|
||||
|
||||
const setCategory = (transactionId: string, categoryId: string | null) => {
|
||||
const updatedTransactions = data.transactions.map((t) => (t.id === transactionId ? { ...t, categoryId } : t))
|
||||
update({ ...data, transactions: updatedTransactions })
|
||||
}
|
||||
|
||||
const bulkReconcile = (reconciled: boolean) => {
|
||||
const updatedTransactions = data.transactions.map((t) =>
|
||||
selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t,
|
||||
)
|
||||
update({ ...data, transactions: updatedTransactions })
|
||||
setSelectedTransactions(new Set())
|
||||
}
|
||||
|
||||
const bulkSetCategory = (categoryId: string | null) => {
|
||||
const updatedTransactions = data.transactions.map((t) =>
|
||||
selectedTransactions.has(t.id) ? { ...t, categoryId } : t,
|
||||
)
|
||||
update({ ...data, transactions: updatedTransactions })
|
||||
setSelectedTransactions(new Set())
|
||||
}
|
||||
|
||||
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)}>
|
||||
<div className="w-3 h-3 rounded-full mr-2" style={{ backgroundColor: cat.color }} />
|
||||
{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"
|
||||
style={{
|
||||
backgroundColor: `${category.color}20`,
|
||||
color: category.color,
|
||||
}}
|
||||
>
|
||||
{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)}>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: cat.color }}
|
||||
/>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user