feat: add custom date range selection to statistics page, enhancing transaction filtering capabilities and user experience

This commit is contained in:
Julien Froidefond
2025-11-29 19:22:13 +01:00
parent 5195f4adad
commit f17b83fb95

View File

@@ -23,38 +23,72 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { CategoryIcon } from "@/components/ui/category-icon";
import { Filter, X, Wallet, CircleSlash, Calendar } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { Button } from "@/components/ui/button";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import type { Account, Category } from "@/lib/types";
type Period = "3months" | "6months" | "12months" | "all";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
export default function StatisticsPage() {
const { data, isLoading } = useBankingData();
const [period, setPeriod] = useState<Period>("6months");
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined);
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
// Get 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]);
}, [period, customStartDate]);
// Get end date (only for custom period)
const endDate = useMemo(() => {
if (period === "custom" && customEndDate) {
return customEndDate;
}
return undefined;
}, [period, customEndDate]);
// Transactions filtered for account filter (by categories, period - not accounts)
const transactionsForAccountFilter = useMemo(() => {
if (!data) return [];
return data.transactions.filter(
(t) => new Date(t.date) >= startDate
).filter((t) => {
return data.transactions.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
if (transactionDate < startDate || transactionDate > endOfDay) {
return false;
}
} else {
// Standard period
if (transactionDate < startDate) {
return false;
}
}
return true;
}).filter((t) => {
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
return !t.categoryId;
@@ -64,28 +98,51 @@ export default function StatisticsPage() {
}
return true;
});
}, [data, startDate, selectedCategories]);
}, [data, startDate, endDate, selectedCategories]);
// Transactions filtered for category filter (by accounts, period - not categories)
const transactionsForCategoryFilter = useMemo(() => {
if (!data) return [];
return data.transactions.filter(
(t) => new Date(t.date) >= startDate
).filter((t) => {
return data.transactions.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
if (transactionDate < startDate || transactionDate > endOfDay) {
return false;
}
} else {
// Standard period
if (transactionDate < startDate) {
return false;
}
}
return true;
}).filter((t) => {
if (!selectedAccounts.includes("all")) {
return selectedAccounts.includes(t.accountId);
}
return true;
});
}, [data, startDate, selectedAccounts]);
}, [data, startDate, endDate, selectedAccounts]);
const stats = useMemo(() => {
if (!data) return null;
let transactions = data.transactions.filter(
(t) => new Date(t.date) >= startDate
);
let transactions = data.transactions.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
return transactionDate >= startDate && transactionDate <= endOfDay;
} else {
// Standard period
return transactionDate >= startDate;
}
});
// Filter by accounts
if (!selectedAccounts.includes("all")) {
@@ -256,7 +313,7 @@ export default function StatisticsPage() {
perAccountBalanceData,
transactionCount: transactions.length,
};
}, [data, startDate, selectedAccounts, selectedCategories]);
}, [data, startDate, endDate, selectedAccounts, selectedCategories]);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
@@ -298,18 +355,108 @@ export default function StatisticsPage() {
<Select
value={period}
onValueChange={(v) => setPeriod(v as Period)}
onValueChange={(v) => {
setPeriod(v as Period);
if (v !== "custom") {
setIsCustomDatePickerOpen(false);
} else {
setIsCustomDatePickerOpen(true);
}
}}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Période" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1month">1 mois</SelectItem>
<SelectItem value="3months">3 mois</SelectItem>
<SelectItem value="6months">6 mois</SelectItem>
<SelectItem value="12months">12 mois</SelectItem>
<SelectItem value="custom">Personnalisé</SelectItem>
<SelectItem value="all">Tout</SelectItem>
</SelectContent>
</Select>
{period === "custom" && (
<Popover open={isCustomDatePickerOpen} onOpenChange={setIsCustomDatePickerOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-[280px] justify-start text-left font-normal">
<Calendar className="mr-2 h-4 w-4" />
{customStartDate && customEndDate ? (
<>
{format(customStartDate, "PPP", { locale: fr })} -{" "}
{format(customEndDate, "PPP", { locale: fr })}
</>
) : customStartDate ? (
format(customStartDate, "PPP", { locale: fr })
) : (
<span className="text-muted-foreground">Sélectionner les dates</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Date de début</label>
<CalendarComponent
mode="single"
selected={customStartDate}
onSelect={(date) => {
setCustomStartDate(date);
if (date && customEndDate && date > customEndDate) {
setCustomEndDate(undefined);
}
}}
locale={fr}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Date de fin</label>
<CalendarComponent
mode="single"
selected={customEndDate}
onSelect={(date) => {
if (date && customStartDate && date < customStartDate) {
return;
}
setCustomEndDate(date);
if (date && customStartDate) {
setIsCustomDatePickerOpen(false);
}
}}
disabled={(date) => {
if (!customStartDate) return true;
return date < customStartDate;
}}
locale={fr}
/>
</div>
{customStartDate && customEndDate && (
<div className="flex gap-2 pt-2 border-t">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => {
setCustomStartDate(undefined);
setCustomEndDate(undefined);
}}
>
Réinitialiser
</Button>
<Button
size="sm"
className="flex-1"
onClick={() => setIsCustomDatePickerOpen(false)}
>
Valider
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
)}
</div>
<ActiveFilters
@@ -326,9 +473,15 @@ export default function StatisticsPage() {
}}
onClearCategories={() => setSelectedCategories(["all"])}
period={period}
onClearPeriod={() => setPeriod("all")}
onClearPeriod={() => {
setPeriod("all");
setCustomStartDate(undefined);
setCustomEndDate(undefined);
}}
accounts={data.accounts}
categories={data.categories}
customStartDate={customStartDate}
customEndDate={customEndDate}
/>
</CardContent>
</Card>
@@ -376,6 +529,8 @@ function ActiveFilters({
onClearPeriod,
accounts,
categories,
customStartDate,
customEndDate,
}: {
selectedAccounts: string[];
onRemoveAccount: (id: string) => void;
@@ -387,6 +542,8 @@ function ActiveFilters({
onClearPeriod: () => void;
accounts: Account[];
categories: Category[];
customStartDate?: Date;
customEndDate?: Date;
}) {
const hasAccounts = !selectedAccounts.includes("all");
const hasCategories = !selectedCategories.includes("all");
@@ -402,12 +559,19 @@ function ActiveFilters({
const getPeriodLabel = (p: Period) => {
switch (p) {
case "1month":
return "1 mois";
case "3months":
return "3 mois";
case "6months":
return "6 mois";
case "12months":
return "12 mois";
case "custom":
if (customStartDate && customEndDate) {
return `${format(customStartDate, "d MMM", { locale: fr })} - ${format(customEndDate, "d MMM yyyy", { locale: fr })}`;
}
return "Personnalisé";
default:
return "Tout";
}