Files
fintrack/app/transactions/page.tsx

458 lines
16 KiB
TypeScript

"use client";
import { useCallback, useMemo } from "react";
import { PageLayout, PageHeader } from "@/components/layout";
import {
RefreshCw,
Receipt,
Euro,
ChevronDown,
ChevronUp,
CheckCircle2,
Tag,
} from "lucide-react";
import {
TransactionFilters,
TransactionBulkActions,
TransactionTable,
TransactionPagination,
formatCurrency,
formatDate,
} from "@/components/transactions";
import { RuleCreateDialog } from "@/components/rules";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import { MonthlyChart } from "@/components/statistics";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Upload } from "lucide-react";
import { useTransactionsPage } from "@/hooks/use-transactions-page";
import { useTransactionMutations } from "@/hooks/use-transaction-mutations";
import { useTransactionRules } from "@/hooks/use-transaction-rules";
import { useTransactionsChartData } from "@/hooks/use-transactions-chart-data";
import { useTransactionsForAccountFilter } from "@/hooks/use-transactions-for-account-filter";
import { useTransactionsForCategoryFilter } from "@/hooks/use-transactions-for-category-filter";
import { useLocalStorage } from "@/hooks/use-local-storage";
export default function TransactionsPage() {
const queryClient = useQueryClient();
// Main page state and logic
const {
metadata,
isLoadingMetadata,
searchQuery,
setSearchQuery,
selectedAccounts,
onAccountsChange,
selectedCategories,
onCategoriesChange,
showReconciled,
onReconciledChange,
period,
onPeriodChange,
customStartDate,
customEndDate,
onCustomStartDateChange,
onCustomEndDateChange,
isCustomDatePickerOpen,
onCustomDatePickerOpenChange,
showDuplicates,
onShowDuplicatesChange,
page,
pageSize,
onPageChange,
sortField,
sortOrder,
onSortChange,
selectedTransactions,
onToggleSelectAll,
onToggleSelectTransaction,
clearSelection,
transactionsData,
isLoadingTransactions,
invalidateTransactions,
duplicateIds,
transactionParams,
} = useTransactionsPage();
// Transaction mutations
const {
toggleReconciled,
markReconciled,
setCategory,
deleteTransaction,
bulkReconcile: handleBulkReconcile,
bulkSetCategory: handleBulkSetCategory,
updatingTransactionIds,
} = useTransactionMutations({
transactionParams,
transactionsData,
});
// Transaction rules
const {
ruleDialogOpen,
setRuleDialogOpen,
ruleGroup,
handleCreateRule,
handleSaveRule,
} = useTransactionRules({
transactionsData,
metadata,
});
// Chart data
const {
monthlyData,
isLoading: isLoadingChart,
totalAmount: chartTotalAmount,
totalCount: chartTotalCount,
transactions: chartTransactions,
} = useTransactionsChartData({
selectedAccounts,
selectedCategories,
period,
customStartDate,
customEndDate,
showReconciled,
searchQuery,
});
// Transactions for account filter (filtered by categories, period, search, reconciled - NOT by accounts)
const { transactions: transactionsForAccountFilter } =
useTransactionsForAccountFilter({
selectedCategories,
period,
customStartDate,
customEndDate,
showReconciled,
searchQuery,
});
// Transactions for category filter (filtered by accounts, period, search, reconciled - NOT by categories)
const { transactions: transactionsForCategoryFilter } =
useTransactionsForCategoryFilter({
selectedAccounts,
period,
customStartDate,
customEndDate,
showReconciled,
searchQuery,
});
const invalidateAll = useCallback(() => {
invalidateTransactions();
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
}, [invalidateTransactions, queryClient]);
const handleBulkReconcileWithClear = useCallback(
(reconciled: boolean) => {
handleBulkReconcile(reconciled, selectedTransactions);
clearSelection();
},
[handleBulkReconcile, selectedTransactions, clearSelection],
);
const handleBulkSetCategoryWithClear = useCallback(
(categoryId: string | null) => {
handleBulkSetCategory(categoryId, selectedTransactions);
clearSelection();
},
[handleBulkSetCategory, selectedTransactions, clearSelection],
);
// Stabilize transactions reference to prevent unnecessary re-renders
const filteredTransactions = useMemo(
() => transactionsData?.transactions || [],
[transactionsData?.transactions],
);
const totalTransactions = transactionsData?.total || 0;
const hasMore = transactionsData?.hasMore || false;
const uncategorizedCount = transactionsData?.uncategorizedCount || 0;
const uncategorizedPercent =
totalTransactions > 0
? Math.round((uncategorizedCount / totalTransactions) * 100)
: 0;
// Use total from chart data (all filtered transactions) or fallback to paginated data
const totalAmount = chartTotalAmount ?? 0;
const displayTotalCount = chartTotalCount ?? totalTransactions;
// Calculate percentages from chart transactions (all filtered transactions)
const reconciledPercent = useMemo(() => {
if (chartTransactions.length === 0) return "0.00";
const reconciledCount = chartTransactions.filter(
(t) => t.isReconciled,
).length;
return ((reconciledCount / chartTransactions.length) * 100).toFixed(2);
}, [chartTransactions]);
const categorizedPercent = useMemo(() => {
if (chartTransactions.length === 0) return "0.00";
const categorizedCount = chartTransactions.filter(
(t) => t.categoryId !== null,
).length;
return ((categorizedCount / chartTransactions.length) * 100).toFixed(2);
}, [chartTransactions]);
// Persist statistics collapsed state in localStorage
const [isStatsExpanded, setIsStatsExpanded] = useLocalStorage(
"transactions-stats-expanded",
true,
);
// Early return for loading state - prevents sidebar flash
if (isLoadingMetadata || !metadata) {
return (
<div className="flex h-screen bg-background">
<main className="flex-1 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</main>
</div>
);
}
return (
<PageLayout>
<PageHeader
title="Transactions"
description={
totalTransactions > 0
? `${totalTransactions} transaction${totalTransactions > 1 ? "s" : ""}${uncategorizedPercent}% non catégorisées`
: `${totalTransactions} transaction${totalTransactions > 1 ? "s" : ""}`
}
actions={
<OFXImportDialog onImportComplete={invalidateAll}>
<Button className="md:h-10 md:px-5">
<Upload className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline">Importer OFX</span>
</Button>
</OFXImportDialog>
}
/>
<TransactionFilters
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
selectedAccounts={selectedAccounts}
onAccountsChange={onAccountsChange}
selectedCategories={selectedCategories}
onCategoriesChange={onCategoriesChange}
showReconciled={showReconciled}
onReconciledChange={onReconciledChange}
period={period}
onPeriodChange={onPeriodChange}
customStartDate={customStartDate}
customEndDate={customEndDate}
onCustomStartDateChange={onCustomStartDateChange}
onCustomEndDateChange={onCustomEndDateChange}
isCustomDatePickerOpen={isCustomDatePickerOpen}
onCustomDatePickerOpenChange={onCustomDatePickerOpenChange}
showDuplicates={showDuplicates}
onShowDuplicatesChange={onShowDuplicatesChange}
accounts={metadata.accounts}
folders={metadata.folders}
categories={metadata.categories}
transactionsForAccountFilter={transactionsForAccountFilter}
transactionsForCategoryFilter={transactionsForCategoryFilter}
/>
{(!isLoadingChart || !isLoadingTransactions) && (
<Card className="mb-6 card-hover">
<Collapsible open={isStatsExpanded} onOpenChange={setIsStatsExpanded}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 py-3 px-6">
<CardTitle className="text-base font-semibold">
Statistiques
{!isStatsExpanded && (
<span className="ml-3 text-sm font-normal text-muted-foreground">
{displayTotalCount} opération
{displayTotalCount > 1 ? "s" : ""} {" "}
{formatCurrency(totalAmount)}
</span>
)}
</CardTitle>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8">
{isStatsExpanded ? (
<>
<ChevronUp className="w-4 h-4 mr-1" />
Réduire
</>
) : (
<>
<ChevronDown className="w-4 h-4 mr-1" />
Afficher
</>
)}
</Button>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="pt-0">
{/* Summary cards */}
{!isLoadingTransactions && (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 mb-6">
<Card className="card-hover">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Nombre de transactions
</p>
<p className="text-2xl font-bold mt-1">
{displayTotalCount}
</p>
</div>
<Receipt
className="w-8 h-8"
style={{ color: "var(--gray)" }}
/>
</div>
</CardContent>
</Card>
<Card className="card-hover">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Total
</p>
<p
className={`text-2xl font-bold mt-1 ${
totalAmount >= 0
? "text-emerald-600"
: "text-red-600"
}`}
>
{formatCurrency(totalAmount)}
</p>
</div>
<Euro
className={`w-8 h-8 ${
totalAmount >= 0
? "text-emerald-600"
: "text-red-600"
}`}
/>
</div>
</CardContent>
</Card>
<Card className="card-hover">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Pointé
</p>
<p className="text-2xl font-bold mt-1 text-primary">
{reconciledPercent}%
</p>
</div>
<CheckCircle2 className="w-8 h-8 text-primary" />
</div>
</CardContent>
</Card>
<Card className="card-hover">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Catégorisé
</p>
<p
className="text-2xl font-bold mt-1"
style={{ color: "var(--blue)" }}
>
{categorizedPercent}%
</p>
</div>
<Tag
className="w-8 h-8"
style={{ color: "var(--blue)" }}
/>
</div>
</CardContent>
</Card>
</div>
)}
{/* Chart */}
{!isLoadingChart && monthlyData.length > 0 && (
<MonthlyChart
data={monthlyData}
formatCurrency={formatCurrency}
collapsible={false}
showDots={
period !== "all" &&
(period === "12months" || monthlyData.length <= 12)
}
/>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
)}
<TransactionBulkActions
selectedCount={selectedTransactions.size}
categories={metadata.categories}
onReconcile={handleBulkReconcileWithClear}
onSetCategory={handleBulkSetCategoryWithClear}
/>
{isLoadingTransactions ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : (
<>
<TransactionTable
transactions={filteredTransactions}
accounts={metadata.accounts}
categories={metadata.categories}
selectedTransactions={selectedTransactions}
sortField={sortField}
sortOrder={sortOrder}
onSortChange={onSortChange}
onToggleSelectAll={onToggleSelectAll}
onToggleSelectTransaction={onToggleSelectTransaction}
onToggleReconciled={toggleReconciled}
onMarkReconciled={markReconciled}
onSetCategory={setCategory}
onCreateRule={handleCreateRule}
onDelete={deleteTransaction}
formatCurrency={formatCurrency}
formatDate={formatDate}
updatingTransactionIds={updatingTransactionIds}
duplicateIds={duplicateIds}
highlightDuplicates={showDuplicates}
/>
<TransactionPagination
page={page}
pageSize={pageSize}
total={totalTransactions}
hasMore={hasMore}
onPageChange={onPageChange}
/>
</>
)}
<RuleCreateDialog
open={ruleDialogOpen}
onOpenChange={setRuleDialogOpen}
group={ruleGroup}
categories={metadata.categories}
onSave={handleSaveRule}
/>
</PageLayout>
);
}