feat: add custom date range selection to statistics page, enhancing transaction filtering capabilities and user experience
This commit is contained in:
@@ -23,38 +23,72 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
import { Filter, X, Wallet, CircleSlash, Calendar } from "lucide-react";
|
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";
|
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() {
|
export default function StatisticsPage() {
|
||||||
const { data, isLoading } = useBankingData();
|
const { data, isLoading } = useBankingData();
|
||||||
const [period, setPeriod] = useState<Period>("6months");
|
const [period, setPeriod] = useState<Period>("6months");
|
||||||
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
|
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
|
||||||
const [selectedCategories, setSelectedCategories] = 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
|
// Get start date based on period
|
||||||
const startDate = useMemo(() => {
|
const startDate = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
switch (period) {
|
switch (period) {
|
||||||
|
case "1month":
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
case "3months":
|
case "3months":
|
||||||
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
|
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
|
||||||
case "6months":
|
case "6months":
|
||||||
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
||||||
case "12months":
|
case "12months":
|
||||||
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
|
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
|
||||||
|
case "custom":
|
||||||
|
return customStartDate || new Date(0);
|
||||||
default:
|
default:
|
||||||
return new Date(0);
|
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)
|
// Transactions filtered for account filter (by categories, period - not accounts)
|
||||||
const transactionsForAccountFilter = useMemo(() => {
|
const transactionsForAccountFilter = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
return data.transactions.filter(
|
return data.transactions.filter((t) => {
|
||||||
(t) => new Date(t.date) >= startDate
|
const transactionDate = new Date(t.date);
|
||||||
).filter((t) => {
|
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("all")) {
|
||||||
if (selectedCategories.includes("uncategorized")) {
|
if (selectedCategories.includes("uncategorized")) {
|
||||||
return !t.categoryId;
|
return !t.categoryId;
|
||||||
@@ -64,28 +98,51 @@ export default function StatisticsPage() {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [data, startDate, selectedCategories]);
|
}, [data, startDate, endDate, selectedCategories]);
|
||||||
|
|
||||||
// Transactions filtered for category filter (by accounts, period - not categories)
|
// Transactions filtered for category filter (by accounts, period - not categories)
|
||||||
const transactionsForCategoryFilter = useMemo(() => {
|
const transactionsForCategoryFilter = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
return data.transactions.filter(
|
return data.transactions.filter((t) => {
|
||||||
(t) => new Date(t.date) >= startDate
|
const transactionDate = new Date(t.date);
|
||||||
).filter((t) => {
|
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")) {
|
if (!selectedAccounts.includes("all")) {
|
||||||
return selectedAccounts.includes(t.accountId);
|
return selectedAccounts.includes(t.accountId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [data, startDate, selectedAccounts]);
|
}, [data, startDate, endDate, selectedAccounts]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
let transactions = data.transactions.filter(
|
let transactions = data.transactions.filter((t) => {
|
||||||
(t) => new Date(t.date) >= startDate
|
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
|
// Filter by accounts
|
||||||
if (!selectedAccounts.includes("all")) {
|
if (!selectedAccounts.includes("all")) {
|
||||||
@@ -256,7 +313,7 @@ export default function StatisticsPage() {
|
|||||||
perAccountBalanceData,
|
perAccountBalanceData,
|
||||||
transactionCount: transactions.length,
|
transactionCount: transactions.length,
|
||||||
};
|
};
|
||||||
}, [data, startDate, selectedAccounts, selectedCategories]);
|
}, [data, startDate, endDate, selectedAccounts, selectedCategories]);
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
@@ -298,18 +355,108 @@ export default function StatisticsPage() {
|
|||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={period}
|
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]">
|
<SelectTrigger className="w-[150px]">
|
||||||
<SelectValue placeholder="Période" />
|
<SelectValue placeholder="Période" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="1month">1 mois</SelectItem>
|
||||||
<SelectItem value="3months">3 mois</SelectItem>
|
<SelectItem value="3months">3 mois</SelectItem>
|
||||||
<SelectItem value="6months">6 mois</SelectItem>
|
<SelectItem value="6months">6 mois</SelectItem>
|
||||||
<SelectItem value="12months">12 mois</SelectItem>
|
<SelectItem value="12months">12 mois</SelectItem>
|
||||||
|
<SelectItem value="custom">Personnalisé</SelectItem>
|
||||||
<SelectItem value="all">Tout</SelectItem>
|
<SelectItem value="all">Tout</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ActiveFilters
|
<ActiveFilters
|
||||||
@@ -326,9 +473,15 @@ export default function StatisticsPage() {
|
|||||||
}}
|
}}
|
||||||
onClearCategories={() => setSelectedCategories(["all"])}
|
onClearCategories={() => setSelectedCategories(["all"])}
|
||||||
period={period}
|
period={period}
|
||||||
onClearPeriod={() => setPeriod("all")}
|
onClearPeriod={() => {
|
||||||
|
setPeriod("all");
|
||||||
|
setCustomStartDate(undefined);
|
||||||
|
setCustomEndDate(undefined);
|
||||||
|
}}
|
||||||
accounts={data.accounts}
|
accounts={data.accounts}
|
||||||
categories={data.categories}
|
categories={data.categories}
|
||||||
|
customStartDate={customStartDate}
|
||||||
|
customEndDate={customEndDate}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -376,6 +529,8 @@ function ActiveFilters({
|
|||||||
onClearPeriod,
|
onClearPeriod,
|
||||||
accounts,
|
accounts,
|
||||||
categories,
|
categories,
|
||||||
|
customStartDate,
|
||||||
|
customEndDate,
|
||||||
}: {
|
}: {
|
||||||
selectedAccounts: string[];
|
selectedAccounts: string[];
|
||||||
onRemoveAccount: (id: string) => void;
|
onRemoveAccount: (id: string) => void;
|
||||||
@@ -387,6 +542,8 @@ function ActiveFilters({
|
|||||||
onClearPeriod: () => void;
|
onClearPeriod: () => void;
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
|
customStartDate?: Date;
|
||||||
|
customEndDate?: Date;
|
||||||
}) {
|
}) {
|
||||||
const hasAccounts = !selectedAccounts.includes("all");
|
const hasAccounts = !selectedAccounts.includes("all");
|
||||||
const hasCategories = !selectedCategories.includes("all");
|
const hasCategories = !selectedCategories.includes("all");
|
||||||
@@ -402,12 +559,19 @@ function ActiveFilters({
|
|||||||
|
|
||||||
const getPeriodLabel = (p: Period) => {
|
const getPeriodLabel = (p: Period) => {
|
||||||
switch (p) {
|
switch (p) {
|
||||||
|
case "1month":
|
||||||
|
return "1 mois";
|
||||||
case "3months":
|
case "3months":
|
||||||
return "3 mois";
|
return "3 mois";
|
||||||
case "6months":
|
case "6months":
|
||||||
return "6 mois";
|
return "6 mois";
|
||||||
case "12months":
|
case "12months":
|
||||||
return "12 mois";
|
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:
|
default:
|
||||||
return "Tout";
|
return "Tout";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user