feat: enhance responsive design and layout consistency across various components, including dashboard, statistics, and rules pages
This commit is contained in:
@@ -68,7 +68,7 @@ export default function DashboardPage() {
|
|||||||
folders={data.folders}
|
folders={data.folders}
|
||||||
value={selectedAccounts}
|
value={selectedAccounts}
|
||||||
onChange={setSelectedAccounts}
|
onChange={setSelectedAccounts}
|
||||||
className="w-[280px]"
|
className="w-full md:w-[280px]"
|
||||||
filteredTransactions={data.transactions}
|
filteredTransactions={data.transactions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -238,10 +238,14 @@ export default function RulesPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Règles de catégorisation"
|
title="Règles de catégorisation"
|
||||||
description={
|
description={
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs md:text-base">
|
||||||
{transactionGroups.length} groupe
|
{transactionGroups.length} groupe
|
||||||
{transactionGroups.length > 1 ? "s" : ""} de transactions similaires
|
{transactionGroups.length > 1 ? "s" : ""} de transactions similaires
|
||||||
<Badge variant="secondary">{uncategorizedCount} non catégorisées</Badge>
|
</span>
|
||||||
|
<Badge variant="secondary" className="text-[10px] md:text-xs">
|
||||||
|
{uncategorizedCount} non catégorisées
|
||||||
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
@@ -272,14 +276,14 @@ export default function RulesPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{transactionGroups.length === 0 ? (
|
{transactionGroups.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-12 md:py-16 text-center px-4">
|
||||||
<Sparkles className="h-12 w-12 text-muted-foreground mb-4" />
|
<Sparkles className="h-8 w-8 md:h-12 md:w-12 text-muted-foreground mb-3 md:mb-4" />
|
||||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
<h3 className="text-base md:text-lg font-medium text-foreground mb-2">
|
||||||
{uncategorizedCount === 0
|
{uncategorizedCount === 0
|
||||||
? "Toutes les transactions sont catégorisées !"
|
? "Toutes les transactions sont catégorisées !"
|
||||||
: "Aucun groupe trouvé"}
|
: "Aucun groupe trouvé"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground max-w-md">
|
<p className="text-xs md:text-sm text-muted-foreground max-w-md">
|
||||||
{uncategorizedCount === 0
|
{uncategorizedCount === 0
|
||||||
? "Continuez à importer des transactions pour voir les suggestions de règles."
|
? "Continuez à importer des transactions pour voir les suggestions de règles."
|
||||||
: filterMinCount > 1
|
: filterMinCount > 1
|
||||||
@@ -288,7 +292,7 @@ export default function RulesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2 md:space-y-3">
|
||||||
{transactionGroups.map((group) => (
|
{transactionGroups.map((group) => (
|
||||||
<RuleGroupCard
|
<RuleGroupCard
|
||||||
key={group.key}
|
key={group.key}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ 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";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
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";
|
||||||
@@ -601,15 +602,15 @@ export default function StatisticsPage() {
|
|||||||
description="Analysez vos dépenses et revenus"
|
description="Analysez vos dépenses et revenus"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-4 md:mb-6">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-3 md:pt-4">
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-2 md:gap-4">
|
||||||
<AccountFilterCombobox
|
<AccountFilterCombobox
|
||||||
accounts={data.accounts}
|
accounts={data.accounts}
|
||||||
folders={data.folders}
|
folders={data.folders}
|
||||||
value={selectedAccounts}
|
value={selectedAccounts}
|
||||||
onChange={setSelectedAccounts}
|
onChange={setSelectedAccounts}
|
||||||
className="w-[280px]"
|
className="w-full md:w-[280px]"
|
||||||
filteredTransactions={transactionsForAccountFilter}
|
filteredTransactions={transactionsForAccountFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -617,7 +618,7 @@ export default function StatisticsPage() {
|
|||||||
categories={data.categories}
|
categories={data.categories}
|
||||||
value={selectedCategories}
|
value={selectedCategories}
|
||||||
onChange={setSelectedCategories}
|
onChange={setSelectedCategories}
|
||||||
className="w-[220px]"
|
className="w-full md:w-[220px]"
|
||||||
filteredTransactions={transactionsForCategoryFilter}
|
filteredTransactions={transactionsForCategoryFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -632,7 +633,7 @@ export default function StatisticsPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-full md:w-[150px]">
|
||||||
<SelectValue placeholder="Période" />
|
<SelectValue placeholder="Période" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -648,7 +649,7 @@ export default function StatisticsPage() {
|
|||||||
{period === "custom" && (
|
{period === "custom" && (
|
||||||
<Popover open={isCustomDatePickerOpen} onOpenChange={setIsCustomDatePickerOpen}>
|
<Popover open={isCustomDatePickerOpen} onOpenChange={setIsCustomDatePickerOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="w-[280px] justify-start text-left font-normal">
|
<Button variant="outline" className="w-full md:w-[280px] justify-start text-left font-normal">
|
||||||
<Calendar className="mr-2 h-4 w-4" />
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
{customStartDate && customEndDate ? (
|
{customStartDate && customEndDate ? (
|
||||||
<>
|
<>
|
||||||
@@ -727,7 +728,7 @@ export default function StatisticsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{internalTransferCategory && (
|
{internalTransferCategory && (
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border border-border rounded-md bg-[var(--card)]">
|
<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
|
<Checkbox
|
||||||
id="exclude-internal-transfers"
|
id="exclude-internal-transfers"
|
||||||
checked={excludeInternalTransfers}
|
checked={excludeInternalTransfers}
|
||||||
@@ -735,7 +736,7 @@ export default function StatisticsPage() {
|
|||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="exclude-internal-transfers"
|
htmlFor="exclude-internal-transfers"
|
||||||
className="text-sm font-medium cursor-pointer select-none"
|
className="text-xs md:text-sm font-medium cursor-pointer select-none"
|
||||||
>
|
>
|
||||||
Exclure Virement interne
|
Exclure Virement interne
|
||||||
</label>
|
</label>
|
||||||
@@ -771,15 +772,15 @@ export default function StatisticsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Vue d'ensemble */}
|
{/* Vue d'ensemble */}
|
||||||
<section className="mb-8">
|
<section className="mb-4 md:mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">Vue d'ensemble</h2>
|
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">Vue d'ensemble</h2>
|
||||||
<StatsSummaryCards
|
<StatsSummaryCards
|
||||||
totalIncome={stats.totalIncome}
|
totalIncome={stats.totalIncome}
|
||||||
totalExpenses={stats.totalExpenses}
|
totalExpenses={stats.totalExpenses}
|
||||||
avgMonthlyExpenses={stats.avgMonthlyExpenses}
|
avgMonthlyExpenses={stats.avgMonthlyExpenses}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6">
|
<div className="mt-4 md:mt-6">
|
||||||
<BalanceLineChart
|
<BalanceLineChart
|
||||||
aggregatedData={stats.aggregatedBalanceData}
|
aggregatedData={stats.aggregatedBalanceData}
|
||||||
perAccountData={stats.perAccountBalanceData}
|
perAccountData={stats.perAccountBalanceData}
|
||||||
@@ -787,7 +788,7 @@ export default function StatisticsPage() {
|
|||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-4 md:mt-6">
|
||||||
<SavingsTrendChart
|
<SavingsTrendChart
|
||||||
data={stats.savingsTrendData}
|
data={stats.savingsTrendData}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
@@ -796,9 +797,9 @@ export default function StatisticsPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Revenus et Dépenses */}
|
{/* Revenus et Dépenses */}
|
||||||
<section className="mb-8">
|
<section className="mb-4 md:mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">Revenus et Dépenses</h2>
|
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">Revenus et Dépenses</h2>
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-4 md:gap-6 lg:grid-cols-2">
|
||||||
<MonthlyChart
|
<MonthlyChart
|
||||||
data={stats.monthlyChartData}
|
data={stats.monthlyChartData}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
@@ -813,7 +814,7 @@ export default function StatisticsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{stats.yearOverYearData.length > 0 && (
|
{stats.yearOverYearData.length > 0 && (
|
||||||
<div className="mt-6">
|
<div className="mt-4 md:mt-6">
|
||||||
<YearOverYearChart
|
<YearOverYearChart
|
||||||
data={stats.yearOverYearData}
|
data={stats.yearOverYearData}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
@@ -823,9 +824,9 @@ export default function StatisticsPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Analyse par Catégorie */}
|
{/* Analyse par Catégorie */}
|
||||||
<section className="mb-8">
|
<section className="mb-4 md:mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">Analyse par Catégorie</h2>
|
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">Analyse par Catégorie</h2>
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-4 md:gap-6">
|
||||||
<CategoryPieChart
|
<CategoryPieChart
|
||||||
data={stats.categoryChartData}
|
data={stats.categoryChartData}
|
||||||
dataByParent={stats.categoryChartDataByParent}
|
dataByParent={stats.categoryChartDataByParent}
|
||||||
@@ -837,7 +838,7 @@ export default function StatisticsPage() {
|
|||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="hidden md:block mt-4 md:mt-6">
|
||||||
<CategoryTrendChart
|
<CategoryTrendChart
|
||||||
data={stats.categoryTrendData}
|
data={stats.categoryTrendData}
|
||||||
dataByParent={stats.categoryTrendDataByParent}
|
dataByParent={stats.categoryTrendDataByParent}
|
||||||
@@ -845,7 +846,7 @@ export default function StatisticsPage() {
|
|||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-4 md:mt-6">
|
||||||
<TopExpensesList
|
<TopExpensesList
|
||||||
expenses={stats.topExpenses}
|
expenses={stats.topExpenses}
|
||||||
categories={data.categories}
|
categories={data.categories}
|
||||||
@@ -885,6 +886,7 @@ function ActiveFilters({
|
|||||||
customStartDate?: Date;
|
customStartDate?: Date;
|
||||||
customEndDate?: Date;
|
customEndDate?: Date;
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const hasAccounts = !selectedAccounts.includes("all");
|
const hasAccounts = !selectedAccounts.includes("all");
|
||||||
const hasCategories = !selectedCategories.includes("all");
|
const hasCategories = !selectedCategories.includes("all");
|
||||||
const hasPeriod = period !== "all";
|
const hasPeriod = period !== "all";
|
||||||
@@ -924,28 +926,28 @@ function ActiveFilters({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border flex-wrap">
|
<div className="flex items-center gap-1.5 md:gap-2 mt-2 md:mt-3 pt-2 md:pt-3 border-t border-border flex-wrap">
|
||||||
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
<Filter className="h-3 w-3 md:h-3.5 md:w-3.5 text-muted-foreground" />
|
||||||
|
|
||||||
{selectedAccs.map((acc) => (
|
{selectedAccs.map((acc) => (
|
||||||
<Badge key={acc.id} variant="secondary" className="gap-1 text-xs font-normal">
|
<Badge key={acc.id} variant="secondary" className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal">
|
||||||
<Wallet className="h-3 w-3" />
|
<Wallet className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||||
{acc.name}
|
{acc.name}
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemoveAccount(acc.id)}
|
onClick={() => onRemoveAccount(acc.id)}
|
||||||
className="ml-1 hover:text-foreground"
|
className="ml-0.5 md:ml-1 hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||||
</button>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isUncategorized && (
|
{isUncategorized && (
|
||||||
<Badge variant="secondary" className="gap-1 text-xs font-normal">
|
<Badge variant="secondary" className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal">
|
||||||
<CircleSlash className="h-3 w-3" />
|
<CircleSlash className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||||
Non catégorisé
|
Non catégorisé
|
||||||
<button onClick={onClearCategories} className="ml-1 hover:text-foreground">
|
<button onClick={onClearCategories} className="ml-0.5 md:ml-1 hover:text-foreground">
|
||||||
<X className="h-3 w-3" />
|
<X className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||||
</button>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -954,36 +956,36 @@ function ActiveFilters({
|
|||||||
<Badge
|
<Badge
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="gap-1 text-xs font-normal"
|
className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${cat.color}15`,
|
backgroundColor: `${cat.color}15`,
|
||||||
borderColor: `${cat.color}30`,
|
borderColor: `${cat.color}30`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CategoryIcon icon={cat.icon} color={cat.color} size={12} />
|
<CategoryIcon icon={cat.icon} color={cat.color} size={isMobile ? 10 : 12} />
|
||||||
{cat.name}
|
{cat.name}
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemoveCategory(cat.id)}
|
onClick={() => onRemoveCategory(cat.id)}
|
||||||
className="ml-1 hover:text-foreground"
|
className="ml-0.5 md:ml-1 hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||||
</button>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{hasPeriod && (
|
{hasPeriod && (
|
||||||
<Badge variant="secondary" className="gap-1 text-xs font-normal">
|
<Badge variant="secondary" className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal">
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||||
{getPeriodLabel(period)}
|
{getPeriodLabel(period)}
|
||||||
<button onClick={onClearPeriod} className="ml-1 hover:text-foreground">
|
<button onClick={onClearPeriod} className="ml-0.5 md:ml-1 hover:text-foreground">
|
||||||
<X className="h-3 w-3" />
|
<X className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||||
</button>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground ml-auto"
|
className="text-[10px] md:text-xs text-muted-foreground hover:text-foreground ml-auto"
|
||||||
>
|
>
|
||||||
Effacer tout
|
Effacer tout
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
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 { Pencil, Trash2 } from "lucide-react";
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import type { Category } from "@/lib/types";
|
import type { Category } from "@/lib/types";
|
||||||
|
|
||||||
interface CategoryCardProps {
|
interface CategoryCardProps {
|
||||||
@@ -21,39 +22,48 @@ export function CategoryCard({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: CategoryCardProps) {
|
}: CategoryCardProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group">
|
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
className="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
|
className="w-4 h-4 md:w-5 md:h-5 rounded-full flex items-center justify-center shrink-0"
|
||||||
style={{ backgroundColor: `${category.color}20` }}
|
style={{ backgroundColor: `${category.color}20` }}
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
icon={category.icon}
|
icon={category.icon}
|
||||||
color={category.color}
|
color={category.color}
|
||||||
size={12}
|
size={isMobile ? 10 : 12}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm truncate">{category.name}</span>
|
<span className="text-xs md:text-sm truncate">{category.name}</span>
|
||||||
<span className="text-sm text-muted-foreground shrink-0">
|
{!isMobile && (
|
||||||
|
<span className="text-xs md:text-sm text-muted-foreground shrink-0">
|
||||||
{stats.count} opération{stats.count > 1 ? "s" : ""} •{" "}
|
{stats.count} opération{stats.count > 1 ? "s" : ""} •{" "}
|
||||||
{formatCurrency(stats.total)}
|
{formatCurrency(stats.total)}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
{isMobile && (
|
||||||
|
<span className="text-[10px] md:text-xs text-muted-foreground shrink-0">
|
||||||
|
{stats.count} 💳
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{category.keywords.length > 0 && (
|
{category.keywords.length > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-[10px] px-1.5 py-0 h-4 shrink-0"
|
className="text-[10px] px-1 md:px-1.5 py-0 h-3 md:h-4 shrink-0"
|
||||||
>
|
>
|
||||||
{category.keywords.length}
|
{category.keywords.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 md:opacity-100 transition-opacity">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6"
|
className="h-6 w-6 md:h-6 md:w-6"
|
||||||
onClick={() => onEdit(category)}
|
onClick={() => onEdit(category)}
|
||||||
>
|
>
|
||||||
<Pencil className="w-3 h-3" />
|
<Pencil className="w-3 h-3" />
|
||||||
@@ -61,7 +71,7 @@ export function CategoryCard({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
className="h-6 w-6 md:h-6 md:w-6 text-destructive hover:text-destructive"
|
||||||
onClick={() => onDelete(category.id)}
|
onClick={() => onDelete(category.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { CategoryCard } from "./category-card";
|
import { CategoryCard } from "./category-card";
|
||||||
import type { Category } from "@/lib/types";
|
import type { Category } from "@/lib/types";
|
||||||
|
|
||||||
@@ -49,51 +50,60 @@ export function ParentCategoryRow({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onNewCategory,
|
onNewCategory,
|
||||||
}: ParentCategoryRowProps) {
|
}: ParentCategoryRowProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg bg-card">
|
<div className="border rounded-lg bg-card">
|
||||||
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
|
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
|
||||||
<div className="flex items-center justify-between px-3 py-2">
|
<div className="flex items-center justify-between px-2 md:px-3 py-1.5 md:py-2">
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
|
<button className="flex items-center gap-1.5 md:gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
<ChevronDown className="w-3 h-3 md:w-4 md:h-4 text-muted-foreground shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
<ChevronRight className="w-3 h-3 md:w-4 md:h-4 text-muted-foreground shrink-0" />
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
|
className="w-5 h-5 md:w-7 md:h-7 rounded-full flex items-center justify-center shrink-0"
|
||||||
style={{ backgroundColor: `${parent.color}20` }}
|
style={{ backgroundColor: `${parent.color}20` }}
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
icon={parent.icon}
|
icon={parent.icon}
|
||||||
color={parent.color}
|
color={parent.color}
|
||||||
size={14}
|
size={isMobile ? 10 : 14}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-sm truncate">{parent.name}</span>
|
<span className="font-medium text-xs md:text-sm truncate">{parent.name}</span>
|
||||||
<span className="text-sm text-muted-foreground shrink-0">
|
{!isMobile && (
|
||||||
|
<span className="text-xs md:text-sm text-muted-foreground shrink-0">
|
||||||
{children.length} • {stats.count} opération
|
{children.length} • {stats.count} opération
|
||||||
{stats.count > 1 ? "s" : ""} • {formatCurrency(stats.total)}
|
{stats.count > 1 ? "s" : ""} • {formatCurrency(stats.total)}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
{isMobile && (
|
||||||
|
<span className="text-[10px] md:text-xs text-muted-foreground shrink-0">
|
||||||
|
{children.length} • {stats.count} 💳
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
<div className="flex items-center gap-0.5 md:gap-1 shrink-0 ml-1 md:ml-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-6 w-6 md:h-7 md:w-7"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onNewCategory(parent.id);
|
onNewCategory(parent.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
<Button variant="ghost" size="icon" className="h-6 w-6 md:h-7 md:w-7">
|
||||||
<MoreVertical className="w-4 h-4" />
|
<MoreVertical className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@@ -115,7 +125,7 @@ export function ParentCategoryRow({
|
|||||||
|
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
{children.length > 0 ? (
|
{children.length > 0 ? (
|
||||||
<div className="px-3 pb-2 space-y-1 ml-6 border-l-2 border-muted ml-5">
|
<div className="px-2 md:px-3 pb-2 space-y-1 ml-4 md:ml-6 border-l-2 border-muted md:ml-5">
|
||||||
{children.map((child) => (
|
{children.map((child) => (
|
||||||
<CategoryCard
|
<CategoryCard
|
||||||
key={child.id}
|
key={child.id}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { TrendingUp, TrendingDown, Wallet, CreditCard } from "lucide-react";
|
import { TrendingUp, TrendingDown, Wallet, CreditCard } from "lucide-react";
|
||||||
import type { BankingData } from "@/lib/types";
|
import type { BankingData } from "@/lib/types";
|
||||||
import { getAccountBalance } from "@/lib/account-utils";
|
import { getAccountBalance } from "@/lib/account-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface OverviewCardsProps {
|
interface OverviewCardsProps {
|
||||||
data: BankingData;
|
data: BankingData;
|
||||||
@@ -47,21 +48,21 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
|
||||||
Solde Total
|
Solde Total
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Wallet className="h-4 w-4 text-muted-foreground" />
|
<Wallet className="h-3 w-3 md:h-4 md:w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl font-bold",
|
"text-xl md:text-2xl font-bold",
|
||||||
totalBalance >= 0 ? "text-emerald-600" : "text-red-600",
|
totalBalance >= 0 ? "text-emerald-600" : "text-red-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(totalBalance)}
|
{formatCurrency(totalBalance)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
|
||||||
{data.accounts.length} compte{data.accounts.length > 1 ? "s" : ""}
|
{data.accounts.length} compte{data.accounts.length > 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -69,16 +70,16 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
|
||||||
Revenus du mois
|
Revenus du mois
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<TrendingUp className="h-4 w-4 text-emerald-600" />
|
<TrendingUp className="h-3 w-3 md:h-4 md:w-4 text-emerald-600" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-emerald-600">
|
<div className="text-xl md:text-2xl font-bold text-emerald-600">
|
||||||
{formatCurrency(income)}
|
{formatCurrency(income)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
|
||||||
{monthTransactions.filter((t) => t.amount > 0).length} opération
|
{monthTransactions.filter((t) => t.amount > 0).length} opération
|
||||||
{monthTransactions.filter((t) => t.amount > 0).length > 1
|
{monthTransactions.filter((t) => t.amount > 0).length > 1
|
||||||
? "s"
|
? "s"
|
||||||
@@ -89,16 +90,16 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
|
||||||
Dépenses du mois
|
Dépenses du mois
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<TrendingDown className="h-4 w-4 text-red-600" />
|
<TrendingDown className="h-3 w-3 md:h-4 md:w-4 text-red-600" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-red-600">
|
<div className="text-xl md:text-2xl font-bold text-red-600">
|
||||||
{formatCurrency(expenses)}
|
{formatCurrency(expenses)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
|
||||||
{monthTransactions.filter((t) => t.amount < 0).length} opération
|
{monthTransactions.filter((t) => t.amount < 0).length} opération
|
||||||
{monthTransactions.filter((t) => t.amount < 0).length > 1
|
{monthTransactions.filter((t) => t.amount < 0).length > 1
|
||||||
? "s"
|
? "s"
|
||||||
@@ -109,14 +110,14 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
|
||||||
Pointage
|
Pointage
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
<CreditCard className="h-3 w-3 md:h-4 md:w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{reconciledPercent}%</div>
|
<div className="text-xl md:text-2xl font-bold">{reconciledPercent}%</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
|
||||||
{reconciled} / {total} opérations pointées
|
{reconciled} / {total} opérations pointées
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -124,5 +125,3 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Transactions récentes</CardTitle>
|
<CardTitle className="text-sm md:text-base">Transactions récentes</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="px-3 md:px-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{recentTransactions.map((transaction) => {
|
{recentTransactions.map((transaction) => {
|
||||||
const category = getCategory(transaction.categoryId);
|
const category = getCategory(transaction.categoryId);
|
||||||
@@ -71,52 +71,25 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={transaction.id}
|
key={transaction.id}
|
||||||
className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors"
|
className="rounded-lg bg-muted/50 hover:bg-muted transition-colors overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex items-start gap-2 md:gap-3 p-2 md:p-3">
|
||||||
|
<div className="flex-shrink-0 pt-0.5">
|
||||||
{transaction.isReconciled ? (
|
{transaction.isReconciled ? (
|
||||||
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
|
<CheckCircle2 className="w-4 h-4 md:w-5 md:h-5 text-emerald-600" />
|
||||||
) : (
|
) : (
|
||||||
<Circle className="w-5 h-5 text-muted-foreground" />
|
<Circle className="w-4 h-4 md:w-5 md:h-5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
<p className="font-medium truncate">
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="font-medium text-xs md:text-base truncate flex-1">
|
||||||
{transaction.description}
|
{transaction.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatDate(transaction.date)}
|
|
||||||
</span>
|
|
||||||
{account && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
• {account.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{category && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-xs gap-1"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${category.color}20`,
|
|
||||||
color: category.color,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CategoryIcon
|
|
||||||
icon={category.icon}
|
|
||||||
color={category.color}
|
|
||||||
size={12}
|
|
||||||
/>
|
|
||||||
{category.name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-semibold tabular-nums",
|
"font-semibold tabular-nums text-xs md:text-base shrink-0 md:hidden",
|
||||||
transaction.amount >= 0
|
transaction.amount >= 0
|
||||||
? "text-emerald-600"
|
? "text-emerald-600"
|
||||||
: "text-red-600",
|
: "text-red-600",
|
||||||
@@ -126,6 +99,48 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
|
|||||||
{formatCurrency(transaction.amount)}
|
{formatCurrency(transaction.amount)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 md:gap-2 mt-1 flex-wrap">
|
||||||
|
<span className="text-[10px] md:text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDate(transaction.date)}
|
||||||
|
</span>
|
||||||
|
{account && (
|
||||||
|
<span className="text-[10px] md:text-xs text-muted-foreground truncate">
|
||||||
|
• {account.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{category && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] md:text-xs gap-1 shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${category.color}20`,
|
||||||
|
color: category.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CategoryIcon
|
||||||
|
icon={category.icon}
|
||||||
|
color={category.color}
|
||||||
|
size={10}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{category.name}</span>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"font-semibold tabular-nums text-sm md:text-base shrink-0 hidden md:block",
|
||||||
|
transaction.amount >= 0
|
||||||
|
? "text-emerald-600"
|
||||||
|
: "text-red-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{transaction.amount >= 0 ? "+" : ""}
|
||||||
|
{formatCurrency(transaction.amount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/", label: "Tableau de bord", icon: LayoutDashboard },
|
{ href: "/", label: "Tableau de bord", icon: LayoutDashboard },
|
||||||
@@ -29,10 +31,14 @@ const navItems = [
|
|||||||
{ href: "/statistics", label: "Statistiques", icon: BarChart3 },
|
{ href: "/statistics", label: "Statistiques", icon: BarChart3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
interface SidebarContentProps {
|
||||||
|
collapsed?: boolean;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({ collapsed = false, onNavigate, showHeader = false }: SidebarContentProps & { showHeader?: boolean }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -46,10 +52,99 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLinkClick = () => {
|
||||||
|
onNavigate?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showHeader && (
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||||
|
<Wallet className="w-5 h-5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-foreground">FinTrack</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<nav className="flex-1 p-2 space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link key={item.href} href={item.href} onClick={handleLinkClick}>
|
||||||
|
<Button
|
||||||
|
variant={isActive ? "secondary" : "ghost"}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start gap-3",
|
||||||
|
collapsed && "justify-center px-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5 shrink-0" />
|
||||||
|
{!collapsed && <span>{item.label}</span>}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-2 border-t border-border space-y-1">
|
||||||
|
<Link href="/settings" onClick={handleLinkClick}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start gap-3",
|
||||||
|
collapsed && "justify-center px-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5 shrink-0" />
|
||||||
|
{!collapsed && <span>Paramètres</span>}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start gap-3 text-destructive hover:text-destructive hover:bg-destructive/10",
|
||||||
|
collapsed && "justify-center px-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5 shrink-0" />
|
||||||
|
{!collapsed && <span>Déconnexion</span>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ open, onOpenChange }: SidebarProps) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="left" className="w-64 p-0">
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<SidebarContent showHeader onNavigate={() => onOpenChange?.(false)} />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col h-screen bg-card border-r border-border transition-all duration-300",
|
"hidden md:flex flex-col h-screen bg-card border-r border-border transition-all duration-300",
|
||||||
collapsed ? "w-16" : "w-64",
|
collapsed ? "w-16" : "w-64",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -76,51 +171,7 @@ export function Sidebar() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 p-2 space-y-1">
|
<SidebarContent collapsed={collapsed} showHeader={false} />
|
||||||
{navItems.map((item) => {
|
|
||||||
const isActive = pathname === item.href;
|
|
||||||
return (
|
|
||||||
<Link key={item.href} href={item.href}>
|
|
||||||
<Button
|
|
||||||
variant={isActive ? "secondary" : "ghost"}
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start gap-3",
|
|
||||||
collapsed && "justify-center px-2",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon className="w-5 h-5 shrink-0" />
|
|
||||||
{!collapsed && <span>{item.label}</span>}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="p-2 border-t border-border space-y-1">
|
|
||||||
<Link href="/settings">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start gap-3",
|
|
||||||
collapsed && "justify-center px-2",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Settings className="w-5 h-5 shrink-0" />
|
|
||||||
{!collapsed && <span>Paramètres</span>}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleSignOut}
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start gap-3 text-destructive hover:text-destructive hover:bg-destructive/10",
|
|
||||||
collapsed && "justify-center px-2",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<LogOut className="w-5 h-5 shrink-0" />
|
|
||||||
{!collapsed && <span>Déconnexion</span>}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Menu } from "lucide-react";
|
||||||
|
import { useSidebarContext } from "@/components/layout/sidebar-context";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -15,16 +20,39 @@ export function PageHeader({
|
|||||||
actions,
|
actions,
|
||||||
rightContent,
|
rightContent,
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
|
const { setOpen } = useSidebarContext();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isMobile && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
<h1 className="text-lg md:text-2xl font-bold text-foreground">{title}</h1>
|
||||||
{description && (
|
{description && (
|
||||||
<div className="text-muted-foreground">{description}</div>
|
<div className="text-xs md:text-base text-muted-foreground mt-1">{description}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{(rightContent || actions) && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{rightContent}
|
{rightContent}
|
||||||
{actions && <div className="flex gap-2">{actions}</div>}
|
{actions && (
|
||||||
|
<div className={cn("flex gap-2", isMobile && "flex-wrap")}>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode, useState } from "react";
|
||||||
|
import { SidebarContext } from "@/components/layout/sidebar-context";
|
||||||
|
|
||||||
interface PageLayoutProps {
|
interface PageLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageLayout({ children }: PageLayoutProps) {
|
export function PageLayout({ children }: PageLayoutProps) {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<SidebarContext.Provider value={{ open: sidebarOpen, setOpen: setSidebarOpen }}>
|
||||||
<Sidebar />
|
<div className="flex h-screen bg-background overflow-hidden">
|
||||||
<main className="flex-1 overflow-auto">
|
<Sidebar open={sidebarOpen} onOpenChange={setSidebarOpen} />
|
||||||
<div className="p-6 space-y-6">{children}</div>
|
<main className="flex-1 overflow-auto overflow-x-hidden">
|
||||||
|
<div className="p-4 md:p-6 space-y-4 md:space-y-6 max-w-full">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</SidebarContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
components/layout/sidebar-context.tsx
Normal file
18
components/layout/sidebar-context.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
interface SidebarContextType {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarContext = createContext<SidebarContextType>({
|
||||||
|
open: false,
|
||||||
|
setOpen: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useSidebarContext() {
|
||||||
|
return useContext(SidebarContext);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ import { ChevronDown, ChevronRight, Plus, Tag } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { CategoryCombobox } from "@/components/ui/category-combobox";
|
import { CategoryCombobox } from "@/components/ui/category-combobox";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Transaction, Category } from "@/lib/types";
|
import type { Transaction, Category } from "@/lib/types";
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ export function RuleGroupCard({
|
|||||||
formatDate,
|
formatDate,
|
||||||
}: RuleGroupCardProps) {
|
}: RuleGroupCardProps) {
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const avgAmount =
|
const avgAmount =
|
||||||
group.transactions.reduce((sum, t) => sum + t.amount, 0) /
|
group.transactions.reduce((sum, t) => sum + t.amount, 0) /
|
||||||
@@ -53,40 +55,42 @@ export function RuleGroupCard({
|
|||||||
<div className="border border-border rounded-lg bg-card overflow-hidden">
|
<div className="border border-border rounded-lg bg-card overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3 p-4 cursor-pointer hover:bg-accent/5 transition-colors"
|
className="flex flex-col md:flex-row md:items-center gap-2 md:gap-3 p-3 md:p-4 cursor-pointer hover:bg-accent/5 transition-colors"
|
||||||
onClick={onToggleExpand}
|
onClick={onToggleExpand}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0">
|
<div className="flex items-center gap-2 md:gap-3 flex-1 min-w-0">
|
||||||
|
<Button variant="ghost" size="icon" className="h-5 w-5 md:h-6 md:w-6 shrink-0">
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-3 w-3 md:h-4 md:w-4" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-3 w-3 md:h-4 md:w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
|
||||||
<span className="font-medium text-foreground truncate">
|
<span className="font-medium text-xs md:text-base text-foreground truncate">
|
||||||
{group.displayName}
|
{group.displayName}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="secondary" className="shrink-0">
|
<Badge variant="secondary" className="text-[10px] md:text-xs shrink-0">
|
||||||
{group.transactions.length} transaction
|
{group.transactions.length} 💳
|
||||||
{group.transactions.length > 1 ? "s" : ""}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1.5 md:gap-2 mt-1 text-[10px] md:text-sm text-muted-foreground">
|
||||||
<Tag className="h-3 w-3" />
|
<Tag className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||||
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
|
<span className="font-mono text-[10px] md:text-xs bg-muted px-1 md:px-1.5 py-0.5 rounded">
|
||||||
{group.suggestedKeyword}
|
{group.suggestedKeyword}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-semibold tabular-nums",
|
"font-semibold tabular-nums text-sm",
|
||||||
isDebit ? "text-destructive" : "text-success"
|
isDebit ? "text-destructive" : "text-success"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -120,11 +124,73 @@ export function RuleGroupCard({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMobile && (
|
||||||
|
<div className="flex items-center gap-2 shrink-0 ml-7">
|
||||||
|
<div onClick={(e) => e.stopPropagation()} className="flex-1">
|
||||||
|
<CategoryCombobox
|
||||||
|
categories={categories}
|
||||||
|
value={selectedCategoryId}
|
||||||
|
onChange={handleCategorySelect}
|
||||||
|
placeholder="Catégoriser..."
|
||||||
|
width="w-full"
|
||||||
|
buttonWidth="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCreateRule();
|
||||||
|
}}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded transactions list */}
|
{/* Expanded transactions list */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-border bg-muted/30">
|
<div className="border-t border-border bg-muted/30">
|
||||||
|
{isMobile ? (
|
||||||
|
<div className="max-h-64 overflow-y-auto divide-y divide-border">
|
||||||
|
{group.transactions.map((transaction) => (
|
||||||
|
<div
|
||||||
|
key={transaction.id}
|
||||||
|
className="p-3 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs md:text-sm font-medium truncate">
|
||||||
|
{transaction.description}
|
||||||
|
</p>
|
||||||
|
{transaction.memo && (
|
||||||
|
<p className="text-[10px] md:text-xs text-muted-foreground truncate mt-0.5">
|
||||||
|
{transaction.memo}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
|
||||||
|
{formatDate(transaction.date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-xs md:text-sm font-semibold tabular-nums shrink-0",
|
||||||
|
transaction.amount < 0
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-success"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(transaction.amount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="max-h-64 overflow-y-auto">
|
<div className="max-h-64 overflow-y-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/50 sticky top-0">
|
<thead className="bg-muted/50 sticky top-0">
|
||||||
@@ -172,6 +238,7 @@ export function RuleGroupCard({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,24 +28,24 @@ export function RulesSearchBar({
|
|||||||
onFilterMinCountChange,
|
onFilterMinCountChange,
|
||||||
}: RulesSearchBarProps) {
|
}: RulesSearchBarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
<div className="flex flex-col gap-2 md:gap-3 mb-4 md:mb-6">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 md:h-4 md:w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Rechercher dans les descriptions..."
|
placeholder="Rechercher..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-9 md:pl-10 text-sm md:text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-2 md:gap-3 flex-wrap">
|
||||||
<Select
|
<Select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onValueChange={(v) => onSortChange(v as "count" | "amount" | "name")}
|
onValueChange={(v) => onSortChange(v as "count" | "amount" | "name")}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-44">
|
<SelectTrigger className="w-full md:w-44 text-sm">
|
||||||
<ArrowUpDown className="h-4 w-4 mr-2" />
|
<ArrowUpDown className="h-3.5 w-3.5 md:h-4 md:w-4 mr-2" />
|
||||||
<SelectValue placeholder="Trier par" />
|
<SelectValue placeholder="Trier par" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -59,8 +59,8 @@ export function RulesSearchBar({
|
|||||||
value={filterMinCount.toString()}
|
value={filterMinCount.toString()}
|
||||||
onValueChange={(v) => onFilterMinCountChange(parseInt(v))}
|
onValueChange={(v) => onFilterMinCountChange(parseInt(v))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-36">
|
<SelectTrigger className="w-full md:w-36 text-sm">
|
||||||
<Filter className="h-4 w-4 mr-2" />
|
<Filter className="h-3.5 w-3.5 md:h-4 md:w-4 mr-2" />
|
||||||
<SelectValue placeholder="Minimum" />
|
<SelectValue placeholder="Minimum" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -57,24 +57,25 @@ export function CategoryPieChart({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-col md:flex-row md:items-center md:justify-between space-y-2 md:space-y-0 pb-2">
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle className="text-sm md:text-base">{title}</CardTitle>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col md:flex-row gap-2 w-full md:w-auto">
|
||||||
{hasParentData && (
|
{hasParentData && (
|
||||||
<Button
|
<Button
|
||||||
variant={groupByParent ? "default" : "ghost"}
|
variant={groupByParent ? "default" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setGroupByParent(!groupByParent)}
|
onClick={() => setGroupByParent(!groupByParent)}
|
||||||
title={groupByParent ? "Afficher toutes les catégories" : "Regrouper par catégories parentes"}
|
title={groupByParent ? "Afficher toutes les catégories" : "Regrouper par catégories parentes"}
|
||||||
|
className="w-full md:w-auto text-xs md:text-sm"
|
||||||
>
|
>
|
||||||
{groupByParent ? (
|
{groupByParent ? (
|
||||||
<>
|
<>
|
||||||
<List className="w-4 h-4 mr-1" />
|
<List className="w-3 h-3 md:w-4 md:h-4 mr-1" />
|
||||||
Par catégorie
|
Par catégorie
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Layers className="w-4 h-4 mr-1" />
|
<Layers className="w-3 h-3 md:w-4 md:h-4 mr-1" />
|
||||||
Par parent
|
Par parent
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -85,15 +86,16 @@ export function CategoryPieChart({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full md:w-auto text-xs md:text-sm"
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<>
|
<>
|
||||||
<ChevronUp className="w-4 h-4 mr-1" />
|
<ChevronUp className="w-3 h-3 md:w-4 md:h-4 mr-1" />
|
||||||
Réduire
|
Réduire
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ChevronDown className="w-4 h-4 mr-1" />
|
<ChevronDown className="w-3 h-3 md:w-4 md:h-4 mr-1" />
|
||||||
Voir tout ({baseData.length})
|
Voir tout ({baseData.length})
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,16 +20,16 @@ export function StatsSummaryCards({
|
|||||||
const savings = totalIncome - totalExpenses;
|
const savings = totalIncome - totalExpenses;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-3 md:gap-4 grid-cols-2 md:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2">
|
||||||
<TrendingUp className="w-4 h-4 text-emerald-600" />
|
<TrendingUp className="w-3 h-3 md:w-4 md:h-4 text-emerald-600" />
|
||||||
Total Revenus
|
Total Revenus
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-emerald-600">
|
<div className="text-lg md:text-2xl font-bold text-emerald-600">
|
||||||
{formatCurrency(totalIncome)}
|
{formatCurrency(totalIncome)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -37,13 +37,13 @@ export function StatsSummaryCards({
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2">
|
||||||
<TrendingDown className="w-4 h-4 text-red-600" />
|
<TrendingDown className="w-3 h-3 md:w-4 md:h-4 text-red-600" />
|
||||||
Total Dépenses
|
Total Dépenses
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-red-600">
|
<div className="text-lg md:text-2xl font-bold text-red-600">
|
||||||
{formatCurrency(totalExpenses)}
|
{formatCurrency(totalExpenses)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -51,13 +51,13 @@ export function StatsSummaryCards({
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2">
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
Moyenne mensuelle
|
Moyenne mensuelle
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-lg md:text-2xl font-bold">
|
||||||
{formatCurrency(avgMonthlyExpenses)}
|
{formatCurrency(avgMonthlyExpenses)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -65,14 +65,14 @@ export function StatsSummaryCards({
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
|
||||||
Économies
|
Économies
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl font-bold",
|
"text-lg md:text-2xl font-bold",
|
||||||
savings >= 0 ? "text-emerald-600" : "text-red-600"
|
savings >= 0 ? "text-emerald-600" : "text-red-600"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import type { Transaction, Category } from "@/lib/types";
|
import type { Transaction, Category } from "@/lib/types";
|
||||||
|
|
||||||
interface TopExpensesListProps {
|
interface TopExpensesListProps {
|
||||||
@@ -15,58 +17,66 @@ export function TopExpensesList({
|
|||||||
categories,
|
categories,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
}: TopExpensesListProps) {
|
}: TopExpensesListProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Top 5 dépenses</CardTitle>
|
<CardTitle className="text-sm md:text-base">Top 5 dépenses</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{expenses.length > 0 ? (
|
{expenses.length > 0 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3 md:space-y-4">
|
||||||
{expenses.map((expense, index) => {
|
{expenses.map((expense, index) => {
|
||||||
const category = categories.find(
|
const category = categories.find(
|
||||||
(c) => c.id === expense.categoryId
|
(c) => c.id === expense.categoryId
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div key={expense.id} className="flex items-center gap-3">
|
<div key={expense.id} className="flex items-start gap-2 md:gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-sm font-semibold">
|
<div className="w-6 h-6 md:w-8 md:h-8 rounded-full bg-muted flex items-center justify-center text-xs md:text-sm font-semibold shrink-0">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium text-sm truncate">
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<p className="font-medium text-xs md:text-sm truncate flex-1">
|
||||||
{expense.description}
|
{expense.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="text-red-600 font-semibold tabular-nums text-xs md:text-sm shrink-0">
|
||||||
<span className="text-xs text-muted-foreground">
|
{formatCurrency(expense.amount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
|
||||||
|
<span className="text-[10px] md:text-xs text-muted-foreground">
|
||||||
{new Date(expense.date).toLocaleDateString("fr-FR")}
|
{new Date(expense.date).toLocaleDateString("fr-FR")}
|
||||||
</span>
|
</span>
|
||||||
{category && (
|
{category && (
|
||||||
<span
|
<Badge
|
||||||
className="text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1"
|
variant="secondary"
|
||||||
|
className="text-[10px] md:text-xs px-1.5 md:px-2 py-0.5 inline-flex items-center gap-1 shrink-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${category.color}20`,
|
backgroundColor: `${category.color}20`,
|
||||||
color: category.color,
|
color: category.color,
|
||||||
|
borderColor: `${category.color}30`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
icon={category.icon}
|
icon={category.icon}
|
||||||
color={category.color}
|
color={category.color}
|
||||||
size={10}
|
size={isMobile ? 8 : 10}
|
||||||
/>
|
/>
|
||||||
|
<span className="truncate max-w-[120px] md:max-w-none">
|
||||||
{category.name}
|
{category.name}
|
||||||
</span>
|
</span>
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-red-600 font-semibold tabular-nums">
|
|
||||||
{formatCurrency(expense.amount)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[200px] flex items-center justify-center text-muted-foreground">
|
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-xs md:text-sm">
|
||||||
Pas de dépenses pour cette période
|
Pas de dépenses pour cette période
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function TransactionFilters({
|
|||||||
folders={folders}
|
folders={folders}
|
||||||
value={selectedAccounts}
|
value={selectedAccounts}
|
||||||
onChange={onAccountsChange}
|
onChange={onAccountsChange}
|
||||||
className="w-[280px]"
|
className="w-full md:w-[280px]"
|
||||||
filteredTransactions={transactionsForAccountFilter}
|
filteredTransactions={transactionsForAccountFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -99,12 +99,12 @@ export function TransactionFilters({
|
|||||||
categories={categories}
|
categories={categories}
|
||||||
value={selectedCategories}
|
value={selectedCategories}
|
||||||
onChange={onCategoriesChange}
|
onChange={onCategoriesChange}
|
||||||
className="w-[220px]"
|
className="w-full md:w-[220px]"
|
||||||
filteredTransactions={transactionsForCategoryFilter}
|
filteredTransactions={transactionsForCategoryFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select value={showReconciled} onValueChange={onReconciledChange}>
|
<Select value={showReconciled} onValueChange={onReconciledChange}>
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectTrigger className="w-full md:w-[160px]">
|
||||||
<SelectValue placeholder="Pointage" />
|
<SelectValue placeholder="Pointage" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -125,7 +125,7 @@ export function TransactionFilters({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-full md:w-[150px]">
|
||||||
<SelectValue placeholder="Période" />
|
<SelectValue placeholder="Période" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -141,7 +141,7 @@ export function TransactionFilters({
|
|||||||
{period === "custom" && (
|
{period === "custom" && (
|
||||||
<Popover open={isCustomDatePickerOpen} onOpenChange={onCustomDatePickerOpenChange}>
|
<Popover open={isCustomDatePickerOpen} onOpenChange={onCustomDatePickerOpenChange}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="w-[280px] justify-start text-left font-normal">
|
<Button variant="outline" className="w-full md:w-[280px] justify-start text-left font-normal">
|
||||||
<Calendar className="mr-2 h-4 w-4" />
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
{customStartDate && customEndDate ? (
|
{customStartDate && customEndDate ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import type { Transaction, Account, Category } from "@/lib/types";
|
import type { Transaction, Account, Category } from "@/lib/types";
|
||||||
|
|
||||||
type SortField = "date" | "amount" | "description";
|
type SortField = "date" | "amount" | "description";
|
||||||
@@ -146,11 +147,14 @@ export function TransactionTable({
|
|||||||
}: TransactionTableProps) {
|
}: TransactionTableProps) {
|
||||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const MOBILE_ROW_HEIGHT = 120;
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: transactions.length,
|
count: transactions.length,
|
||||||
getScrollElement: () => parentRef.current,
|
getScrollElement: () => parentRef.current,
|
||||||
estimateSize: () => ROW_HEIGHT,
|
estimateSize: () => (isMobile ? MOBILE_ROW_HEIGHT : ROW_HEIGHT),
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -201,17 +205,165 @@ export function TransactionTable({
|
|||||||
setFocusedIndex(null);
|
setFocusedIndex(null);
|
||||||
}, [transactions.length]);
|
}, [transactions.length]);
|
||||||
|
|
||||||
const getAccount = (accountId: string) => {
|
const getAccount = useCallback((accountId: string) => {
|
||||||
return accounts.find((a) => a.id === accountId);
|
return accounts.find((a) => a.id === accountId);
|
||||||
};
|
}, [accounts]);
|
||||||
|
|
||||||
|
const getCategory = useCallback((categoryId: string | null) => {
|
||||||
|
if (!categoryId) return null;
|
||||||
|
return categories.find((c) => c.id === categoryId);
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{transactions.length === 0 ? (
|
{transactions.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
<p className="text-muted-foreground">Aucune transaction trouvée</p>
|
<p className="text-muted-foreground">Aucune transaction trouvée</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : isMobile ? (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<div
|
||||||
|
ref={parentRef}
|
||||||
|
className="overflow-auto"
|
||||||
|
style={{ height: "calc(100vh - 300px)", minHeight: "400px" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
|
width: "100%",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const transaction = transactions[virtualRow.index];
|
||||||
|
const account = getAccount(transaction.accountId);
|
||||||
|
const category = getCategory(transaction.categoryId);
|
||||||
|
const isFocused = focusedIndex === virtualRow.index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={transaction.id}
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
// Désactiver le pointage au clic sur mobile
|
||||||
|
if (!isMobile) {
|
||||||
|
handleRowClick(virtualRow.index, transaction.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border",
|
||||||
|
transaction.isReconciled && "bg-emerald-500/5",
|
||||||
|
isFocused && "bg-primary/10 ring-1 ring-primary/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedTransactions.has(transaction.id)}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
onToggleSelectTransaction(transaction.id);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-xs md:text-sm truncate">
|
||||||
|
{transaction.description}
|
||||||
|
</p>
|
||||||
|
{transaction.memo && (
|
||||||
|
<p className="text-[10px] md:text-xs text-muted-foreground truncate mt-1">
|
||||||
|
{transaction.memo}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"font-semibold tabular-nums text-sm md:text-base shrink-0",
|
||||||
|
transaction.amount >= 0
|
||||||
|
? "text-emerald-600"
|
||||||
|
: "text-red-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{transaction.amount >= 0 ? "+" : ""}
|
||||||
|
{formatCurrency(transaction.amount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 md:gap-3 flex-wrap">
|
||||||
|
<span className="text-[10px] md:text-xs text-muted-foreground">
|
||||||
|
{formatDate(transaction.date)}
|
||||||
|
</span>
|
||||||
|
{account && (
|
||||||
|
<span className="text-[10px] md:text-xs text-muted-foreground">
|
||||||
|
• {account.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div onClick={(e) => e.stopPropagation()} className="flex-1">
|
||||||
|
<CategoryCombobox
|
||||||
|
categories={categories}
|
||||||
|
value={transaction.categoryId}
|
||||||
|
onChange={(categoryId) =>
|
||||||
|
onSetCategory(transaction.id, categoryId)
|
||||||
|
}
|
||||||
|
showBadge
|
||||||
|
align="start"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCreateRule(transaction);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Wand2 className="w-4 h-4 mr-2" />
|
||||||
|
Créer une règle
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (
|
||||||
|
confirm(
|
||||||
|
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
onDelete(transaction.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-red-600 focus:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Supprimer
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
{/* Header fixe */}
|
{/* Header fixe */}
|
||||||
|
|||||||
@@ -8,13 +8,22 @@ export function useIsMobile() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
const checkMobile = () => {
|
||||||
const onChange = () => {
|
const mobile = window.innerWidth < MOBILE_BREAKPOINT;
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
setIsMobile((prev) => {
|
||||||
|
// Éviter les re-renders inutiles si la valeur n'a pas changé
|
||||||
|
if (prev === mobile) return prev;
|
||||||
|
return mobile;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
mql.addEventListener("change", onChange);
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
// Vérification initiale
|
||||||
return () => mql.removeEventListener("change", onChange);
|
checkMobile();
|
||||||
|
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||||
|
mql.addEventListener("change", checkMobile);
|
||||||
|
|
||||||
|
return () => mql.removeEventListener("change", checkMobile);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return !!isMobile;
|
return !!isMobile;
|
||||||
|
|||||||
Reference in New Issue
Block a user