feat: add mobile-friendly filter sheet to statistics and transaction components, enhancing user experience with improved layout and accessibility

This commit is contained in:
Julien Froidefond
2025-12-07 17:29:48 +01:00
parent 1548ce4b0d
commit a33c41f1bd
3 changed files with 649 additions and 357 deletions

View File

@@ -34,6 +34,13 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Calendar as CalendarComponent } from "@/components/ui/calendar"; import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -45,6 +52,8 @@ 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 isMobile = useIsMobile();
const [sheetOpen, setSheetOpen] = useState(false);
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[]>([ const [selectedCategories, setSelectedCategories] = useState<string[]>([
@@ -53,10 +62,10 @@ export default function StatisticsPage() {
const [excludeInternalTransfers, setExcludeInternalTransfers] = const [excludeInternalTransfers, setExcludeInternalTransfers] =
useState(true); useState(true);
const [customStartDate, setCustomStartDate] = useState<Date | undefined>( const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
undefined, undefined
); );
const [customEndDate, setCustomEndDate] = useState<Date | undefined>( const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
undefined, undefined
); );
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false); const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
@@ -91,7 +100,7 @@ export default function StatisticsPage() {
const internalTransferCategory = useMemo(() => { const internalTransferCategory = useMemo(() => {
if (!data) return null; if (!data) return null;
return data.categories.find( return data.categories.find(
(c) => c.name.toLowerCase() === "virement interne", (c) => c.name.toLowerCase() === "virement interne"
); );
}, [data]); }, [data]);
@@ -207,7 +216,7 @@ export default function StatisticsPage() {
// Filter by accounts // Filter by accounts
if (!selectedAccounts.includes("all")) { if (!selectedAccounts.includes("all")) {
transactions = transactions.filter((t) => transactions = transactions.filter((t) =>
selectedAccounts.includes(t.accountId), selectedAccounts.includes(t.accountId)
); );
} }
@@ -217,7 +226,7 @@ export default function StatisticsPage() {
transactions = transactions.filter((t) => !t.categoryId); transactions = transactions.filter((t) => !t.categoryId);
} else { } else {
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.categoryId && selectedCategories.includes(t.categoryId), (t) => t.categoryId && selectedCategories.includes(t.categoryId)
); );
} }
} }
@@ -225,7 +234,7 @@ export default function StatisticsPage() {
// Exclude "Virement interne" category if checkbox is checked // Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) { if (excludeInternalTransfers && internalTransferCategory) {
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.categoryId !== internalTransferCategory.id, (t) => t.categoryId !== internalTransferCategory.id
); );
} }
@@ -297,7 +306,7 @@ export default function StatisticsPage() {
}); });
const categoryChartDataByParent = Array.from( const categoryChartDataByParent = Array.from(
categoryTotalsByParent.entries(), categoryTotalsByParent.entries()
) )
.map(([groupId, total]) => { .map(([groupId, total]) => {
const category = data.categories.find((c) => c.id === groupId); const category = data.categories.find((c) => c.id === groupId);
@@ -312,7 +321,7 @@ export default function StatisticsPage() {
// Top expenses - deduplicate by ID and sort by amount (most negative first) // Top expenses - deduplicate by ID and sort by amount (most negative first)
const uniqueTransactions = Array.from( const uniqueTransactions = Array.from(
new Map(transactions.map((t) => [t.id, t])).values(), new Map(transactions.map((t) => [t.id, t])).values()
); );
const topExpenses = uniqueTransactions const topExpenses = uniqueTransactions
.filter((t) => t.amount < 0) .filter((t) => t.amount < 0)
@@ -338,7 +347,7 @@ export default function StatisticsPage() {
// Balance evolution - Aggregated (using filtered transactions) // Balance evolution - Aggregated (using filtered transactions)
const sortedFilteredTransactions = [...transactions].sort( const sortedFilteredTransactions = [...transactions].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
); );
// Calculate starting balance: initialBalance + transactions before startDate // Calculate starting balance: initialBalance + transactions before startDate
@@ -350,7 +359,7 @@ export default function StatisticsPage() {
// Start with initial balances // Start with initial balances
runningBalance = accountsToUse.reduce( runningBalance = accountsToUse.reduce(
(sum, acc) => sum + (acc.initialBalance || 0), (sum, acc) => sum + (acc.initialBalance || 0),
0, 0
); );
// Add all transactions before the start date for these accounts // Add all transactions before the start date for these accounts
@@ -387,7 +396,7 @@ export default function StatisticsPage() {
}); });
const aggregatedBalanceData = Array.from( const aggregatedBalanceData = Array.from(
aggregatedBalanceByDate.entries(), aggregatedBalanceByDate.entries()
).map(([date, balance]) => ({ ).map(([date, balance]) => ({
date: new Date(date).toLocaleDateString("fr-FR", { date: new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit", day: "2-digit",
@@ -643,170 +652,208 @@ export default function StatisticsPage() {
description="Analysez vos dépenses et revenus" description="Analysez vos dépenses et revenus"
/> />
<Card className="mb-4 md:mb-6"> {isMobile ? (
<CardContent className="pt-3 md:pt-4"> <>
<div className="flex flex-wrap gap-2 md:gap-4"> <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<AccountFilterCombobox <SheetTrigger asChild>
accounts={data.accounts} <Button variant="outline" className="w-full mb-4">
folders={data.folders} <Filter className="w-4 h-4 mr-2" />
value={selectedAccounts} Filtres
onChange={setSelectedAccounts} {((!selectedAccounts.includes("all") &&
className="w-full md:w-[280px]" selectedAccounts.length > 0) ||
filteredTransactions={transactionsForAccountFilter} (!selectedCategories.includes("all") &&
/> selectedCategories.length > 0) ||
period !== "6months") && (
<CategoryFilterCombobox <Badge variant="secondary" className="ml-2">
categories={data.categories} {[
value={selectedCategories} !selectedAccounts.includes("all") &&
onChange={setSelectedCategories} selectedAccounts.length,
className="w-full md:w-[220px]" !selectedCategories.includes("all") &&
filteredTransactions={transactionsForCategoryFilter} selectedCategories.length,
/> period !== "6months" && 1,
]
<Select .filter(Boolean)
value={period} .reduce((a, b) => (a || 0) + (b || 0), 0)}
onValueChange={(v) => { </Badge>
setPeriod(v as Period); )}
if (v !== "custom") { </Button>
setIsCustomDatePickerOpen(false); </SheetTrigger>
} else { <SheetContent
setIsCustomDatePickerOpen(true); side="bottom"
} className="h-[85vh] overflow-y-auto px-4 pb-6"
}}
> >
<SelectTrigger className="w-full md:w-[150px]"> <SheetHeader className="px-0">
<SelectValue placeholder="Période" /> <SheetTitle>Filtres</SheetTitle>
</SelectTrigger> </SheetHeader>
<SelectContent> <div className="mt-6 space-y-4 px-0">
<SelectItem value="1month">1 mois</SelectItem> <AccountFilterCombobox
<SelectItem value="3months">3 mois</SelectItem> accounts={data.accounts}
<SelectItem value="6months">6 mois</SelectItem> folders={data.folders}
<SelectItem value="12months">12 mois</SelectItem> value={selectedAccounts}
<SelectItem value="custom">Personnalisé</SelectItem> onChange={setSelectedAccounts}
<SelectItem value="all">Tout</SelectItem> className="w-full"
</SelectContent> filteredTransactions={transactionsForAccountFilter}
</Select>
{period === "custom" && (
<Popover
open={isCustomDatePickerOpen}
onOpenChange={setIsCustomDatePickerOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full md: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>
)}
{internalTransferCategory && (
<div className="flex items-center gap-2 px-2 md:px-3 py-1.5 md:py-2 border border-border rounded-md bg-[var(--card)]">
<Checkbox
id="exclude-internal-transfers"
checked={excludeInternalTransfers}
onCheckedChange={(checked) =>
setExcludeInternalTransfers(checked === true)
}
/> />
<label
htmlFor="exclude-internal-transfers"
className="text-xs md:text-sm font-medium cursor-pointer select-none"
>
Exclure Virement interne
</label>
</div>
)}
</div>
<CategoryFilterCombobox
categories={data.categories}
value={selectedCategories}
onChange={setSelectedCategories}
className="w-full"
filteredTransactions={transactionsForCategoryFilter}
/>
<Select
value={period}
onValueChange={(v) => {
setPeriod(v as Period);
if (v !== "custom") {
setIsCustomDatePickerOpen(false);
} else {
setIsCustomDatePickerOpen(true);
}
}}
>
<SelectTrigger className="w-full">
<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-full 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>
)}
{internalTransferCategory && (
<div className="flex items-center gap-2 px-2 py-1.5 border border-border rounded-md bg-[var(--card)]">
<Checkbox
id="exclude-internal-transfers-mobile"
checked={excludeInternalTransfers}
onCheckedChange={(checked) =>
setExcludeInternalTransfers(checked === true)
}
/>
<label
htmlFor="exclude-internal-transfers-mobile"
className="text-sm font-medium cursor-pointer select-none"
>
Exclure Virement interne
</label>
</div>
)}
</div>
</SheetContent>
</Sheet>
<ActiveFilters <ActiveFilters
selectedAccounts={selectedAccounts} selectedAccounts={selectedAccounts}
onRemoveAccount={(id) => { onRemoveAccount={(id) => {
const newAccounts = selectedAccounts.filter((a) => a !== id); const newAccounts = selectedAccounts.filter((a) => a !== id);
setSelectedAccounts( setSelectedAccounts(
newAccounts.length > 0 ? newAccounts : ["all"], newAccounts.length > 0 ? newAccounts : ["all"]
); );
}} }}
onClearAccounts={() => setSelectedAccounts(["all"])} onClearAccounts={() => setSelectedAccounts(["all"])}
@@ -814,7 +861,7 @@ export default function StatisticsPage() {
onRemoveCategory={(id) => { onRemoveCategory={(id) => {
const newCategories = selectedCategories.filter((c) => c !== id); const newCategories = selectedCategories.filter((c) => c !== id);
setSelectedCategories( setSelectedCategories(
newCategories.length > 0 ? newCategories : ["all"], newCategories.length > 0 ? newCategories : ["all"]
); );
}} }}
onClearCategories={() => setSelectedCategories(["all"])} onClearCategories={() => setSelectedCategories(["all"])}
@@ -829,8 +876,201 @@ export default function StatisticsPage() {
customStartDate={customStartDate} customStartDate={customStartDate}
customEndDate={customEndDate} customEndDate={customEndDate}
/> />
</CardContent> </>
</Card> ) : (
<Card className="mb-4 md:mb-6">
<CardContent className="pt-3 md:pt-4">
<div className="flex flex-wrap gap-2 md:gap-4">
<AccountFilterCombobox
accounts={data.accounts}
folders={data.folders}
value={selectedAccounts}
onChange={setSelectedAccounts}
className="w-full md:w-[280px]"
filteredTransactions={transactionsForAccountFilter}
/>
<CategoryFilterCombobox
categories={data.categories}
value={selectedCategories}
onChange={setSelectedCategories}
className="w-full md:w-[220px]"
filteredTransactions={transactionsForCategoryFilter}
/>
<Select
value={period}
onValueChange={(v) => {
setPeriod(v as Period);
if (v !== "custom") {
setIsCustomDatePickerOpen(false);
} else {
setIsCustomDatePickerOpen(true);
}
}}
>
<SelectTrigger className="w-full md: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-full md: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>
)}
{internalTransferCategory && (
<div className="flex items-center gap-2 px-2 md:px-3 py-1.5 md:py-2 border border-border rounded-md bg-[var(--card)]">
<Checkbox
id="exclude-internal-transfers"
checked={excludeInternalTransfers}
onCheckedChange={(checked) =>
setExcludeInternalTransfers(checked === true)
}
/>
<label
htmlFor="exclude-internal-transfers"
className="text-xs md:text-sm font-medium cursor-pointer select-none"
>
Exclure Virement interne
</label>
</div>
)}
</div>
<ActiveFilters
selectedAccounts={selectedAccounts}
onRemoveAccount={(id) => {
const newAccounts = selectedAccounts.filter((a) => a !== id);
setSelectedAccounts(
newAccounts.length > 0 ? newAccounts : ["all"]
);
}}
onClearAccounts={() => setSelectedAccounts(["all"])}
selectedCategories={selectedCategories}
onRemoveCategory={(id) => {
const newCategories = selectedCategories.filter(
(c) => c !== id
);
setSelectedCategories(
newCategories.length > 0 ? newCategories : ["all"]
);
}}
onClearCategories={() => setSelectedCategories(["all"])}
period={period}
onClearPeriod={() => {
setPeriod("all");
setCustomStartDate(undefined);
setCustomEndDate(undefined);
}}
accounts={data.accounts}
categories={data.categories}
customStartDate={customStartDate}
customEndDate={customEndDate}
/>
</CardContent>
</Card>
)}
{/* Vue d'ensemble */} {/* Vue d'ensemble */}
<section className="mb-4 md:mb-8"> <section className="mb-4 md:mb-8">
@@ -963,7 +1203,7 @@ function ActiveFilters({
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id)); const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
const selectedCats = categories.filter((c) => const selectedCats = categories.filter((c) =>
selectedCategories.includes(c.id), selectedCategories.includes(c.id)
); );
const isUncategorized = selectedCategories.includes("uncategorized"); const isUncategorized = selectedCategories.includes("uncategorized");

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -20,9 +21,17 @@ import {
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { Calendar as CalendarComponent } from "@/components/ui/calendar"; import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Search, X, Filter, Wallet, Calendar } from "lucide-react"; import { Search, X, Filter, Wallet, Calendar } from "lucide-react";
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 type { Account, Category, Folder, Transaction } from "@/lib/types"; import type { Account, Category, Folder, Transaction } from "@/lib/types";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all"; type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
@@ -74,195 +83,238 @@ export function TransactionFilters({
transactionsForAccountFilter, transactionsForAccountFilter,
transactionsForCategoryFilter, transactionsForCategoryFilter,
}: TransactionFiltersProps) { }: TransactionFiltersProps) {
return ( const isMobile = useIsMobile();
<Card> const [sheetOpen, setSheetOpen] = useState(false);
<CardContent className="pt-4">
<div className="flex flex-wrap gap-4"> const filtersContent = (
<div className="flex-1 min-w-[200px]"> <>
<div className="relative"> <div className="flex flex-wrap gap-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <div className="flex-1 min-w-[200px]">
<Input <div className="relative">
placeholder="Rechercher..." <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
value={searchQuery} <Input
onChange={(e) => onSearchChange(e.target.value)} placeholder="Rechercher..."
className="pl-9" value={searchQuery}
/> onChange={(e) => onSearchChange(e.target.value)}
</div> className="pl-9"
/>
</div> </div>
<AccountFilterCombobox
accounts={accounts}
folders={folders}
value={selectedAccounts}
onChange={onAccountsChange}
className="w-full md:w-[280px]"
filteredTransactions={transactionsForAccountFilter}
/>
<CategoryFilterCombobox
categories={categories}
value={selectedCategories}
onChange={onCategoriesChange}
className="w-full md:w-[220px]"
filteredTransactions={transactionsForCategoryFilter}
/>
<Select value={showReconciled} onValueChange={onReconciledChange}>
<SelectTrigger className="w-full md:w-[160px]">
<SelectValue placeholder="Pointage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tout</SelectItem>
<SelectItem value="reconciled">Pointées</SelectItem>
<SelectItem value="not-reconciled">Non pointées</SelectItem>
</SelectContent>
</Select>
<Select
value={period}
onValueChange={(v) => {
onPeriodChange(v as Period);
if (v !== "custom") {
onCustomDatePickerOpenChange(false);
} else {
onCustomDatePickerOpenChange(true);
}
}}
>
<SelectTrigger className="w-full md: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={onCustomDatePickerOpenChange}
>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full md: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) => {
onCustomStartDateChange(date);
if (date && customEndDate && date > customEndDate) {
onCustomEndDateChange(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;
}
onCustomEndDateChange(date);
if (date && customStartDate) {
onCustomDatePickerOpenChange(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={() => {
onCustomStartDateChange(undefined);
onCustomEndDateChange(undefined);
}}
>
Réinitialiser
</Button>
<Button
size="sm"
className="flex-1"
onClick={() => onCustomDatePickerOpenChange(false)}
>
Valider
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
)}
</div> </div>
<ActiveFilters <AccountFilterCombobox
searchQuery={searchQuery}
onClearSearch={() => onSearchChange("")}
selectedAccounts={selectedAccounts}
onRemoveAccount={(id) => {
const newAccounts = selectedAccounts.filter((a) => a !== id);
onAccountsChange(newAccounts.length > 0 ? newAccounts : ["all"]);
}}
onClearAccounts={() => onAccountsChange(["all"])}
selectedCategories={selectedCategories}
onRemoveCategory={(id) => {
const newCategories = selectedCategories.filter((c) => c !== id);
onCategoriesChange(
newCategories.length > 0 ? newCategories : ["all"],
);
}}
onClearCategories={() => onCategoriesChange(["all"])}
showReconciled={showReconciled}
onClearReconciled={() => onReconciledChange("all")}
period={period}
onClearPeriod={() => {
onPeriodChange("all");
onCustomStartDateChange(undefined);
onCustomEndDateChange(undefined);
}}
customStartDate={customStartDate}
customEndDate={customEndDate}
accounts={accounts} accounts={accounts}
categories={categories} folders={folders}
value={selectedAccounts}
onChange={onAccountsChange}
className="w-full md:w-[280px]"
filteredTransactions={transactionsForAccountFilter}
/> />
</CardContent>
<CategoryFilterCombobox
categories={categories}
value={selectedCategories}
onChange={onCategoriesChange}
className="w-full md:w-[220px]"
filteredTransactions={transactionsForCategoryFilter}
/>
<Select value={showReconciled} onValueChange={onReconciledChange}>
<SelectTrigger className="w-full md:w-[160px]">
<SelectValue placeholder="Pointage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tout</SelectItem>
<SelectItem value="reconciled">Pointées</SelectItem>
<SelectItem value="not-reconciled">Non pointées</SelectItem>
</SelectContent>
</Select>
<Select
value={period}
onValueChange={(v) => {
onPeriodChange(v as Period);
if (v !== "custom") {
onCustomDatePickerOpenChange(false);
} else {
onCustomDatePickerOpenChange(true);
}
}}
>
<SelectTrigger className="w-full md: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={onCustomDatePickerOpenChange}
>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full md: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) => {
onCustomStartDateChange(date);
if (date && customEndDate && date > customEndDate) {
onCustomEndDateChange(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;
}
onCustomEndDateChange(date);
if (date && customStartDate) {
onCustomDatePickerOpenChange(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={() => {
onCustomStartDateChange(undefined);
onCustomEndDateChange(undefined);
}}
>
Réinitialiser
</Button>
<Button
size="sm"
className="flex-1"
onClick={() => onCustomDatePickerOpenChange(false)}
>
Valider
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
)}
</div>
<ActiveFilters
searchQuery={searchQuery}
onClearSearch={() => onSearchChange("")}
selectedAccounts={selectedAccounts}
onRemoveAccount={(id) => {
const newAccounts = selectedAccounts.filter((a) => a !== id);
onAccountsChange(newAccounts.length > 0 ? newAccounts : ["all"]);
}}
onClearAccounts={() => onAccountsChange(["all"])}
selectedCategories={selectedCategories}
onRemoveCategory={(id) => {
const newCategories = selectedCategories.filter((c) => c !== id);
onCategoriesChange(
newCategories.length > 0 ? newCategories : ["all"]
);
}}
onClearCategories={() => onCategoriesChange(["all"])}
showReconciled={showReconciled}
onClearReconciled={() => onReconciledChange("all")}
period={period}
onClearPeriod={() => {
onPeriodChange("all");
onCustomStartDateChange(undefined);
onCustomEndDateChange(undefined);
}}
customStartDate={customStartDate}
customEndDate={customEndDate}
accounts={accounts}
categories={categories}
/>
</>
);
if (isMobile) {
const activeFiltersCount =
(searchQuery.trim() !== "" ? 1 : 0) +
(!selectedAccounts.includes("all") ? selectedAccounts.length : 0) +
(!selectedCategories.includes("all") ? selectedCategories.length : 0) +
(showReconciled !== "all" ? 1 : 0) +
(period !== "all" ? 1 : 0);
return (
<>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger asChild>
<Button variant="outline" className="w-full">
<Filter className="w-4 h-4 mr-2" />
Filtres
{activeFiltersCount > 0 && (
<Badge variant="secondary" className="ml-2">
{activeFiltersCount}
</Badge>
)}
</Button>
</SheetTrigger>
<SheetContent
side="bottom"
className="h-[85vh] overflow-y-auto px-4 pb-6"
>
<SheetHeader className="px-0">
<SheetTitle>Filtres</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-4 px-0">{filtersContent}</div>
</SheetContent>
</Sheet>
</>
);
}
return (
<Card>
<CardContent className="pt-4">{filtersContent}</CardContent>
</Card> </Card>
); );
} }
@@ -315,7 +367,7 @@ function ActiveFilters({
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id)); const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
const selectedCats = categories.filter((c) => const selectedCats = categories.filter((c) =>
selectedCategories.includes(c.id), selectedCategories.includes(c.id)
); );
const isUncategorized = selectedCategories.includes("uncategorized"); const isUncategorized = selectedCategories.includes("uncategorized");

View File

@@ -85,7 +85,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sheet-header" data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn("flex flex-col gap-1.5 p-4 pt-6", className)}
{...props} {...props}
/> />
); );