Compare commits

...

11 Commits

Author SHA1 Message Date
Julien Froidefond
8a4f6d31b8 feat: enhance tooltip and global styles for improved visibility; implement custom tooltip rendering in CategoryTrendChart and enforce opacity settings in globals.css for Radix Portal and Recharts tooltips
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m7s
2025-12-23 12:28:13 +01:00
Julien Froidefond
804b0f0aad feat: add sidebar-opaque color variable and update Sidebar component styling; enhance visual consistency by applying new background color to the sidebar 2025-12-23 12:15:49 +01:00
Julien Froidefond
f295e86fc2 refactor: improve code formatting and consistency in StatisticsPage and TopExpensesList components; standardize quotation marks and enhance readability across various sections
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s
2025-12-23 11:49:31 +01:00
Julien Froidefond
c57daa9cc8 refactor: standardize quotation marks in pnpm-lock.yaml and improve code formatting across various components; enhance readability and maintain consistency in code style 2025-12-23 11:42:02 +01:00
Julien Froidefond
01c1f25de2 feat: add transaction statistics to TransactionsPage; implement reconciled and categorized percentage calculations, enhance card layout, and update UI components for improved data presentation
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m11s
2025-12-23 11:27:06 +01:00
Julien Froidefond
9de7d1a467 feat: enhance tooltip functionality in BalanceLineChart; implement custom content rendering for improved data presentation and user interaction, including dynamic styling and formatting of displayed values
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m4s
2025-12-23 11:14:07 +01:00
Julien Froidefond
407486a109 feat: implement top expenses categorization and enhance UI with tabs; display top 10 expenses per top 5 parent categories, improving data organization and user navigation in the statistics page 2025-12-23 11:07:15 +01:00
Julien Froidefond
e0597b0dcb feat: update theme management across the application; change default theme to 'light', disable system theme option, and add ThemeCard component in settings for enhanced user customization
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m23s
2025-12-23 07:59:33 +01:00
Julien Froidefond
b2eac21bdf refactor: clean up imports and improve code consistency across various components; remove unused imports in page.tsx, add missing imports in categories page, and standardize formatting in hooks and chart components
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m30s
2025-12-22 08:46:59 +01:00
Julien Froidefond
7c3f522531 feat: refine Sidebar component layout and styling; adjust padding, spacing, and button styles for improved visual consistency and user experience 2025-12-22 08:45:41 +01:00
Julien Froidefond
4f13134ef0 feat: enhance global styles and component themes with new semantic colors; integrate ThemeProvider for improved theme management and update color usage across various components for consistency 2025-12-22 08:40:25 +01:00
71 changed files with 6248 additions and 3195 deletions

View File

@@ -134,7 +134,11 @@ export default function AccountsPage() {
// Convert accountsWithStats to regular accounts for compatibility // Convert accountsWithStats to regular accounts for compatibility
const accounts = accountsWithStats.map( const accounts = accountsWithStats.map(
({ transactionCount: _transactionCount, calculatedBalance: _calculatedBalance, ...account }) => account, ({
transactionCount: _transactionCount,
calculatedBalance: _calculatedBalance,
...account
}) => account,
); );
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
@@ -165,10 +169,11 @@ export default function AccountsPage() {
// Calculer le balance à partir du solde total et du solde initial // Calculer le balance à partir du solde total et du solde initial
// balance = totalBalance - initialBalance // balance = totalBalance - initialBalance
const balance = formData.totalBalance - formData.initialBalance; const balance = formData.totalBalance - formData.initialBalance;
// Convertir "folder-root" en null // Convertir "folder-root" en null
const folderId = formData.folderId === "folder-root" ? null : formData.folderId; const folderId =
formData.folderId === "folder-root" ? null : formData.folderId;
const updatedAccount = { const updatedAccount = {
...editingAccount, ...editingAccount,
name: formData.name, name: formData.name,
@@ -325,10 +330,10 @@ export default function AccountsPage() {
const result = await response.json(); const result = await response.json();
invalidateAllAccountQueries(queryClient); invalidateAllAccountQueries(queryClient);
// Réinitialiser la sélection // Réinitialiser la sélection
setSelectedAccounts(new Set()); setSelectedAccounts(new Set());
// Afficher un message de succès // Afficher un message de succès
alert( alert(
`Fusion réussie ! ${result.transactionCount} transactions déplacées vers le compte de destination.`, `Fusion réussie ! ${result.transactionCount} transactions déplacées vers le compte de destination.`,
@@ -384,7 +389,16 @@ export default function AccountsPage() {
// Update cache directly // Update cache directly
queryClient.setQueryData( queryClient.setQueryData(
["accounts-with-stats"], ["accounts-with-stats"],
(old: Array<Account & { transactionCount: number; calculatedBalance: number }> | undefined) => { (
old:
| Array<
Account & {
transactionCount: number;
calculatedBalance: number;
}
>
| undefined,
) => {
if (!old) return old; if (!old) return old;
return old.map((a) => (a.id === accountId ? updatedAccount : a)); return old.map((a) => (a.id === accountId ? updatedAccount : a));
}, },
@@ -564,25 +578,27 @@ export default function AccountsPage() {
(f: FolderType) => f.id === account.folderId, (f: FolderType) => f.id === account.folderId,
); );
const accountWithStats = accountsWithStats.find( const accountWithStats = accountsWithStats.find(
(a) => a.id === account.id, (a) => a.id === account.id,
); );
return ( return (
<AccountCard <AccountCard
key={account.id} key={account.id}
account={account} account={account}
folder={folder} folder={folder}
transactionCount={getTransactionCount(account.id)} transactionCount={getTransactionCount(account.id)}
calculatedBalance={accountWithStats?.calculatedBalance} calculatedBalance={
onEdit={handleEdit} accountWithStats?.calculatedBalance
onDelete={handleDelete} }
formatCurrency={formatCurrency} onEdit={handleEdit}
isSelected={selectedAccounts.has(account.id)} onDelete={handleDelete}
onSelect={toggleSelectAccount} formatCurrency={formatCurrency}
draggableId={`account-${account.id}`} isSelected={selectedAccounts.has(account.id)}
compact={isCompactView} onSelect={toggleSelectAccount}
/> draggableId={`account-${account.id}`}
); compact={isCompactView}
/>
);
})} })}
</div> </div>
</FolderDropZone> </FolderDropZone>
@@ -694,7 +710,9 @@ export default function AccountsPage() {
account={account} account={account}
folder={accountFolder} folder={accountFolder}
transactionCount={getTransactionCount(account.id)} transactionCount={getTransactionCount(account.id)}
calculatedBalance={accountWithStats?.calculatedBalance} calculatedBalance={
accountWithStats?.calculatedBalance
}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
formatCurrency={formatCurrency} formatCurrency={formatCurrency}

View File

@@ -126,4 +126,3 @@ export async function POST(request: NextRequest) {
); );
} }
} }

View File

@@ -66,4 +66,3 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: errorMessage }, { status: 500 }); return NextResponse.json({ error: errorMessage }, { status: 500 });
} }
} }

View File

