feat: implement localStorage persistence for user preferences in categories, statistics, transactions, and sidebar components; enhance UI with collapsible elements and improved layout

This commit is contained in:
Julien Froidefond
2025-12-21 08:24:04 +01:00
parent b3e99a15d2
commit c358845033
6 changed files with 135 additions and 22 deletions

View File

@@ -29,6 +29,7 @@ import {
} from "@/lib/store-db"; } from "@/lib/store-db";
import type { Category, Transaction } from "@/lib/types"; import type { Category, Transaction } from "@/lib/types";
import { invalidateAllCategoryQueries } from "@/lib/cache-utils"; import { invalidateAllCategoryQueries } from "@/lib/cache-utils";
import { useLocalStorage } from "@/hooks/use-local-storage";
interface RecategorizationResult { interface RecategorizationResult {
transaction: Transaction; transaction: Transaction;
@@ -58,6 +59,12 @@ export default function CategoriesPage() {
const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false); const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false);
const [isRecategorizing, setIsRecategorizing] = 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 // Organiser les catégories par parent
const { parentCategories, childrenByParent, orphanCategories } = const { parentCategories, childrenByParent, orphanCategories } =
useMemo(() => { useMemo(() => {
@@ -97,13 +104,17 @@ export default function CategoriesPage() {
}; };
}, [metadata?.categories]); }, [metadata?.categories]);
// Initialiser tous les parents comme ouverts // Initialiser tous les parents selon la préférence sauvegardée
useEffect(() => { useEffect(() => {
if (parentCategories.length > 0 && expandedParents.size === 0) { if (parentCategories.length > 0 && expandedParents.size === 0) {
if (expandAllByDefault) {
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id))); setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
} else {
setExpandedParents(new Set());
}
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentCategories.length]); }, [parentCategories.length, expandAllByDefault]);
const refresh = useCallback(() => { const refresh = useCallback(() => {
invalidateAllCategoryQueries(queryClient); invalidateAllCategoryQueries(queryClient);
@@ -162,10 +173,12 @@ export default function CategoriesPage() {
const expandAll = () => { const expandAll = () => {
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id))); setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
setExpandAllByDefault(true);
}; };
const collapseAll = () => { const collapseAll = () => {
setExpandedParents(new Set()); setExpandedParents(new Set());
setExpandAllByDefault(false);
}; };
const allExpanded = const allExpanded =

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect } from "react";
import { PageLayout, LoadingState, PageHeader } from "@/components/layout"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import { import {
StatsSummaryCards, StatsSummaryCards,
@@ -46,6 +46,7 @@ import { Button } from "@/components/ui/button";
import { format } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { useLocalStorage } from "@/hooks/use-local-storage";
import type { Account, Category } from "@/lib/types"; import type { Account, Category } from "@/lib/types";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all"; type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
@@ -54,21 +55,59 @@ export default function StatisticsPage() {
const { data, isLoading } = useBankingData(); const { data, isLoading } = useBankingData();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
const [period, setPeriod] = useState<Period>("6months");
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]); // Persister les filtres dans le localStorage
const [selectedCategories, setSelectedCategories] = useState<string[]>([ const [period, setPeriod] = useLocalStorage<Period>(
"all", "statistics-period",
]); "6months"
);
const [selectedAccounts, setSelectedAccounts] = useLocalStorage<string[]>(
"statistics-selected-accounts",
["all"]
);
const [selectedCategories, setSelectedCategories] = useLocalStorage<string[]>(
"statistics-selected-categories",
["all"]
);
const [excludeInternalTransfers, setExcludeInternalTransfers] = const [excludeInternalTransfers, setExcludeInternalTransfers] =
useState(true); useLocalStorage("statistics-exclude-internal-transfers", true);
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
undefined, // 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>( const customEndDate = useMemo(
undefined, () => (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); 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 // Get start date based on period
const startDate = useMemo(() => { const startDate = useMemo(() => {
const now = new Date(); const now = new Date();

View File

@@ -2,7 +2,7 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { PageLayout, PageHeader } from "@/components/layout"; import { PageLayout, PageHeader } from "@/components/layout";
import { RefreshCw, Receipt, Euro, ChevronDown } from "lucide-react"; import { RefreshCw, Receipt, Euro, ChevronDown, ChevronUp } from "lucide-react";
import { import {
TransactionFilters, TransactionFilters,
TransactionBulkActions, TransactionBulkActions,
@@ -27,6 +27,7 @@ import { useTransactionsPage } from "@/hooks/use-transactions-page";
import { useTransactionMutations } from "@/hooks/use-transaction-mutations"; import { useTransactionMutations } from "@/hooks/use-transaction-mutations";
import { useTransactionRules } from "@/hooks/use-transaction-rules"; import { useTransactionRules } from "@/hooks/use-transaction-rules";
import { useTransactionsChartData } from "@/hooks/use-transactions-chart-data"; import { useTransactionsChartData } from "@/hooks/use-transactions-chart-data";
import { useLocalStorage } from "@/hooks/use-local-storage";
export default function TransactionsPage() { export default function TransactionsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -150,6 +151,12 @@ export default function TransactionsPage() {
const totalAmount = chartTotalAmount ?? 0; const totalAmount = chartTotalAmount ?? 0;
const displayTotalCount = chartTotalCount ?? totalTransactions; const displayTotalCount = chartTotalCount ?? totalTransactions;
// Persist statistics collapsed state in localStorage
const [isStatsExpanded, setIsStatsExpanded] = useLocalStorage(
"transactions-stats-expanded",
true
);
// For filter comboboxes, we'll use empty arrays for now // For filter comboboxes, we'll use empty arrays for now
// They can be enhanced later with separate queries if needed // They can be enhanced later with separate queries if needed
const transactionsForAccountFilter: never[] = []; const transactionsForAccountFilter: never[] = [];
@@ -213,15 +220,24 @@ export default function TransactionsPage() {
{(!isLoadingChart || !isLoadingTransactions) && ( {(!isLoadingChart || !isLoadingTransactions) && (
<Card className="mb-6"> <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"> <CardHeader className="flex flex-row items-center justify-between space-y-0 py-3 px-6">
<CardTitle className="text-base font-semibold"> <CardTitle className="text-base font-semibold">
Statistiques Statistiques
</CardTitle> </CardTitle>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8"> <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 Réduire
</>
) : (
<>
<ChevronDown className="w-4 h-4 mr-1" />
Afficher
</>
)}
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
</CardHeader> </CardHeader>

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
@@ -21,6 +20,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { Sheet, SheetContent } from "@/components/ui/sheet"; import { Sheet, SheetContent } from "@/components/ui/sheet";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { useLocalStorage } from "@/hooks/use-local-storage";
const navItems = [ const navItems = [
{ href: "/", label: "Tableau de bord", icon: LayoutDashboard }, { href: "/", label: "Tableau de bord", icon: LayoutDashboard },
@@ -158,7 +158,7 @@ interface SidebarProps {
} }
export function Sidebar({ open, onOpenChange }: SidebarProps) { export function Sidebar({ open, onOpenChange }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useLocalStorage("sidebar-collapsed", false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
if (isMobile) { if (isMobile) {

View File

@@ -446,7 +446,7 @@ export function TransactionTable({
<div className="p-3 text-sm font-medium text-muted-foreground"> <div className="p-3 text-sm font-medium text-muted-foreground">
Compte Compte
</div> </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 Catégorie
</div> </div>
<div className="p-3 text-right"> <div className="p-3 text-right">
@@ -552,7 +552,7 @@ export function TransactionTable({
{account?.name || "-"} {account?.name || "-"}
</div> </div>
<div <div
className="p-3 relative" className="p-3 relative flex items-center justify-center"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{updatingTransactionIds.has(transaction.id) && ( {updatingTransactionIds.has(transaction.id) && (

View 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];
}