All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m11s
458 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|