Compare commits
5 Commits
dbcf8e7abd
...
82e27524b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e27524b5 | ||
|
|
8b62cd8385 | ||
|
|
a01345c1fb | ||
|
|
c358845033 | ||
|
|
b3e99a15d2 |
@@ -29,6 +29,7 @@ import {
|
||||
} from "@/lib/store-db";
|
||||
import type { Category, Transaction } from "@/lib/types";
|
||||
import { invalidateAllCategoryQueries } from "@/lib/cache-utils";
|
||||
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||
|
||||
interface RecategorizationResult {
|
||||
transaction: Transaction;
|
||||
@@ -58,6 +59,12 @@ export default function CategoriesPage() {
|
||||
const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false);
|
||||
const [isRecategorizing, setIsRecategorizing] = useState(false);
|
||||
|
||||
// Persister l'état "tout déplier" dans le localStorage
|
||||
const [expandAllByDefault, setExpandAllByDefault] = useLocalStorage(
|
||||
"categories-expand-all-by-default",
|
||||
true
|
||||
);
|
||||
|
||||
// Organiser les catégories par parent
|
||||
const { parentCategories, childrenByParent, orphanCategories } =
|
||||
useMemo(() => {
|
||||
@@ -97,13 +104,17 @@ export default function CategoriesPage() {
|
||||
};
|
||||
}, [metadata?.categories]);
|
||||
|
||||
// Initialiser tous les parents comme ouverts
|
||||
// Initialiser tous les parents selon la préférence sauvegardée
|
||||
useEffect(() => {
|
||||
if (parentCategories.length > 0 && expandedParents.size === 0) {
|
||||
if (expandAllByDefault) {
|
||||
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
|
||||
} else {
|
||||
setExpandedParents(new Set());
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parentCategories.length]);
|
||||
}, [parentCategories.length, expandAllByDefault]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
invalidateAllCategoryQueries(queryClient);
|
||||
@@ -162,10 +173,12 @@ export default function CategoriesPage() {
|
||||
|
||||
const expandAll = () => {
|
||||
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
|
||||
setExpandAllByDefault(true);
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedParents(new Set());
|
||||
setExpandAllByDefault(false);
|
||||
};
|
||||
|
||||
const allExpanded =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
||||
import {
|
||||
StatsSummaryCards,
|
||||
@@ -46,6 +46,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||
import type { Account, Category } from "@/lib/types";
|
||||
|
||||
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
||||
@@ -54,21 +55,59 @@ export default function StatisticsPage() {
|
||||
const { data, isLoading } = useBankingData();
|
||||
const isMobile = useIsMobile();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [period, setPeriod] = useState<Period>("6months");
|
||||
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([
|
||||
"all",
|
||||
]);
|
||||
|
||||
// Persister les filtres dans le localStorage
|
||||
const [period, setPeriod] = useLocalStorage<Period>(
|
||||
"statistics-period",
|
||||
"6months"
|
||||
);
|
||||
const [selectedAccounts, setSelectedAccounts] = useLocalStorage<string[]>(
|
||||
"statistics-selected-accounts",
|
||||
["all"]
|
||||
);
|
||||
const [selectedCategories, setSelectedCategories] = useLocalStorage<string[]>(
|
||||
"statistics-selected-categories",
|
||||
["all"]
|
||||
);
|
||||
const [excludeInternalTransfers, setExcludeInternalTransfers] =
|
||||
useState(true);
|
||||
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
||||
undefined,
|
||||
useLocalStorage("statistics-exclude-internal-transfers", true);
|
||||
|
||||
// Pour les dates, on stocke les ISO strings et on les convertit
|
||||
const [customStartDateISO, setCustomStartDateISO] = useLocalStorage<
|
||||
string | null
|
||||
>("statistics-custom-start-date", null);
|
||||
const [customEndDateISO, setCustomEndDateISO] = useLocalStorage<
|
||||
string | null
|
||||
>("statistics-custom-end-date", null);
|
||||
|
||||
// Convertir les ISO strings en Date
|
||||
const customStartDate = useMemo(
|
||||
() => (customStartDateISO ? new Date(customStartDateISO) : undefined),
|
||||
[customStartDateISO]
|
||||
);
|
||||
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
|
||||
undefined,
|
||||
const customEndDate = useMemo(
|
||||
() => (customEndDateISO ? new Date(customEndDateISO) : undefined),
|
||||
[customEndDateISO]
|
||||
);
|
||||
|
||||
// Fonctions pour mettre à jour les dates avec persistance
|
||||
const setCustomStartDate = (date: Date | undefined) => {
|
||||
setCustomStartDateISO(date ? date.toISOString() : null);
|
||||
};
|
||||
const setCustomEndDate = (date: Date | undefined) => {
|
||||
setCustomEndDateISO(date ? date.toISOString() : null);
|
||||
};
|
||||
|
||||
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
|
||||
|
||||
// Nettoyer les dates personnalisées quand on change de période (sauf si on passe à "custom")
|
||||
useEffect(() => {
|
||||
if (period !== "custom" && (customStartDateISO || customEndDateISO)) {
|
||||
setCustomStartDateISO(null);
|
||||
setCustomEndDateISO(null);
|
||||
}
|
||||
}, [period, customStartDateISO, customEndDateISO, setCustomStartDateISO, setCustomEndDateISO]);
|
||||
|
||||
// Get start date based on period
|
||||
const startDate = useMemo(() => {
|
||||
const now = new Date();
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { PageLayout, PageHeader } from "@/components/layout";
|
||||
import {
|
||||
RefreshCw,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Receipt,
|
||||
Euro,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { RefreshCw, Receipt, Euro, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import {
|
||||
TransactionFilters,
|
||||
TransactionBulkActions,
|
||||
@@ -30,20 +23,16 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Upload } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
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();
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Main page state and logic
|
||||
const {
|
||||
@@ -126,6 +115,28 @@ export default function TransactionsPage() {
|
||||
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"] });
|
||||
@@ -147,7 +158,11 @@ export default function TransactionsPage() {
|
||||
[handleBulkSetCategory, selectedTransactions, clearSelection]
|
||||
);
|
||||
|
||||
const filteredTransactions = transactionsData?.transactions || [];
|
||||
// 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;
|
||||
@@ -160,10 +175,11 @@ export default function TransactionsPage() {
|
||||
const totalAmount = chartTotalAmount ?? 0;
|
||||
const displayTotalCount = chartTotalCount ?? totalTransactions;
|
||||
|
||||
// For filter comboboxes, we'll use empty arrays for now
|
||||
// They can be enhanced later with separate queries if needed
|
||||
const transactionsForAccountFilter: never[] = [];
|
||||
const transactionsForCategoryFilter: never[] = [];
|
||||
// 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) {
|
||||
@@ -223,15 +239,31 @@ export default function TransactionsPage() {
|
||||
|
||||
{(!isLoadingChart || !isLoadingTransactions) && (
|
||||
<Card className="mb-6">
|
||||
<Collapsible defaultOpen={true}>
|
||||
<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">
|
||||
<ChevronDown className="w-4 h-4 mr-1" />
|
||||
{isStatsExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="w-4 h-4 mr-1" />
|
||||
Réduire
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-4 h-4 mr-1" />
|
||||
Afficher
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</CardHeader>
|
||||
@@ -310,19 +342,6 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
Plein écran
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TransactionTable
|
||||
transactions={filteredTransactions}
|
||||
accounts={metadata.accounts}
|
||||
@@ -355,74 +374,6 @@ export default function TransactionsPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
|
||||
<DialogContent
|
||||
className="!max-w-none !max-h-none !w-full !h-full !p-0 flex flex-col !rounded-none !translate-x-0 !translate-y-0 !top-0 !left-0 !right-0 !bottom-0 !m-0"
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
maxWidth: "100vw",
|
||||
maxHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Transactions</DialogTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
Réduire
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto px-6 pb-6 min-h-0">
|
||||
<div className="mb-4">
|
||||
<TransactionBulkActions
|
||||
selectedCount={selectedTransactions.size}
|
||||
categories={metadata.categories}
|
||||
onReconcile={handleBulkReconcileWithClear}
|
||||
onSetCategory={handleBulkSetCategoryWithClear}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<TransactionPagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={totalTransactions}
|
||||
hasMore={hasMore}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<RuleCreateDialog
|
||||
open={ruleDialogOpen}
|
||||
onOpenChange={setRuleDialogOpen}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||
@@ -37,7 +38,13 @@ export function CategoryCard({
|
||||
size={isMobile ? 10 : 12}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs md:text-sm truncate">{category.name}</span>
|
||||
<Link
|
||||
href={`/transactions?categoryIds=${category.id}`}
|
||||
className="text-xs md:text-sm truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{category.name}
|
||||
</Link>
|
||||
{!isMobile && (
|
||||
<span className="text-xs md:text-sm text-muted-foreground shrink-0">
|
||||
{stats.count} opération{stats.count > 1 ? "s" : ""} •{" "}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -73,9 +74,13 @@ export function ParentCategoryRow({
|
||||
size={isMobile ? 10 : 14}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium text-xs md:text-sm truncate">
|
||||
<Link
|
||||
href={`/transactions?categoryIds=${parent.id}`}
|
||||
className="font-medium text-xs md:text-sm truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{parent.name}
|
||||
</span>
|
||||
</Link>
|
||||
{!isMobile && (
|
||||
<span className="text-xs md:text-sm text-muted-foreground shrink-0">
|
||||
{children.length} • {stats.count} opération
|
||||
|
||||
@@ -36,10 +36,43 @@ export function CategoryBreakdown({ data }: CategoryBreakdownProps) {
|
||||
value: total,
|
||||
color: category?.color || "#94a3b8",
|
||||
icon: category?.icon || "HelpCircle",
|
||||
categoryId: categoryId === "uncategorized" ? null : categoryId,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 6);
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
// Category breakdown grouped by parent
|
||||
const categoryTotalsByParent = new Map<string, number>();
|
||||
monthExpenses.forEach((t) => {
|
||||
const category = data.categories.find((c) => c.id === t.categoryId);
|
||||
// Use parent category ID if exists, otherwise use the category itself
|
||||
let groupId: string;
|
||||
if (!category) {
|
||||
groupId = "uncategorized";
|
||||
} else if (category.parentId) {
|
||||
groupId = category.parentId;
|
||||
} else {
|
||||
// Category is a parent itself
|
||||
groupId = category.id;
|
||||
}
|
||||
const current = categoryTotalsByParent.get(groupId) || 0;
|
||||
categoryTotalsByParent.set(groupId, current + Math.abs(t.amount));
|
||||
});
|
||||
|
||||
const chartDataByParent: CategoryChartData[] = Array.from(
|
||||
categoryTotalsByParent.entries(),
|
||||
)
|
||||
.map(([groupId, total]) => {
|
||||
const category = data.categories.find((c) => c.id === groupId);
|
||||
return {
|
||||
name: category?.name || "Non catégorisé",
|
||||
value: total,
|
||||
color: category?.color || "#94a3b8",
|
||||
icon: category?.icon || "HelpCircle",
|
||||
categoryId: groupId === "uncategorized" ? null : groupId,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
@@ -51,6 +84,8 @@ export function CategoryBreakdown({ data }: CategoryBreakdownProps) {
|
||||
return (
|
||||
<CategoryPieChart
|
||||
data={chartData}
|
||||
dataByParent={chartDataByParent}
|
||||
categories={data.categories}
|
||||
formatCurrency={formatCurrency}
|
||||
title="Dépenses par catégorie"
|
||||
height={250}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { signOut } from "next-auth/react";
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Tableau de bord", icon: LayoutDashboard },
|
||||
@@ -158,7 +158,7 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
export function Sidebar({ open, onOpenChange }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [collapsed, setCollapsed] = useLocalStorage("sidebar-collapsed", false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (isMobile) {
|
||||
|
||||
@@ -169,7 +169,7 @@ export function TransactionTable({
|
||||
setFocusedIndex(index);
|
||||
onMarkReconciled(transactionId);
|
||||
},
|
||||
[onMarkReconciled],
|
||||
[onMarkReconciled]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -198,7 +198,7 @@ export function TransactionTable({
|
||||
}
|
||||
}
|
||||
},
|
||||
[focusedIndex, transactions, onMarkReconciled, virtualizer],
|
||||
[focusedIndex, transactions, onMarkReconciled, virtualizer]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -215,7 +215,7 @@ export function TransactionTable({
|
||||
(accountId: string) => {
|
||||
return accounts.find((a) => a.id === accountId);
|
||||
},
|
||||
[accounts],
|
||||
[accounts]
|
||||
);
|
||||
|
||||
const getCategory = useCallback(
|
||||
@@ -223,7 +223,7 @@ export function TransactionTable({
|
||||
if (!categoryId) return null;
|
||||
return categories.find((c) => c.id === categoryId);
|
||||
},
|
||||
[categories],
|
||||
[categories]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -281,7 +281,7 @@ export function TransactionTable({
|
||||
"p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border",
|
||||
transaction.isReconciled && "bg-emerald-500/5",
|
||||
isFocused && "bg-primary/10 ring-1 ring-primary/30",
|
||||
isDuplicate && "shadow-sm",
|
||||
isDuplicate && "shadow-sm"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -323,7 +323,7 @@ export function TransactionTable({
|
||||
"font-semibold tabular-nums text-sm md:text-base shrink-0",
|
||||
transaction.amount >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-red-600",
|
||||
: "text-red-600"
|
||||
)}
|
||||
>
|
||||
{transaction.amount >= 0 ? "+" : ""}
|
||||
@@ -358,7 +358,7 @@ export function TransactionTable({
|
||||
showBadge
|
||||
align="start"
|
||||
disabled={updatingTransactionIds.has(
|
||||
transaction.id,
|
||||
transaction.id
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -391,7 +391,7 @@ export function TransactionTable({
|
||||
e.stopPropagation();
|
||||
if (
|
||||
confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
|
||||
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`
|
||||
)
|
||||
) {
|
||||
onDelete(transaction.id);
|
||||
@@ -446,7 +446,7 @@ export function TransactionTable({
|
||||
<div className="p-3 text-sm font-medium text-muted-foreground">
|
||||
Compte
|
||||
</div>
|
||||
<div className="p-3 text-sm font-medium text-muted-foreground">
|
||||
<div className="p-3 text-sm font-medium text-muted-foreground text-center">
|
||||
Catégorie
|
||||
</div>
|
||||
<div className="p-3 text-right">
|
||||
@@ -464,7 +464,7 @@ export function TransactionTable({
|
||||
<div className="p-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Body virtualisé */}
|
||||
{/* Body virtualisé ou non selon le mode */}
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="overflow-auto"
|
||||
@@ -507,7 +507,7 @@ export function TransactionTable({
|
||||
"grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer",
|
||||
transaction.isReconciled && "bg-emerald-500/5",
|
||||
isFocused && "bg-primary/10 ring-1 ring-primary/30",
|
||||
isDuplicate && "shadow-sm",
|
||||
isDuplicate && "shadow-sm"
|
||||
)}
|
||||
>
|
||||
<div className="p-3" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -552,7 +552,7 @@ export function TransactionTable({
|
||||
{account?.name || "-"}
|
||||
</div>
|
||||
<div
|
||||
className="p-3 relative"
|
||||
className="p-3 relative flex items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{updatingTransactionIds.has(transaction.id) && (
|
||||
@@ -576,7 +576,7 @@ export function TransactionTable({
|
||||
"p-3 text-right font-semibold tabular-nums",
|
||||
transaction.amount >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-red-600",
|
||||
: "text-red-600"
|
||||
)}
|
||||
>
|
||||
{transaction.amount >= 0 ? "+" : ""}
|
||||
@@ -643,7 +643,7 @@ export function TransactionTable({
|
||||
e.stopPropagation();
|
||||
if (
|
||||
confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
|
||||
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`
|
||||
)
|
||||
) {
|
||||
onDelete(transaction.id);
|
||||
|
||||
45
hooks/use-local-storage.ts
Normal file
45
hooks/use-local-storage.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Hook pour gérer la persistance d'une valeur dans le localStorage
|
||||
*/
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
): [T, (value: T | ((val: T) => T)) => void] {
|
||||
// État pour stocker la valeur
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return initialValue;
|
||||
}
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.error(`Error reading localStorage key "${key}":`, error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Fonction pour mettre à jour la valeur
|
||||
const setValue = (value: T | ((val: T) => T)) => {
|
||||
try {
|
||||
// Permet d'utiliser une fonction comme setState
|
||||
const valueToStore =
|
||||
value instanceof Function ? value(storedValue) : value;
|
||||
|
||||
setStoredValue(valueToStore);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
|
||||
109
hooks/use-transactions-for-account-filter.ts
Normal file
109
hooks/use-transactions-for-account-filter.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTransactions } from "@/lib/hooks";
|
||||
import { useBankingMetadata } from "@/lib/hooks";
|
||||
import type { TransactionsPaginatedParams } from "@/services/banking.service";
|
||||
|
||||
interface UseTransactionsForAccountFilterParams {
|
||||
selectedCategories: string[];
|
||||
period: "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
||||
customStartDate?: Date;
|
||||
customEndDate?: Date;
|
||||
showReconciled: string;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch transactions filtered by categories, period, search, reconciled
|
||||
* but NOT by accounts. Used for displaying account totals in account filter.
|
||||
*/
|
||||
export function useTransactionsForAccountFilter({
|
||||
selectedCategories,
|
||||
period,
|
||||
customStartDate,
|
||||
customEndDate,
|
||||
showReconciled,
|
||||
searchQuery,
|
||||
}: UseTransactionsForAccountFilterParams) {
|
||||
const { data: metadata } = useBankingMetadata();
|
||||
|
||||
// Calculate start date based on period
|
||||
const startDate = useMemo(() => {
|
||||
const now = new Date();
|
||||
switch (period) {
|
||||
case "1month":
|
||||
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
case "3months":
|
||||
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
|
||||
case "6months":
|
||||
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
||||
case "12months":
|
||||
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
|
||||
case "custom":
|
||||
return customStartDate || new Date(0);
|
||||
default:
|
||||
return new Date(0);
|
||||
}
|
||||
}, [period, customStartDate]);
|
||||
|
||||
// Calculate end date (only for custom period)
|
||||
const endDate = useMemo(() => {
|
||||
if (period === "custom" && customEndDate) {
|
||||
return customEndDate;
|
||||
}
|
||||
return undefined;
|
||||
}, [period, customEndDate]);
|
||||
|
||||
// Build params for fetching all transactions (no pagination, no account filter)
|
||||
const filterParams = useMemo<TransactionsPaginatedParams>(() => {
|
||||
const params: TransactionsPaginatedParams = {
|
||||
limit: 10000, // Large limit to get all transactions
|
||||
offset: 0,
|
||||
sortField: "date",
|
||||
sortOrder: "asc",
|
||||
};
|
||||
|
||||
if (startDate && period !== "all") {
|
||||
params.startDate = startDate.toISOString().split("T")[0];
|
||||
}
|
||||
if (endDate) {
|
||||
params.endDate = endDate.toISOString().split("T")[0];
|
||||
}
|
||||
// NOTE: We intentionally don't filter by accounts here
|
||||
if (!selectedCategories.includes("all")) {
|
||||
if (selectedCategories.includes("uncategorized")) {
|
||||
params.includeUncategorized = true;
|
||||
} else {
|
||||
params.categoryIds = selectedCategories;
|
||||
}
|
||||
}
|
||||
if (searchQuery) {
|
||||
params.search = searchQuery;
|
||||
}
|
||||
if (showReconciled !== "all") {
|
||||
params.isReconciled = showReconciled === "reconciled";
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [
|
||||
startDate,
|
||||
endDate,
|
||||
selectedCategories,
|
||||
searchQuery,
|
||||
showReconciled,
|
||||
period,
|
||||
]);
|
||||
|
||||
// Fetch all filtered transactions (without account filter)
|
||||
const { data: transactionsData, isLoading } = useTransactions(
|
||||
filterParams,
|
||||
!!metadata
|
||||
);
|
||||
|
||||
return {
|
||||
transactions: transactionsData?.transactions || [],
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
105
hooks/use-transactions-for-category-filter.ts
Normal file
105
hooks/use-transactions-for-category-filter.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTransactions } from "@/lib/hooks";
|
||||
import { useBankingMetadata } from "@/lib/hooks";
|
||||
import type { TransactionsPaginatedParams } from "@/services/banking.service";
|
||||
|
||||
interface UseTransactionsForCategoryFilterParams {
|
||||
selectedAccounts: string[];
|
||||
period: "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
||||
customStartDate?: Date;
|
||||
customEndDate?: Date;
|
||||
showReconciled: string;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch transactions filtered by accounts, period, search, reconciled
|
||||
* but NOT by categories. Used for displaying transaction counts in category filter.
|
||||
*/
|
||||
export function useTransactionsForCategoryFilter({
|
||||
selectedAccounts,
|
||||
period,
|
||||
customStartDate,
|
||||
customEndDate,
|
||||
showReconciled,
|
||||
searchQuery,
|
||||
}: UseTransactionsForCategoryFilterParams) {
|
||||
const { data: metadata } = useBankingMetadata();
|
||||
|
||||
// Calculate start date based on period
|
||||
const startDate = useMemo(() => {
|
||||
const now = new Date();
|
||||
switch (period) {
|
||||
case "1month":
|
||||
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
case "3months":
|
||||
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
|
||||
case "6months":
|
||||
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
||||
case "12months":
|
||||
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
|
||||
case "custom":
|
||||
return customStartDate || new Date(0);
|
||||
default:
|
||||
return new Date(0);
|
||||
}
|
||||
}, [period, customStartDate]);
|
||||
|
||||
// Calculate end date (only for custom period)
|
||||
const endDate = useMemo(() => {
|
||||
if (period === "custom" && customEndDate) {
|
||||
return customEndDate;
|
||||
}
|
||||
return undefined;
|
||||
}, [period, customEndDate]);
|
||||
|
||||
// Build params for fetching all transactions (no pagination, no category filter)
|
||||
const filterParams = useMemo<TransactionsPaginatedParams>(() => {
|
||||
const params: TransactionsPaginatedParams = {
|
||||
limit: 10000, // Large limit to get all transactions
|
||||
offset: 0,
|
||||
sortField: "date",
|
||||
sortOrder: "asc",
|
||||
};
|
||||
|
||||
if (startDate && period !== "all") {
|
||||
params.startDate = startDate.toISOString().split("T")[0];
|
||||
}
|
||||
if (endDate) {
|
||||
params.endDate = endDate.toISOString().split("T")[0];
|
||||
}
|
||||
if (!selectedAccounts.includes("all")) {
|
||||
params.accountIds = selectedAccounts;
|
||||
}
|
||||
// NOTE: We intentionally don't filter by categories here
|
||||
if (searchQuery) {
|
||||
params.search = searchQuery;
|
||||
}
|
||||
if (showReconciled !== "all") {
|
||||
params.isReconciled = showReconciled === "reconciled";
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [
|
||||
startDate,
|
||||
endDate,
|
||||
selectedAccounts,
|
||||
searchQuery,
|
||||
showReconciled,
|
||||
period,
|
||||
]);
|
||||
|
||||
// Fetch all filtered transactions (without category filter)
|
||||
const { data: transactionsData, isLoading } = useTransactions(
|
||||
filterParams,
|
||||
!!metadata
|
||||
);
|
||||
|
||||
return {
|
||||
transactions: transactionsData?.transactions || [],
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useTransactions,
|
||||
useDuplicateIds,
|
||||
} from "@/lib/hooks";
|
||||
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||
import type { TransactionsPaginatedParams } from "@/services/banking.service";
|
||||
import type { Category } from "@/lib/types";
|
||||
|
||||
@@ -30,7 +31,7 @@ export function useTransactionsPage() {
|
||||
"all",
|
||||
]);
|
||||
const [showReconciled, setShowReconciled] = useState<string>("all");
|
||||
const [period, setPeriod] = useState<Period>("3months");
|
||||
const [period, setPeriod] = useLocalStorage<Period>("transactions-period", "3months");
|
||||
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user