@@ -11,6 +11,7 @@ import {
import { useBankingMetadata, useCategoryStats } from "@/lib/hooks"; import { useBankingMetadata, useCategoryStats } from "@/lib/hooks";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -62,7 +63,7 @@ export default function CategoriesPage() {
// Persister l'état "tout déplier" dans le localStorage // Persister l'état "tout déplier" dans le localStorage
const [expandAllByDefault, setExpandAllByDefault] = useLocalStorage( const [expandAllByDefault, setExpandAllByDefault] = useLocalStorage(
"categories-expand-all-by-default", "categories-expand-all-by-default",
true true,
); );
// Organiser les catégories par parent // Organiser les catégories par parent
@@ -108,7 +109,9 @@ export default function CategoriesPage() {
useEffect(() => { useEffect(() => {
if (parentCategories.length > 0 && expandedParents.size === 0) { if (parentCategories.length > 0 && expandedParents.size === 0) {
if (expandAllByDefault) { if (expandAllByDefault) {
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id))); setExpandedParents(
new Set(parentCategories.map((p: Category) => p.id)),
);
} else { } else {
setExpandedParents(new Set()); setExpandedParents(new Set());
} }

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import "./globals.css";
import { AuthSessionProvider } from "@/components/providers/session-provider"; import { AuthSessionProvider } from "@/components/providers/session-provider";
import { QueryProvider } from "@/components/providers/query-provider"; import { QueryProvider } from "@/components/providers/query-provider";
import { BackgroundProvider } from "@/components/providers/background-provider"; import { BackgroundProvider } from "@/components/providers/background-provider";
import { ThemeProvider } from "@/components/theme-provider";
const _geist = Geist({ subsets: ["latin"] }); const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] }); const _geistMono = Geist_Mono({ subsets: ["latin"] });
@@ -22,12 +23,19 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="fr"> <html lang="fr" suppressHydrationWarning>
<body className="font-sans antialiased"> <body className="font-sans antialiased">
<BackgroundProvider /> <ThemeProvider
<QueryProvider> attribute="class"
<AuthSessionProvider>{children}</AuthSessionProvider> defaultTheme="light"
</QueryProvider> enableSystem={false}
disableTransitionOnChange
>
<BackgroundProvider />
<QueryProvider>
<AuthSessionProvider>{children}</AuthSessionProvider>
</QueryProvider>
</ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@@ -8,7 +8,6 @@ import { AccountsSummary } from "@/components/dashboard/accounts-summary";
import { CategoryBreakdown } from "@/components/dashboard/category-breakdown"; import { CategoryBreakdown } from "@/components/dashboard/category-breakdown";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog"; import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox"; import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox";
import { Card, CardContent } from "@/components/ui/card";
import { useBankingData } from "@/lib/hooks"; import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Upload } from "lucide-react"; import { Upload } from "lucide-react";

View File

@@ -36,20 +36,17 @@ export default function RulesPage() {
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata(); const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
// Fetch uncategorized transactions only // Fetch uncategorized transactions only
const { const { data: transactionsData, isLoading: isLoadingTransactions } =
data: transactionsData, useTransactions(
isLoading: isLoadingTransactions, {
invalidate: invalidateTransactions, limit: 10000, // Large limit to get all uncategorized
} = useTransactions( offset: 0,
{ includeUncategorized: true,
limit: 10000, // Large limit to get all uncategorized },
offset: 0, !!metadata,
includeUncategorized: true, );
},
!!metadata,
);
const refresh = useCallback(() => { const _refresh = useCallback(() => {
invalidateAllTransactionQueries(queryClient); invalidateAllTransactionQueries(queryClient);
invalidateAllCategoryQueries(queryClient); invalidateAllCategoryQueries(queryClient);
}, [queryClient]); }, [queryClient]);

View File

@@ -10,6 +10,7 @@ import {
PasswordCard, PasswordCard,
ReconcileDateRangeCard, ReconcileDateRangeCard,
BackgroundCard, BackgroundCard,
ThemeCard,
} from "@/components/settings"; } from "@/components/settings";
import { useBankingData } from "@/lib/hooks"; import { useBankingData } from "@/lib/hooks";
import type { BankingData } from "@/lib/types"; import type { BankingData } from "@/lib/types";
@@ -127,6 +128,8 @@ export default function SettingsPage() {
<PasswordCard /> <PasswordCard />
<ThemeCard />
<BackgroundCard /> <BackgroundCard />
<ReconcileDateRangeCard /> <ReconcileDateRangeCard />

View File

@@ -55,7 +55,7 @@ export default function StatisticsPage() {
const { data, isLoading } = useBankingData(); const { data, isLoading } = useBankingData();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
// Persister les filtres dans le localStorage // Persister les filtres dans le localStorage
const [period, setPeriod] = useLocalStorage<Period>( const [period, setPeriod] = useLocalStorage<Period>(
"statistics-period", "statistics-period",
@@ -71,7 +71,7 @@ export default function StatisticsPage() {
); );
const [excludeInternalTransfers, setExcludeInternalTransfers] = const [excludeInternalTransfers, setExcludeInternalTransfers] =
useLocalStorage("statistics-exclude-internal-transfers", true); useLocalStorage("statistics-exclude-internal-transfers", true);
// Pour les dates, on stocke les ISO strings et on les convertit // Pour les dates, on stocke les ISO strings et on les convertit
const [customStartDateISO, setCustomStartDateISO] = useLocalStorage< const [customStartDateISO, setCustomStartDateISO] = useLocalStorage<
string | null string | null
@@ -79,7 +79,7 @@ export default function StatisticsPage() {
const [customEndDateISO, setCustomEndDateISO] = useLocalStorage< const [customEndDateISO, setCustomEndDateISO] = useLocalStorage<
string | null string | null
>("statistics-custom-end-date", null); >("statistics-custom-end-date", null);
// Convertir les ISO strings en Date // Convertir les ISO strings en Date
const customStartDate = useMemo( const customStartDate = useMemo(
() => (customStartDateISO ? new Date(customStartDateISO) : undefined), () => (customStartDateISO ? new Date(customStartDateISO) : undefined),
@@ -89,7 +89,7 @@ export default function StatisticsPage() {
() => (customEndDateISO ? new Date(customEndDateISO) : undefined), () => (customEndDateISO ? new Date(customEndDateISO) : undefined),
[customEndDateISO] [customEndDateISO]
); );
// Fonctions pour mettre à jour les dates avec persistance // Fonctions pour mettre à jour les dates avec persistance
const setCustomStartDate = (date: Date | undefined) => { const setCustomStartDate = (date: Date | undefined) => {
setCustomStartDateISO(date ? date.toISOString() : null); setCustomStartDateISO(date ? date.toISOString() : null);
@@ -97,7 +97,7 @@ export default function StatisticsPage() {
const setCustomEndDate = (date: Date | undefined) => { const setCustomEndDate = (date: Date | undefined) => {
setCustomEndDateISO(date ? date.toISOString() : null); setCustomEndDateISO(date ? date.toISOString() : null);
}; };
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false); const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
// Nettoyer les dates personnalisées quand on change de période (sauf si on passe à "custom") // Nettoyer les dates personnalisées quand on change de période (sauf si on passe à "custom")
@@ -106,7 +106,13 @@ export default function StatisticsPage() {
setCustomStartDateISO(null); setCustomStartDateISO(null);
setCustomEndDateISO(null); setCustomEndDateISO(null);
} }
}, [period, customStartDateISO, customEndDateISO, setCustomStartDateISO, setCustomEndDateISO]); }, [
period,
customStartDateISO,
customEndDateISO,
setCustomStartDateISO,
setCustomEndDateISO,
]);
// Get start date based on period // Get start date based on period
const startDate = useMemo(() => { const startDate = useMemo(() => {
@@ -139,7 +145,7 @@ export default function StatisticsPage() {
const internalTransferCategory = useMemo(() => { const internalTransferCategory = useMemo(() => {
if (!data) return null; if (!data) return null;
return data.categories.find( return data.categories.find(
(c) => c.name.toLowerCase() === "virement interne", (c) => c.name.toLowerCase() === "virement interne"
); );
}, [data]); }, [data]);
@@ -255,7 +261,7 @@ export default function StatisticsPage() {
// Filter by accounts // Filter by accounts
if (!selectedAccounts.includes("all")) { if (!selectedAccounts.includes("all")) {
transactions = transactions.filter((t) => transactions = transactions.filter((t) =>
selectedAccounts.includes(t.accountId), selectedAccounts.includes(t.accountId)
); );
} }
@@ -265,7 +271,7 @@ export default function StatisticsPage() {
transactions = transactions.filter((t) => !t.categoryId); transactions = transactions.filter((t) => !t.categoryId);
} else { } else {
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.categoryId && selectedCategories.includes(t.categoryId), (t) => t.categoryId && selectedCategories.includes(t.categoryId)
); );
} }
} }
@@ -273,7 +279,7 @@ export default function StatisticsPage() {
// Exclude "Virement interne" category if checkbox is checked // Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) { if (excludeInternalTransfers && internalTransferCategory) {
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.categoryId !== internalTransferCategory.id, (t) => t.categoryId !== internalTransferCategory.id
); );
} }
@@ -346,7 +352,7 @@ export default function StatisticsPage() {
}); });
const categoryChartDataByParent = Array.from( const categoryChartDataByParent = Array.from(
categoryTotalsByParent.entries(), categoryTotalsByParent.entries()
) )
.map(([groupId, total]) => { .map(([groupId, total]) => {
const category = data.categories.find((c) => c.id === groupId); const category = data.categories.find((c) => c.id === groupId);
@@ -360,22 +366,53 @@ export default function StatisticsPage() {
}) })
.sort((a, b) => b.value - a.value); .sort((a, b) => b.value - a.value);
// Top expenses - deduplicate by ID and sort by amount (most negative first) // Top expenses by top parent categories - deduplicate by ID
const uniqueTransactions = Array.from( const uniqueTransactions = Array.from(
new Map(transactions.map((t) => [t.id, t])).values(), new Map(transactions.map((t) => [t.id, t])).values()
); );
const topExpenses = uniqueTransactions const expenses = uniqueTransactions.filter((t) => t.amount < 0);
.filter((t) => t.amount < 0)
.sort((a, b) => { // Get top 5 parent categories by total expenses
// Sort by amount (most negative first) const topParentCategories = Array.from(categoryTotalsByParent.entries())
if (a.amount !== b.amount) { .map(([groupId, total]) => ({
return a.amount - b.amount; groupId: groupId === "uncategorized" ? null : groupId,
} total,
// If same amount, sort by date (most recent first) for stable sorting }))
return new Date(b.date).getTime() - new Date(a.date).getTime(); .sort((a, b) => b.total - a.total)
})
.slice(0, 5); .slice(0, 5);
// Get top 10 expenses per top parent category (from all its subcategories)
const topExpensesByCategory = topParentCategories.map(({ groupId }) => {
const categoryExpenses = expenses
.filter((t) => {
if (groupId === null) {
return !t.categoryId;
}
// Check if transaction belongs to this parent category or its subcategories
const category = data.categories.find((c) => c.id === t.categoryId);
if (!category) {
return false;
}
// Use parent category ID if exists, otherwise use the category itself
const transactionGroupId = category.parentId || category.id;
return transactionGroupId === groupId;
})
.sort((a, b) => {
// Sort by amount (most negative first)
if (a.amount !== b.amount) {
return a.amount - b.amount;
}
// If same amount, sort by date (most recent first) for stable sorting
return new Date(b.date).getTime() - new Date(a.date).getTime();
})
.slice(0, 10);
return {
categoryId: groupId,
expenses: categoryExpenses,
};
});
// Summary // Summary
const totalIncome = transactions const totalIncome = transactions
.filter((t) => t.amount >= 0) .filter((t) => t.amount >= 0)
@@ -388,7 +425,7 @@ export default function StatisticsPage() {
// Balance evolution - Aggregated (using filtered transactions) // Balance evolution - Aggregated (using filtered transactions)
const sortedFilteredTransactions = [...transactions].sort( const sortedFilteredTransactions = [...transactions].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
); );
// Calculate starting balance: initialBalance + transactions before startDate // Calculate starting balance: initialBalance + transactions before startDate
@@ -400,7 +437,7 @@ export default function StatisticsPage() {
// Start with initial balances // Start with initial balances
runningBalance = accountsToUse.reduce( runningBalance = accountsToUse.reduce(
(sum, acc) => sum + (acc.initialBalance || 0), (sum, acc) => sum + (acc.initialBalance || 0),
0, 0
); );
// Add all transactions before the start date for these accounts // Add all transactions before the start date for these accounts
@@ -437,7 +474,7 @@ export default function StatisticsPage() {
}); });
const aggregatedBalanceData = Array.from( const aggregatedBalanceData = Array.from(
aggregatedBalanceByDate.entries(), aggregatedBalanceByDate.entries()
).map(([date, balance]) => ({ ).map(([date, balance]) => ({
date: new Date(date).toLocaleDateString("fr-FR", { date: new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit", day: "2-digit",
@@ -653,13 +690,14 @@ export default function StatisticsPage() {
monthlyChartData, monthlyChartData,
categoryChartData, categoryChartData,
categoryChartDataByParent, categoryChartDataByParent,
topExpenses, topExpensesByCategory,
totalIncome, totalIncome,
totalExpenses, totalExpenses,
avgMonthlyExpenses, avgMonthlyExpenses,
aggregatedBalanceData, aggregatedBalanceData,
perAccountBalanceData, perAccountBalanceData,
transactionCount: transactions.length, transactionCount: transactions.length,
transactions, // Toutes les transactions filtrées pour le graphique
savingsTrendData, savingsTrendData,
categoryTrendData, categoryTrendData,
categoryTrendDataByParent, categoryTrendDataByParent,
@@ -894,7 +932,7 @@ export default function StatisticsPage() {
onRemoveAccount={(id) => { onRemoveAccount={(id) => {
const newAccounts = selectedAccounts.filter((a) => a !== id); const newAccounts = selectedAccounts.filter((a) => a !== id);
setSelectedAccounts( setSelectedAccounts(
newAccounts.length > 0 ? newAccounts : ["all"], newAccounts.length > 0 ? newAccounts : ["all"]
); );
}} }}
onClearAccounts={() => setSelectedAccounts(["all"])} onClearAccounts={() => setSelectedAccounts(["all"])}
@@ -902,7 +940,7 @@ export default function StatisticsPage() {
onRemoveCategory={(id) => { onRemoveCategory={(id) => {
const newCategories = selectedCategories.filter((c) => c !== id); const newCategories = selectedCategories.filter((c) => c !== id);
setSelectedCategories( setSelectedCategories(
newCategories.length > 0 ? newCategories : ["all"], newCategories.length > 0 ? newCategories : ["all"]
); );
}} }}
onClearCategories={() => setSelectedCategories(["all"])} onClearCategories={() => setSelectedCategories(["all"])}
@@ -1000,7 +1038,11 @@ export default function StatisticsPage() {
selected={customStartDate} selected={customStartDate}
onSelect={(date) => { onSelect={(date) => {
setCustomStartDate(date); setCustomStartDate(date);
if (date && customEndDate && date > customEndDate) { if (
date &&
customEndDate &&
date > customEndDate
) {
setCustomEndDate(undefined); setCustomEndDate(undefined);
} }
}} }}
@@ -1040,26 +1082,26 @@ export default function StatisticsPage() {
</div> </div>
{customStartDate && customEndDate && ( {customStartDate && customEndDate && (
<div className="flex gap-2 pt-2 border-t px-3 pb-3"> <div className="flex gap-2 pt-2 border-t px-3 pb-3">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="flex-1" className="flex-1"
onClick={() => { onClick={() => {
setCustomStartDate(undefined); setCustomStartDate(undefined);
setCustomEndDate(undefined); setCustomEndDate(undefined);
}} }}
> >
Réinitialiser Réinitialiser
</Button> </Button>
<Button <Button
size="sm" size="sm"
className="flex-1" className="flex-1"
onClick={() => setIsCustomDatePickerOpen(false)} onClick={() => setIsCustomDatePickerOpen(false)}
> >
Valider Valider
</Button> </Button>
</div> </div>
)} )}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
)} )}
@@ -1088,17 +1130,17 @@ export default function StatisticsPage() {
onRemoveAccount={(id) => { onRemoveAccount={(id) => {
const newAccounts = selectedAccounts.filter((a) => a !== id); const newAccounts = selectedAccounts.filter((a) => a !== id);
setSelectedAccounts( setSelectedAccounts(
newAccounts.length > 0 ? newAccounts : ["all"], newAccounts.length > 0 ? newAccounts : ["all"]
); );
}} }}
onClearAccounts={() => setSelectedAccounts(["all"])} onClearAccounts={() => setSelectedAccounts(["all"])}
selectedCategories={selectedCategories} selectedCategories={selectedCategories}
onRemoveCategory={(id) => { onRemoveCategory={(id) => {
const newCategories = selectedCategories.filter( const newCategories = selectedCategories.filter(
(c) => c !== id, (c) => c !== id
); );
setSelectedCategories( setSelectedCategories(
newCategories.length > 0 ? newCategories : ["all"], newCategories.length > 0 ? newCategories : ["all"]
); );
}} }}
onClearCategories={() => setSelectedCategories(["all"])} onClearCategories={() => setSelectedCategories(["all"])}
@@ -1200,9 +1242,10 @@ export default function StatisticsPage() {
</div> </div>
<div className="mt-4 md:mt-6"> <div className="mt-4 md:mt-6">
<TopExpensesList <TopExpensesList
expenses={stats.topExpenses} expensesByCategory={stats.topExpensesByCategory}
categories={data.categories} categories={data.categories}
formatCurrency={formatCurrency} formatCurrency={formatCurrency}
allTransactions={stats.transactions}
/> />
</div> </div>
</section> </section>
@@ -1248,7 +1291,7 @@ function ActiveFilters({
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id)); const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
const selectedCats = categories.filter((c) => const selectedCats = categories.filter((c) =>
selectedCategories.includes(c.id), selectedCategories.includes(c.id)
); );
const isUncategorized = selectedCategories.includes("uncategorized"); const isUncategorized = selectedCategories.includes("uncategorized");

View File

@@ -2,7 +2,15 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { PageLayout, PageHeader } from "@/components/layout"; import { PageLayout, PageHeader } from "@/components/layout";
import { RefreshCw, Receipt, Euro, ChevronDown, ChevronUp } from "lucide-react"; import {
RefreshCw,
Receipt,
Euro,
ChevronDown,
ChevronUp,
CheckCircle2,
Tag,
} from "lucide-react";
import { import {
TransactionFilters, TransactionFilters,
TransactionBulkActions, TransactionBulkActions,
@@ -105,6 +113,7 @@ export default function TransactionsPage() {
isLoading: isLoadingChart, isLoading: isLoadingChart,
totalAmount: chartTotalAmount, totalAmount: chartTotalAmount,
totalCount: chartTotalCount, totalCount: chartTotalCount,
transactions: chartTransactions,
} = useTransactionsChartData({ } = useTransactionsChartData({
selectedAccounts, selectedAccounts,
selectedCategories, selectedCategories,
@@ -147,7 +156,7 @@ export default function TransactionsPage() {
handleBulkReconcile(reconciled, selectedTransactions); handleBulkReconcile(reconciled, selectedTransactions);
clearSelection(); clearSelection();
}, },
[handleBulkReconcile, selectedTransactions, clearSelection] [handleBulkReconcile, selectedTransactions, clearSelection],
); );
const handleBulkSetCategoryWithClear = useCallback( const handleBulkSetCategoryWithClear = useCallback(
@@ -155,13 +164,13 @@ export default function TransactionsPage() {
handleBulkSetCategory(categoryId, selectedTransactions); handleBulkSetCategory(categoryId, selectedTransactions);
clearSelection(); clearSelection();
}, },
[handleBulkSetCategory, selectedTransactions, clearSelection] [handleBulkSetCategory, selectedTransactions, clearSelection],
); );
// Stabilize transactions reference to prevent unnecessary re-renders // Stabilize transactions reference to prevent unnecessary re-renders
const filteredTransactions = useMemo( const filteredTransactions = useMemo(
() => transactionsData?.transactions || [], () => transactionsData?.transactions || [],
[transactionsData?.transactions] [transactionsData?.transactions],
); );
const totalTransactions = transactionsData?.total || 0; const totalTransactions = transactionsData?.total || 0;
const hasMore = transactionsData?.hasMore || false; const hasMore = transactionsData?.hasMore || false;
@@ -175,10 +184,27 @@ export default function TransactionsPage() {
const totalAmount = chartTotalAmount ?? 0; const totalAmount = chartTotalAmount ?? 0;
const displayTotalCount = chartTotalCount ?? totalTransactions; const displayTotalCount = chartTotalCount ?? totalTransactions;
// Calculate percentages from chart transactions (all filtered transactions)
const reconciledPercent = useMemo(() => {
if (chartTransactions.length === 0) return "0.00";
const reconciledCount = chartTransactions.filter(
(t) => t.isReconciled,
).length;
return ((reconciledCount / chartTransactions.length) * 100).toFixed(2);
}, [chartTransactions]);
const categorizedPercent = useMemo(() => {
if (chartTransactions.length === 0) return "0.00";
const categorizedCount = chartTransactions.filter(
(t) => t.categoryId !== null,
).length;
return ((categorizedCount / chartTransactions.length) * 100).toFixed(2);
}, [chartTransactions]);
// Persist statistics collapsed state in localStorage // Persist statistics collapsed state in localStorage
const [isStatsExpanded, setIsStatsExpanded] = useLocalStorage( const [isStatsExpanded, setIsStatsExpanded] = useLocalStorage(
"transactions-stats-expanded", "transactions-stats-expanded",
true true,
); );
// Early return for loading state - prevents sidebar flash // Early return for loading state - prevents sidebar flash
@@ -271,7 +297,7 @@ export default function TransactionsPage() {
<CardContent className="pt-0"> <CardContent className="pt-0">
{/* Summary cards */} {/* Summary cards */}
{!isLoadingTransactions && ( {!isLoadingTransactions && (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 mb-6"> <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 mb-6">
<Card className="card-hover"> <Card className="card-hover">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -283,7 +309,10 @@ export default function TransactionsPage() {
{displayTotalCount} {displayTotalCount}
</p> </p>
</div> </div>
<Receipt className="w-8 h-8 text-muted-foreground" /> <Receipt
className="w-8 h-8"
style={{ color: "var(--gray)" }}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -304,7 +333,49 @@ export default function TransactionsPage() {
{formatCurrency(totalAmount)} {formatCurrency(totalAmount)}
</p> </p>
</div> </div>
<Euro className="w-8 h-8 text-muted-foreground" /> <Euro
className={`w-8 h-8 ${
totalAmount >= 0
? "text-emerald-600"
: "text-red-600"
}`}
/>
</div>
</CardContent>
</Card>
<Card className="card-hover">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Pointé
</p>
<p className="text-2xl font-bold mt-1 text-primary">
{reconciledPercent}%
</p>
</div>
<CheckCircle2 className="w-8 h-8 text-primary" />
</div>
</CardContent>
</Card>
<Card className="card-hover">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Catégorisé
</p>
<p
className="text-2xl font-bold mt-1"
style={{ color: "var(--blue)" }}
>
{categorizedPercent}%
</p>
</div>
<Tag
className="w-8 h-8"
style={{ color: "var(--blue)" }}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -55,7 +55,8 @@ export function AccountCard({
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const Icon = accountTypeIcons[account.type]; const Icon = accountTypeIcons[account.type];
const realBalance = getAccountBalance(account); const realBalance = getAccountBalance(account);
const hasBalanceDifference = calculatedBalance !== undefined && const hasBalanceDifference =
calculatedBalance !== undefined &&
Math.abs(account.balance - calculatedBalance) > 0.01; Math.abs(account.balance - calculatedBalance) > 0.01;
const { const {
@@ -188,7 +189,7 @@ export function AccountCard({
? "text-base" ? "text-base"
: "text-xl", : "text-xl",
!compact && !isMobile && "mb-1.5", !compact && !isMobile && "mb-1.5",
realBalance >= 0 ? "text-emerald-600" : "text-red-600", realBalance >= 0 ? "text-success" : "text-destructive",
)} )}
> >
{formatCurrency(realBalance)} {formatCurrency(realBalance)}
@@ -200,7 +201,8 @@ export function AccountCard({
</span> </span>
{hasBalanceDifference && ( {hasBalanceDifference && (
<span className="text-xs text-destructive font-semibold"> <span className="text-xs text-destructive font-semibold">
(diff: {formatCurrency(account.balance - calculatedBalance)}) (diff: {formatCurrency(account.balance - calculatedBalance)}
)
</span> </span>
)} )}
</div> </div>

View File

@@ -137,7 +137,8 @@ export function AccountEditDialog({
placeholder="0.00" placeholder="0.00"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Solde de départ pour équilibrer le compte. Le balance sera calculé automatiquement (solde total - solde initial). Solde de départ pour équilibrer le compte. Le balance sera calculé
automatiquement (solde total - solde initial).
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -54,7 +54,11 @@ export function AccountMergeSelectDialog({
} }
const handleMerge = async () => { const handleMerge = async () => {
if (!sourceAccountId || !targetAccountId || sourceAccountId === targetAccountId) { if (
!sourceAccountId ||
!targetAccountId ||
sourceAccountId === targetAccountId
) {
return; return;
} }
@@ -105,13 +109,20 @@ export function AccountMergeSelectDialog({
<div className="space-y-3"> <div className="space-y-3">
<Label>Compte à conserver (destination)</Label> <Label>Compte à conserver (destination)</Label>
<RadioGroup value={targetAccountId} onValueChange={setTargetAccountId}> <RadioGroup
value={targetAccountId}
onValueChange={setTargetAccountId}
>
{selectedAccounts.map((account) => ( {selectedAccounts.map((account) => (
<div <div
key={account.id} key={account.id}
className="flex items-start space-x-3 rounded-lg border p-3 hover:bg-accent" className="flex items-start space-x-3 rounded-lg border p-3 hover:bg-accent"
> >
<RadioGroupItem value={account.id} id={account.id} className="mt-1" /> <RadioGroupItem
value={account.id}
id={account.id}
className="mt-1"
/>
<Label <Label
htmlFor={account.id} htmlFor={account.id}
className="flex-1 cursor-pointer space-y-1" className="flex-1 cursor-pointer space-y-1"
@@ -120,7 +131,9 @@ export function AccountMergeSelectDialog({
<div className="text-xs text-muted-foreground space-y-0.5"> <div className="text-xs text-muted-foreground space-y-0.5">
<div>Numéro: {account.accountNumber}</div> <div>Numéro: {account.accountNumber}</div>
<div>Bank ID: {account.bankId}</div> <div>Bank ID: {account.bankId}</div>
<div>Solde: {formatCurrency(getAccountBalance(account))}</div> <div>
Solde: {formatCurrency(getAccountBalance(account))}
</div>
</div> </div>
</Label> </Label>
</div> </div>
@@ -133,9 +146,9 @@ export function AccountMergeSelectDialog({
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
<strong>Attention :</strong> Toutes les transactions du compte " <strong>Attention :</strong> Toutes les transactions du compte "
{sourceAccount.name}" seront déplacées vers "{targetAccount.name}". Le {sourceAccount.name}" seront déplacées vers "
compte "{sourceAccount.name}" sera supprimé après la fusion. Cette {targetAccount.name}". Le compte "{sourceAccount.name}" sera
action est irréversible. supprimé après la fusion. Cette action est irréversible.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@@ -153,4 +166,3 @@ export function AccountMergeSelectDialog({
</Dialog> </Dialog>
); );
} }

View File

@@ -125,7 +125,7 @@ export function ParentCategoryRow({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onDelete(parent.id)} onClick={() => onDelete(parent.id)}
className="text-red-600" className="text-destructive"
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Supprimer Supprimer

View File

@@ -89,7 +89,7 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<span <span
className={cn( className={cn(
"text-xs font-semibold tabular-nums ml-auto", "text-xs font-semibold tabular-nums ml-auto",
folderTotal >= 0 ? "text-emerald-600" : "text-red-600", folderTotal >= 0 ? "text-success" : "text-destructive",
)} )}
> >
{formatCurrency(folderTotal)} {formatCurrency(folderTotal)}
@@ -130,9 +130,7 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<span <span
className={cn( className={cn(
"font-bold tabular-nums text-base", "font-bold tabular-nums text-base",
realBalance >= 0 realBalance >= 0 ? "text-success" : "text-destructive",
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400",
)} )}
> >
{formatCurrency(realBalance)} {formatCurrency(realBalance)}
@@ -204,7 +202,7 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<span <span
className={cn( className={cn(
"text-xs font-semibold tabular-nums ml-auto", "text-xs font-semibold tabular-nums ml-auto",
orphanTotal >= 0 ? "text-emerald-600" : "text-red-600", orphanTotal >= 0 ? "text-success" : "text-destructive",
)} )}
> >
{formatCurrency(orphanTotal)} {formatCurrency(orphanTotal)}
@@ -245,8 +243,8 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
className={cn( className={cn(
"font-bold tabular-nums text-base", "font-bold tabular-nums text-base",
realBalance >= 0 realBalance >= 0
? "text-emerald-600 dark:text-emerald-400" ? "text-success"
: "text-red-600 dark:text-red-400", : "text-destructive",
)} )}
> >
{formatCurrency(realBalance)} {formatCurrency(realBalance)}

View File

@@ -19,7 +19,7 @@ interface OverviewCardsProps {
export function OverviewCards({ data }: OverviewCardsProps) { export function OverviewCards({ data }: OverviewCardsProps) {
const totalBalance = data.accounts.reduce( const totalBalance = data.accounts.reduce(
(sum, acc) => sum + getAccountBalance(acc), (sum, acc) => sum + getAccountBalance(acc),
0 0,
); );
const thisMonth = new Date(); const thisMonth = new Date();
@@ -27,7 +27,7 @@ export function OverviewCards({ data }: OverviewCardsProps) {
const thisMonthStr = thisMonth.toISOString().slice(0, 7); const thisMonthStr = thisMonth.toISOString().slice(0, 7);
const monthTransactions = data.transactions.filter((t) => const monthTransactions = data.transactions.filter((t) =>
t.date.startsWith(thisMonthStr) t.date.startsWith(thisMonthStr),
); );
const income = monthTransactions const income = monthTransactions
@@ -41,13 +41,13 @@ export function OverviewCards({ data }: OverviewCardsProps) {
const reconciled = data.transactions.filter((t) => t.isReconciled).length; const reconciled = data.transactions.filter((t) => t.isReconciled).length;
const total = data.transactions.length; const total = data.transactions.length;
const reconciledPercent = const reconciledPercent =
total > 0 ? Math.round((reconciled / total) * 100) : 0; total > 0 ? ((reconciled / total) * 100).toFixed(2) : "0.00";
const categorized = data.transactions.filter( const categorized = data.transactions.filter(
(t) => t.categoryId !== null (t) => t.categoryId !== null,
).length; ).length;
const categorizedPercent = const categorizedPercent =
total > 0 ? Math.round((categorized / total) * 100) : 0; total > 0 ? ((categorized / total) * 100).toFixed(2) : "0.00";
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
@@ -60,8 +60,11 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<div className="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5"> <div className="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
<Card className="stat-card-gradient-1 card-hover group relative overflow-hidden"> <Card className="stat-card-gradient-1 card-hover group relative overflow-hidden">
{/* Icône en arrière-plan */} {/* Icône en arrière-plan */}
<div className="absolute bottom-2 right-2 opacity-[0.04] dark:opacity-[0.03] z-0 pointer-events-none"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<Wallet className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-primary" strokeWidth={1} /> <Wallet
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-primary"
strokeWidth={1}
/>
</div> </div>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10"> <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0"> <CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
@@ -72,9 +75,7 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<div <div
className={cn( className={cn(
"text-2xl sm:text-3xl md:text-3xl lg:text-2xl xl:text-3xl font-black tracking-tight mb-3 leading-tight break-words", "text-2xl sm:text-3xl md:text-3xl lg:text-2xl xl:text-3xl font-black tracking-tight mb-3 leading-tight break-words",
totalBalance >= 0 totalBalance >= 0 ? "text-success" : "text-destructive",
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400"
)} )}
> >
{formatCurrency(totalBalance)} {formatCurrency(totalBalance)}
@@ -87,8 +88,11 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<Card className="stat-card-gradient-2 card-hover group relative overflow-hidden"> <Card className="stat-card-gradient-2 card-hover group relative overflow-hidden">
{/* Icône en arrière-plan */} {/* Icône en arrière-plan */}
<div className="absolute bottom-2 right-2 opacity-[0.04] dark:opacity-[0.03] z-0 pointer-events-none"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<TrendingUp className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-success" strokeWidth={1} /> <TrendingUp
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-success"
strokeWidth={1}
/>
</div> </div>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10"> <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0"> <CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
@@ -110,8 +114,11 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<Card className="stat-card-gradient-3 card-hover group relative overflow-hidden"> <Card className="stat-card-gradient-3 card-hover group relative overflow-hidden">
{/* Icône en arrière-plan */} {/* Icône en arrière-plan */}
<div className="absolute bottom-2 right-2 opacity-[0.04] dark:opacity-[0.03] z-0 pointer-events-none"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<TrendingDown className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-destructive" strokeWidth={1} /> <TrendingDown
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-destructive"
strokeWidth={1}
/>
</div> </div>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10"> <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0"> <CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
@@ -133,8 +140,11 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<Card className="stat-card-gradient-4 card-hover group relative overflow-hidden"> <Card className="stat-card-gradient-4 card-hover group relative overflow-hidden">
{/* Icône en arrière-plan */} {/* Icône en arrière-plan */}
<div className="absolute bottom-2 right-2 opacity-[0.04] dark:opacity-[0.03] z-0 pointer-events-none"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<CreditCard className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-chart-4" strokeWidth={1} /> <CreditCard
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-chart-4"
strokeWidth={1}
/>
</div> </div>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10"> <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0"> <CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
@@ -153,8 +163,11 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<Card className="stat-card-gradient-5 card-hover group relative overflow-hidden"> <Card className="stat-card-gradient-5 card-hover group relative overflow-hidden">
{/* Icône en arrière-plan */} {/* Icône en arrière-plan */}
<div className="absolute bottom-2 right-2 opacity-[0.04] dark:opacity-[0.03] z-0 pointer-events-none"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<Tag className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-chart-5" strokeWidth={1} /> <Tag
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-chart-5"
strokeWidth={1}
/>
</div> </div>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10"> <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0"> <CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">

View File

@@ -88,8 +88,8 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
className={cn( className={cn(
"font-black tabular-nums text-sm md:text-base shrink-0 md:hidden", "font-black tabular-nums text-sm md:text-base shrink-0 md:hidden",
transaction.amount >= 0 transaction.amount >= 0
? "text-emerald-600 dark:text-emerald-400" ? "text-success"
: "text-red-600 dark:text-red-400" : "text-destructive",
)} )}
> >
{transaction.amount >= 0 ? "+" : ""} {transaction.amount >= 0 ? "+" : ""}
@@ -130,8 +130,8 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
className={cn( className={cn(
"font-black tabular-nums text-base md:text-lg shrink-0 hidden md:block leading-tight", "font-black tabular-nums text-base md:text-lg shrink-0 hidden md:block leading-tight",
transaction.amount >= 0 transaction.amount >= 0
? "text-emerald-600 dark:text-emerald-400" ? "text-success"
: "text-red-600 dark:text-red-400" : "text-destructive",
)} )}
> >
{transaction.amount >= 0 ? "+" : ""} {transaction.amount >= 0 ? "+" : ""}

View File

@@ -77,25 +77,27 @@ function SidebarContent({
</div> </div>
)} )}
<nav className={cn("flex-1 space-y-2", collapsed ? "p-2" : "p-4")}> <nav className={cn("flex-1 pt-2", collapsed ? "p-2" : "p-4")}>
{navItems.map((item) => { {navItems.map((item) => {
const isActive = pathname === item.href; const isActive = pathname === item.href;
return ( return (
<Link key={item.href} href={item.href} onClick={handleLinkClick}> <Link
key={item.href}
href={item.href}
onClick={handleLinkClick}
className="block mb-2 first:mt-0"
>
<Button <Button
variant={isActive ? "secondary" : "ghost"} variant={isActive ? "secondary" : "ghost"}
className={cn( className={cn(
"w-full justify-start gap-4 h-12 rounded-2xl", "w-full justify-start gap-3 h-12 rounded-2xl px-3",
isActive && isActive &&
"bg-gradient-to-r from-primary/15 via-primary/10 to-primary/5 border-2 border-primary/30 shadow-lg shadow-primary/10 backdrop-blur-sm", "bg-gradient-to-r from-primary/15 via-primary/10 to-primary/5 border-2 border-primary/30 shadow-lg shadow-primary/10 backdrop-blur-sm",
collapsed && "justify-center px-2 w-12 mx-auto", collapsed && "justify-center px-2 w-12 mx-auto",
)} )}
> >
<item.icon <item.icon
className={cn( className={cn("w-5 h-5 shrink-0", isActive && "text-primary")}
"w-5 h-5 shrink-0",
isActive && "text-primary",
)}
/> />
{!collapsed && ( {!collapsed && (
<span <span
@@ -115,15 +117,15 @@ function SidebarContent({
<div <div
className={cn( className={cn(
"border-t border-border/30 space-y-2", "border-t border-border/30 pt-2",
collapsed ? "p-2" : "p-4", collapsed ? "p-2" : "p-4",
)} )}
> >
<Link href="/settings" onClick={handleLinkClick}> <Link href="/settings" onClick={handleLinkClick} className="block mb-2">
<Button <Button
variant="ghost" variant="ghost"
className={cn( className={cn(
"w-full justify-start gap-4 h-12 rounded-2xl", "w-full justify-start gap-3 h-12 rounded-2xl px-3",
collapsed && "justify-center px-2 w-12 mx-auto", collapsed && "justify-center px-2 w-12 mx-auto",
)} )}
> >
@@ -137,7 +139,7 @@ function SidebarContent({
variant="ghost" variant="ghost"
onClick={handleSignOut} onClick={handleSignOut}
className={cn( className={cn(
"w-full justify-start gap-4 h-12 rounded-2xl", "w-full justify-start gap-3 h-12 rounded-2xl px-3 mb-2",
"text-destructive", "text-destructive",
collapsed && "justify-center px-2 w-12 mx-auto", collapsed && "justify-center px-2 w-12 mx-auto",
)} )}
@@ -164,7 +166,11 @@ export function Sidebar({ open, onOpenChange }: SidebarProps) {
if (isMobile) { if (isMobile) {
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-64 p-0"> <SheetContent
side="left"
className="w-64 p-0 text-sidebar-foreground border-sidebar-border"
style={{ backgroundColor: 'var(--sidebar-opaque)' }}
>
<SheetTitle className="sr-only">Navigation</SheetTitle> <SheetTitle className="sr-only">Navigation</SheetTitle>
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<SidebarContent <SidebarContent
@@ -180,7 +186,7 @@ export function Sidebar({ open, onOpenChange }: SidebarProps) {
return ( return (
<aside <aside
className={cn( className={cn(
"hidden md:flex flex-col h-screen glass border-r border-border", "hidden md:flex flex-col h-screen bg-sidebar text-sidebar-foreground border-r border-sidebar-border",
"backdrop-blur-xl", "backdrop-blur-xl",
collapsed ? "w-16" : "w-64", collapsed ? "w-16" : "w-64",
)} )}
@@ -205,10 +211,7 @@ export function Sidebar({ open, onOpenChange }: SidebarProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
className={cn( className={cn("rounded-xl", collapsed ? "" : "ml-auto")}
"rounded-xl",
collapsed ? "" : "ml-auto",
)}
> >
{collapsed ? ( {collapsed ? (
<ChevronRight className="w-5 h-5" /> <ChevronRight className="w-5 h-5" />

View File

@@ -74,7 +74,7 @@ export function DraggableAccountItem({
<span <span
className={cn( className={cn(
"text-sm tabular-nums", "text-sm tabular-nums",
realBalance >= 0 ? "text-emerald-600" : "text-red-600", realBalance >= 0 ? "text-success" : "text-destructive",
)} )}
> >
{formatCurrency(realBalance)} {formatCurrency(realBalance)}

View File

@@ -120,7 +120,7 @@ export function DraggableFolderItem({
<span <span
className={cn( className={cn(
"text-sm font-semibold tabular-nums", "text-sm font-semibold tabular-nums",
folderTotal >= 0 ? "text-emerald-600" : "text-red-600", folderTotal >= 0 ? "text-success" : "text-destructive",
)} )}
> >
{formatCurrency(folderTotal)} {formatCurrency(folderTotal)}
@@ -145,7 +145,7 @@ export function DraggableFolderItem({
{folder.id !== "folder-root" && ( {folder.id !== "folder-root" && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onDelete(folder.id)} onClick={() => onDelete(folder.id)}
className="text-red-600" className="text-destructive"
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Supprimer Supprimer

View File

@@ -505,9 +505,9 @@ export function OFXImportDialog({
{importResults.map((result, i) => ( {importResults.map((result, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
{result.error ? ( {result.error ? (
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" /> <AlertCircle className="w-4 h-4 text-destructive flex-shrink-0" />
) : ( ) : (
<CheckCircle2 className="w-4 h-4 text-emerald-500 flex-shrink-0" /> <CheckCircle2 className="w-4 h-4 text-success flex-shrink-0" />
)} )}
<span className="truncate">{result.fileName}</span> <span className="truncate">{result.fileName}</span>
{!result.error && ( {!result.error && (
@@ -524,16 +524,16 @@ export function OFXImportDialog({
{step === "success" && ( {step === "success" && (
<div className="py-4"> <div className="py-4">
<CheckCircle2 className="w-16 h-16 mx-auto mb-4 text-emerald-600" /> <CheckCircle2 className="w-16 h-16 mx-auto mb-4 text-success" />
{importResults.length > 1 && ( {importResults.length > 1 && (
<div className="max-h-48 overflow-auto space-y-1 text-sm mb-4 border rounded-lg p-2"> <div className="max-h-48 overflow-auto space-y-1 text-sm mb-4 border rounded-lg p-2">
{importResults.map((result, i) => ( {importResults.map((result, i) => (
<div key={i} className="flex items-center gap-2 py-1"> <div key={i} className="flex items-center gap-2 py-1">
{result.error ? ( {result.error ? (
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" /> <AlertCircle className="w-4 h-4 text-destructive flex-shrink-0" />
) : ( ) : (
<CheckCircle2 className="w-4 h-4 text-emerald-500 flex-shrink-0" /> <CheckCircle2 className="w-4 h-4 text-success flex-shrink-0" />
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="truncate font-medium"> <p className="truncate font-medium">
@@ -544,7 +544,7 @@ export function OFXImportDialog({
</p> </p>
</div> </div>
{result.error ? ( {result.error ? (
<span className="text-xs text-red-500 flex-shrink-0"> <span className="text-xs text-destructive flex-shrink-0">
{result.error} {result.error}
</span> </span>
) : ( ) : (
@@ -559,7 +559,7 @@ export function OFXImportDialog({
)} )}
{errorCount > 0 && ( {errorCount > 0 && (
<p className="text-sm text-red-600 mb-4 text-center"> <p className="text-sm text-destructive mb-4 text-center">
{errorCount} fichier{errorCount > 1 ? "s" : ""} en erreur {errorCount} fichier{errorCount > 1 ? "s" : ""} en erreur
</p> </p>
)} )}
@@ -572,7 +572,7 @@ export function OFXImportDialog({
{step === "error" && ( {step === "error" && (
<div className="text-center py-4"> <div className="text-center py-4">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-red-600" /> <AlertCircle className="w-16 h-16 mx-auto mb-4 text-destructive" />
<Button onClick={() => setStep("upload")}>Réessayer</Button> <Button onClick={() => setStep("upload")}>Réessayer</Button>
</div> </div>
)} )}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { ReactNode } from "react"; import { ReactNode, useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Menu } from "lucide-react"; import { Menu } from "lucide-react";
import { useSidebarContext } from "@/components/layout/sidebar-context"; import { useSidebarContext } from "@/components/layout/sidebar-context";
@@ -21,6 +21,30 @@ export function PageHeader({
}: PageHeaderProps) { }: PageHeaderProps) {
const { setOpen } = useSidebarContext(); const { setOpen } = useSidebarContext();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [textColor, setTextColor] = useState("var(--foreground)");
useEffect(() => {
const checkDarkBackground = () => {
const pageBackground = document.querySelector(".page-background");
if (pageBackground?.classList.contains("bg-solid-dark")) {
setTextColor("#f5f5f5");
} else {
setTextColor("var(--foreground)");
}
};
checkDarkBackground();
const observer = new MutationObserver(checkDarkBackground);
const pageBackground = document.querySelector(".page-background");
if (pageBackground) {
observer.observe(pageBackground, {
attributes: true,
attributeFilter: ["class"],
});
}
return () => observer.disconnect();
}, []);
return ( return (
<div className="flex flex-col gap-4 mb-2"> <div className="flex flex-col gap-4 mb-2">
@@ -37,7 +61,13 @@ export function PageHeader({
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2"> <div className="flex items-start justify-between gap-2 mb-2">
<h1 className="text-2xl md:text-4xl lg:text-5xl font-black text-foreground tracking-tight leading-tight flex-1 min-w-0"> <h1
className="text-2xl md:text-4xl lg:text-5xl font-black tracking-tight leading-tight flex-1 min-w-0"
style={{
color: textColor,
WebkitTextFillColor: textColor,
}}
>
{title} {title}
</h1> </h1>
{rightContent && <div className="shrink-0">{rightContent}</div>} {rightContent && <div className="shrink-0">{rightContent}</div>}

View File

@@ -12,7 +12,7 @@ export function BackgroundProvider() {
const applyBackground = () => { const applyBackground = () => {
try { try {
const pageBackground = document.querySelector( const pageBackground = document.querySelector(
".page-background" ".page-background",
) as HTMLElement; ) as HTMLElement;
if (!pageBackground) return; if (!pageBackground) return;
@@ -30,7 +30,7 @@ export function BackgroundProvider() {
"bg-gradient-orange", "bg-gradient-orange",
"bg-solid-light", "bg-solid-light",
"bg-solid-dark", "bg-solid-dark",
"bg-custom-image" "bg-custom-image",
); );
const root = document.documentElement; const root = document.documentElement;
@@ -39,7 +39,7 @@ export function BackgroundProvider() {
pageBackground.classList.add("bg-custom-image"); pageBackground.classList.add("bg-custom-image");
root.style.setProperty( root.style.setProperty(
"--custom-background-image", "--custom-background-image",
`url(${settings.customImageUrl})` `url(${settings.customImageUrl})`,
); );
} else { } else {
pageBackground.classList.add(`bg-${settings.type || "default"}`); pageBackground.classList.add(`bg-${settings.type || "default"}`);

View File

@@ -133,7 +133,7 @@ export function RuleCreateDialog({
catégorisées. catégorisées.
</p> </p>
{existingCategory && ( {existingCategory && (
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400"> <div className="flex items-center gap-2 text-xs text-warning">
<AlertCircle className="h-3 w-3" /> <AlertCircle className="h-3 w-3" />
<span> <span>
Ce mot-clé existe déjà dans &quot;{existingCategory.name} Ce mot-clé existe déjà dans &quot;{existingCategory.name}

View File

@@ -35,42 +35,56 @@ const DEFAULT_BACKGROUNDS: Array<{
value: BackgroundType; value: BackgroundType;
label: string; label: string;
preview: string; preview: string;
description?: string;
}> = [ }> = [
{ {
value: "default", value: "default",
label: "Par défaut", label: "Neutre",
preview: preview:
"linear-gradient(135deg, oklch(0.98 0.01 280) 0%, oklch(0.97 0.012 270) 50%, oklch(0.98 0.01 290) 100%)", "linear-gradient(135deg, oklch(0.985 0 0) 0%, oklch(0.97 0.005 260) 50%, oklch(0.985 0 0) 100%)",
description: "Fond neutre et élégant",
}, },
{ {
value: "gradient-blue", value: "gradient-blue",
label: "Dégradé bleu", label: "Océan",
preview: "linear-gradient(135deg, #e0f2fe 0%, #bae6fd 50%, #7dd3fc 100%)", preview:
"linear-gradient(135deg, oklch(0.95 0.03 230) 0%, oklch(0.88 0.08 225) 50%, oklch(0.78 0.12 220) 100%)",
description: "Dégradé bleu apaisant",
}, },
{ {
value: "gradient-purple", value: "gradient-purple",
label: "Dégradé violet", label: "Améthyste",
preview: "linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 50%, #d8b4fe 100%)", preview:
"linear-gradient(135deg, oklch(0.95 0.04 300) 0%, oklch(0.88 0.1 295) 50%, oklch(0.78 0.15 290) 100%)",
description: "Dégradé violet sophistiqué",
}, },
{ {
value: "gradient-green", value: "gradient-green",
label: "Dégradé vert", label: "Forêt",
preview: "linear-gradient(135deg, #dcfce7 0%, #bbf7d0 50%, #86efac 100%)", preview:
"linear-gradient(135deg, oklch(0.95 0.04 160) 0%, oklch(0.88 0.1 155) 50%, oklch(0.78 0.14 150) 100%)",
description: "Dégradé vert naturel",
}, },
{ {
value: "gradient-orange", value: "gradient-orange",
label: "Dégradé orange", label: "Aurore",
preview: "linear-gradient(135deg, #fff7ed 0%, #ffedd5 50%, #fed7aa 100%)", preview:
"linear-gradient(135deg, oklch(0.97 0.03 80) 0%, oklch(0.92 0.08 60) 50%, oklch(0.85 0.14 45) 100%)",
description: "Dégradé orange chaleureux",
}, },
{ {
value: "solid-light", value: "solid-light",
label: "Solide clair", label: "Lumineux",
preview: "#ffffff", preview:
"linear-gradient(135deg, oklch(1 0 0) 0%, oklch(0.98 0.005 260) 100%)",
description: "Fond blanc épuré",
}, },
{ {
value: "solid-dark", value: "solid-dark",
label: "Solide sombre", label: "Minuit",
preview: "#1e293b", preview:
"linear-gradient(135deg, oklch(0.18 0.02 260) 0%, oklch(0.08 0.015 250) 100%)",
description: "Fond sombre immersif",
}, },
]; ];
@@ -82,14 +96,14 @@ export function BackgroundCard() {
const currentSettings = useMemo<BackgroundSettings>( const currentSettings = useMemo<BackgroundSettings>(
() => backgroundSettings || { type: "default" }, () => backgroundSettings || { type: "default" },
[backgroundSettings] [backgroundSettings],
); );
const [customImageUrl, setCustomImageUrl] = useState( const [customImageUrl, setCustomImageUrl] = useState(
currentSettings.customImageUrl || "" currentSettings.customImageUrl || "",
); );
const [showCustomInput, setShowCustomInput] = useState( const [showCustomInput, setShowCustomInput] = useState(
currentSettings.type === "custom-image" currentSettings.type === "custom-image",
); );
// Synchroniser customImageUrl avec les settings // Synchroniser customImageUrl avec les settings
@@ -105,7 +119,7 @@ export function BackgroundCard() {
const applyBackground = (settings: BackgroundSettings) => { const applyBackground = (settings: BackgroundSettings) => {
const root = document.documentElement; const root = document.documentElement;
const pageBackground = document.querySelector( const pageBackground = document.querySelector(
".page-background" ".page-background",
) as HTMLElement; ) as HTMLElement;
if (!pageBackground) return; if (!pageBackground) return;
@@ -119,14 +133,14 @@ export function BackgroundCard() {
"bg-gradient-orange", "bg-gradient-orange",
"bg-solid-light", "bg-solid-light",
"bg-solid-dark", "bg-solid-dark",
"bg-custom-image" "bg-custom-image",
); );
if (settings.type === "custom-image" && settings.customImageUrl) { if (settings.type === "custom-image" && settings.customImageUrl) {
pageBackground.classList.add("bg-custom-image"); pageBackground.classList.add("bg-custom-image");
root.style.setProperty( root.style.setProperty(
"--custom-background-image", "--custom-background-image",
`url(${settings.customImageUrl})` `url(${settings.customImageUrl})`,
); );
} else { } else {
pageBackground.classList.add(`bg-${settings.type || "default"}`); pageBackground.classList.add(`bg-${settings.type || "default"}`);
@@ -135,7 +149,7 @@ export function BackgroundCard() {
// Déclencher un événement personnalisé pour notifier les autres composants // Déclencher un événement personnalisé pour notifier les autres composants
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("background-changed", { detail: settings }) new CustomEvent("background-changed", { detail: settings }),
); );
}; };
@@ -215,7 +229,7 @@ export function BackgroundCard() {
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all", "relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
currentSettings.type === bg.value currentSettings.type === bg.value
? "border-primary bg-primary/5" ? "border-primary bg-primary/5"
: "border-border hover:border-primary/50" : "border-border hover:border-primary/50",
)} )}
> >
<RadioGroupItem <RadioGroupItem
@@ -238,7 +252,7 @@ export function BackgroundCard() {
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all", "relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
currentSettings.type === "custom-image" currentSettings.type === "custom-image"
? "border-primary bg-primary/5" ? "border-primary bg-primary/5"
: "border-border hover:border-primary/50" : "border-border hover:border-primary/50",
)} )}
> >
<RadioGroupItem <RadioGroupItem

View File

@@ -59,9 +59,9 @@ export function DangerZoneCard({
} }
}; };
return ( return (
<Card className="border-red-200"> <Card className="border-destructive/30 card-hover">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600"> <CardTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="w-5 h-5" /> <Trash2 className="w-5 h-5" />
Zone dangereuse Zone dangereuse
</CardTitle> </CardTitle>

View File

@@ -5,3 +5,4 @@ export { BackupCard } from "./backup-card";
export { PasswordCard } from "./password-card"; export { PasswordCard } from "./password-card";
export { ReconcileDateRangeCard } from "./reconcile-date-range-card"; export { ReconcileDateRangeCard } from "./reconcile-date-range-card";
export { BackgroundCard } from "./background-card"; export { BackgroundCard } from "./background-card";
export { ThemeCard } from "./theme-card";

View File

@@ -45,11 +45,12 @@ export function ReconcileDateRangeCard() {
setIsReconciling(true); setIsReconciling(true);
try { try {
const endDateStr = format(endDate, "yyyy-MM-dd"); const endDateStr = format(endDate, "yyyy-MM-dd");
const body: { endDate: string; startDate?: string; reconciled: boolean } = { const body: { endDate: string; startDate?: string; reconciled: boolean } =
endDate: endDateStr, {
reconciled: true, endDate: endDateStr,
}; reconciled: true,
};
if (startDate) { if (startDate) {
body.startDate = format(startDate, "yyyy-MM-dd"); body.startDate = format(startDate, "yyyy-MM-dd");
} }
@@ -69,7 +70,7 @@ export function ReconcileDateRangeCard() {
} }
const result = await response.json(); const result = await response.json();
// Invalider toutes les requêtes de transactions pour rafraîchir les données // Invalider toutes les requêtes de transactions pour rafraîchir les données
invalidateAllTransactionQueries(queryClient); invalidateAllTransactionQueries(queryClient);
@@ -118,7 +119,7 @@ export function ReconcileDateRangeCard() {
<> <>
{startDate ? ( {startDate ? (
<> <>
{format(startDate, "PPP", { locale: fr})} -{" "} {format(startDate, "PPP", { locale: fr })} -{" "}
{format(endDate, "PPP", { locale: fr })} {format(endDate, "PPP", { locale: fr })}
</> </>
) : ( ) : (
@@ -134,7 +135,10 @@ export function ReconcileDateRangeCard() {
<div className="p-3 flex gap-4"> <div className="p-3 flex gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium"> <label className="text-sm font-medium">
Date de début <span className="text-xs text-muted-foreground">(optionnel)</span> Date de début{" "}
<span className="text-xs text-muted-foreground">
(optionnel)
</span>
</label> </label>
<div className="scale-90 origin-top-left"> <div className="scale-90 origin-top-left">
<CalendarComponent <CalendarComponent
@@ -233,8 +237,8 @@ export function ReconcileDateRangeCard() {
) : ( ) : (
<>jusqu'au {format(endDate, "PPP", { locale: fr })}</> <>jusqu'au {format(endDate, "PPP", { locale: fr })}</>
)}{" "} )}{" "}
comme pointées. Seules les opérations non encore pointées seront comme pointées. Seules les opérations non encore pointées
modifiées. seront modifiées.
</> </>
)} )}
</AlertDialogDescription> </AlertDialogDescription>
@@ -257,4 +261,3 @@ export function ReconcileDateRangeCard() {
</Card> </Card>
); );
} }

View File

@@ -0,0 +1,93 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Moon, Sun } from "lucide-react";
import { cn } from "@/lib/utils";
const THEMES = [
{
value: "light",
label: "Clair",
icon: Sun,
description: "Thème clair",
},
{
value: "dark",
label: "Sombre",
icon: Moon,
description: "Thème sombre",
},
] as const;
export function ThemeCard() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
// Éviter le flash de contenu non stylé
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
const currentTheme = theme || "light";
return (
<Card className="card-hover">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Moon className="w-5 h-5" />
Thème
</CardTitle>
<CardDescription>
Choisissez le thème d'affichage de l'application
</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup
value={currentTheme}
onValueChange={(value) => setTheme(value)}
>
<div className="grid grid-cols-2 gap-3">
{THEMES.map((themeOption) => {
const Icon = themeOption.icon;
return (
<label
key={themeOption.value}
htmlFor={`theme-${themeOption.value}`}
className={cn(
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
currentTheme === themeOption.value
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50",
)}
>
<RadioGroupItem
value={themeOption.value}
id={`theme-${themeOption.value}`}
className="sr-only"
/>
<Icon className="w-6 h-6 mb-2" />
<span className="text-sm font-medium">
{themeOption.label}
</span>
</label>
);
})}
</div>
</RadioGroup>
</CardContent>
</Card>
);
}

View File

@@ -103,11 +103,38 @@ export function BalanceLineChart({
tick={{ fill: "var(--muted-foreground)" }} tick={{ fill: "var(--muted-foreground)" }}
/> />
<Tooltip <Tooltip
formatter={(value: number) => formatCurrency(value)} content={({ active, payload }) => {
contentStyle={{ if (!active || !payload?.length) return null;
backgroundColor: "var(--card)", return (
border: "1px solid var(--border)", <div
borderRadius: "8px", className="px-3 py-2 rounded-lg shadow-lg"
style={{
backgroundColor: "var(--popover)",
border: "1px solid var(--border)",
opacity: 1,
backdropFilter: "blur(8px)",
}}
>
{payload.map((entry, index) => (
<div
key={`tooltip-${index}`}
className="flex items-center gap-2"
style={{ color: entry.color }}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm font-medium">
{entry.name}:
</span>
<span className="text-sm font-semibold">
{formatCurrency(entry.value as number)}
</span>
</div>
))}
</div>
);
}} }}
/> />
{mode === "aggregated" ? ( {mode === "aggregated" ? (

View File

@@ -31,10 +31,18 @@ export function CategoryBarChart({
const displayData = data.slice(0, maxItems).reverse(); // Reverse pour avoir le plus grand en haut const displayData = data.slice(0, maxItems).reverse(); // Reverse pour avoir le plus grand en haut
// Custom tick component for clickable labels // Custom tick component for clickable labels
const CustomYAxisTick = ({ x, y, payload }: any) => { const CustomYAxisTick = ({
x,
y,
payload,
}: {
x: number;
y: number;
payload: { value: string };
}) => {
const categoryName = payload.value; const categoryName = payload.value;
const item = displayData.find((d) => d.name === categoryName); const item = displayData.find((d) => d.name === categoryName);
if (!item) { if (!item) {
return ( return (
<text <text

View File

@@ -166,11 +166,67 @@ export function CategoryTrendChart({
tick={{ fill: "var(--muted-foreground)" }} tick={{ fill: "var(--muted-foreground)" }}
/> />
<Tooltip <Tooltip
formatter={(value: number) => formatCurrency(value)} content={({ active, payload, label }) => {
contentStyle={{ if (!active || !payload?.length) return null;
backgroundColor: "var(--card)",
border: "1px solid var(--border)", // Filtrer seulement les catégories qui ont une valeur
borderRadius: "8px", const entriesWithValue = payload.filter(
(entry) => entry.value !== undefined && entry.value !== null && Number(entry.value) !== 0
);
if (entriesWithValue.length === 0) return null;
return (
<div
className="px-2.5 py-2 rounded-lg shadow-lg"
style={{
backgroundColor: "var(--background)",
border: "1px solid var(--border)",
opacity: 1,
}}
>
<div className="text-xs font-medium text-foreground mb-1.5 pb-1 border-b border-border/50">
{label}
</div>
<div className="space-y-1">
{entriesWithValue.map((entry, index) => {
const categoryId = entry.dataKey as string;
const categoryInfo = getCategoryInfo(categoryId);
const categoryName = getCategoryName(categoryId);
const value = entry.value as number;
return (
<div
key={`tooltip-${categoryId}-${index}`}
className="flex items-center gap-1.5"
>
{categoryInfo ? (
<CategoryIcon
icon={categoryInfo.icon}
color={categoryInfo.color}
size={12}
/>
) : (
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: "#94a3b8" }}
/>
)}
<span className="text-xs text-foreground flex-1 min-w-0 truncate">
{categoryName}
</span>
<span className="text-xs font-semibold text-foreground tabular-nums whitespace-nowrap">
{formatCurrency(value)}
</span>
</div>
);
})}
</div>
</div>
);
}}
wrapperStyle={{
opacity: 1,
}} }}
/> />
<Legend <Legend
@@ -179,8 +235,9 @@ export function CategoryTrendChart({
const allCategoryIds = Array.from(categoryTotals.keys()); const allCategoryIds = Array.from(categoryTotals.keys());
return ( return (
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 mt-2"> <div className="max-h-[60px] overflow-y-auto overflow-x-hidden pr-2 mt-2">
{allCategoryIds.map((categoryId) => { <div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
{allCategoryIds.map((categoryId) => {
const categoryInfo = getCategoryInfo(categoryId); const categoryInfo = getCategoryInfo(categoryId);
const categoryName = getCategoryName(categoryId); const categoryName = getCategoryName(categoryId);
if (!categoryInfo && categoryId !== "uncategorized") if (!categoryInfo && categoryId !== "uncategorized")
@@ -233,6 +290,7 @@ export function CategoryTrendChart({
</button> </button>
); );
})} })}
</div>
</div> </div>
); );
}} }}
@@ -259,6 +317,7 @@ export function CategoryTrendChart({
strokeWidth={isSelected ? 2 : 1} strokeWidth={isSelected ? 2 : 1}
strokeOpacity={isSelected ? 1 : 0.3} strokeOpacity={isSelected ? 1 : 0.3}
dot={false} dot={false}
connectNulls={true}
hide={!isSelected && selectedCategories.length > 0} hide={!isSelected && selectedCategories.length > 0}
/> />
); );

View File

@@ -54,7 +54,7 @@ export function MonthlyChart({
// Formater les labels de manière plus compacte // Formater les labels de manière plus compacte
const formatMonthLabel = (month: string) => { const formatMonthLabel = (month: string) => {
// Format: "janv. 24" -> "janv 24" (enlever le point) // Format: "janv. 24" -> "janv 24" (enlever le point)
return month.replace('.', ''); return month.replace(".", "");
}; };
const chartContent = ( const chartContent = (
@@ -62,10 +62,18 @@ export function MonthlyChart({
{data.length > 0 ? ( {data.length > 0 ? (
<div className="h-[400px] sm:h-[300px]"> <div className="h-[400px] sm:h-[300px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ left: 0, right: 10, top: 10, bottom: data.length > 6 ? 80 : 60 }}> <LineChart
data={data}
margin={{
left: 0,
right: 10,
top: 10,
bottom: data.length > 6 ? 80 : 60,
}}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis <XAxis
dataKey="month" dataKey="month"
className="text-xs" className="text-xs"
angle={data.length > 6 ? -45 : 0} angle={data.length > 6 ? -45 : 0}
textAnchor={data.length > 6 ? "end" : "middle"} textAnchor={data.length > 6 ? "end" : "middle"}

View File

@@ -36,13 +36,13 @@ export function SavingsTrendChart({
<CardTitle>Évolution des économies</CardTitle> <CardTitle>Évolution des économies</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isPositive ? ( {isPositive ? (
<TrendingUp className="w-4 h-4 text-emerald-600" /> <TrendingUp className="w-4 h-4 text-success" />
) : ( ) : (
<TrendingDown className="w-4 h-4 text-red-600" /> <TrendingDown className="w-4 h-4 text-destructive" />
)} )}
<span <span
className={`text-sm font-semibold ${ className={`text-sm font-semibold ${
isPositive ? "text-emerald-600" : "text-red-600" isPositive ? "text-success" : "text-destructive"
}`} }`}
> >
{formatCurrency(latestSavings)} {formatCurrency(latestSavings)}

View File

@@ -23,8 +23,11 @@ export function StatsSummaryCards({
<div className="grid gap-3 md:gap-4 grid-cols-2 md:grid-cols-4"> <div className="grid gap-3 md:gap-4 grid-cols-2 md:grid-cols-4">
<Card className="stat-card-textured relative overflow-hidden"> <Card className="stat-card-textured relative overflow-hidden">
{/* Icône en arrière-plan */} {/* Icône en arrière-plan */}
<div className="absolute bottom-2 right-2 opacity-[0.04] dark:opacity-[0.03] z-0 pointer-events-none"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<TrendingUp className="h-16 w-16 md:h-20 md:w-20 text-emerald-600 dark:text-emerald-400" strokeWidth={1} /> <TrendingUp
className="h-16 w-16 md:h-20 md:w-20 text-success"
strokeWidth={1}
/>
</div> </div>
<CardHeader className="pb-3 px-5 pt-5 relative z-10"> <CardHeader className="pb-3 px-5 pt-5 relative z-10">
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider"> <CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
@@ -32,7 +35,7 @@ export function StatsSummaryCards({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="px-5 pb-5 pt-0 relative z-10"> <CardContent className="px-5 pb-5 pt-0 relative z-10">
<div className="text-xl md:text-2xl font-black text-emerald-600 dark:text-emerald-400 tracking-tight"> <div className="text-xl md:text-2xl font-black text-success tracking-tight">
{formatCurrency(totalIncome)} {formatCurrency(totalIncome)}
</div> </div>
</CardContent> </CardContent>
@@ -40,8 +43,11 @@ export function StatsSummaryCards({
<Card className="stat-card-textured relative overflow-hidden"> <Card className="stat-card-textured relative overflow-hidden">
{/* Icône en arrière-plan */} {/* Icône en arrière-plan */}
<div className="absolute bottom-2 right-2 opacity-[0.04] dark:opacity-[0.03] z-0 pointer-events-none"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<TrendingDown className="h-16 w-16 md:h-20 md:w-20 text-red-600 dark:text-red-400" strokeWidth={1} /> <TrendingDown
className="h-16 w-16 md:h-20 md:w-20 text-destructive"
strokeWidth={1}
/>
</div> </div>
<CardHeader className="pb-3 px-5 pt-5 relative z-10"> <CardHeader className="pb-3 px-5 pt-5 relative z-10">
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider"> <CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
@@ -49,7 +55,7 @@ export function StatsSummaryCards({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="px-5 pb-5 pt-0 relative z-10"> <CardContent className="px-5 pb-5 pt-0 relative z-10">
<div className="text-xl md:text-2xl font-black text-red-600 dark:text-red-400 tracking-tight"> <div className="text-xl md:text-2xl font-black text-destructive tracking-tight">
{formatCurrency(totalExpenses)} {formatCurrency(totalExpenses)}
</div> </div>
</CardContent> </CardContent>
@@ -57,8 +63,11 @@ export function StatsSummaryCards({
<Card className="stat-card-textured relative overflow-hidden"> <Card className="stat-card-textured relative overflow-hidden">
{/* Icône en arrière-plan */} {/* Icône en arrière-plan */}
<div className="absolute bottom-2 right-2 opacity-[0.04] dark:opacity-[0.03] z-0 pointer-events-none"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<ArrowRight className="h-16 w-16 md:h-20 md:w-20 text-muted-foreground/40" strokeWidth={1} /> <ArrowRight
className="h-16 w-16 md:h-20 md:w-20 text-muted-foreground/40"
strokeWidth={1}
/>
</div> </div>
<CardHeader className="pb-3 px-5 pt-5 relative z-10"> <CardHeader className="pb-3 px-5 pt-5 relative z-10">
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider"> <CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
@@ -74,13 +83,13 @@ export function StatsSummaryCards({
<Card className="stat-card-textured relative overflow-hidden"> <Card className="stat-card-textured relative overflow-hidden">
{/* Icône en arrière-plan */} {/* Icône en arrière-plan */}
<div className="absolute bottom-2 right-2 opacity-[0.04] dark:opacity-[0.03] z-0 pointer-events-none"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<div className={cn( <div
"h-16 w-16 md:h-20 md:w-20 rounded-full border-2", className={cn(
savings >= 0 "h-16 w-16 md:h-20 md:w-20 rounded-full border-2",
? "border-emerald-600 dark:border-emerald-400" savings >= 0 ? "border-success" : "border-destructive",
: "border-red-600 dark:border-red-400" )}
)} /> />
</div> </div>
<CardHeader className="pb-3 px-5 pt-5 relative z-10"> <CardHeader className="pb-3 px-5 pt-5 relative z-10">
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider"> <CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
@@ -91,7 +100,7 @@ export function StatsSummaryCards({
<div <div
className={cn( className={cn(
"text-xl md:text-2xl font-black tracking-tight", "text-xl md:text-2xl font-black tracking-tight",
savings >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400", savings >= 0 ? "text-success" : "text-destructive",
)} )}
> >
{formatCurrency(savings)} {formatCurrency(savings)}

View File

@@ -1,89 +1,468 @@
"use client"; "use client";
import { useState, useMemo, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
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 { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { List, ListOrdered } from "lucide-react";
import type { Transaction, Category } from "@/lib/types"; import type { Transaction, Category } from "@/lib/types";
interface TopExpensesListProps { interface TopExpensesListProps {
expenses: Transaction[]; expensesByCategory: Array<{
categoryId: string | null;
expenses: Transaction[];
}>;
categories: Category[]; categories: Category[];
formatCurrency: (amount: number) => string; formatCurrency: (amount: number) => string;
allTransactions?: Transaction[]; // Toutes les transactions filtrées pour calculer toutes les dépenses
} }
export function TopExpensesList({ export function TopExpensesList({
expenses, expensesByCategory,
categories, categories,
formatCurrency, formatCurrency,
allTransactions = [],
}: TopExpensesListProps) { }: TopExpensesListProps) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [showAllExpenses, setShowAllExpenses] = useState(false);
// Filtrer les catégories qui ont des dépenses
const categoriesWithExpenses = expensesByCategory.filter(
(group) => group.expenses.length > 0,
);
const hasExpenses = categoriesWithExpenses.length > 0;
// Déterminer la valeur par défaut du premier onglet
const defaultTabValue =
categoriesWithExpenses.length > 0
? categoriesWithExpenses[0].categoryId || "uncategorized"
: "";
const [activeTab, setActiveTab] = useState<string>(() => defaultTabValue);
// Mettre à jour activeTab quand defaultTabValue change ou si activeTab est invalide
useEffect(() => {
if (!defaultTabValue) return;
// Vérifier si activeTab correspond à une catégorie valide
const isValidTab = categoriesWithExpenses.some(
(group) => (group.categoryId || "uncategorized") === activeTab,
);
// Si activeTab est vide ou invalide, utiliser defaultTabValue
if (!activeTab || !isValidTab) {
setActiveTab(defaultTabValue);
}
}, [defaultTabValue, categoriesWithExpenses, activeTab]);
// Calculer les données du graphique pour la catégorie active
const chartData = useMemo(() => {
if (!hasExpenses) return [];
// Utiliser activeTab ou defaultTabValue comme fallback
const currentTab = activeTab || defaultTabValue;
if (!currentTab) return [];
const activeCategoryGroup = categoriesWithExpenses.find(
(group) => (group.categoryId || "uncategorized") === currentTab,
);
if (!activeCategoryGroup) {
return [];
}
// Si showAllExpenses est activé et qu'on a toutes les transactions, utiliser toutes les dépenses de la catégorie
let expenses: Transaction[];
if (showAllExpenses && allTransactions.length > 0) {
// Filtrer toutes les transactions pour obtenir toutes les dépenses de cette catégorie parente
const categoryId = activeCategoryGroup.categoryId;
expenses = allTransactions
.filter((t) => t.amount < 0) // Seulement les dépenses
.filter((t) => {
if (categoryId === null) {
return !t.categoryId;
}
// Vérifier si la transaction appartient à cette catégorie parente ou ses sous-catégories
const category = categories.find((c) => c.id === t.categoryId);
if (!category) {
return false;
}
const transactionGroupId = category.parentId || category.id;
return transactionGroupId === categoryId;
});
} else {
// Utiliser seulement les top 10
expenses = activeCategoryGroup.expenses;
}
if (expenses.length === 0) {
return [];
}
// Grouper les dépenses par période
// Si moins de 30 jours, groupe par jour
// Si moins de 6 mois, groupe par semaine
// Sinon groupe par mois
const sortedExpenses = [...expenses].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
);
if (sortedExpenses.length === 0) return [];
const firstDate = new Date(sortedExpenses[0].date);
const lastDate = new Date(
sortedExpenses[sortedExpenses.length - 1].date,
);
const daysDiff =
(lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24);
let groupBy: "day" | "week" | "month";
if (daysDiff <= 30) {
groupBy = "day";
} else if (daysDiff <= 180) {
groupBy = "week";
} else {
groupBy = "month";
}
// Fonction helper pour obtenir la clé de période d'une date
const getPeriodKey = (date: Date): { key: string; dateKey: string } => {
if (groupBy === "day") {
return {
key: date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
}),
dateKey: date.toISOString().substring(0, 10), // YYYY-MM-DD
};
} else if (groupBy === "week") {
// Semaine commençant le lundi
const weekStart = new Date(date);
const day = weekStart.getDay();
const diff = weekStart.getDate() - day + (day === 0 ? -6 : 1);
weekStart.setDate(diff);
return {
key: `Sem. ${weekStart.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
})}`,
dateKey: weekStart.toISOString().substring(0, 10), // YYYY-MM-DD
};
} else {
return {
key: date.toLocaleDateString("fr-FR", {
month: "short",
year: "numeric",
}),
dateKey: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`, // YYYY-MM
};
}
};
// Grouper les dépenses par période
const groupedData = new Map<
string,
{ total: number; dateKey: string }
>();
sortedExpenses.forEach((expense) => {
const date = new Date(expense.date);
const { key, dateKey } = getPeriodKey(date);
const current = groupedData.get(key);
if (current) {
current.total += Math.abs(expense.amount);
} else {
groupedData.set(key, {
total: Math.abs(expense.amount),
dateKey,
});
}
});
// Générer toutes les périodes entre la première et la dernière date
const allPeriodsMap = new Map<
string,
{ period: string; montant: number; dateKey: string }
>();
const currentDate = new Date(firstDate);
const endDate = new Date(lastDate);
// Normaliser les dates selon le type de groupement
if (groupBy === "day") {
currentDate.setHours(0, 0, 0, 0);
endDate.setHours(23, 59, 59, 999);
} else if (groupBy === "week") {
// Début de la semaine de la première date
const day = currentDate.getDay();
const diff = currentDate.getDate() - day + (day === 0 ? -6 : 1);
currentDate.setDate(diff);
currentDate.setHours(0, 0, 0, 0);
// Début de la semaine de la dernière date (pour la boucle)
const lastDay = endDate.getDay();
const lastDiff = endDate.getDate() - lastDay + (lastDay === 0 ? -6 : 1);
endDate.setDate(lastDiff);
endDate.setHours(0, 0, 0, 0);
} else {
// Mois : premier jour du mois
currentDate.setDate(1);
currentDate.setHours(0, 0, 0, 0);
endDate.setDate(1);
endDate.setHours(0, 0, 0, 0);
}
while (currentDate <= endDate) {
const { key, dateKey } = getPeriodKey(currentDate);
const existingData = groupedData.get(key);
// Utiliser dateKey comme clé unique pour éviter les doublons
if (!allPeriodsMap.has(dateKey)) {
allPeriodsMap.set(dateKey, {
period: key,
montant: existingData ? Math.round(existingData.total) : 0,
dateKey,
});
}
// Passer à la période suivante
if (groupBy === "day") {
currentDate.setDate(currentDate.getDate() + 1);
} else if (groupBy === "week") {
currentDate.setDate(currentDate.getDate() + 7);
} else {
// Mois suivant
currentDate.setMonth(currentDate.getMonth() + 1);
}
}
return Array.from(allPeriodsMap.values()) .sort((a, b) =>
a.dateKey.localeCompare(b.dateKey),
);
}, [
hasExpenses,
categoriesWithExpenses,
activeTab,
defaultTabValue,
showAllExpenses,
allTransactions,
categories,
]);
return ( return (
<Card className="card-hover"> <Card className="card-hover">
<CardHeader> <CardHeader>
<CardTitle className="text-sm md:text-base">Top 5 dépenses</CardTitle> <CardTitle className="text-sm md:text-base">
Top 10 dépenses par top 5 catégories parentes
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{expenses.length > 0 ? ( {hasExpenses ? (
<div className="space-y-3 md:space-y-4"> <Tabs
{expenses.map((expense, index) => { defaultValue={defaultTabValue}
const category = categories.find( value={activeTab || defaultTabValue}
(c) => c.id === expense.categoryId, onValueChange={setActiveTab}
); className="w-full"
>
<TabsList className="w-full flex-wrap h-auto p-1 mb-4">
{categoriesWithExpenses.map(({ categoryId, expenses }) => {
const category = categoryId
? categories.find((c) => c.id === categoryId)
: null;
const tabValue = categoryId || "uncategorized";
return (
<TabsTrigger
key={tabValue}
value={tabValue}
className="flex items-center gap-1.5 md:gap-2 text-xs md:text-sm"
>
{category && (
<CategoryIcon
icon={category.icon}
color={category.color}
size={isMobile ? 12 : 14}
/>
)}
<span className="truncate max-w-[100px] md:max-w-none">
{category?.name || "Non catégorisé"}
</span>
</TabsTrigger>
);
})}
</TabsList>
{categoriesWithExpenses.map(({ categoryId, expenses }) => {
const category = categoryId
? categories.find((c) => c.id === categoryId)
: null;
const tabValue = categoryId || "uncategorized";
return ( return (
<div <TabsContent key={tabValue} value={tabValue}>
key={expense.id} <div className="space-y-2 md:space-y-3">
className="flex items-start gap-2 md:gap-3" {expenses.map((expense, index) => (
> <div
<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"> key={expense.id}
{index + 1} className="flex items-start gap-2 md:gap-3"
</div> >
<div className="flex-1 min-w-0"> <div className="w-5 h-5 md:w-6 md:h-6 rounded-full bg-muted flex items-center justify-center text-[10px] md:text-xs font-semibold shrink-0">
<div className="flex items-start justify-between gap-2 mb-1"> {index + 1}
<p className="font-medium text-xs md:text-sm truncate flex-1"> </div>
{expense.description} <div className="flex-1 min-w-0">
</p> <div className="flex items-start justify-between gap-2 mb-1">
<div className="text-red-600 font-semibold tabular-nums text-xs md:text-sm shrink-0"> <p className="font-medium text-xs md:text-sm truncate flex-1">
{formatCurrency(expense.amount)} {expense.description}
</div> </p>
</div> <div className="text-destructive font-semibold tabular-nums text-xs md:text-sm shrink-0">
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap"> {formatCurrency(expense.amount)}
<span className="text-[10px] md:text-xs text-muted-foreground"> </div>
{new Date(expense.date).toLocaleDateString("fr-FR")} </div>
</span> <div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
{category && ( <span className="text-[10px] md:text-xs text-muted-foreground">
<Link {new Date(expense.date).toLocaleDateString(
href={`/transactions?categoryIds=${category.id}`} "fr-FR",
className="inline-block" )}
>
<Badge
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 hover:opacity-80 transition-opacity cursor-pointer"
style={{
backgroundColor: `${category.color}20`,
color: category.color,
borderColor: `${category.color}30`,
}}
>
<CategoryIcon
icon={category.icon}
color={category.color}
size={isMobile ? 8 : 10}
/>
<span className="truncate max-w-[120px] md:max-w-none">
{category.name}
</span> </span>
</Badge> {expense.categoryId &&
</Link> (() => {
)} const expenseCategory = categories.find(
</div> (c) => c.id === expense.categoryId,
);
// Afficher seulement si c'est une sous-catégorie (a un parentId)
if (expenseCategory?.parentId) {
return (
<Link
href={`/transactions?categoryIds=${expenseCategory.id}`}
className="inline-block"
>
<Badge
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 hover:opacity-80 transition-opacity cursor-pointer"
style={{
backgroundColor: `${expenseCategory.color}20`,
color: expenseCategory.color,
borderColor: `${expenseCategory.color}30`,
}}
>
<CategoryIcon
icon={expenseCategory.icon}
color={expenseCategory.color}
size={isMobile ? 8 : 10}
/>
<span className="truncate max-w-[100px] md:max-w-none">
{expenseCategory.name}
</span>
</Badge>
</Link>
);
}
return null;
})()}
</div>
</div>
</div>
))}
</div>
</TabsContent>
);
})}
{/* Graphique d'évolution temporelle */}
{chartData.length > 0 && (() => {
const currentTab = activeTab || defaultTabValue;
const activeCategoryGroup = categoriesWithExpenses.find(
(group) => (group.categoryId || "uncategorized") === currentTab,
);
const activeCategory = activeCategoryGroup?.categoryId
? categories.find((c) => c.id === activeCategoryGroup.categoryId)
: null;
const barColor = activeCategory?.color || "var(--destructive)";
return (
<div className="mt-6 pt-6 border-t border-border">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold">
Évolution des dépenses dans le temps
</h3>
{allTransactions.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setShowAllExpenses(!showAllExpenses)}
className="text-xs"
>
{showAllExpenses ? (
<>
<ListOrdered className="w-3 h-3 mr-1.5" />
Top 10 seulement
</>
) : (
<>
<List className="w-3 h-3 mr-1.5" />
Voir toutes les dépenses
</>
)}
</Button>
)}
</div>
<div className="h-[250px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<XAxis
dataKey="period"
className="text-xs"
interval="preserveStartEnd"
angle={-45}
textAnchor="end"
height={60}
/>
<YAxis
className="text-xs"
width={80}
tickFormatter={(v) => {
if (Math.abs(v) >= 1000) {
return `${(v / 1000).toFixed(1)}k€`;
}
return `${Math.round(v)}`;
}}
tick={{ fill: "var(--muted-foreground)" }}
/>
<Tooltip
formatter={(value: number) => formatCurrency(value)}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
/>
<Bar
dataKey="montant"
fill={barColor}
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div> </div>
</div> </div>
); );
})} })()}
</div> </Tabs>
) : ( ) : (
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-xs md:text-sm"> <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

View File

@@ -245,26 +245,26 @@ export function TransactionFilters({
</div> </div>
{customStartDate && customEndDate && ( {customStartDate && customEndDate && (
<div className="flex gap-2 pt-2 border-t px-3 pb-3"> <div className="flex gap-2 pt-2 border-t px-3 pb-3">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="flex-1" className="flex-1"
onClick={() => { onClick={() => {
onCustomStartDateChange(undefined); onCustomStartDateChange(undefined);
onCustomEndDateChange(undefined); onCustomEndDateChange(undefined);
}} }}
> >
Réinitialiser Réinitialiser
</Button> </Button>
<Button <Button
size="sm" size="sm"
className="flex-1" className="flex-1"
onClick={() => onCustomDatePickerOpenChange(false)} onClick={() => onCustomDatePickerOpenChange(false)}
> >
Valider Valider
</Button> </Button>
</div> </div>
)} )}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
)} )}

View File

@@ -169,7 +169,7 @@ export function TransactionTable({
setFocusedIndex(index); setFocusedIndex(index);
onMarkReconciled(transactionId); onMarkReconciled(transactionId);
}, },
[onMarkReconciled] [onMarkReconciled],
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
@@ -198,7 +198,7 @@ export function TransactionTable({
} }
} }
}, },
[focusedIndex, transactions, onMarkReconciled, virtualizer] [focusedIndex, transactions, onMarkReconciled, virtualizer],
); );
useEffect(() => { useEffect(() => {
@@ -215,7 +215,7 @@ export function TransactionTable({
(accountId: string) => { (accountId: string) => {
return accounts.find((a) => a.id === accountId); return accounts.find((a) => a.id === accountId);
}, },
[accounts] [accounts],
); );
const getCategory = useCallback( const getCategory = useCallback(
@@ -223,11 +223,11 @@ export function TransactionTable({
if (!categoryId) return null; if (!categoryId) return null;
return categories.find((c) => c.id === categoryId); return categories.find((c) => c.id === categoryId);
}, },
[categories] [categories],
); );
return ( return (
<Card className="overflow-hidden w-full"> <Card className="overflow-hidden w-full card-hover">
<CardContent className="p-0 w-full"> <CardContent className="p-0 w-full">
{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">
@@ -281,7 +281,7 @@ export function TransactionTable({
"p-4 md:p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border", "p-4 md:p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border",
transaction.isReconciled && "bg-emerald-500/5", transaction.isReconciled && "bg-emerald-500/5",
isFocused && "bg-primary/10 ring-1 ring-primary/30", isFocused && "bg-primary/10 ring-1 ring-primary/30",
isDuplicate && "shadow-sm" isDuplicate && "shadow-sm",
)} )}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -323,8 +323,8 @@ export function TransactionTable({
className={cn( className={cn(
"font-semibold tabular-nums text-base md:text-base shrink-0", "font-semibold tabular-nums text-base md:text-base shrink-0",
transaction.amount >= 0 transaction.amount >= 0
? "text-emerald-600" ? "text-success"
: "text-red-600" : "text-destructive",
)} )}
> >
{transaction.amount >= 0 ? "+" : ""} {transaction.amount >= 0 ? "+" : ""}
@@ -359,7 +359,7 @@ export function TransactionTable({
showBadge showBadge
align="start" align="start"
disabled={updatingTransactionIds.has( disabled={updatingTransactionIds.has(
transaction.id transaction.id,
)} )}
/> />
</div> </div>
@@ -392,13 +392,13 @@ export function TransactionTable({
e.stopPropagation(); e.stopPropagation();
if ( if (
confirm( confirm(
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}` `Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
) )
) { ) {
onDelete(transaction.id); onDelete(transaction.id);
} }
}} }}
className="text-red-600 focus:text-red-600" className="text-destructive focus:text-destructive"
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Supprimer Supprimer
@@ -508,7 +508,7 @@ export function TransactionTable({
"grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer", "grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer",
transaction.isReconciled && "bg-emerald-500/5", transaction.isReconciled && "bg-emerald-500/5",
isFocused && "bg-primary/10 ring-1 ring-primary/30", isFocused && "bg-primary/10 ring-1 ring-primary/30",
isDuplicate && "shadow-sm" isDuplicate && "shadow-sm",
)} )}
> >
<div className="p-3" onClick={(e) => e.stopPropagation()}> <div className="p-3" onClick={(e) => e.stopPropagation()}>
@@ -576,8 +576,8 @@ export function TransactionTable({
className={cn( className={cn(
"p-3 text-right font-semibold tabular-nums", "p-3 text-right font-semibold tabular-nums",
transaction.amount >= 0 transaction.amount >= 0
? "text-emerald-600" ? "text-success"
: "text-red-600" : "text-destructive",
)} )}
> >
{transaction.amount >= 0 ? "+" : ""} {transaction.amount >= 0 ? "+" : ""}
@@ -592,7 +592,7 @@ export function TransactionTable({
className="p-1 hover:bg-muted rounded" className="p-1 hover:bg-muted rounded"
> >
{transaction.isReconciled ? ( {transaction.isReconciled ? (
<CheckCircle2 className="w-5 h-5 text-emerald-600" /> <CheckCircle2 className="w-5 h-5 text-success" />
) : ( ) : (
<Circle className="w-5 h-5 text-muted-foreground" /> <Circle className="w-5 h-5 text-muted-foreground" />
)} )}
@@ -644,13 +644,13 @@ export function TransactionTable({
e.stopPropagation(); e.stopPropagation();
if ( if (
confirm( confirm(
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}` `Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
) )
) { ) {
onDelete(transaction.id); onDelete(transaction.id);
} }
}} }}
className="text-red-600 focus:text-red-600" className="text-destructive focus:text-destructive"
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Supprimer Supprimer

View File

@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/30 aria-invalid:border-destructive overflow-hidden",
{ {
variants: { variants: {
variant: { variant: {
@@ -14,7 +14,7 @@ const badgeVariants = cva(
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive: destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/30",
outline: outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
}, },

View File

@@ -5,16 +5,16 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/30 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"bg-gradient-to-r from-primary via-primary/95 to-primary/90 text-primary-foreground shadow-xl shadow-primary/30 backdrop-blur-sm border border-primary/20", "bg-gradient-to-r from-primary via-primary/95 to-primary/90 text-primary-foreground shadow-xl shadow-primary/30 backdrop-blur-sm border border-primary/20",
destructive: destructive:
"bg-gradient-to-r from-destructive via-destructive/95 to-destructive/90 text-white shadow-xl shadow-destructive/30 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 backdrop-blur-sm border border-destructive/20", "bg-gradient-to-r from-destructive via-destructive/95 to-destructive/90 text-destructive-foreground shadow-xl shadow-destructive/30 focus-visible:ring-destructive/30 backdrop-blur-sm border border-destructive/20",
outline: outline:
"border-2 bg-background/95 backdrop-blur-md shadow-md dark:bg-input/40 dark:border-input", "border-2 bg-background/95 backdrop-blur-md shadow-md border-border",
secondary: secondary:
"bg-gradient-to-r from-secondary via-secondary/95 to-secondary/90 text-secondary-foreground backdrop-blur-sm", "bg-gradient-to-r from-secondary via-secondary/95 to-secondary/90 text-secondary-foreground backdrop-blur-sm",
ghost: "backdrop-blur-sm", ghost: "backdrop-blur-sm",
@@ -33,7 +33,7 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
); );
function Button({ function Button({

View File

@@ -201,7 +201,7 @@ function CalendarDayButton({
data-range-end={modifiers.range_end} data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle} data-range-middle={modifiers.range_middle}
className={cn( className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70", "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day, defaultClassNames.day,
className, className,
)} )}

View File

@@ -99,10 +99,7 @@ export function CategoryFilterCombobox({
if (isAll || isUncategorized) { if (isAll || isUncategorized) {
// Start fresh with this category and its children (if parent) // Start fresh with this category and its children (if parent)
if (isParentCategory && childCategories.length > 0) { if (isParentCategory && childCategories.length > 0) {
newSelection = [ newSelection = [newValue, ...childCategories.map((child) => child.id)];
newValue,
...childCategories.map((child) => child.id),
];
} else { } else {
newSelection = [newValue]; newSelection = [newValue];
} }
@@ -111,7 +108,7 @@ export function CategoryFilterCombobox({
if (isParentCategory && childCategories.length > 0) { if (isParentCategory && childCategories.length > 0) {
const childIds = childCategories.map((child) => child.id); const childIds = childCategories.map((child) => child.id);
newSelection = value.filter( newSelection = value.filter(
(v) => v !== newValue && !childIds.includes(v) (v) => v !== newValue && !childIds.includes(v),
); );
} else { } else {
newSelection = value.filter((v) => v !== newValue); newSelection = value.filter((v) => v !== newValue);

View File

@@ -14,7 +14,7 @@ function Checkbox({
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer border-input bg-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/30 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...props}

View File

@@ -126,7 +126,7 @@ function ContextMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/15 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}

View File

@@ -60,7 +60,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 overflow-y-auto sm:max-w-lg", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 overflow-y-auto sm:max-w-lg",
className, className,
)} )}
{...props} {...props}

View File

@@ -76,7 +76,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/15 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}

View File

@@ -117,7 +117,7 @@ function FieldLabel({
className={cn( className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50", "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4", "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10", "has-data-[state=checked]:bg-primary/8 has-data-[state=checked]:border-primary",
className, className,
)} )}
{...props} {...props}

View File

@@ -13,7 +13,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group" data-slot="input-group"
role="group" role="group"
className={cn( className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none", "group/input-group border-input bg-input relative flex w-full items-center rounded-md border shadow-xs outline-none",
"h-9 has-[>textarea]:h-auto", "h-9 has-[>textarea]:h-auto",
// Variants based on alignment. // Variants based on alignment.
@@ -26,7 +26,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]", "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state. // Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", "has-[[data-slot][aria-invalid=true]]:ring-destructive/30 has-[[data-slot][aria-invalid=true]]:border-destructive",
className, className,
)} )}
@@ -135,7 +135,7 @@ function InputGroupInput({
<Input <Input
data-slot="input-group-control" data-slot="input-group-control"
className={cn( className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent", "flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0",
className, className,
)} )}
{...props} {...props}
@@ -151,7 +151,7 @@ function InputGroupTextarea({
<Textarea <Textarea
data-slot="input-group-control" data-slot="input-group-control"
className={cn( className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent", "flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0",
className, className,
)} )}
{...props} {...props}

View File

@@ -51,7 +51,7 @@ function InputOTPSlot({
data-slot="input-otp-slot" data-slot="input-otp-slot"
data-active={isActive} data-active={isActive}
className={cn( className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]", "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/30 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive bg-input border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className, className,
)} )}
{...props} {...props}

View File

@@ -9,13 +9,13 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
"dark:bg-input/40 border-input h-9 w-full min-w-0 rounded-lg border bg-background/50 backdrop-blur-sm px-3 py-1 text-base", "bg-input border-border h-9 w-full min-w-0 rounded-lg border backdrop-blur-sm px-3 py-1 text-base",
"shadow-sm transition-all duration-200 outline-none", "shadow-sm outline-none",
"file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium", "file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-primary/50 focus-visible:ring-primary/20 focus-visible:ring-[3px] focus-visible:shadow-md focus-visible:shadow-primary/10", "focus-visible:border-primary/50 focus-visible:ring-primary/20 focus-visible:ring-[3px] focus-visible:shadow-md focus-visible:shadow-primary/10",
"hover:border-primary/30 hover:shadow-sm", "hover:border-primary/30 hover:shadow-sm",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/30 aria-invalid:border-destructive",
className, className,
)} )}
{...props} {...props}

View File

@@ -7,7 +7,7 @@ function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
className={cn( className={cn(
"bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none", "bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3", "[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10", "[[data-slot=tooltip-content]_&]:bg-background/15 [[data-slot=tooltip-content]_&]:text-background",
className, className,
)} )}
{...props} {...props}

View File

@@ -103,7 +103,7 @@ function MenubarItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/15 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}

View File

@@ -27,7 +27,7 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item <RadioGroupPrimitive.Item
data-slot="radio-group-item" data-slot="radio-group-item"
className={cn( className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/30 aria-invalid:border-destructive bg-input aspect-square size-4 shrink-0 rounded-full border shadow-xs outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...props}

View File

@@ -37,7 +37,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/30 aria-invalid:border-destructive bg-input hover:bg-input/80 flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}

View File

@@ -4,11 +4,11 @@ import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner"; import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme(); const { theme = "light" } = useTheme();
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme === "system" ? "dark" : (theme as ToasterProps["theme"])}
className="toaster group" className="toaster group"
style={ style={
{ {

View File

@@ -13,7 +13,7 @@ function Switch({
<SwitchPrimitive.Root <SwitchPrimitive.Root
data-slot="switch" data-slot="switch"
className={cn( className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
@@ -21,7 +21,7 @@ function Switch({
<SwitchPrimitive.Thumb <SwitchPrimitive.Thumb
data-slot="switch-thumb" data-slot="switch-thumb"
className={ className={
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0" "bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
} }
/> />
</SwitchPrimitive.Root> </SwitchPrimitive.Root>

View File

@@ -42,7 +42,7 @@ function TabsTrigger({
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "data-[state=active]:bg-background data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring data-[state=active]:border-input text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}

View File

@@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"border-input placeholder:text-muted-foreground", "border-input placeholder:text-muted-foreground",
"dark:bg-input/40 flex field-sizing-content min-h-16 w-full rounded-lg border bg-background/50 backdrop-blur-sm px-3 py-2 text-base", "bg-input flex field-sizing-content min-h-16 w-full rounded-lg border backdrop-blur-sm px-3 py-2 text-base",
"shadow-sm transition-all duration-200 outline-none", "shadow-sm outline-none",
"focus-visible:border-primary/50 focus-visible:ring-primary/20 focus-visible:ring-[3px] focus-visible:shadow-md focus-visible:shadow-primary/10", "focus-visible:border-primary/50 focus-visible:ring-primary/20 focus-visible:ring-[3px] focus-visible:shadow-md focus-visible:shadow-primary/10",
"hover:border-primary/30 hover:shadow-sm", "hover:border-primary/30 hover:shadow-sm",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/30 aria-invalid:border-destructive",
"disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className, className,
)} )}

View File

@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const toggleVariants = cva( const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none aria-invalid:ring-destructive/30 aria-invalid:border-destructive whitespace-nowrap",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -7,7 +7,7 @@ import { useState } from "react";
*/ */
export function useLocalStorage<T>( export function useLocalStorage<T>(
key: string, key: string,
initialValue: T initialValue: T,
): [T, (value: T | ((val: T) => T)) => void] { ): [T, (value: T | ((val: T) => T)) => void] {
// État pour stocker la valeur // État pour stocker la valeur
const [storedValue, setStoredValue] = useState<T>(() => { const [storedValue, setStoredValue] = useState<T>(() => {
@@ -29,9 +29,9 @@ export function useLocalStorage<T>(
// Permet d'utiliser une fonction comme setState // Permet d'utiliser une fonction comme setState
const valueToStore = const valueToStore =
value instanceof Function ? value(storedValue) : value; value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore); setStoredValue(valueToStore);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(valueToStore)); window.localStorage.setItem(key, JSON.stringify(valueToStore));
} }
@@ -42,4 +42,3 @@ export function useLocalStorage<T>(
return [storedValue, setValue]; return [storedValue, setValue];
} }

View File

@@ -146,13 +146,13 @@ export function useTransactionsBalanceChart({
// Fetch transactions before startDate for initial balance calculation // Fetch transactions before startDate for initial balance calculation
const { data: beforeStartDateData } = useTransactions( const { data: beforeStartDateData } = useTransactions(
beforeStartDateParams, beforeStartDateParams,
!!metadata && period !== "all" && !!startDate !!metadata && period !== "all" && !!startDate,
); );
// Fetch all filtered transactions for chart // Fetch all filtered transactions for chart
const { data: transactionsData, isLoading } = useTransactions( const { data: transactionsData, isLoading } = useTransactions(
chartParams, chartParams,
!!metadata !!metadata,
); );
// Calculate balance chart data // Calculate balance chart data
@@ -169,7 +169,7 @@ export function useTransactionsBalanceChart({
// Sort transactions by date // Sort transactions by date
const sortedTransactions = [...transactions].sort( const sortedTransactions = [...transactions].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
); );
// Calculate starting balance: initialBalance + transactions before startDate // Calculate starting balance: initialBalance + transactions before startDate
@@ -181,7 +181,7 @@ export function useTransactionsBalanceChart({
// Start with initial balances // Start with initial balances
runningBalance = accountsToUse.reduce( runningBalance = accountsToUse.reduce(
(sum: number, acc: Account) => sum + (acc.initialBalance || 0), (sum: number, acc: Account) => sum + (acc.initialBalance || 0),
0 0,
); );
// Add transactions before startDate if we have them // Add transactions before startDate if we have them
@@ -190,7 +190,7 @@ export function useTransactionsBalanceChart({
(t) => { (t) => {
const transactionDate = new Date(t.date); const transactionDate = new Date(t.date);
return transactionDate < startDate; return transactionDate < startDate;
} },
); );
beforeStartTransactions.forEach((t) => { beforeStartTransactions.forEach((t) => {
runningBalance += t.amount; runningBalance += t.amount;
@@ -206,7 +206,7 @@ export function useTransactionsBalanceChart({
}); });
const aggregatedBalanceData: BalanceChartDataPoint[] = Array.from( const aggregatedBalanceData: BalanceChartDataPoint[] = Array.from(
aggregatedBalanceByDate.entries() aggregatedBalanceByDate.entries(),
).map(([date, balance]) => ({ ).map(([date, balance]) => ({
date: new Date(date).toLocaleDateString("fr-FR", { date: new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit", day: "2-digit",
@@ -234,7 +234,7 @@ export function useTransactionsBalanceChart({
(t) => { (t) => {
const transactionDate = new Date(t.date); const transactionDate = new Date(t.date);
return transactionDate < startDate; return transactionDate < startDate;
} },
); );
beforeStartTransactions.forEach((t) => { beforeStartTransactions.forEach((t) => {
const currentBalance = accountRunningBalances.get(t.accountId) || 0; const currentBalance = accountRunningBalances.get(t.accountId) || 0;
@@ -246,7 +246,7 @@ export function useTransactionsBalanceChart({
const transactionsForAccounts = selectedAccounts.includes("all") const transactionsForAccounts = selectedAccounts.includes("all")
? sortedTransactions ? sortedTransactions
: sortedTransactions.filter((t) => : sortedTransactions.filter((t) =>
selectedAccounts.includes(t.accountId) selectedAccounts.includes(t.accountId),
); );
transactionsForAccounts.forEach((t) => { transactionsForAccounts.forEach((t) => {
@@ -276,7 +276,7 @@ export function useTransactionsBalanceChart({
(t) => { (t) => {
const transactionDate = new Date(t.date); const transactionDate = new Date(t.date);
return transactionDate < startDate && t.accountId === account.id; return transactionDate < startDate && t.accountId === account.id;
} },
); );
beforeStartTransactions.forEach((t) => { beforeStartTransactions.forEach((t) => {
accountStartingBalance += t.amount; accountStartingBalance += t.amount;
@@ -304,7 +304,7 @@ export function useTransactionsBalanceChart({
}); });
return point; return point;
} },
); );
return { return {

View File

@@ -115,7 +115,7 @@ export function useTransactionsChartData({
// Fetch all filtered transactions for chart // Fetch all filtered transactions for chart
const { data: transactionsData, isLoading } = useTransactions( const { data: transactionsData, isLoading } = useTransactions(
chartParams, chartParams,
!!metadata !!metadata,
); );
// Calculate monthly chart data // Calculate monthly chart data
@@ -141,13 +141,13 @@ export function useTransactionsChartData({
// Format months with year: use short format for better readability // Format months with year: use short format for better readability
const sortedMonths = Array.from(monthlyMap.entries()).sort((a, b) => const sortedMonths = Array.from(monthlyMap.entries()).sort((a, b) =>
a[0].localeCompare(b[0]) a[0].localeCompare(b[0]),
); );
const monthlyChartData: MonthlyChartData[] = sortedMonths.map( const monthlyChartData: MonthlyChartData[] = sortedMonths.map(
([monthKey, values]) => { ([monthKey, values]) => {
const date = new Date(monthKey + "-01"); const date = new Date(monthKey + "-01");
// Format: "janv. 24" instead of "janv. 2024" for compactness // Format: "janv. 24" instead of "janv. 2024" for compactness
const monthLabel = date.toLocaleDateString("fr-FR", { const monthLabel = date.toLocaleDateString("fr-FR", {
month: "short", month: "short",
@@ -160,11 +160,11 @@ export function useTransactionsChartData({
depenses: values.expenses, depenses: values.expenses,
solde: values.income - values.expenses, solde: values.income - values.expenses,
}; };
} },
); );
return monthlyChartData; return monthlyChartData;
}, [transactionsData]); }, [transactionsData, metadata]);
// Calculate category chart data (expenses only) // Calculate category chart data (expenses only)
const categoryData = useMemo(() => { const categoryData = useMemo(() => {
@@ -184,10 +184,12 @@ export function useTransactionsChartData({
}); });
const categoryChartData: CategoryChartData[] = Array.from( const categoryChartData: CategoryChartData[] = Array.from(
categoryTotals.entries() categoryTotals.entries(),
) )
.map(([categoryId, total]) => { .map(([categoryId, total]) => {
const category = metadata.categories.find((c: Category) => c.id === categoryId); const category = metadata.categories.find(
(c: Category) => c.id === categoryId,
);
return { return {
name: category?.name || "Non catégorisé", name: category?.name || "Non catégorisé",
value: Math.round(total), value: Math.round(total),
@@ -204,10 +206,7 @@ export function useTransactionsChartData({
// Calculate total amount and count from all filtered transactions // Calculate total amount and count from all filtered transactions
const totalAmount = useMemo(() => { const totalAmount = useMemo(() => {
if (!transactionsData) return 0; if (!transactionsData) return 0;
return transactionsData.transactions.reduce( return transactionsData.transactions.reduce((sum, t) => sum + t.amount, 0);
(sum, t) => sum + t.amount,
0
);
}, [transactionsData]); }, [transactionsData]);
const totalCount = useMemo(() => { const totalCount = useMemo(() => {
@@ -220,6 +219,6 @@ export function useTransactionsChartData({
isLoading, isLoading,
totalAmount, totalAmount,
totalCount, totalCount,
transactions: transactionsData?.transactions || [],
}; };
} }

View File

@@ -98,7 +98,7 @@ export function useTransactionsForAccountFilter({
// Fetch all filtered transactions (without account filter) // Fetch all filtered transactions (without account filter)
const { data: transactionsData, isLoading } = useTransactions( const { data: transactionsData, isLoading } = useTransactions(
filterParams, filterParams,
!!metadata !!metadata,
); );
return { return {
@@ -106,4 +106,3 @@ export function useTransactionsForAccountFilter({
isLoading, isLoading,
}; };
} }

View File

@@ -94,7 +94,7 @@ export function useTransactionsForCategoryFilter({
// Fetch all filtered transactions (without category filter) // Fetch all filtered transactions (without category filter)
const { data: transactionsData, isLoading } = useTransactions( const { data: transactionsData, isLoading } = useTransactions(
filterParams, filterParams,
!!metadata !!metadata,
); );
return { return {
@@ -102,4 +102,3 @@ export function useTransactionsForCategoryFilter({
isLoading, isLoading,
}; };
} }

View File

@@ -31,7 +31,10 @@ export function useTransactionsPage() {
"all", "all",
]); ]);
const [showReconciled, setShowReconciled] = useState<string>("all"); const [showReconciled, setShowReconciled] = useState<string>("all");
const [period, setPeriod] = useLocalStorage<Period>("transactions-period", "3months"); const [period, setPeriod] = useLocalStorage<Period>(
"transactions-period",
"3months",
);
const [customStartDate, setCustomStartDate] = useState<Date | undefined>( const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
undefined, undefined,
); );
@@ -75,29 +78,29 @@ export function useTransactionsPage() {
useEffect(() => { useEffect(() => {
const categoryIdsParam = searchParams.get("categoryIds"); const categoryIdsParam = searchParams.get("categoryIds");
const includeUncategorizedParam = searchParams.get("includeUncategorized"); const includeUncategorizedParam = searchParams.get("includeUncategorized");
if (categoryIdsParam && metadata) { if (categoryIdsParam && metadata) {
const categoryIds = categoryIdsParam.split(","); const categoryIds = categoryIdsParam.split(",");
// Expand parent categories to include their children // Expand parent categories to include their children
const expandedCategoryIds = new Set<string>(categoryIds); const expandedCategoryIds = new Set<string>(categoryIds);
categoryIds.forEach((categoryId) => { categoryIds.forEach((categoryId) => {
// Check if this is a parent category // Check if this is a parent category
const category = metadata.categories.find( const category = metadata.categories.find(
(c: Category) => c.id === categoryId (c: Category) => c.id === categoryId,
); );
if (category && category.parentId === null) { if (category && category.parentId === null) {
// Find all children of this parent // Find all children of this parent
const children = metadata.categories.filter( const children = metadata.categories.filter(
(c: Category) => c.parentId === categoryId (c: Category) => c.parentId === categoryId,
); );
children.forEach((child: Category) => { children.forEach((child: Category) => {
expandedCategoryIds.add(child.id); expandedCategoryIds.add(child.id);
}); });
} }
}); });
setSelectedCategories(Array.from(expandedCategoryIds)); setSelectedCategories(Array.from(expandedCategoryIds));
setPage(0); setPage(0);
} else if (includeUncategorizedParam === "true") { } else if (includeUncategorizedParam === "true") {
@@ -205,15 +208,18 @@ export function useTransactionsPage() {
setPage(0); setPage(0);
}, []); }, []);
const handlePeriodChange = useCallback((p: Period) => { const handlePeriodChange = useCallback(
setPeriod(p); (p: Period) => {
setPage(0); setPeriod(p);
if (p !== "custom") { setPage(0);
setIsCustomDatePickerOpen(false); if (p !== "custom") {
} else { setIsCustomDatePickerOpen(false);
setIsCustomDatePickerOpen(true); } else {
} setIsCustomDatePickerOpen(true);
}, []); }
},
[setPeriod],
);
const handleCustomStartDateChange = useCallback((date: Date | undefined) => { const handleCustomStartDateChange = useCallback((date: Date | undefined) => {
setCustomStartDate(date); setCustomStartDate(date);

6735
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,12 @@ async function main() {
const accountNumbers = process.argv.slice(2); const accountNumbers = process.argv.slice(2);
if (accountNumbers.length === 0) { if (accountNumbers.length === 0) {
console.error("Usage: tsx scripts/fix-account-balances.ts <accountNumber1> [accountNumber2] ..."); console.error(
console.error("Exemple: tsx scripts/fix-account-balances.ts 0748461N022 7555880857A"); "Usage: tsx scripts/fix-account-balances.ts <accountNumber1> [accountNumber2] ...",
);
console.error(
"Exemple: tsx scripts/fix-account-balances.ts 0748461N022 7555880857A",
);
process.exit(1); process.exit(1);
} }
@@ -47,7 +51,9 @@ async function main() {
console.log(` Balance calculée: ${calculatedBalance}`); console.log(` Balance calculée: ${calculatedBalance}`);
if (Math.abs(account.balance - calculatedBalance) > 0.01) { if (Math.abs(account.balance - calculatedBalance) > 0.01) {
console.log(`\n⚠ Différence détectée: ${Math.abs(account.balance - calculatedBalance).toFixed(2)}`); console.log(
`\n⚠ Différence détectée: ${Math.abs(account.balance - calculatedBalance).toFixed(2)}`,
);
console.log(`Mise à jour du solde...`); console.log(`Mise à jour du solde...`);
await prisma.account.update({ await prisma.account.update({
@@ -68,9 +74,7 @@ async function main() {
await prisma.$disconnect(); await prisma.$disconnect();
} }
main() main().catch((e) => {
.catch((e) => { console.error("Erreur:", e);
console.error("Erreur:", e); process.exit(1);
process.exit(1); });
});

View File

@@ -4,7 +4,9 @@ async function main() {
const accountNumber = process.argv[2]; const accountNumber = process.argv[2];
if (!accountNumber) { if (!accountNumber) {
console.error("Usage: tsx scripts/merge-duplicate-accounts.ts <accountNumber>"); console.error(
"Usage: tsx scripts/merge-duplicate-accounts.ts <accountNumber>",
);
process.exit(1); process.exit(1);
} }
@@ -52,11 +54,19 @@ async function main() {
// Le compte avec bankId numérique (pas "FR") est le bon - le garder comme principal // Le compte avec bankId numérique (pas "FR") est le bon - le garder comme principal
// Si pas de bankId numérique, garder le plus récent // Si pas de bankId numérique, garder le plus récent
const primaryAccount = accounts.find(acc => acc.bankId !== "FR" && acc.bankId !== "") || accounts[0]; const primaryAccount =
const accountsToMerge = accounts.filter(acc => acc.id !== primaryAccount.id); accounts.find((acc) => acc.bankId !== "FR" && acc.bankId !== "") ||
accounts[0];
const accountsToMerge = accounts.filter(
(acc) => acc.id !== primaryAccount.id,
);
console.log(`\nCompte principal (conservé): ${primaryAccount.id} (bankId: ${primaryAccount.bankId})`); console.log(
console.log(`Comptes à fusionner: ${accountsToMerge.map((a) => `${a.id} (bankId: ${a.bankId})`).join(", ")}\n`); `\nCompte principal (conservé): ${primaryAccount.id} (bankId: ${primaryAccount.bankId})`,
);
console.log(
`Comptes à fusionner: ${accountsToMerge.map((a) => `${a.id} (bankId: ${a.bankId})`).join(", ")}\n`,
);
// Calculer l'initialBalance total (somme des initialBalance) // Calculer l'initialBalance total (somme des initialBalance)
const totalInitialBalance = accounts.reduce( const totalInitialBalance = accounts.reduce(
@@ -111,7 +121,9 @@ async function main() {
0, 0,
); );
console.log(`Balance calculée à partir des transactions: ${calculatedBalance}`); console.log(
`Balance calculée à partir des transactions: ${calculatedBalance}`,
);
// Mettre à jour la balance du compte principal // Mettre à jour la balance du compte principal
// Garder le bankId du compte principal (celui qui est correct) // Garder le bankId du compte principal (celui qui est correct)
@@ -125,12 +137,14 @@ async function main() {
// Garder le bankId du compte principal (le bon) // Garder le bankId du compte principal (le bon)
bankId: primaryAccount.bankId, bankId: primaryAccount.bankId,
// Garder le dernier import le plus récent parmi tous les comptes // Garder le dernier import le plus récent parmi tous les comptes
lastImport: lastImport: accounts.reduce(
accounts.reduce((latest, acc) => { (latest, acc) => {
if (!acc.lastImport) return latest; if (!acc.lastImport) return latest;
if (!latest) return acc.lastImport; if (!latest) return acc.lastImport;
return acc.lastImport > latest ? acc.lastImport : latest; return acc.lastImport > latest ? acc.lastImport : latest;
}, null as string | null), },
null as string | null,
),
}, },
}); });
@@ -152,7 +166,9 @@ async function main() {
if (finalAccount) { if (finalAccount) {
console.log(`\nVérification finale:`); console.log(`\nVérification finale:`);
console.log(` - Transactions dans le compte: ${finalAccount.transactions.length}`); console.log(
` - Transactions dans le compte: ${finalAccount.transactions.length}`,
);
console.log(` - Balance: ${finalAccount.balance}`); console.log(` - Balance: ${finalAccount.balance}`);
console.log(` - Bank ID: ${finalAccount.bankId}`); console.log(` - Bank ID: ${finalAccount.bankId}`);
} }
@@ -160,9 +176,7 @@ async function main() {
await prisma.$disconnect(); await prisma.$disconnect();
} }
main() main().catch((e) => {
.catch((e) => { console.error("Erreur:", e);
console.error("Erreur:", e); process.exit(1);
process.exit(1); });
});

View File

@@ -396,7 +396,9 @@ export const bankingService = {
}); });
return accounts.map( return accounts.map(
(a): Account & { transactionCount: number; calculatedBalance: number } => ({ (
a,
): Account & { transactionCount: number; calculatedBalance: number } => ({
id: a.id, id: a.id,
name: a.name, name: a.name,
bankId: a.bankId, bankId: a.bankId,

View File

@@ -1,125 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--font-sans: "Geist", "Geist Fallback";
--font-mono: "Geist Mono", "Geist Mono Fallback";
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}