feat: enhance responsive design and layout consistency across various components, including dashboard, statistics, and rules pages

This commit is contained in:
Julien Froidefond
2025-12-01 08:34:28 +01:00
parent 86236aeb04
commit b3b25412ad
19 changed files with 731 additions and 349 deletions

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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}

View File

@@ -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";

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View 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);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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})
</> </>
)} )}

View File

@@ -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"
)} )}
> >

View File

@@ -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>
)} )}

View File

@@ -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 ? (
<> <>

View File

@@ -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 */}

View File

@@ -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;