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:
@@ -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) {
|
||||||
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
|
if (expandAllByDefault) {
|
||||||
|
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 =
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
Réduire
|
<>
|
||||||
|
<ChevronUp className="w-4 h-4 mr-1" />
|
||||||
|
Réduire
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="w-4 h-4 mr-1" />
|
||||||
|
Afficher
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) && (
|
||||||
|
|||||||
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];
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user