Compare commits

...

52 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
Julien Froidefond
6c14484636 feat: introduce customizable background options with new gradient and solid color styles; integrate BackgroundProvider and BackgroundCard components for enhanced user experience in settings and layout
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m52s
2025-12-21 13:43:16 +01:00
Julien Froidefond
2452e30a0f feat: enhance card components across the application with consistent hover effects and improved layout; update spacing in categories page for better visual hierarchy
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m5s
2025-12-21 13:38:59 +01:00
Julien Froidefond
b3ae6059ca feat: improve MonthlyChart and transaction data formatting; dynamically adjust X-axis intervals and enhance month label presentation for better readability 2025-12-21 13:35:49 +01:00
Julien Froidefond
6f78dca1f0 feat: refine card styles and interactions across the dashboard; adjust glassmorphism effects, enhance texture for statistic cards, and improve layout for account filter component 2025-12-21 13:33:23 +01:00
Julien Froidefond
c4707e5511 feat: update fintech card styles with modern design enhancements; add subtle texture effects and adjust gradient backgrounds for improved visual appeal in overview and statistics cards
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m52s
2025-12-21 13:29:43 +01:00
Julien Froidefond
2887a6a750 feat: enhance Sidebar and TransactionTable components with improved accessibility and layout adjustments; add SheetTitle for navigation context and refine padding and width for better responsiveness 2025-12-21 13:25:27 +01:00
Julien Froidefond
82e27524b5 feat: enhance TransactionsPage with total count and amount display; update CategoryCard and ParentCategoryRow to link to transaction filters by category; implement localStorage for transaction period preference
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m57s
2025-12-21 08:33:19 +01:00
Julien Froidefond
8b62cd8385 feat: add account and category filter functionality to TransactionsPage; implement hooks for filtering transactions based on selected accounts and categories 2025-12-21 08:29:10 +01:00
Julien Froidefond
a01345c1fb feat: implement category breakdown by parent in dashboard; enhance chart data handling to include totals for parent categories 2025-12-21 08:26:01 +01:00
Julien Froidefond
c358845033 feat: implement localStorage persistence for user preferences in categories, statistics, transactions, and sidebar components; enhance UI with collapsible elements and improved layout 2025-12-21 08:24:04 +01:00
Julien Froidefond
b3e99a15d2 refactor: optimize TransactionsPage component by removing fullscreen functionality and stabilizing transactions reference with useMemo; clean up unused imports 2025-12-21 08:17:33 +01:00
Julien Froidefond
dbcf8e7abd refactor: update grid layout for transactions and overview cards; adjust chart dimensions and axis properties for improved responsiveness
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m56s
2025-12-21 07:42:09 +01:00
Julien Froidefond
55f0e5c625 feat: enhance category filter functionality to include child categories in selection and deselection; update transaction page to expand selected categories based on parent-child relationships 2025-12-21 07:38:57 +01:00
Julien Froidefond
b4dace0673 feat: enhance transactions page with total amount and count display; integrate collapsible statistics card and update chart data handling 2025-12-21 07:35:35 +01:00
Julien Froidefond
aa2c656c00 feat: integrate monthly chart with collapsible feature in transactions page; update transaction period to default to last 3 months 2025-12-21 07:31:29 +01:00
Julien Froidefond
53798176a0 feat: add ReconcileDateRangeCard to settings page; enhance date picker layout in statistics and transaction filters components
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m24s
2025-12-20 12:05:30 +01:00
Julien Froidefond
8b81dfe8c0 feat: add account merging functionality with dialog support; update bulk actions to include merge option
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m39s
2025-12-20 11:42:42 +01:00
Julien Froidefond
376bc8f84e feat: add total balance calculation and display in account management; update account card to show calculated balance
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m26s
2025-12-20 11:30:26 +01:00
Julien Froidefond
4e1e623f93 feat: add new categorized statistics card to dashboard with gradient styling and percentage display
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m27s
2025-12-20 11:09:32 +01:00
Julien Froidefond
4f7a80de1c feat: implement clickable category links in charts and lists; handle uncategorized transactions in URL parameters 2025-12-20 11:07:35 +01:00
Julien Froidefond
d61d9181c7 feat: add uncategorized transaction count and percentage to transactions page; adjust table height for better layout 2025-12-20 11:04:11 +01:00
Julien Froidefond
dff2a9061f refactor: remove transition effects from various components for improved performance and consistency 2025-12-20 11:02:11 +01:00
Julien Froidefond
4445a38380 refactor: enhance dialog and alert components with max-height and overflow properties for improved responsiveness 2025-12-20 11:00:56 +01:00
Julien Froidefond
198bf44a96 chore: update deploy workflow to rebuild Docker images during deployment for improved consistency
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m5s
2025-12-13 12:14:36 +01:00
Julien Froidefond
c300e1d7a6 chore: optimize Dockerfile by using cache for pnpm installation to improve build performance
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-13 12:13:49 +01:00
Julien Froidefond
8e5c60a684 fix: update next version
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6s
2025-12-13 07:25:40 +01:00
Julien Froidefond
27d4612217 chore: add label to docker-compose.yml to disable Watchtower for improved container management
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7s
2025-12-11 12:07:33 +01:00
Julien Froidefond
3f4381c26e chore: update DATABASE_URL in docker-compose.yml to use absolute path for improved consistency in database configuration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6s
2025-12-11 11:40:09 +01:00
Julien Froidefond
b219ca8748 chore: update docker-compose.yml and deploy workflow to use DATA_VOLUME_PATH variable for improved flexibility in volume configuration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 8s
2025-12-11 11:29:43 +01:00
Julien Froidefond
f2ad63852c chore: enable Docker BuildKit and CLI build in deploy workflow for improved build performance
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 31s
2025-12-11 08:55:09 +01:00
Julien Froidefond
0db4555257 chore: add environment variables for NEXTAUTH_SECRET, NEXTAUTH_URL, and DATABASE_URL in deploy workflow for enhanced deployment configuration
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-11 08:53:47 +01:00
Julien Froidefond
30dc3e9732 chore: update NEXTAUTH_URL and DATABASE_URL in docker-compose.yml for local development configuration
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 20m12s
2025-12-11 08:24:03 +01:00
Julien Froidefond
c8988c42bd chore: remove env_file entry from docker-compose.yml to simplify configuration
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-11 08:23:08 +01:00
Julien Froidefond
b282b477a2 chore: update NEXTAUTH_URL and DATABASE_URL in docker-compose.yml for production environment configuration
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 5s
2025-12-11 08:18:33 +01:00
Julien Froidefond
385f68bbdf refactor: rename app service to banking-app in docker-compose.yml for clarity and consistency
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 3s
2025-12-10 14:28:28 +01:00
Julien Froidefond
f8919b19b3 refactor: standardize code formatting and improve consistency across various components and API routes for enhanced readability and maintainability 2025-12-10 14:28:05 +01:00
Julien Froidefond
299a66e6ff fix: prevent event propagation on checkbox click in transaction table for improved user interaction 2025-12-08 14:05:23 +01:00
Julien Froidefond
8d947ad70f refactor: enhance cache invalidation logic across banking API routes and components for improved data consistency and performance 2025-12-08 14:04:12 +01:00
Julien Froidefond
53bae084c4 refactor: streamline transaction page logic by consolidating state management and enhancing pagination, improving overall performance and maintainability 2025-12-08 12:52:59 +01:00
Julien Froidefond
ba4d112cb8 feat: add duplicate transaction detection and display in transactions page, enhancing user experience with visual indicators for duplicates 2025-12-08 09:50:32 +01:00
Julien Froidefond
cb8628ce39 refactor: standardize code formatting and improve readability across multiple components, including transaction handling and sidebar layout adjustments 2025-12-08 09:28:09 +01:00
111 changed files with 6630 additions and 1661 deletions

View File

@@ -14,9 +14,3 @@ README.md
.vscode .vscode
.idea .idea

View File

@@ -0,0 +1,23 @@
name: Deploy with Docker Compose
on:
push:
branches:
- main # adapte la branche que tu veux déployer
jobs:
deploy:
runs-on: mac-orbstack-runner # le nom que tu as donné au runner
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy stack
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ vars.NEXTAUTH_URL }}
DATA_VOLUME_PATH: ${{ vars.DATA_VOLUME_PATH }}
run: |
docker compose up -d --build

View File

@@ -12,7 +12,8 @@ WORKDIR /app
# Copy package files # Copy package files
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# Rebuild the source code only when needed # Rebuild the source code only when needed
FROM base AS builder FROM base AS builder

View File

@@ -16,6 +16,7 @@ import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import { import {
AccountCard, AccountCard,
AccountEditDialog, AccountEditDialog,
AccountMergeSelectDialog,
AccountBulkActions, AccountBulkActions,
} from "@/components/accounts"; } from "@/components/accounts";
import { FolderEditDialog } from "@/components/folders"; import { FolderEditDialog } from "@/components/folders";
@@ -28,6 +29,7 @@ import {
updateFolder, updateFolder,
deleteFolder, deleteFolder,
} from "@/lib/store-db"; } from "@/lib/store-db";
import { invalidateAllAccountQueries } from "@/lib/cache-utils";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -85,8 +87,7 @@ export default function AccountsPage() {
// refresh function is not used directly, invalidations are done inline // refresh function is not used directly, invalidations are done inline
const refreshSilent = async () => { const refreshSilent = async () => {
await queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] }); invalidateAllAccountQueries(queryClient);
await queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
}; };
const [editingAccount, setEditingAccount] = useState<Account | null>(null); const [editingAccount, setEditingAccount] = useState<Account | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
@@ -99,6 +100,7 @@ export default function AccountsPage() {
folderId: "folder-root", folderId: "folder-root",
externalUrl: "", externalUrl: "",
initialBalance: 0, initialBalance: 0,
totalBalance: 0,
}); });
// Folder management state // Folder management state
@@ -111,6 +113,7 @@ export default function AccountsPage() {
}); });
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [isCompactView, setIsCompactView] = useState(false); const [isCompactView, setIsCompactView] = useState(false);
const [isMergeDialogOpen, setIsMergeDialogOpen] = useState(false);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -131,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, ...account }) => account, ({
transactionCount: _transactionCount,
calculatedBalance: _calculatedBalance,
...account
}) => account,
); );
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
@@ -143,12 +150,14 @@ export default function AccountsPage() {
const handleEdit = (account: Account) => { const handleEdit = (account: Account) => {
setEditingAccount(account); setEditingAccount(account);
const totalBalance = getAccountBalance(account);
setFormData({ setFormData({
name: account.name, name: account.name,
type: account.type, type: account.type,
folderId: account.folderId || "folder-root", folderId: account.folderId || "folder-root",
externalUrl: account.externalUrl || "", externalUrl: account.externalUrl || "",
initialBalance: account.initialBalance || 0, initialBalance: account.initialBalance || 0,
totalBalance: totalBalance,
}); });
setIsDialogOpen(true); setIsDialogOpen(true);
}; };
@@ -157,17 +166,25 @@ export default function AccountsPage() {
if (!editingAccount) return; if (!editingAccount) return;
try { try {
// Calculer le balance à partir du solde total et du solde initial
// balance = totalBalance - initialBalance
const balance = formData.totalBalance - formData.initialBalance;
// Convertir "folder-root" en null
const folderId =
formData.folderId === "folder-root" ? null : formData.folderId;
const updatedAccount = { const updatedAccount = {
...editingAccount, ...editingAccount,
name: formData.name, name: formData.name,
type: formData.type, type: formData.type,
folderId: formData.folderId, folderId: folderId,
externalUrl: formData.externalUrl || null, externalUrl: formData.externalUrl || null,
initialBalance: formData.initialBalance, initialBalance: formData.initialBalance,
balance: balance,
}; };
await updateAccount(updatedAccount); await updateAccount(updatedAccount);
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] }); invalidateAllAccountQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
setIsDialogOpen(false); setIsDialogOpen(false);
setEditingAccount(null); setEditingAccount(null);
} catch (error) { } catch (error) {
@@ -181,8 +198,7 @@ export default function AccountsPage() {
try { try {
await deleteAccount(accountId); await deleteAccount(accountId);
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] }); invalidateAllAccountQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) { } catch (error) {
console.error("Error deleting account:", error); console.error("Error deleting account:", error);
alert("Erreur lors de la suppression du compte"); alert("Erreur lors de la suppression du compte");
@@ -210,8 +226,7 @@ export default function AccountsPage() {
throw new Error("Failed to delete accounts"); throw new Error("Failed to delete accounts");
} }
setSelectedAccounts(new Set()); setSelectedAccounts(new Set());
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] }); invalidateAllAccountQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) { } catch (error) {
console.error("Error deleting accounts:", error); console.error("Error deleting accounts:", error);
alert("Erreur lors de la suppression des comptes"); alert("Erreur lors de la suppression des comptes");
@@ -267,8 +282,7 @@ export default function AccountsPage() {
icon: "folder", icon: "folder",
}); });
} }
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] }); invalidateAllAccountQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
setIsFolderDialogOpen(false); setIsFolderDialogOpen(false);
} catch (error) { } catch (error) {
console.error("Error saving folder:", error); console.error("Error saving folder:", error);
@@ -286,14 +300,50 @@ export default function AccountsPage() {
try { try {
await deleteFolder(folderId); await deleteFolder(folderId);
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] }); invalidateAllAccountQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) { } catch (error) {
console.error("Error deleting folder:", error); console.error("Error deleting folder:", error);
alert("Erreur lors de la suppression du dossier"); alert("Erreur lors de la suppression du dossier");
} }
}; };
const handleMergeAccounts = async (
sourceAccountId: string,
targetAccountId: string,
) => {
try {
const response = await fetch("/api/banking/accounts/merge", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
sourceAccountId,
targetAccountId,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to merge accounts");
}
const result = await response.json();
invalidateAllAccountQueries(queryClient);
// Réinitialiser la sélection
setSelectedAccounts(new Set());
// Afficher un message de succès
alert(
`Fusion réussie ! ${result.transactionCount} transactions déplacées vers le compte de destination.`,
);
} catch (error) {
console.error("Error merging accounts:", error);
throw error;
}
};
// Drag and drop handlers // Drag and drop handlers
const handleDragStart = (event: DragStartEvent) => { const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string); setActiveId(event.active.id as string);
@@ -339,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 }> | 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));
}, },
@@ -353,8 +412,7 @@ export default function AccountsPage() {
} catch (error) { } catch (error) {
console.error("Error moving account:", error); console.error("Error moving account:", error);
// Rollback en cas d'erreur - refresh data // Rollback en cas d'erreur - refresh data
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] }); invalidateAllAccountQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
alert("Erreur lors du déplacement du compte"); alert("Erreur lors du déplacement du compte");
} }
} }
@@ -464,7 +522,13 @@ export default function AccountsPage() {
<> <>
<AccountBulkActions <AccountBulkActions
selectedCount={selectedAccounts.size} selectedCount={selectedAccounts.size}
selectedAccountIds={Array.from(selectedAccounts)}
onDelete={handleBulkDelete} onDelete={handleBulkDelete}
onMerge={
selectedAccounts.size === 2
? () => setIsMergeDialogOpen(true)
: undefined
}
/> />
<DndContext <DndContext
sensors={sensors} sensors={sensors}
@@ -514,12 +578,18 @@ export default function AccountsPage() {
(f: FolderType) => f.id === account.folderId, (f: FolderType) => f.id === account.folderId,
); );
const accountWithStats = accountsWithStats.find(
(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
}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
formatCurrency={formatCurrency} formatCurrency={formatCurrency}
@@ -631,12 +701,18 @@ export default function AccountsPage() {
(f: FolderType) => f.id === account.folderId, (f: FolderType) => f.id === account.folderId,
); );
const accountWithStats = accountsWithStats.find(
(a) => a.id === account.id,
);
return ( return (
<AccountCard <AccountCard
key={account.id} key={account.id}
account={account} account={account}
folder={accountFolder} folder={accountFolder}
transactionCount={getTransactionCount(account.id)} transactionCount={getTransactionCount(account.id)}
calculatedBalance={
accountWithStats?.calculatedBalance
}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
formatCurrency={formatCurrency} formatCurrency={formatCurrency}
@@ -702,6 +778,21 @@ export default function AccountsPage() {
folders={metadata.folders} folders={metadata.folders}
onSave={handleSaveFolder} onSave={handleSaveFolder}
/> />
<AccountMergeSelectDialog
open={isMergeDialogOpen}
onOpenChange={(open) => {
setIsMergeDialogOpen(open);
if (!open) {
// Réinitialiser la sélection après fusion
setSelectedAccounts(new Set());
}
}}
accounts={accounts}
selectedAccountIds={Array.from(selectedAccounts)}
onMerge={handleMergeAccounts}
formatCurrency={formatCurrency}
/>
</PageLayout> </PageLayout>
); );
} }

View File

@@ -0,0 +1,128 @@
import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth-utils";
export async function POST(request: NextRequest) {
const authError = await requireAuth();
if (authError) return authError;
try {
const { sourceAccountId, targetAccountId } = await request.json();
if (!sourceAccountId || !targetAccountId) {
return NextResponse.json(
{ error: "Source and target account IDs are required" },
{ status: 400 },
);
}
if (sourceAccountId === targetAccountId) {
return NextResponse.json(
{ error: "Source and target accounts must be different" },
{ status: 400 },
);
}
// Vérifier que les comptes existent
const [sourceAccount, targetAccount] = await Promise.all([
prisma.account.findUnique({
where: { id: sourceAccountId },
include: {
transactions: {
select: {
id: true,
},
},
},
}),
prisma.account.findUnique({
where: { id: targetAccountId },
}),
]);
if (!sourceAccount || !targetAccount) {
return NextResponse.json(
{ error: "One or both accounts not found" },
{ status: 404 },
);
}
const transactionCount = sourceAccount.transactions.length;
// Déplacer toutes les transactions vers le compte cible
await prisma.transaction.updateMany({
where: {
accountId: sourceAccountId,
},
data: {
accountId: targetAccountId,
},
});
// Recalculer le solde du compte cible à partir de toutes ses transactions
const allTransactions = await prisma.transaction.findMany({
where: {
accountId: targetAccountId,
},
select: {
amount: true,
},
});
const calculatedBalance = allTransactions.reduce(
(sum, t) => sum + t.amount,
0,
);
// Calculer l'initialBalance total (somme des deux comptes)
const totalInitialBalance =
sourceAccount.initialBalance + targetAccount.initialBalance;
// Mettre à jour le compte cible
await prisma.account.update({
where: {
id: targetAccountId,
},
data: {
balance: calculatedBalance,
initialBalance: totalInitialBalance,
// Garder le bankId du compte cible (celui qui est conservé)
// Garder le dernier import le plus récent
lastImport:
sourceAccount.lastImport && targetAccount.lastImport
? sourceAccount.lastImport > targetAccount.lastImport
? sourceAccount.lastImport
: targetAccount.lastImport
: sourceAccount.lastImport || targetAccount.lastImport,
},
});
// Supprimer le compte source
await prisma.account.delete({
where: {
id: sourceAccountId,
},
});
// Revalider les caches
revalidatePath("/accounts", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json({
success: true,
message: `Fusion réussie : ${transactionCount} transactions déplacées`,
targetAccountId,
transactionCount,
calculatedBalance,
});
} catch (error) {
console.error("Error merging accounts:", error);
return NextResponse.json(
{ error: "Failed to merge accounts" },
{ status: 500 },
);
}
}

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { accountService } from "@/services/account.service"; import { accountService } from "@/services/account.service";
import { bankingService } from "@/services/banking.service"; import { bankingService } from "@/services/banking.service";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
@@ -14,11 +15,7 @@ export async function GET(request: NextRequest) {
if (withStats) { if (withStats) {
const accountsWithStats = await bankingService.getAccountsWithStats(); const accountsWithStats = await bankingService.getAccountsWithStats();
return NextResponse.json(accountsWithStats, { return NextResponse.json(accountsWithStats);
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
} }
return NextResponse.json({ error: "Invalid request" }, { status: 400 }); return NextResponse.json({ error: "Invalid request" }, { status: 400 });
@@ -38,7 +35,18 @@ export async function POST(request: Request) {
try { try {
const data: Omit<Account, "id"> = await request.json(); const data: Omit<Account, "id"> = await request.json();
const created = await accountService.create(data); const created = await accountService.create(data);
return NextResponse.json(created);
// Revalider le cache serveur
revalidatePath("/accounts", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(created, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error creating account:", error); console.error("Error creating account:", error);
return NextResponse.json( return NextResponse.json(
@@ -55,7 +63,18 @@ export async function PUT(request: Request) {
try { try {
const account: Account = await request.json(); const account: Account = await request.json();
const updated = await accountService.update(account.id, account); const updated = await accountService.update(account.id, account);
return NextResponse.json(updated);
// Revalider le cache serveur
revalidatePath("/accounts", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(updated, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error updating account:", error); console.error("Error updating account:", error);
return NextResponse.json( return NextResponse.json(
@@ -84,7 +103,21 @@ export async function DELETE(request: Request) {
); );
} }
await accountService.deleteMany(accountIds); await accountService.deleteMany(accountIds);
return NextResponse.json({ success: true, count: accountIds.length });
// Revalider le cache serveur
revalidatePath("/accounts", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(
{ success: true, count: accountIds.length },
{
headers: {
"Cache-Control": "no-store",
},
},
);
} }
if (!id) { if (!id) {
@@ -95,7 +128,21 @@ export async function DELETE(request: Request) {
} }
await accountService.delete(id); await accountService.delete(id);
return NextResponse.json({ success: true });
// Revalider le cache serveur
revalidatePath("/accounts", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(
{ success: true },
{
headers: {
"Cache-Control": "no-store",
},
},
);
} catch (error) { } catch (error) {
console.error("Error deleting account:", error); console.error("Error deleting account:", error);
return NextResponse.json( return NextResponse.json(

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { categoryService } from "@/services/category.service"; import { categoryService } from "@/services/category.service";
import { bankingService } from "@/services/banking.service"; import { bankingService } from "@/services/banking.service";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
@@ -14,11 +15,7 @@ export async function GET(request: NextRequest) {
if (statsOnly) { if (statsOnly) {
const stats = await bankingService.getCategoryStats(); const stats = await bankingService.getCategoryStats();
return NextResponse.json(stats, { return NextResponse.json(stats);
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
} }
return NextResponse.json({ error: "Invalid request" }, { status: 400 }); return NextResponse.json({ error: "Invalid request" }, { status: 400 });
@@ -37,7 +34,18 @@ export async function POST(request: Request) {
try { try {
const data: Omit<Category, "id"> = await request.json(); const data: Omit<Category, "id"> = await request.json();
const created = await categoryService.create(data); const created = await categoryService.create(data);
return NextResponse.json(created);
// Revalider le cache serveur
revalidatePath("/categories", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/rules", "page");
return NextResponse.json(created, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error creating category:", error); console.error("Error creating category:", error);
return NextResponse.json( return NextResponse.json(
@@ -54,7 +62,18 @@ export async function PUT(request: Request) {
try { try {
const category: Category = await request.json(); const category: Category = await request.json();
const updated = await categoryService.update(category.id, category); const updated = await categoryService.update(category.id, category);
return NextResponse.json(updated);
// Revalider le cache serveur
revalidatePath("/categories", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/rules", "page");
return NextResponse.json(updated, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error updating category:", error); console.error("Error updating category:", error);
return NextResponse.json( return NextResponse.json(
@@ -80,7 +99,21 @@ export async function DELETE(request: Request) {
} }
await categoryService.delete(id); await categoryService.delete(id);
return NextResponse.json({ success: true });
// Revalider le cache serveur
revalidatePath("/categories", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/rules", "page");
return NextResponse.json(
{ success: true },
{
headers: {
"Cache-Control": "no-store",
},
},
);
} catch (error) { } catch (error) {
console.error("Error deleting category:", error); console.error("Error deleting category:", error);
return NextResponse.json( return NextResponse.json(

View File

@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { transactionService } from "@/services/transaction.service";
import { requireAuth } from "@/lib/auth-utils";
export async function GET() {
const authError = await requireAuth();
if (authError) return authError;
try {
const duplicateIds = await transactionService.getDuplicateIds();
return NextResponse.json({ duplicateIds: Array.from(duplicateIds) });
} catch (error) {
console.error("Error finding duplicate IDs:", error);
return NextResponse.json(
{ error: "Failed to find duplicate IDs" },
{ status: 500 },
);
}
}

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { folderService, FolderNotFoundError } from "@/services/folder.service"; import { folderService, FolderNotFoundError } from "@/services/folder.service";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
import type { Folder } from "@/lib/types"; import type { Folder } from "@/lib/types";
@@ -9,7 +10,15 @@ export async function POST(request: Request) {
try { try {
const data: Omit<Folder, "id"> = await request.json(); const data: Omit<Folder, "id"> = await request.json();
const created = await folderService.create(data); const created = await folderService.create(data);
return NextResponse.json(created);
// Revalider le cache des pages
revalidatePath("/accounts", "page");
return NextResponse.json(created, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error creating folder:", error); console.error("Error creating folder:", error);
return NextResponse.json( return NextResponse.json(
@@ -26,7 +35,15 @@ export async function PUT(request: Request) {
try { try {
const folder: Folder = await request.json(); const folder: Folder = await request.json();
const updated = await folderService.update(folder.id, folder); const updated = await folderService.update(folder.id, folder);
return NextResponse.json(updated);
// Revalider le cache des pages
revalidatePath("/accounts", "page");
return NextResponse.json(updated, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error updating folder:", error); console.error("Error updating folder:", error);
return NextResponse.json( return NextResponse.json(
@@ -52,7 +69,18 @@ export async function DELETE(request: Request) {
} }
await folderService.delete(id); await folderService.delete(id);
return NextResponse.json({ success: true });
// Revalider le cache des pages
revalidatePath("/accounts", "page");
return NextResponse.json(
{ success: true },
{
headers: {
"Cache-Control": "no-store",
},
},
);
} catch (error) { } catch (error) {
if (error instanceof FolderNotFoundError) { if (error instanceof FolderNotFoundError) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 }); return NextResponse.json({ error: "Folder not found" }, { status: 404 });

View File

@@ -12,19 +12,11 @@ export async function GET(request: NextRequest) {
if (metadataOnly) { if (metadataOnly) {
const metadata = await bankingService.getMetadata(); const metadata = await bankingService.getMetadata();
return NextResponse.json(metadata, { return NextResponse.json(metadata);
headers: {
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
},
});
} }
const data = await bankingService.getAllData(); const data = await bankingService.getAllData();
return NextResponse.json(data, { return NextResponse.json(data);
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
} catch (error) { } catch (error) {
console.error("Error fetching banking data:", error); console.error("Error fetching banking data:", error);
return NextResponse.json( return NextResponse.json(

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
@@ -15,10 +16,23 @@ export async function POST() {
}, },
}); });
return NextResponse.json({ // Revalider le cache des pages
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/categories", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(
{
success: true, success: true,
count: result.count, count: result.count,
}); },
{
headers: {
"Cache-Control": "no-store",
},
},
);
} catch (error) { } catch (error) {
console.error("Error clearing categories:", error); console.error("Error clearing categories:", error);
return NextResponse.json( return NextResponse.json(

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { transactionService } from "@/services/transaction.service"; import { transactionService } from "@/services/transaction.service";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
@@ -8,7 +9,17 @@ export async function POST() {
try { try {
const result = await transactionService.deduplicate(); const result = await transactionService.deduplicate();
return NextResponse.json(result);
// Revalider le cache des pages
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(result, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error deduplicating transactions:", error); console.error("Error deduplicating transactions:", error);
return NextResponse.json( return NextResponse.json(

View File

@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { transactionService } from "@/services/transaction.service";
import { requireAuth } from "@/lib/auth-utils";
export async function POST(request: NextRequest) {
const authError = await requireAuth();
if (authError) return authError;
try {
const body = await request.json();
const { startDate, endDate, reconciled = true } = body;
if (!endDate) {
return NextResponse.json(
{ error: "endDate is required" },
{ status: 400 },
);
}
// Validate date format (YYYY-MM-DD)
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (startDate && !dateRegex.test(startDate)) {
return NextResponse.json(
{ error: "Invalid startDate format. Expected YYYY-MM-DD" },
{ status: 400 },
);
}
if (!dateRegex.test(endDate)) {
return NextResponse.json(
{ error: "Invalid endDate format. Expected YYYY-MM-DD" },
{ status: 400 },
);
}
if (startDate && startDate > endDate) {
return NextResponse.json(
{ error: "startDate must be before or equal to endDate" },
{ status: 400 },
);
}
const result = await transactionService.reconcileByDateRange(
startDate,
endDate,
reconciled,
);
// Revalider le cache des pages
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
revalidatePath("/settings", "page");
return NextResponse.json(result, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) {
console.error("Error reconciling transactions by date range:", error);
const errorMessage =
error instanceof Error
? error.message
: "Failed to reconcile transactions";
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { transactionService } from "@/services/transaction.service"; import { transactionService } from "@/services/transaction.service";
import { bankingService } from "@/services/banking.service"; import { bankingService } from "@/services/banking.service";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
@@ -28,7 +29,7 @@ export async function GET(request: NextRequest) {
searchParams.get("includeUncategorized") === "true"; searchParams.get("includeUncategorized") === "true";
const search = searchParams.get("search") || undefined; const search = searchParams.get("search") || undefined;
const isReconciledParam = searchParams.get("isReconciled"); const isReconciledParam = searchParams.get("isReconciled");
const isReconciled = const isReconciled: boolean | "all" =
isReconciledParam === "true" isReconciledParam === "true"
? true ? true
: isReconciledParam === "false" : isReconciledParam === "false"
@@ -40,7 +41,7 @@ export async function GET(request: NextRequest) {
const sortOrder = const sortOrder =
(searchParams.get("sortOrder") as "asc" | "desc") || "desc"; (searchParams.get("sortOrder") as "asc" | "desc") || "desc";
const result = await bankingService.getTransactionsPaginated({ const params = {
limit, limit,
offset, offset,
startDate, startDate,
@@ -52,13 +53,13 @@ export async function GET(request: NextRequest) {
isReconciled, isReconciled,
sortField, sortField,
sortOrder, sortOrder,
}); };
return NextResponse.json(result, { // Pas de cache serveur pour garantir des données toujours à jour
headers: { // Le cache client React Query gère déjà la mise en cache côté client
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120", const result = await bankingService.getTransactionsPaginated(params);
},
}); return NextResponse.json(result);
} catch (error) { } catch (error) {
console.error("Error fetching transactions:", error); console.error("Error fetching transactions:", error);
return NextResponse.json( return NextResponse.json(
@@ -74,7 +75,17 @@ export async function POST(request: Request) {
try { try {
const transactions: Transaction[] = await request.json(); const transactions: Transaction[] = await request.json();
const result = await transactionService.createMany(transactions); const result = await transactionService.createMany(transactions);
return NextResponse.json(result);
// Revalider le cache des pages
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(result, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error creating transactions:", error); console.error("Error creating transactions:", error);
return NextResponse.json( return NextResponse.json(
@@ -94,7 +105,17 @@ export async function PUT(request: Request) {
transaction.id, transaction.id,
transaction, transaction,
); );
return NextResponse.json(updated);
// Revalider le cache des pages
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(updated, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error updating transaction:", error); console.error("Error updating transaction:", error);
return NextResponse.json( return NextResponse.json(
@@ -120,14 +141,24 @@ export async function DELETE(request: Request) {
} }
await transactionService.delete(id); await transactionService.delete(id);
return NextResponse.json({ success: true });
// Revalider le cache des pages
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(
{ success: true },
{
headers: {
"Cache-Control": "no-store",
},
},
);
} catch (error) { } catch (error) {
console.error("Error deleting transaction:", error); console.error("Error deleting transaction:", error);
const errorMessage = const errorMessage =
error instanceof Error ? error.message : "Failed to delete transaction"; error instanceof Error ? error.message : "Failed to delete transaction";
return NextResponse.json( return NextResponse.json({ error: errorMessage }, { status: 500 });
{ 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,
@@ -28,6 +29,8 @@ import {
deleteCategory, deleteCategory,
} from "@/lib/store-db"; } from "@/lib/store-db";
import type { Category, Transaction } from "@/lib/types"; import type { Category, Transaction } from "@/lib/types";
import { invalidateAllCategoryQueries } from "@/lib/cache-utils";
import { useLocalStorage } from "@/hooks/use-local-storage";
interface RecategorizationResult { interface RecategorizationResult {
transaction: Transaction; transaction: Transaction;
@@ -57,6 +60,12 @@ export default function CategoriesPage() {
const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false); const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false);
const [isRecategorizing, setIsRecategorizing] = useState(false); const [isRecategorizing, setIsRecategorizing] = useState(false);
// Persister l'état "tout déplier" dans le localStorage
const [expandAllByDefault, setExpandAllByDefault] = useLocalStorage(
"categories-expand-all-by-default",
true,
);
// Organiser les catégories par parent // Organiser les catégories par parent
const { parentCategories, childrenByParent, orphanCategories } = const { parentCategories, childrenByParent, orphanCategories } =
useMemo(() => { useMemo(() => {
@@ -96,17 +105,22 @@ export default function CategoriesPage() {
}; };
}, [metadata?.categories]); }, [metadata?.categories]);
// Initialiser tous les parents comme ouverts // Initialiser tous les parents selon la préférence sauvegardée
useEffect(() => { useEffect(() => {
if (parentCategories.length > 0 && expandedParents.size === 0) { if (parentCategories.length > 0 && expandedParents.size === 0) {
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id))); if (expandAllByDefault) {
setExpandedParents(
new Set(parentCategories.map((p: Category) => p.id)),
);
} else {
setExpandedParents(new Set());
}
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentCategories.length]); }, [parentCategories.length, expandAllByDefault]);
const refresh = useCallback(() => { const refresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); invalidateAllCategoryQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["category-stats"] });
}, [queryClient]); }, [queryClient]);
const getCategoryStats = useCallback( const getCategoryStats = useCallback(
@@ -162,10 +176,12 @@ export default function CategoriesPage() {
const expandAll = () => { const expandAll = () => {
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id))); setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
setExpandAllByDefault(true);
}; };
const collapseAll = () => { const collapseAll = () => {
setExpandedParents(new Set()); setExpandedParents(new Set());
setExpandAllByDefault(false);
}; };
const allExpanded = const allExpanded =
@@ -338,7 +354,7 @@ export default function CategoriesPage() {
onToggleAll={allExpanded ? collapseAll : expandAll} onToggleAll={allExpanded ? collapseAll : expandAll}
/> />
<div className="space-y-1"> <div className="space-y-3 md:space-y-4">
{filteredParentCategories.map((parent: Category) => { {filteredParentCategories.map((parent: Category) => {
const allChildren = childrenByParent[parent.id] || []; const allChildren = childrenByParent[parent.id] || [];
const children = searchQuery.trim() const children = searchQuery.trim()
@@ -374,8 +390,8 @@ export default function CategoriesPage() {
})} })}
{orphanCategories.length > 0 && ( {orphanCategories.length > 0 && (
<div className="border rounded-lg bg-card"> <Card className="card-hover">
<div className="px-3 py-2 border-b"> <div className="px-3 py-2 border-b border-border">
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
Catégories non classées ({orphanCategories.length}) Catégories non classées ({orphanCategories.length})
</span> </span>
@@ -392,7 +408,7 @@ export default function CategoriesPage() {
/> />
))} ))}
</div> </div>
</div> </Card>
)} )}
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,8 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; 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 { 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"] });
@@ -21,11 +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">
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange
>
<BackgroundProvider />
<QueryProvider> <QueryProvider>
<AuthSessionProvider>{children}</AuthSessionProvider> <AuthSessionProvider>{children}</AuthSessionProvider>
</QueryProvider> </QueryProvider>
</ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@@ -48,7 +48,7 @@ export default function LoginPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-[var(--background)] p-4 page-background"> <div className="min-h-screen flex items-center justify-center bg-[var(--background)] p-4 page-background">
<Card className="w-full max-w-md page-content"> <Card className="w-full max-w-md page-content card-hover">
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
<div className="flex items-center justify-center mb-4"> <div className="flex items-center justify-center mb-4">
<Lock className="w-12 h-12 text-[var(--primary)]" /> <Lock className="w-12 h-12 text-[var(--primary)]" />

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";
@@ -60,8 +59,7 @@ export default function DashboardPage() {
} }
/> />
<Card className="mb-6 card-hover"> <div className="mb-6">
<CardContent className="px-5 py-5 sm:px-6 sm:py-6">
<AccountFilterCombobox <AccountFilterCombobox
accounts={data.accounts} accounts={data.accounts}
folders={data.folders} folders={data.folders}
@@ -70,8 +68,7 @@ export default function DashboardPage() {
className="w-full md:w-[320px]" className="w-full md:w-[320px]"
filteredTransactions={data.transactions} filteredTransactions={data.transactions}
/> />
</CardContent> </div>
</Card>
<OverviewCards data={filteredData} /> <OverviewCards data={filteredData} />

View File

@@ -18,6 +18,10 @@ import {
suggestKeyword, suggestKeyword,
} from "@/components/rules/constants"; } from "@/components/rules/constants";
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@/lib/types";
import {
invalidateAllTransactionQueries,
invalidateAllCategoryQueries,
} from "@/lib/cache-utils";
interface TransactionGroup { interface TransactionGroup {
key: string; key: string;
@@ -32,31 +36,26 @@ 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,
} = useTransactions(
{ {
limit: 10000, // Large limit to get all uncategorized limit: 10000, // Large limit to get all uncategorized
offset: 0, offset: 0,
includeUncategorized: true, includeUncategorized: true,
}, },
!!metadata !!metadata,
); );
const refresh = useCallback(() => { const _refresh = useCallback(() => {
invalidateTransactions(); invalidateAllTransactionQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); invalidateAllCategoryQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["category-stats"] }); }, [queryClient]);
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
}, [invalidateTransactions, queryClient]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<"count" | "amount" | "name">("count"); const [sortBy, setSortBy] = useState<"count" | "amount" | "name">("count");
const [filterMinCount, setFilterMinCount] = useState(2); const [filterMinCount, setFilterMinCount] = useState(2);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()); const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [selectedGroup, setSelectedGroup] = useState<TransactionGroup | null>( const [selectedGroup, setSelectedGroup] = useState<TransactionGroup | null>(
null null,
); );
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isAutoCategorizing, setIsAutoCategorizing] = useState(false); const [isAutoCategorizing, setIsAutoCategorizing] = useState(false);
@@ -87,7 +86,7 @@ export default function RulesPage() {
totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0), totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0),
suggestedKeyword: suggestKeyword(descriptions), suggestedKeyword: suggestKeyword(descriptions),
}; };
} },
); );
// Filter by search query // Filter by search query
@@ -98,7 +97,7 @@ export default function RulesPage() {
(g) => (g) =>
g.displayName.toLowerCase().includes(query) || g.displayName.toLowerCase().includes(query) ||
g.key.includes(query) || g.key.includes(query) ||
g.suggestedKeyword.toLowerCase().includes(query) g.suggestedKeyword.toLowerCase().includes(query),
); );
} }
@@ -167,7 +166,7 @@ export default function RulesPage() {
// 1. Add keyword to category // 1. Add keyword to category
const category = metadata.categories.find( const category = metadata.categories.find(
(c: { id: string }) => c.id === ruleData.categoryId (c: { id: string }) => c.id === ruleData.categoryId,
); );
if (!category) { if (!category) {
throw new Error("Category not found"); throw new Error("Category not found");
@@ -175,7 +174,7 @@ export default function RulesPage() {
// Check if keyword already exists // Check if keyword already exists
const keywordExists = category.keywords.some( const keywordExists = category.keywords.some(
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase() (k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
); );
if (!keywordExists) { if (!keywordExists) {
@@ -193,14 +192,16 @@ export default function RulesPage() {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, categoryId: ruleData.categoryId }), body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
}) }),
) ),
); );
} }
refresh(); // Invalider toutes les queries liées
invalidateAllTransactionQueries(queryClient);
invalidateAllCategoryQueries(queryClient);
}, },
[metadata, refresh] [metadata, queryClient],
); );
const handleAutoCategorize = useCallback(async () => { const handleAutoCategorize = useCallback(async () => {
@@ -214,7 +215,7 @@ export default function RulesPage() {
for (const transaction of uncategorized) { for (const transaction of uncategorized) {
const categoryId = autoCategorize( const categoryId = autoCategorize(
transaction.description + " " + (transaction.memo || ""), transaction.description + " " + (transaction.memo || ""),
metadata.categories metadata.categories,
); );
if (categoryId) { if (categoryId) {
await fetch("/api/banking/transactions", { await fetch("/api/banking/transactions", {
@@ -226,9 +227,11 @@ export default function RulesPage() {
} }
} }
refresh(); // Invalider toutes les queries liées
invalidateAllTransactionQueries(queryClient);
invalidateAllCategoryQueries(queryClient);
alert( alert(
`${categorizedCount} transaction(s) catégorisée(s) automatiquement` `${categorizedCount} transaction(s) catégorisée(s) automatiquement`,
); );
} catch (error) { } catch (error) {
console.error("Error auto-categorizing:", error); console.error("Error auto-categorizing:", error);
@@ -236,7 +239,7 @@ export default function RulesPage() {
} finally { } finally {
setIsAutoCategorizing(false); setIsAutoCategorizing(false);
} }
}, [metadata, transactionsData, refresh]); }, [metadata, transactionsData, queryClient]);
const handleCategorizeGroup = useCallback( const handleCategorizeGroup = useCallback(
async (group: TransactionGroup, categoryId: string | null) => { async (group: TransactionGroup, categoryId: string | null) => {
@@ -247,16 +250,18 @@ export default function RulesPage() {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, categoryId }), body: JSON.stringify({ ...t, categoryId }),
}) }),
) ),
); );
refresh(); // Invalider toutes les queries liées
invalidateAllTransactionQueries(queryClient);
invalidateAllCategoryQueries(queryClient);
} catch (error) { } catch (error) {
console.error("Error categorizing group:", error); console.error("Error categorizing group:", error);
alert("Erreur lors de la catégorisation"); alert("Erreur lors de la catégorisation");
} }
}, },
[refresh] [queryClient],
); );
if ( if (

View File

@@ -8,6 +8,9 @@ import {
OFXInfoCard, OFXInfoCard,
BackupCard, BackupCard,
PasswordCard, PasswordCard,
ReconcileDateRangeCard,
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";
@@ -125,6 +128,12 @@ export default function SettingsPage() {
<PasswordCard /> <PasswordCard />
<ThemeCard />
<BackgroundCard />
<ReconcileDateRangeCard />
<DangerZoneCard <DangerZoneCard
categorizedCount={categorizedCount} categorizedCount={categorizedCount}
onClearCategories={clearAllCategories} onClearCategories={clearAllCategories}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect } from "react";
import { PageLayout, LoadingState, PageHeader } from "@/components/layout"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import { import {
StatsSummaryCards, StatsSummaryCards,
@@ -46,6 +46,7 @@ import { Button } from "@/components/ui/button";
import { format } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { useLocalStorage } from "@/hooks/use-local-storage";
import type { Account, Category } from "@/lib/types"; import type { Account, Category } from "@/lib/types";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all"; type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
@@ -54,21 +55,65 @@ 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);
const [period, setPeriod] = useState<Period>("6months");
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]); // Persister les filtres dans le localStorage
const [selectedCategories, setSelectedCategories] = useState<string[]>([ const [period, setPeriod] = useLocalStorage<Period>(
"all", "statistics-period",
]); "6months"
);
const [selectedAccounts, setSelectedAccounts] = useLocalStorage<string[]>(
"statistics-selected-accounts",
["all"]
);
const [selectedCategories, setSelectedCategories] = useLocalStorage<string[]>(
"statistics-selected-categories",
["all"]
);
const [excludeInternalTransfers, setExcludeInternalTransfers] = const [excludeInternalTransfers, setExcludeInternalTransfers] =
useState(true); useLocalStorage("statistics-exclude-internal-transfers", true);
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
undefined, // Pour les dates, on stocke les ISO strings et on les convertit
const [customStartDateISO, setCustomStartDateISO] = useLocalStorage<
string | null
>("statistics-custom-start-date", null);
const [customEndDateISO, setCustomEndDateISO] = useLocalStorage<
string | null
>("statistics-custom-end-date", null);
// Convertir les ISO strings en Date
const customStartDate = useMemo(
() => (customStartDateISO ? new Date(customStartDateISO) : undefined),
[customStartDateISO]
); );
const [customEndDate, setCustomEndDate] = useState<Date | undefined>( const customEndDate = useMemo(
undefined, () => (customEndDateISO ? new Date(customEndDateISO) : undefined),
[customEndDateISO]
); );
// Fonctions pour mettre à jour les dates avec persistance
const setCustomStartDate = (date: Date | undefined) => {
setCustomStartDateISO(date ? date.toISOString() : null);
};
const setCustomEndDate = (date: Date | undefined) => {
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")
useEffect(() => {
if (period !== "custom" && (customStartDateISO || customEndDateISO)) {
setCustomStartDateISO(null);
setCustomEndDateISO(null);
}
}, [
period,
customStartDateISO,
customEndDateISO,
setCustomStartDateISO,
setCustomEndDateISO,
]);
// Get start date based on period // Get start date based on period
const startDate = useMemo(() => { const startDate = useMemo(() => {
const now = new Date(); const now = new Date();
@@ -100,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]);
@@ -216,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)
); );
} }
@@ -226,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)
); );
} }
} }
@@ -234,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
); );
} }
@@ -281,6 +326,7 @@ export default function StatisticsPage() {
value: Math.round(total), value: Math.round(total),
color: category?.color || "#94a3b8", color: category?.color || "#94a3b8",
icon: category?.icon || "HelpCircle", icon: category?.icon || "HelpCircle",
categoryId: categoryId === "uncategorized" ? null : categoryId,
}; };
}) })
.sort((a, b) => b.value - a.value); .sort((a, b) => b.value - a.value);
@@ -306,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);
@@ -315,16 +361,42 @@ export default function StatisticsPage() {
value: Math.round(total), value: Math.round(total),
color: category?.color || "#94a3b8", color: category?.color || "#94a3b8",
icon: category?.icon || "HelpCircle", icon: category?.icon || "HelpCircle",
categoryId: groupId === "uncategorized" ? null : groupId,
}; };
}) })
.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)
// Get top 5 parent categories by total expenses
const topParentCategories = Array.from(categoryTotalsByParent.entries())
.map(([groupId, total]) => ({
groupId: groupId === "uncategorized" ? null : groupId,
total,
}))
.sort((a, b) => b.total - a.total)
.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((a, b) => {
// Sort by amount (most negative first) // Sort by amount (most negative first)
if (a.amount !== b.amount) { if (a.amount !== b.amount) {
@@ -333,7 +405,13 @@ export default function StatisticsPage() {
// If same amount, sort by date (most recent first) for stable sorting // If same amount, sort by date (most recent first) for stable sorting
return new Date(b.date).getTime() - new Date(a.date).getTime(); return new Date(b.date).getTime() - new Date(a.date).getTime();
}) })
.slice(0, 5); .slice(0, 10);
return {
categoryId: groupId,
expenses: categoryExpenses,
};
});
// Summary // Summary
const totalIncome = transactions const totalIncome = transactions
@@ -347,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
@@ -359,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
@@ -396,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",
@@ -612,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,
@@ -853,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"])}
@@ -861,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"])}
@@ -948,27 +1027,34 @@ export default function StatisticsPage() {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
<div className="p-4 space-y-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 Date de début
</label> </label>
<div className="scale-90 origin-top-left">
<CalendarComponent <CalendarComponent
mode="single" mode="single"
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);
} }
}} }}
locale={fr} locale={fr}
/> />
</div> </div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium"> <label className="text-sm font-medium">
Date de fin Date de fin
</label> </label>
<div className="scale-90 origin-top-left">
<CalendarComponent <CalendarComponent
mode="single" mode="single"
selected={customEndDate} selected={customEndDate}
@@ -992,8 +1078,10 @@ export default function StatisticsPage() {
locale={fr} locale={fr}
/> />
</div> </div>
</div>
</div>
{customStartDate && customEndDate && ( {customStartDate && customEndDate && (
<div className="flex gap-2 pt-2 border-t"> <div className="flex gap-2 pt-2 border-t px-3 pb-3">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -1014,7 +1102,6 @@ export default function StatisticsPage() {
</Button> </Button>
</div> </div>
)} )}
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
)} )}
@@ -1043,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"])}
@@ -1155,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>
@@ -1203,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

@@ -1,531 +1,211 @@
"use client"; "use client";
import { useState, useMemo, useEffect, useCallback } from "react"; import { useCallback, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { PageLayout, PageHeader } from "@/components/layout"; import { PageLayout, PageHeader } from "@/components/layout";
import { RefreshCw } from "lucide-react"; import {
RefreshCw,
Receipt,
Euro,
ChevronDown,
ChevronUp,
CheckCircle2,
Tag,
} from "lucide-react";
import { import {
TransactionFilters, TransactionFilters,
TransactionBulkActions, TransactionBulkActions,
TransactionTable, TransactionTable,
TransactionPagination,
formatCurrency,
formatDate,
} from "@/components/transactions"; } from "@/components/transactions";
import { RuleCreateDialog } from "@/components/rules"; import { RuleCreateDialog } from "@/components/rules";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog"; import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import { import { MonthlyChart } from "@/components/statistics";
useBankingMetadata,
useTransactions,
getTransactionsQueryKey,
} from "@/lib/hooks";
import { updateCategory } from "@/lib/store-db";
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 { Upload } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Transaction } from "@/lib/types";
import type { TransactionsPaginatedParams } from "@/services/banking.service";
import { import {
normalizeDescription, Collapsible,
suggestKeyword, CollapsibleContent,
} from "@/components/rules/constants"; CollapsibleTrigger,
} from "@/components/ui/collapsible";
type SortField = "date" | "amount" | "description"; import { Upload } from "lucide-react";
type SortOrder = "asc" | "desc"; import { useTransactionsPage } from "@/hooks/use-transactions-page";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all"; import { useTransactionMutations } from "@/hooks/use-transaction-mutations";
import { useTransactionRules } from "@/hooks/use-transaction-rules";
const PAGE_SIZE = 100; import { useTransactionsChartData } from "@/hooks/use-transactions-chart-data";
import { useTransactionsForAccountFilter } from "@/hooks/use-transactions-for-account-filter";
import { useTransactionsForCategoryFilter } from "@/hooks/use-transactions-for-category-filter";
import { useLocalStorage } from "@/hooks/use-local-storage";
export default function TransactionsPage() { export default function TransactionsPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
const [page, setPage] = useState(0);
// Debounce search query // Main page state and logic
useEffect(() => { const {
const timer = setTimeout(() => { metadata,
setDebouncedSearchQuery(searchQuery); isLoadingMetadata,
setPage(0); // Reset to first page when search changes searchQuery,
}, 300); setSearchQuery,
return () => clearTimeout(timer); selectedAccounts,
}, [searchQuery]); onAccountsChange,
selectedCategories,
useEffect(() => { onCategoriesChange,
const accountId = searchParams.get("accountId"); showReconciled,
if (accountId) { onReconciledChange,
setSelectedAccounts([accountId]); period,
setPage(0); onPeriodChange,
} customStartDate,
}, [searchParams]); customEndDate,
onCustomStartDateChange,
const [selectedCategories, setSelectedCategories] = useState<string[]>([ onCustomEndDateChange,
"all", isCustomDatePickerOpen,
]); onCustomDatePickerOpenChange,
const [showReconciled, setShowReconciled] = useState<string>("all"); showDuplicates,
const [period, setPeriod] = useState<Period>("all"); onShowDuplicatesChange,
const [customStartDate, setCustomStartDate] = useState<Date | undefined>( page,
undefined pageSize,
); onPageChange,
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
undefined
);
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
const [sortField, setSortField] = useState<SortField>("date");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
new Set()
);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(
null
);
const [updatingTransactionIds, setUpdatingTransactionIds] = useState<
Set<string>
>(new Set());
// Get start date based on period
const startDate = useMemo(() => {
const now = new Date();
switch (period) {
case "1month":
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
case "3months":
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
case "6months":
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
case "12months":
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
case "custom":
return customStartDate || new Date(0);
default:
return new Date(0);
}
}, [period, customStartDate]);
// Get end date (only for custom period)
const endDate = useMemo(() => {
if (period === "custom" && customEndDate) {
return customEndDate;
}
return undefined;
}, [period, customEndDate]);
// Build transaction query params
const transactionParams = useMemo(() => {
const params: TransactionsPaginatedParams = {
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
sortField, sortField,
sortOrder, sortOrder,
}; onSortChange,
selectedTransactions,
onToggleSelectAll,
onToggleSelectTransaction,
clearSelection,
transactionsData,
isLoadingTransactions,
invalidateTransactions,
duplicateIds,
transactionParams,
} = useTransactionsPage();
if (startDate && period !== "all") { // Transaction mutations
params.startDate = startDate.toISOString().split("T")[0]; const {
} toggleReconciled,
if (endDate) { markReconciled,
params.endDate = endDate.toISOString().split("T")[0]; setCategory,
} deleteTransaction,
if (!selectedAccounts.includes("all")) { bulkReconcile: handleBulkReconcile,
params.accountIds = selectedAccounts; bulkSetCategory: handleBulkSetCategory,
} updatingTransactionIds,
if (!selectedCategories.includes("all")) { } = useTransactionMutations({
if (selectedCategories.includes("uncategorized")) { transactionParams,
params.includeUncategorized = true; transactionsData,
} else { });
params.categoryIds = selectedCategories;
}
}
if (debouncedSearchQuery) {
params.search = debouncedSearchQuery;
}
if (showReconciled !== "all") {
params.isReconciled = showReconciled === "reconciled";
}
return params; // Transaction rules
}, [ const {
page, ruleDialogOpen,
startDate, setRuleDialogOpen,
endDate, ruleGroup,
handleCreateRule,
handleSaveRule,
} = useTransactionRules({
transactionsData,
metadata,
});
// Chart data
const {
monthlyData,
isLoading: isLoadingChart,
totalAmount: chartTotalAmount,
totalCount: chartTotalCount,
transactions: chartTransactions,
} = useTransactionsChartData({
selectedAccounts, selectedAccounts,
selectedCategories, selectedCategories,
debouncedSearchQuery,
showReconciled,
sortField,
sortOrder,
period, period,
]); customStartDate,
customEndDate,
// Fetch transactions with pagination showReconciled,
const { searchQuery,
data: transactionsData,
isLoading: isLoadingTransactions,
invalidate: invalidateTransactions,
} = useTransactions(transactionParams, !!metadata);
// For filter comboboxes, we'll use empty arrays for now
// They can be enhanced later with separate queries if needed
const transactionsForAccountFilter: Transaction[] = [];
const transactionsForCategoryFilter: Transaction[] = [];
const handleCreateRule = useCallback((transaction: Transaction) => {
setRuleTransaction(transaction);
setRuleDialogOpen(true);
}, []);
// Create a virtual group for the rule dialog based on selected transaction
// Note: This requires fetching similar transactions - simplified for now
const ruleGroup = useMemo(() => {
if (!ruleTransaction || !transactionsData) return null;
// Use transactions from current page to find similar ones
const normalizedDesc = normalizeDescription(ruleTransaction.description);
const similarTransactions = transactionsData.transactions.filter(
(t) => normalizeDescription(t.description) === normalizedDesc
);
if (similarTransactions.length === 0) return null;
return {
key: normalizedDesc,
displayName: ruleTransaction.description,
transactions: similarTransactions,
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
suggestedKeyword: suggestKeyword(
similarTransactions.map((t) => t.description)
),
};
}, [ruleTransaction, transactionsData]);
const handleSaveRule = useCallback(
async (ruleData: {
keyword: string;
categoryId: string;
applyToExisting: boolean;
transactionIds: string[];
}) => {
if (!metadata) return;
// 1. Add keyword to category
const category = metadata.categories.find(
(c: { id: string }) => c.id === ruleData.categoryId
);
if (!category) {
throw new Error("Category not found");
}
// Check if keyword already exists
const keywordExists = category.keywords.some(
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase()
);
if (!keywordExists) {
await updateCategory({
...category,
keywords: [...category.keywords, ruleData.keyword],
}); });
}
// 2. Apply to existing transactions if requested // Transactions for account filter (filtered by categories, period, search, reconciled - NOT by accounts)
if (ruleData.applyToExisting) { const { transactions: transactionsForAccountFilter } =
await Promise.all( useTransactionsForAccountFilter({
ruleData.transactionIds.map((id) => selectedCategories,
fetch("/api/banking/transactions", { period,
method: "PUT", customStartDate,
headers: { "Content-Type": "application/json" }, customEndDate,
body: JSON.stringify({ id, categoryId: ruleData.categoryId }), showReconciled,
}) searchQuery,
) });
);
}
// Invalidate queries // Transactions for category filter (filtered by accounts, period, search, reconciled - NOT by categories)
queryClient.invalidateQueries({ queryKey: ["transactions"] }); const { transactions: transactionsForCategoryFilter } =
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); useTransactionsForCategoryFilter({
setRuleDialogOpen(false); selectedAccounts,
}, period,
[metadata, queryClient] customStartDate,
); customEndDate,
showReconciled,
searchQuery,
});
const invalidateAll = useCallback(() => { const invalidateAll = useCallback(() => {
invalidateTransactions(); invalidateTransactions();
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
}, [invalidateTransactions, queryClient]); }, [invalidateTransactions, queryClient]);
const formatCurrency = (amount: number) => { const handleBulkReconcileWithClear = useCallback(
return new Intl.NumberFormat("fr-FR", { (reconciled: boolean) => {
style: "currency", handleBulkReconcile(reconciled, selectedTransactions);
currency: "EUR", clearSelection();
}).format(amount); },
}; [handleBulkReconcile, selectedTransactions, clearSelection],
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const toggleReconciled = async (transactionId: string) => {
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId
);
if (!transaction) return;
const updatedTransaction = {
...transaction,
isReconciled: !transaction.isReconciled,
};
try {
await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTransaction),
});
invalidateTransactions();
} catch (error) {
console.error("Failed to update transaction:", error);
}
};
const markReconciled = async (transactionId: string) => {
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId
);
if (!transaction || transaction.isReconciled) return;
const updatedTransaction = {
...transaction,
isReconciled: true,
};
try {
await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTransaction),
});
invalidateTransactions();
} catch (error) {
console.error("Failed to update transaction:", error);
}
};
const setCategory = async (
transactionId: string,
categoryId: string | null
) => {
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId
);
if (!transaction) return;
setUpdatingTransactionIds((prev) => new Set(prev).add(transactionId));
try {
const response = await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...transaction, categoryId }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Mise à jour directe du cache après succès
const queryKey = getTransactionsQueryKey(transactionParams);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
t.id === transactionId ? { ...t, categoryId } : t
),
};
});
} catch (error) {
console.error("Failed to update transaction:", error);
invalidateTransactions();
} finally {
setUpdatingTransactionIds((prev) => {
const next = new Set(prev);
next.delete(transactionId);
return next;
});
}
};
const bulkReconcile = async (reconciled: boolean) => {
if (!transactionsData) return;
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
selectedTransactions.has(t.id)
); );
setSelectedTransactions(new Set()); const handleBulkSetCategoryWithClear = useCallback(
(categoryId: string | null) => {
try { handleBulkSetCategory(categoryId, selectedTransactions);
await Promise.all( clearSelection();
transactionsToUpdate.map((t) => },
fetch("/api/banking/transactions", { [handleBulkSetCategory, selectedTransactions, clearSelection],
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, isReconciled: reconciled }),
})
)
);
invalidateTransactions();
} catch (error) {
console.error("Failed to update transactions:", error);
}
};
const bulkSetCategory = async (categoryId: string | null) => {
if (!transactionsData) return;
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
selectedTransactions.has(t.id)
); );
const transactionIds = transactionsToUpdate.map((t) => t.id); // Stabilize transactions reference to prevent unnecessary re-renders
setSelectedTransactions(new Set()); const filteredTransactions = useMemo(
setUpdatingTransactionIds((prev) => { () => transactionsData?.transactions || [],
const next = new Set(prev); [transactionsData?.transactions],
transactionIds.forEach((id) => next.add(id));
return next;
});
try {
await Promise.all(
transactionsToUpdate.map((t) =>
fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, categoryId }),
})
)
); );
// Mise à jour directe du cache après succès
const queryKey = getTransactionsQueryKey(transactionParams);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
transactionIds.includes(t.id) ? { ...t, categoryId } : t
),
};
});
} catch (error) {
console.error("Failed to update transactions:", error);
invalidateTransactions();
} finally {
setUpdatingTransactionIds((prev) => {
const next = new Set(prev);
transactionIds.forEach((id) => next.delete(id));
return next;
});
}
};
const toggleSelectAll = () => {
if (!transactionsData) return;
if (selectedTransactions.size === transactionsData.transactions.length) {
setSelectedTransactions(new Set());
} else {
setSelectedTransactions(
new Set(transactionsData.transactions.map((t) => t.id))
);
}
};
const toggleSelectTransaction = (id: string) => {
const newSelected = new Set(selectedTransactions);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedTransactions(newSelected);
};
const handleSortChange = (field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortOrder(field === "date" ? "desc" : "asc");
}
setPage(0);
};
const deleteTransaction = async (transactionId: string) => {
// Remove from selected if selected
const newSelected = new Set(selectedTransactions);
newSelected.delete(transactionId);
setSelectedTransactions(newSelected);
// Sauvegarder les données actuelles pour pouvoir les restaurer en cas d'erreur
const queryKey = getTransactionsQueryKey(transactionParams);
const previousData =
queryClient.getQueryData<typeof transactionsData>(queryKey);
// Mise à jour optimiste du cache
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.filter(
(t) => t.id !== transactionId
),
total: oldData.total - 1,
};
});
try {
const response = await fetch(
`/api/banking/transactions?id=${transactionId}`,
{
method: "DELETE",
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.error || `Failed to delete transaction: ${response.status}`
);
}
// Ne pas invalider immédiatement - la mise à jour optimiste est déjà correcte
// On invalide seulement les autres queries qui pourraient être affectées (métadonnées, stats)
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) {
console.error("Failed to delete transaction:", error);
// Restaurer les données précédentes en cas d'erreur
if (previousData) {
queryClient.setQueryData(queryKey, previousData);
}
// Invalider pour récupérer les données correctes du serveur
invalidateTransactions();
}
};
const filteredTransactions = transactionsData?.transactions || [];
const totalTransactions = transactionsData?.total || 0; const totalTransactions = transactionsData?.total || 0;
const hasMore = transactionsData?.hasMore || false; const hasMore = transactionsData?.hasMore || false;
const uncategorizedCount = transactionsData?.uncategorizedCount || 0;
const uncategorizedPercent =
totalTransactions > 0
? Math.round((uncategorizedCount / totalTransactions) * 100)
: 0;
// Use total from chart data (all filtered transactions) or fallback to paginated data
const totalAmount = chartTotalAmount ?? 0;
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
const [isStatsExpanded, setIsStatsExpanded] = useLocalStorage(
"transactions-stats-expanded",
true,
);
// Early return for loading state - prevents sidebar flash // Early return for loading state - prevents sidebar flash
if (isLoadingMetadata || !metadata) { if (isLoadingMetadata || !metadata) {
@@ -542,7 +222,11 @@ export default function TransactionsPage() {
<PageLayout> <PageLayout>
<PageHeader <PageHeader
title="Transactions" title="Transactions"
description={`${totalTransactions} transaction${totalTransactions > 1 ? "s" : ""}`} description={
totalTransactions > 0
? `${totalTransactions} transaction${totalTransactions > 1 ? "s" : ""}${uncategorizedPercent}% non catégorisées`
: `${totalTransactions} transaction${totalTransactions > 1 ? "s" : ""}`
}
actions={ actions={
<OFXImportDialog onImportComplete={invalidateAll}> <OFXImportDialog onImportComplete={invalidateAll}>
<Button className="md:h-10 md:px-5"> <Button className="md:h-10 md:px-5">
@@ -557,42 +241,21 @@ export default function TransactionsPage() {
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
selectedAccounts={selectedAccounts} selectedAccounts={selectedAccounts}
onAccountsChange={(accounts) => { onAccountsChange={onAccountsChange}
setSelectedAccounts(accounts);
setPage(0);
}}
selectedCategories={selectedCategories} selectedCategories={selectedCategories}
onCategoriesChange={(categories) => { onCategoriesChange={onCategoriesChange}
setPage(0);
setSelectedCategories(categories);
}}
showReconciled={showReconciled} showReconciled={showReconciled}
onReconciledChange={(value) => { onReconciledChange={onReconciledChange}
setShowReconciled(value);
setPage(0);
}}
period={period} period={period}
onPeriodChange={(p) => { onPeriodChange={onPeriodChange}
setPeriod(p);
setPage(0);
if (p !== "custom") {
setIsCustomDatePickerOpen(false);
} else {
setIsCustomDatePickerOpen(true);
}
}}
customStartDate={customStartDate} customStartDate={customStartDate}
customEndDate={customEndDate} customEndDate={customEndDate}
onCustomStartDateChange={(date) => { onCustomStartDateChange={onCustomStartDateChange}
setCustomStartDate(date); onCustomEndDateChange={onCustomEndDateChange}
setPage(0);
}}
onCustomEndDateChange={(date) => {
setCustomEndDate(date);
setPage(0);
}}
isCustomDatePickerOpen={isCustomDatePickerOpen} isCustomDatePickerOpen={isCustomDatePickerOpen}
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen} onCustomDatePickerOpenChange={onCustomDatePickerOpenChange}
showDuplicates={showDuplicates}
onShowDuplicatesChange={onShowDuplicatesChange}
accounts={metadata.accounts} accounts={metadata.accounts}
folders={metadata.folders} folders={metadata.folders}
categories={metadata.categories} categories={metadata.categories}
@@ -600,11 +263,148 @@ export default function TransactionsPage() {
transactionsForCategoryFilter={transactionsForCategoryFilter} transactionsForCategoryFilter={transactionsForCategoryFilter}
/> />
{(!isLoadingChart || !isLoadingTransactions) && (
<Card className="mb-6 card-hover">
<Collapsible open={isStatsExpanded} onOpenChange={setIsStatsExpanded}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 py-3 px-6">
<CardTitle className="text-base font-semibold">
Statistiques
{!isStatsExpanded && (
<span className="ml-3 text-sm font-normal text-muted-foreground">
{displayTotalCount} opération
{displayTotalCount > 1 ? "s" : ""} {" "}
{formatCurrency(totalAmount)}
</span>
)}
</CardTitle>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8">
{isStatsExpanded ? (
<>
<ChevronUp className="w-4 h-4 mr-1" />
Réduire
</>
) : (
<>
<ChevronDown className="w-4 h-4 mr-1" />
Afficher
</>
)}
</Button>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="pt-0">
{/* Summary cards */}
{!isLoadingTransactions && (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 mb-6">
<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">
Nombre de transactions
</p>
<p className="text-2xl font-bold mt-1">
{displayTotalCount}
</p>
</div>
<Receipt
className="w-8 h-8"
style={{ color: "var(--gray)" }}
/>
</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">
Total
</p>
<p
className={`text-2xl font-bold mt-1 ${
totalAmount >= 0
? "text-emerald-600"
: "text-red-600"
}`}
>
{formatCurrency(totalAmount)}
</p>
</div>
<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>
</CardContent>
</Card>
</div>
)}
{/* Chart */}
{!isLoadingChart && monthlyData.length > 0 && (
<MonthlyChart
data={monthlyData}
formatCurrency={formatCurrency}
collapsible={false}
showDots={
period !== "all" &&
(period === "12months" || monthlyData.length <= 12)
}
/>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
)}
<TransactionBulkActions <TransactionBulkActions
selectedCount={selectedTransactions.size} selectedCount={selectedTransactions.size}
categories={metadata.categories} categories={metadata.categories}
onReconcile={bulkReconcile} onReconcile={handleBulkReconcileWithClear}
onSetCategory={bulkSetCategory} onSetCategory={handleBulkSetCategoryWithClear}
/> />
{isLoadingTransactions ? ( {isLoadingTransactions ? (
@@ -620,9 +420,9 @@ export default function TransactionsPage() {
selectedTransactions={selectedTransactions} selectedTransactions={selectedTransactions}
sortField={sortField} sortField={sortField}
sortOrder={sortOrder} sortOrder={sortOrder}
onSortChange={handleSortChange} onSortChange={onSortChange}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={onToggleSelectAll}
onToggleSelectTransaction={toggleSelectTransaction} onToggleSelectTransaction={onToggleSelectTransaction}
onToggleReconciled={toggleReconciled} onToggleReconciled={toggleReconciled}
onMarkReconciled={markReconciled} onMarkReconciled={markReconciled}
onSetCategory={setCategory} onSetCategory={setCategory}
@@ -631,36 +431,17 @@ export default function TransactionsPage() {
formatCurrency={formatCurrency} formatCurrency={formatCurrency}
formatDate={formatDate} formatDate={formatDate}
updatingTransactionIds={updatingTransactionIds} updatingTransactionIds={updatingTransactionIds}
duplicateIds={duplicateIds}
highlightDuplicates={showDuplicates}
/> />
{/* Pagination controls */} <TransactionPagination
{totalTransactions > PAGE_SIZE && ( page={page}
<div className="flex items-center justify-between mt-4"> pageSize={pageSize}
<div className="text-sm text-muted-foreground"> total={totalTransactions}
Affichage de {page * PAGE_SIZE + 1} à{" "} hasMore={hasMore}
{Math.min((page + 1) * PAGE_SIZE, totalTransactions)} sur{" "} onPageChange={onPageChange}
{totalTransactions} />
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={!hasMore}
>
Suivant
</Button>
</div>
</div>
)}
</> </>
)} )}

View File

@@ -2,29 +2,35 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Trash2 } from "lucide-react"; import { Trash2, GitMerge } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface AccountBulkActionsProps { interface AccountBulkActionsProps {
selectedCount: number; selectedCount: number;
selectedAccountIds: string[];
onDelete: () => void; onDelete: () => void;
onMerge?: () => void;
} }
export function AccountBulkActions({ export function AccountBulkActions({
selectedCount, selectedCount,
selectedAccountIds: _selectedAccountIds,
onDelete, onDelete,
onMerge,
}: AccountBulkActionsProps) { }: AccountBulkActionsProps) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
if (selectedCount === 0) return null; if (selectedCount === 0) return null;
const canMerge = selectedCount === 2 && onMerge;
return ( return (
<Card className="bg-destructive/5 border-destructive/20 sticky top-0 z-10 mb-4"> <Card className="bg-destructive/5 border-destructive/20 sticky top-0 z-10 mb-4">
<CardContent className={cn("py-3", isMobile && "px-3")}> <CardContent className={cn("py-3", isMobile && "px-3")}>
<div <div
className={cn( className={cn(
"flex items-center gap-2 sm:gap-4", "flex items-center gap-2 sm:gap-4 flex-wrap",
isMobile && "flex-col sm:flex-row", isMobile && "flex-col sm:flex-row",
)} )}
> >
@@ -32,6 +38,19 @@ export function AccountBulkActions({
{selectedCount} compte{selectedCount > 1 ? "s" : ""} sélectionné {selectedCount} compte{selectedCount > 1 ? "s" : ""} sélectionné
{selectedCount > 1 ? "s" : ""} {selectedCount > 1 ? "s" : ""}
</span> </span>
{canMerge && (
<Button
size={isMobile ? "sm" : "sm"}
variant="default"
onClick={onMerge}
className={cn(isMobile && "w-full sm:w-auto")}
>
<GitMerge
className={cn(isMobile ? "w-3.5 h-3.5" : "w-4 h-4", "mr-1")}
/>
Fusionner
</Button>
)}
<Button <Button
size={isMobile ? "sm" : "sm"} size={isMobile ? "sm" : "sm"}
variant="destructive" variant="destructive"

View File

@@ -29,6 +29,7 @@ interface AccountCardProps {
account: Account; account: Account;
folder?: Folder; folder?: Folder;
transactionCount: number; transactionCount: number;
calculatedBalance?: number;
onEdit: (account: Account) => void; onEdit: (account: Account) => void;
onDelete: (accountId: string) => void; onDelete: (accountId: string) => void;
formatCurrency: (amount: number) => string; formatCurrency: (amount: number) => string;
@@ -42,6 +43,7 @@ export function AccountCard({
account, account,
folder, folder,
transactionCount, transactionCount,
calculatedBalance,
onEdit, onEdit,
onDelete, onDelete,
formatCurrency, formatCurrency,
@@ -53,6 +55,9 @@ 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 &&
Math.abs(account.balance - calculatedBalance) > 0.01;
const { const {
attributes, attributes,
@@ -82,7 +87,6 @@ export function AccountCard({
"relative group", "relative group",
isSelected && "ring-2 ring-primary shadow-lg shadow-primary/10", isSelected && "ring-2 ring-primary shadow-lg shadow-primary/10",
isDragging && "bg-muted/80 opacity-60", isDragging && "bg-muted/80 opacity-60",
"hover:scale-[1.02] transition-transform duration-200",
)} )}
> >
<CardHeader className={cn("pb-0", isMobile && "px-2 pt-2")}> <CardHeader className={cn("pb-0", isMobile && "px-2 pt-2")}>
@@ -173,6 +177,7 @@ export function AccountCard({
className={cn(isMobile ? "px-2 pb-2 pt-1" : "pt-1", compact && "pt-0")} className={cn(isMobile ? "px-2 pb-2 pt-1" : "pt-1", compact && "pt-0")}
> >
<div className="flex items-center justify-between gap-1.5"> <div className="flex items-center justify-between gap-1.5">
<div className="flex-1 min-w-0">
<div <div
className={cn( className={cn(
"font-bold truncate", "font-bold truncate",
@@ -184,11 +189,25 @@ 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)}
</div> </div>
{!compact && calculatedBalance !== undefined && (
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">
Calculé: {formatCurrency(calculatedBalance)}
</span>
{hasBalanceDifference && (
<span className="text-xs text-destructive font-semibold">
(diff: {formatCurrency(account.balance - calculatedBalance)}
)
</span>
)}
</div>
)}
</div>
{compact && ( {compact && (
<Link <Link
href={`/transactions?accountId=${account.id}`} href={`/transactions?accountId=${account.id}`}

View File

@@ -25,6 +25,7 @@ interface AccountFormData {
folderId: string; folderId: string;
externalUrl: string; externalUrl: string;
initialBalance: number; initialBalance: number;
totalBalance: number;
} }
interface AccountEditDialogProps { interface AccountEditDialogProps {
@@ -101,21 +102,43 @@ export function AccountEditDialog({
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Solde initial</Label> <Label>Solde total</Label>
<Input <Input
type="number" type="number"
step="0.01" step="0.01"
value={formData.initialBalance} value={formData.totalBalance}
onChange={(e) => onChange={(e) =>
onFormDataChange({ onFormDataChange({
...formData, ...formData,
initialBalance: parseFloat(e.target.value) || 0, totalBalance: parseFloat(e.target.value) || 0,
}) })
} }
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 Solde total du compte (balance + solde initial)
</p>
</div>
<div className="space-y-2">
<Label>Solde initial</Label>
<Input
type="number"
step="0.01"
value={formData.initialBalance}
onChange={(e) => {
const newInitialBalance = parseFloat(e.target.value) || 0;
onFormDataChange({
...formData,
initialBalance: newInitialBalance,
// Ajuster le solde total pour maintenir la cohérence
totalBalance: formData.totalBalance,
});
}}
placeholder="0.00"
/>
<p className="text-xs text-muted-foreground">
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

@@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertTriangle, Info } from "lucide-react";
import type { Account } from "@/lib/types";
import { getAccountBalance } from "@/lib/account-utils";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
interface AccountMergeSelectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
accounts: Account[];
selectedAccountIds: string[];
onMerge: (sourceAccountId: string, targetAccountId: string) => Promise<void>;
formatCurrency: (amount: number) => string;
}
export function AccountMergeSelectDialog({
open,
onOpenChange,
accounts,
selectedAccountIds,
onMerge,
formatCurrency,
}: AccountMergeSelectDialogProps) {
const [targetAccountId, setTargetAccountId] = useState<string>("");
const [isMerging, setIsMerging] = useState(false);
const selectedAccounts = accounts.filter((a) =>
selectedAccountIds.includes(a.id),
);
const sourceAccountId =
selectedAccounts.length === 2 && targetAccountId
? selectedAccounts.find((a) => a.id !== targetAccountId)?.id || ""
: "";
const sourceAccount = accounts.find((a) => a.id === sourceAccountId);
const targetAccount = accounts.find((a) => a.id === targetAccountId);
// Initialiser avec le premier compte si pas encore sélectionné
if (open && selectedAccounts.length === 2 && !targetAccountId) {
setTargetAccountId(selectedAccounts[0].id);
}
const handleMerge = async () => {
if (
!sourceAccountId ||
!targetAccountId ||
sourceAccountId === targetAccountId
) {
return;
}
setIsMerging(true);
try {
await onMerge(sourceAccountId, targetAccountId);
setTargetAccountId("");
onOpenChange(false);
} catch (error) {
console.error("Error merging accounts:", error);
alert("Erreur lors de la fusion des comptes");
} finally {
setIsMerging(false);
}
};
const canMerge =
selectedAccounts.length === 2 &&
sourceAccountId &&
targetAccountId &&
sourceAccountId !== targetAccountId &&
sourceAccount &&
targetAccount;
if (selectedAccounts.length !== 2) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Fusionner des comptes</DialogTitle>
<DialogDescription>
Choisissez quel compte conserver. Toutes les transactions de l'autre
compte seront déplacées vers celui-ci.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Le compte sélectionné sera conservé et utilisé pour les prochains
imports. Choisissez celui qui a le bon bankId.
</AlertDescription>
</Alert>
<div className="space-y-3">
<Label>Compte à conserver (destination)</Label>
<RadioGroup
value={targetAccountId}
onValueChange={setTargetAccountId}
>
{selectedAccounts.map((account) => (
<div
key={account.id}
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"
/>
<Label
htmlFor={account.id}
className="flex-1 cursor-pointer space-y-1"
>
<div className="font-medium">{account.name}</div>
<div className="text-xs text-muted-foreground space-y-0.5">
<div>Numéro: {account.accountNumber}</div>
<div>Bank ID: {account.bankId}</div>
<div>
Solde: {formatCurrency(getAccountBalance(account))}
</div>
</div>
</Label>
</div>
))}
</RadioGroup>
</div>
{canMerge && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>Attention :</strong> Toutes les transactions du compte "
{sourceAccount.name}" seront déplacées vers "
{targetAccount.name}". Le compte "{sourceAccount.name}" sera
supprimé après la fusion. Cette action est irréversible.
</AlertDescription>
</Alert>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button onClick={handleMerge} disabled={!canMerge || isMerging}>
{isMerging ? "Fusion en cours..." : "Fusionner"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,4 +1,5 @@
export { AccountCard } from "./account-card"; export { AccountCard } from "./account-card";
export { AccountEditDialog } from "./account-edit-dialog"; export { AccountEditDialog } from "./account-edit-dialog";
export { AccountMergeSelectDialog } from "./account-merge-select-dialog";
export { AccountBulkActions } from "./account-bulk-actions"; export { AccountBulkActions } from "./account-bulk-actions";
export { accountTypeIcons, accountTypeLabels } from "./constants"; export { accountTypeIcons, accountTypeLabels } from "./constants";

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { CategoryIcon } from "@/components/ui/category-icon"; import { CategoryIcon } from "@/components/ui/category-icon";
@@ -25,7 +26,7 @@ export function CategoryCard({
const isMobile = useIsMobile(); const isMobile = useIsMobile();
return ( return (
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group"> <div className="flex items-center justify-between py-1.5 px-2 rounded group">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<div <div
className="w-4 h-4 md:w-5 md:h-5 rounded-full flex items-center justify-center shrink-0" className="w-4 h-4 md:w-5 md:h-5 rounded-full flex items-center justify-center shrink-0"
@@ -37,7 +38,13 @@ export function CategoryCard({
size={isMobile ? 10 : 12} size={isMobile ? 10 : 12}
/> />
</div> </div>
<span className="text-xs md:text-sm truncate">{category.name}</span> <Link
href={`/transactions?categoryIds=${category.id}`}
className="text-xs md:text-sm truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{category.name}
</Link>
{!isMobile && ( {!isMobile && (
<span className="text-xs md:text-sm text-muted-foreground shrink-0"> <span className="text-xs md:text-sm text-muted-foreground shrink-0">
{stats.count} opération{stats.count > 1 ? "s" : ""} {" "} {stats.count} opération{stats.count > 1 ? "s" : ""} {" "}
@@ -59,7 +66,7 @@ export function CategoryCard({
)} )}
</div> </div>
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 md:opacity-100 transition-opacity"> <div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 md:opacity-100">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -12,6 +13,7 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { Card } from "@/components/ui/card";
import { CategoryIcon } from "@/components/ui/category-icon"; import { CategoryIcon } from "@/components/ui/category-icon";
import { import {
Plus, Plus,
@@ -53,11 +55,11 @@ export function ParentCategoryRow({
const isMobile = useIsMobile(); const isMobile = useIsMobile();
return ( return (
<div className="border rounded-lg bg-card"> <Card className="card-hover">
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}> <Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
<div className="flex items-center justify-between px-2 md:px-3 py-1.5 md:py-2"> <div className="flex items-center justify-between px-2 md:px-3 py-1.5 md:py-2">
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button className="flex items-center gap-1.5 md:gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0"> <button className="flex items-center gap-1.5 md:gap-2 hover:opacity-80 flex-1 min-w-0">
{isExpanded ? ( {isExpanded ? (
<ChevronDown className="w-3 h-3 md:w-4 md:h-4 text-muted-foreground shrink-0" /> <ChevronDown className="w-3 h-3 md:w-4 md:h-4 text-muted-foreground shrink-0" />
) : ( ) : (
@@ -73,9 +75,13 @@ export function ParentCategoryRow({
size={isMobile ? 10 : 14} size={isMobile ? 10 : 14}
/> />
</div> </div>
<span className="font-medium text-xs md:text-sm truncate"> <Link
href={`/transactions?categoryIds=${parent.id}`}
className="font-medium text-xs md:text-sm truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{parent.name} {parent.name}
</span> </Link>
{!isMobile && ( {!isMobile && (
<span className="text-xs md:text-sm text-muted-foreground shrink-0"> <span className="text-xs md:text-sm text-muted-foreground shrink-0">
{children.length} {stats.count} opération {children.length} {stats.count} opération
@@ -119,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
@@ -150,6 +156,6 @@ export function ParentCategoryRow({
)} )}
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</div> </Card>
); );
} }

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)}
@@ -113,11 +113,11 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
return ( return (
<div <div
key={account.id} key={account.id}
className="space-y-2.5 p-3 rounded-xl bg-muted/30 hover:bg-muted/50 border border-border/50 hover:border-primary/20 transition-all duration-300 group" className="space-y-2.5 p-3 rounded-xl bg-muted/30 border border-border/50 group"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center group-hover:scale-110 transition-transform duration-300"> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center">
<Building2 className="w-5 h-5 text-primary" /> <Building2 className="w-5 h-5 text-primary" />
</div> </div>
<div> <div>
@@ -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)}
@@ -225,11 +223,11 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
return ( return (
<div <div
key={account.id} key={account.id}
className="space-y-2.5 p-3 rounded-xl bg-muted/30 hover:bg-muted/50 border border-border/50 hover:border-primary/20 transition-all duration-300 group" className="space-y-2.5 p-3 rounded-xl bg-muted/30 border border-border/50 group"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center group-hover:scale-110 transition-transform duration-300"> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center">
<Building2 className="w-5 h-5 text-primary" /> <Building2 className="w-5 h-5 text-primary" />
</div> </div>
<div> <div>
@@ -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

@@ -36,10 +36,43 @@ export function CategoryBreakdown({ data }: CategoryBreakdownProps) {
value: total, value: total,
color: category?.color || "#94a3b8", color: category?.color || "#94a3b8",
icon: category?.icon || "HelpCircle", icon: category?.icon || "HelpCircle",
categoryId: categoryId === "uncategorized" ? null : categoryId,
}; };
}) })
.sort((a, b) => b.value - a.value) .sort((a, b) => b.value - a.value);
.slice(0, 6);
// Category breakdown grouped by parent
const categoryTotalsByParent = new Map<string, number>();
monthExpenses.forEach((t) => {
const category = data.categories.find((c) => c.id === t.categoryId);
// Use parent category ID if exists, otherwise use the category itself
let groupId: string;
if (!category) {
groupId = "uncategorized";
} else if (category.parentId) {
groupId = category.parentId;
} else {
// Category is a parent itself
groupId = category.id;
}
const current = categoryTotalsByParent.get(groupId) || 0;
categoryTotalsByParent.set(groupId, current + Math.abs(t.amount));
});
const chartDataByParent: CategoryChartData[] = Array.from(
categoryTotalsByParent.entries(),
)
.map(([groupId, total]) => {
const category = data.categories.find((c) => c.id === groupId);
return {
name: category?.name || "Non catégorisé",
value: total,
color: category?.color || "#94a3b8",
icon: category?.icon || "HelpCircle",
categoryId: groupId === "uncategorized" ? null : groupId,
};
})
.sort((a, b) => b.value - a.value);
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
@@ -51,6 +84,8 @@ export function CategoryBreakdown({ data }: CategoryBreakdownProps) {
return ( return (
<CategoryPieChart <CategoryPieChart
data={chartData} data={chartData}
dataByParent={chartDataByParent}
categories={data.categories}
formatCurrency={formatCurrency} formatCurrency={formatCurrency}
title="Dépenses par catégorie" title="Dépenses par catégorie"
height={250} height={250}

View File

@@ -1,7 +1,13 @@
"use client"; "use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown, Wallet, CreditCard } from "lucide-react"; import {
TrendingUp,
TrendingDown,
Wallet,
CreditCard,
Tag,
} from "lucide-react";
import type { BankingData } from "@/lib/types"; import type { BankingData } from "@/lib/types";
import { getAccountBalance } from "@/lib/account-utils"; import { getAccountBalance } from "@/lib/account-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -35,7 +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(
(t) => t.categoryId !== null,
).length;
const categorizedPercent =
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", {
@@ -45,49 +57,53 @@ export function OverviewCards({ data }: OverviewCardsProps) {
}; };
return ( return (
<div className="grid gap-4 sm:gap-6 grid-cols-2 lg:grid-cols-4"> <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">
<div className="absolute inset-0 bg-gradient-to-br from-primary/8 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" /> {/* Icône en arrière-plan */}
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest"> <Wallet
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-primary"
strokeWidth={1}
/>
</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">
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
Solde Total Solde Total
</CardTitle> </CardTitle>
<div className="rounded-2xl bg-gradient-to-br from-primary/30 via-primary/20 to-primary/10 p-3 shrink-0 group-hover:scale-110 group-hover:rotate-3 transition-all duration-300 shadow-lg shadow-primary/20">
<Wallet className="h-5 w-5 text-primary" />
</div>
</CardHeader> </CardHeader>
<CardContent className="px-6 pb-6 sm:px-7 sm:pb-7 lg:px-5 lg:pb-5 pt-0 relative z-10"> <CardContent className="px-5 pb-5 sm:px-6 sm:pb-6 pt-0 relative z-10">
<div <div
className={cn( className={cn(
"text-2xl sm:text-3xl md:text-3xl lg:text-xl xl:text-xl font-black tracking-tight mb-4 leading-none 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)}
</div> </div>
<p className="text-xs sm:text-sm font-semibold text-muted-foreground/60"> <p className="text-xs sm:text-sm font-medium text-muted-foreground/70">
{data.accounts.length} compte{data.accounts.length > 1 ? "s" : ""} {data.accounts.length} compte{data.accounts.length > 1 ? "s" : ""}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="stat-card-gradient-2 card-hover group relative overflow-hidden"> <Card className="stat-card-gradient-2 card-hover group relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-success/8 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" /> {/* Icône en arrière-plan */}
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest"> <TrendingUp
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-success"
strokeWidth={1}
/>
</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">
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
Revenus du mois Revenus du mois
</CardTitle> </CardTitle>
<div className="rounded-2xl bg-gradient-to-br from-success/30 via-success/20 to-success/10 p-3 shrink-0 group-hover:scale-110 group-hover:rotate-3 transition-all duration-300 shadow-lg shadow-success/20">
<TrendingUp className="h-5 w-5 text-success" />
</div>
</CardHeader> </CardHeader>
<CardContent className="px-6 pb-6 sm:px-7 sm:pb-7 lg:px-5 lg:pb-5 pt-0 relative z-10"> <CardContent className="px-5 pb-5 sm:px-6 sm:pb-6 pt-0 relative z-10">
<div className="text-2xl sm:text-3xl md:text-3xl lg:text-xl xl:text-xl font-black tracking-tight text-success mb-4 leading-none break-words"> <div className="text-2xl sm:text-3xl md:text-3xl lg:text-2xl xl:text-3xl font-black tracking-tight text-success mb-3 leading-tight break-words">
{formatCurrency(income)} {formatCurrency(income)}
</div> </div>
<p className="text-xs sm:text-sm font-semibold text-muted-foreground/60"> <p className="text-xs sm:text-sm font-medium text-muted-foreground/70">
{monthTransactions.filter((t) => t.amount > 0).length} opération {monthTransactions.filter((t) => t.amount > 0).length} opération
{monthTransactions.filter((t) => t.amount > 0).length > 1 {monthTransactions.filter((t) => t.amount > 0).length > 1
? "s" ? "s"
@@ -97,20 +113,23 @@ export function OverviewCards({ data }: OverviewCardsProps) {
</Card> </Card>
<Card className="stat-card-gradient-3 card-hover group relative overflow-hidden"> <Card className="stat-card-gradient-3 card-hover group relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-destructive/8 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" /> {/* Icône en arrière-plan */}
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest"> <TrendingDown
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-destructive"
strokeWidth={1}
/>
</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">
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
Dépenses du mois Dépenses du mois
</CardTitle> </CardTitle>
<div className="rounded-2xl bg-gradient-to-br from-destructive/30 via-destructive/20 to-destructive/10 p-3 shrink-0 group-hover:scale-110 group-hover:rotate-3 transition-all duration-300 shadow-lg shadow-destructive/20">
<TrendingDown className="h-5 w-5 text-destructive" />
</div>
</CardHeader> </CardHeader>
<CardContent className="px-6 pb-6 sm:px-7 sm:pb-7 lg:px-5 lg:pb-5 pt-0 relative z-10"> <CardContent className="px-5 pb-5 sm:px-6 sm:pb-6 pt-0 relative z-10">
<div className="text-2xl sm:text-3xl md:text-3xl lg:text-xl xl:text-xl font-black tracking-tight text-destructive mb-4 leading-none break-words"> <div className="text-2xl sm:text-3xl md:text-3xl lg:text-2xl xl:text-3xl font-black tracking-tight text-destructive mb-3 leading-tight break-words">
{formatCurrency(expenses)} {formatCurrency(expenses)}
</div> </div>
<p className="text-xs sm:text-sm font-semibold text-muted-foreground/60"> <p className="text-xs sm:text-sm font-medium text-muted-foreground/70">
{monthTransactions.filter((t) => t.amount < 0).length} opération {monthTransactions.filter((t) => t.amount < 0).length} opération
{monthTransactions.filter((t) => t.amount < 0).length > 1 {monthTransactions.filter((t) => t.amount < 0).length > 1
? "s" ? "s"
@@ -120,24 +139,50 @@ export function OverviewCards({ data }: OverviewCardsProps) {
</Card> </Card>
<Card className="stat-card-gradient-4 card-hover group relative overflow-hidden"> <Card className="stat-card-gradient-4 card-hover group relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-chart-4/8 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" /> {/* Icône en arrière-plan */}
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest"> <CreditCard
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-chart-4"
strokeWidth={1}
/>
</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">
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
Pointage Pointage
</CardTitle> </CardTitle>
<div className="rounded-2xl bg-gradient-to-br from-chart-4/30 via-chart-4/20 to-chart-4/10 p-3 shrink-0 group-hover:scale-110 group-hover:rotate-3 transition-all duration-300 shadow-lg shadow-chart-4/20">
<CreditCard className="h-5 w-5 text-chart-4" />
</div>
</CardHeader> </CardHeader>
<CardContent className="px-6 pb-6 sm:px-7 sm:pb-7 lg:px-5 lg:pb-5 pt-0 relative z-10"> <CardContent className="px-5 pb-5 sm:px-6 sm:pb-6 pt-0 relative z-10">
<div className="text-2xl sm:text-3xl md:text-3xl lg:text-xl xl:text-xl font-black tracking-tight mb-4 leading-none break-words"> <div className="text-2xl sm:text-3xl md:text-3xl lg:text-2xl xl:text-3xl font-black tracking-tight mb-3 leading-tight break-words">
{reconciledPercent}% {reconciledPercent}%
</div> </div>
<p className="text-xs sm:text-sm font-semibold text-muted-foreground/60"> <p className="text-xs sm:text-sm font-medium text-muted-foreground/70">
{reconciled} / {total} opérations pointées {reconciled} / {total} opérations pointées
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="stat-card-gradient-5 card-hover group relative overflow-hidden">
{/* Icône en arrière-plan */}
<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}
/>
</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">
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
Catégorisation
</CardTitle>
</CardHeader>
<CardContent className="px-5 pb-5 sm:px-6 sm:pb-6 pt-0 relative z-10">
<div className="text-2xl sm:text-3xl md:text-3xl lg:text-2xl xl:text-3xl font-black tracking-tight mb-3 leading-tight break-words">
{categorizedPercent}%
</div>
<p className="text-xs sm:text-sm font-medium text-muted-foreground/70">
{categorized} / {total} opérations catégorisées
</p>
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -61,13 +61,13 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
} }
return ( return (
<Card className="card-hover"> <Card className="card-hover relative overflow-hidden">
<CardHeader className="pb-5"> <CardHeader className="pb-5 relative z-10">
<CardTitle className="text-lg md:text-xl font-black tracking-tight"> <CardTitle className="text-lg md:text-xl font-black tracking-tight">
Transactions récentes Transactions récentes
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="px-5 md:px-6"> <CardContent className="px-5 md:px-6 relative z-10">
<div className="space-y-3"> <div className="space-y-3">
{recentTransactions.map((transaction) => { {recentTransactions.map((transaction) => {
const category = getCategory(transaction.categoryId); const category = getCategory(transaction.categoryId);
@@ -76,7 +76,7 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
return ( return (
<div <div
key={transaction.id} key={transaction.id}
className="group rounded-2xl bg-gradient-to-r from-muted/50 via-muted/30 to-muted/20 hover:from-muted/70 hover:via-muted/50 hover:to-muted/40 border-2 border-border/40 hover:border-primary/30 transition-all duration-300 overflow-hidden hover:shadow-lg hover:shadow-primary/10 hover:scale-[1.02] backdrop-blur-sm" className="group rounded-2xl bg-gradient-to-r from-muted/50 via-muted/30 to-muted/20 border-2 border-border/40 overflow-hidden backdrop-blur-sm"
> >
<div className="flex items-start gap-4 md:gap-5 p-4 md:p-5"> <div className="flex items-start gap-4 md:gap-5 p-4 md:p-5">
<div className="flex-1 min-w-0 overflow-hidden"> <div className="flex-1 min-w-0 overflow-hidden">
@@ -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

@@ -1,6 +1,5 @@
"use client"; "use client";
import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
@@ -19,8 +18,9 @@ import {
LogOut, LogOut,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Sheet, SheetContent } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { useLocalStorage } from "@/hooks/use-local-storage";
const navItems = [ const navItems = [
{ href: "/", label: "Tableau de bord", icon: LayoutDashboard }, { href: "/", label: "Tableau de bord", icon: LayoutDashboard },
@@ -77,29 +77,27 @@ function SidebarContent({
</div> </div>
)} )}
<nav className={cn( <nav className={cn("flex-1 pt-2", collapsed ? "p-2" : "p-4")}>
"flex-1 space-y-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 transition-all duration-300", "w-full justify-start gap-3 h-12 rounded-2xl px-3",
"hover:bg-muted/70 hover:scale-[1.02] hover:shadow-md",
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 transition-all duration-300",
isActive && "text-primary scale-110",
)}
/> />
{!collapsed && ( {!collapsed && (
<span <span
@@ -117,15 +115,17 @@ function SidebarContent({
})} })}
</nav> </nav>
<div className={cn( <div
"border-t border-border/30 space-y-2", className={cn(
collapsed ? "p-2" : "p-4" "border-t border-border/30 pt-2",
)}> 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 transition-all duration-300 hover:bg-muted/70 hover:scale-[1.02] hover:shadow-md", "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",
)} )}
> >
@@ -139,8 +139,8 @@ function SidebarContent({
variant="ghost" variant="ghost"
onClick={handleSignOut} onClick={handleSignOut}
className={cn( className={cn(
"w-full justify-start gap-4 h-12 rounded-2xl transition-all duration-300", "w-full justify-start gap-3 h-12 rounded-2xl px-3 mb-2",
"text-destructive hover:text-destructive hover:bg-destructive/15 hover:scale-[1.02] hover:shadow-md", "text-destructive",
collapsed && "justify-center px-2 w-12 mx-auto", collapsed && "justify-center px-2 w-12 mx-auto",
)} )}
> >
@@ -160,13 +160,18 @@ interface SidebarProps {
} }
export function Sidebar({ open, onOpenChange }: SidebarProps) { export function Sidebar({ open, onOpenChange }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useLocalStorage("sidebar-collapsed", false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
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>
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<SidebarContent <SidebarContent
showHeader showHeader
@@ -181,15 +186,17 @@ 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 transition-all duration-300", "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",
)} )}
> >
<div className={cn( <div
"flex items-center border-b border-border/30 transition-all duration-300", className={cn(
collapsed ? "justify-center p-4" : "justify-between p-6" "flex items-center border-b border-border/30",
)}> collapsed ? "justify-center p-4" : "justify-between p-6",
)}
>
{!collapsed && ( {!collapsed && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary via-primary/90 to-primary/80 flex items-center justify-center shadow-xl shadow-primary/30 backdrop-blur-sm"> <div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary via-primary/90 to-primary/80 flex items-center justify-center shadow-xl shadow-primary/30 backdrop-blur-sm">
@@ -204,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")}
"hover:bg-muted/60 rounded-xl transition-all duration-300 hover:scale-110",
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,12 +61,16 @@ 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 && ( {rightContent && <div className="shrink-0">{rightContent}</div>}
<div className="shrink-0">{rightContent}</div>
)}
</div> </div>
{description && ( {description && (
<div className="text-base md:text-lg text-muted-foreground/70 font-semibold"> <div className="text-base md:text-lg text-muted-foreground/70 font-semibold">

View File

@@ -15,10 +15,10 @@ export function PageLayout({ children }: PageLayoutProps) {
<SidebarContext.Provider <SidebarContext.Provider
value={{ open: sidebarOpen, setOpen: setSidebarOpen }} value={{ open: sidebarOpen, setOpen: setSidebarOpen }}
> >
<div className="flex h-screen bg-background overflow-hidden page-background"> <div className="flex h-screen bg-background overflow-hidden page-background bg-default">
<Sidebar open={sidebarOpen} onOpenChange={setSidebarOpen} /> <Sidebar open={sidebarOpen} onOpenChange={setSidebarOpen} />
<main className="flex-1 overflow-auto overflow-x-hidden page-content"> <main className="flex-1 overflow-auto overflow-x-hidden page-content">
<div className="p-6 md:p-8 lg:p-10 space-y-6 md:space-y-8 max-w-full"> <div className="p-3 md:p-8 lg:p-10 space-y-4 md:space-y-8 max-w-full">
{children} {children}
</div> </div>
</main> </main>

View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect } from "react";
interface BackgroundSettings {
type: string;
customImageUrl?: string;
}
export function BackgroundProvider() {
useEffect(() => {
const applyBackground = () => {
try {
const pageBackground = document.querySelector(
".page-background",
) as HTMLElement;
if (!pageBackground) return;
const stored = localStorage.getItem("background-settings");
const settings: BackgroundSettings = stored
? JSON.parse(stored)
: { type: "default" };
// Retirer toutes les classes de fond
pageBackground.classList.remove(
"bg-default",
"bg-gradient-blue",
"bg-gradient-purple",
"bg-gradient-green",
"bg-gradient-orange",
"bg-solid-light",
"bg-solid-dark",
"bg-custom-image",
);
const root = document.documentElement;
if (settings.type === "custom-image" && settings.customImageUrl) {
pageBackground.classList.add("bg-custom-image");
root.style.setProperty(
"--custom-background-image",
`url(${settings.customImageUrl})`,
);
} else {
pageBackground.classList.add(`bg-${settings.type || "default"}`);
root.style.removeProperty("--custom-background-image");
}
} catch (error) {
console.error("Error applying background:", error);
}
};
// Appliquer immédiatement
applyBackground();
// Observer pour les changements de DOM (si le page-background est ajouté plus tard)
const observer = new MutationObserver(() => {
applyBackground();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
// Écouter les changements dans localStorage (autres onglets)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === "background-settings") {
applyBackground();
}
};
// Écouter les événements personnalisés (même onglet)
const handleBackgroundChanged = () => {
applyBackground();
};
window.addEventListener("storage", handleStorageChange);
window.addEventListener("background-changed", handleBackgroundChanged);
return () => {
observer.disconnect();
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener("background-changed", handleBackgroundChanged);
};
}, []);
return null;
}

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

@@ -5,6 +5,7 @@ import { ChevronDown, ChevronRight, Plus, Tag } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { CategoryCombobox } from "@/components/ui/category-combobox"; import { CategoryCombobox } from "@/components/ui/category-combobox";
import { Card } from "@/components/ui/card";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Transaction, Category } from "@/lib/types"; import type { Transaction, Category } from "@/lib/types";
@@ -54,10 +55,10 @@ export function RuleGroupCard({
}; };
return ( return (
<div className="border border-border rounded-lg bg-card overflow-hidden"> <Card className="card-hover overflow-hidden">
{/* Header */} {/* Header */}
<div <div
className="flex flex-col md:flex-row md:items-center gap-2 md:gap-3 p-3 md:p-4 cursor-pointer hover:bg-accent/5 transition-colors" className="flex flex-col md:flex-row md:items-center gap-2 md:gap-3 p-3 md:p-4 cursor-pointer hover:bg-accent/5"
onClick={onToggleExpand} onClick={onToggleExpand}
> >
<div className="flex items-center gap-2 md:gap-3 flex-1 min-w-0"> <div className="flex items-center gap-2 md:gap-3 flex-1 min-w-0">
@@ -247,6 +248,6 @@ export function RuleGroupCard({
)} )}
</div> </div>
)} )}
</div> </Card>
); );
} }

View File

@@ -0,0 +1,326 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Image, X } from "lucide-react";
import { useLocalStorage } from "@/hooks/use-local-storage";
import { cn } from "@/lib/utils";
type BackgroundType =
| "default"
| "gradient-blue"
| "gradient-purple"
| "gradient-green"
| "gradient-orange"
| "solid-light"
| "solid-dark"
| "custom-image";
interface BackgroundSettings {
type: BackgroundType;
customImageUrl?: string;
}
const DEFAULT_BACKGROUNDS: Array<{
value: BackgroundType;
label: string;
preview: string;
description?: string;
}> = [
{
value: "default",
label: "Neutre",
preview:
"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",
label: "Océan",
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",
label: "Améthyste",
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",
label: "Forêt",
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",
label: "Aurore",
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",
label: "Lumineux",
preview:
"linear-gradient(135deg, oklch(1 0 0) 0%, oklch(0.98 0.005 260) 100%)",
description: "Fond blanc épuré",
},
{
value: "solid-dark",
label: "Minuit",
preview:
"linear-gradient(135deg, oklch(0.18 0.02 260) 0%, oklch(0.08 0.015 250) 100%)",
description: "Fond sombre immersif",
},
];
export function BackgroundCard() {
const [backgroundSettings, setBackgroundSettings] =
useLocalStorage<BackgroundSettings>("background-settings", {
type: "default",
});
const currentSettings = useMemo<BackgroundSettings>(
() => backgroundSettings || { type: "default" },
[backgroundSettings],
);
const [customImageUrl, setCustomImageUrl] = useState(
currentSettings.customImageUrl || "",
);
const [showCustomInput, setShowCustomInput] = useState(
currentSettings.type === "custom-image",
);
// Synchroniser customImageUrl avec les settings
useEffect(() => {
if (
currentSettings.type === "custom-image" &&
currentSettings.customImageUrl
) {
setCustomImageUrl(currentSettings.customImageUrl);
}
}, [currentSettings]);
const applyBackground = (settings: BackgroundSettings) => {
const root = document.documentElement;
const pageBackground = document.querySelector(
".page-background",
) as HTMLElement;
if (!pageBackground) return;
// Retirer toutes les classes de fond
pageBackground.classList.remove(
"bg-default",
"bg-gradient-blue",
"bg-gradient-purple",
"bg-gradient-green",
"bg-gradient-orange",
"bg-solid-light",
"bg-solid-dark",
"bg-custom-image",
);
if (settings.type === "custom-image" && settings.customImageUrl) {
pageBackground.classList.add("bg-custom-image");
root.style.setProperty(
"--custom-background-image",
`url(${settings.customImageUrl})`,
);
} else {
pageBackground.classList.add(`bg-${settings.type || "default"}`);
root.style.removeProperty("--custom-background-image");
}
// Déclencher un événement personnalisé pour notifier les autres composants
window.dispatchEvent(
new CustomEvent("background-changed", { detail: settings }),
);
};
const handleBackgroundChange = (type: BackgroundType) => {
if (type === "custom-image") {
setShowCustomInput(true);
if (customImageUrl.trim()) {
const newSettings: BackgroundSettings = {
type,
customImageUrl: customImageUrl.trim(),
};
setBackgroundSettings(newSettings);
applyBackground(newSettings);
} else {
const newSettings: BackgroundSettings = { type };
setBackgroundSettings(newSettings);
}
} else {
setShowCustomInput(false);
const newSettings: BackgroundSettings = { type };
setBackgroundSettings(newSettings);
applyBackground(newSettings);
}
};
const handleCustomImageSubmit = () => {
if (customImageUrl.trim()) {
const newSettings: BackgroundSettings = {
type: "custom-image",
customImageUrl: customImageUrl.trim(),
};
setBackgroundSettings(newSettings);
applyBackground(newSettings);
}
};
const handleRemoveCustomImage = () => {
setCustomImageUrl("");
setShowCustomInput(false);
const newSettings: BackgroundSettings = { type: "default" };
setBackgroundSettings(newSettings);
applyBackground(newSettings);
};
// Appliquer le fond au chargement et quand il change
useEffect(() => {
if (typeof window !== "undefined") {
applyBackground(currentSettings);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSettings.type, currentSettings.customImageUrl]);
return (
<Card className="card-hover">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Image className="w-5 h-5" />
Fond du site
</CardTitle>
<CardDescription>
Personnalisez l'apparence du fond de l'application
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<RadioGroup
value={currentSettings.type}
onValueChange={(value) =>
handleBackgroundChange(value as BackgroundType)
}
>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{DEFAULT_BACKGROUNDS.map((bg) => (
<label
key={bg.value}
htmlFor={`bg-${bg.value}`}
className={cn(
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
currentSettings.type === bg.value
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50",
)}
>
<RadioGroupItem
value={bg.value}
id={`bg-${bg.value}`}
className="sr-only"
/>
<div
className="w-full h-16 rounded-md mb-2 border border-border/50"
style={{ background: bg.preview }}
/>
<span className="text-xs font-medium text-center">
{bg.label}
</span>
</label>
))}
<label
htmlFor="bg-custom-image"
className={cn(
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
currentSettings.type === "custom-image"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50",
)}
>
<RadioGroupItem
value="custom-image"
id="bg-custom-image"
className="sr-only"
/>
<div className="w-full h-16 rounded-md mb-2 border border-border/50 bg-muted flex items-center justify-center">
<Image className="w-6 h-6 text-muted-foreground" />
</div>
<span className="text-xs font-medium text-center">
Image personnalisée
</span>
</label>
</div>
</RadioGroup>
{showCustomInput && (
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<div className="space-y-2">
<Label htmlFor="custom-image-url">URL de l'image</Label>
<div className="flex gap-2">
<Input
id="custom-image-url"
type="url"
placeholder="https://example.com/image.jpg"
value={customImageUrl}
onChange={(e) => setCustomImageUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCustomImageSubmit();
}
}}
/>
{customImageUrl && (
<Button
variant="outline"
size="icon"
onClick={handleRemoveCustomImage}
>
<X className="w-4 h-4" />
</Button>
)}
</div>
</div>
<Button
onClick={handleCustomImageSubmit}
disabled={!customImageUrl.trim()}
>
Appliquer l'image
</Button>
{currentSettings.type === "custom-image" &&
currentSettings.customImageUrl && (
<div className="mt-3">
<div className="text-xs text-muted-foreground mb-2">
Aperçu :
</div>
<div
className="w-full h-32 rounded-md border border-border bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url(${currentSettings.customImageUrl})`,
}}
/>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

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

@@ -3,3 +3,6 @@ export { DangerZoneCard } from "./danger-zone-card";
export { OFXInfoCard } from "./ofx-info-card"; export { OFXInfoCard } from "./ofx-info-card";
export { BackupCard } from "./backup-card"; export { BackupCard } from "./backup-card";
export { PasswordCard } from "./password-card"; export { PasswordCard } from "./password-card";
export { ReconcileDateRangeCard } from "./reconcile-date-range-card";
export { BackgroundCard } from "./background-card";
export { ThemeCard } from "./theme-card";

View File

@@ -0,0 +1,263 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { CheckCircle2, Calendar } from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { useQueryClient } from "@tanstack/react-query";
import { invalidateAllTransactionQueries } from "@/lib/cache-utils";
export function ReconcileDateRangeCard() {
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
const [isReconciling, setIsReconciling] = useState(false);
const queryClient = useQueryClient();
const handleReconcile = async () => {
if (!endDate) return;
setIsReconciling(true);
try {
const endDateStr = format(endDate, "yyyy-MM-dd");
const body: { endDate: string; startDate?: string; reconciled: boolean } =
{
endDate: endDateStr,
reconciled: true,
};
if (startDate) {
body.startDate = format(startDate, "yyyy-MM-dd");
}
const response = await fetch(
"/api/banking/transactions/reconcile-date-range",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Erreur lors du pointage");
}
const result = await response.json();
// Invalider toutes les requêtes de transactions pour rafraîchir les données
invalidateAllTransactionQueries(queryClient);
alert(
`${result.updatedCount} opération${result.updatedCount > 1 ? "s" : ""} pointée${result.updatedCount > 1 ? "s" : ""}`,
);
// Réinitialiser les dates
setStartDate(undefined);
setEndDate(undefined);
setIsDatePickerOpen(false);
} catch (error) {
console.error(error);
alert(
error instanceof Error
? error.message
: "Erreur lors du pointage des opérations",
);
} finally {
setIsReconciling(false);
}
};
const canReconcile = endDate && (!startDate || startDate <= endDate);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5" />
Pointer les opérations par date
</CardTitle>
<CardDescription>
Marquer toutes les opérations pointées dans une plage de dates
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Popover open={isDatePickerOpen} onOpenChange={setIsDatePickerOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
>
<Calendar className="mr-2 h-4 w-4" />
{endDate ? (
<>
{startDate ? (
<>
{format(startDate, "PPP", { locale: fr })} -{" "}
{format(endDate, "PPP", { locale: fr })}
</>
) : (
<>Jusqu'au {format(endDate, "PPP", { locale: fr })}</>
)}
</>
) : (
<span>Sélectionner une date de fin</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-3 flex gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Date de début{" "}
<span className="text-xs text-muted-foreground">
(optionnel)
</span>
</label>
<div className="scale-90 origin-top-left">
<CalendarComponent
mode="single"
selected={startDate}
onSelect={(date) => {
setStartDate(date);
if (date && endDate && date > endDate) {
setEndDate(undefined);
}
}}
locale={fr}
/>
</div>
{startDate && (
<Button
variant="ghost"
size="sm"
className="w-full text-xs"
onClick={() => setStartDate(undefined)}
>
Supprimer
</Button>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Date de fin</label>
<div className="scale-90 origin-top-left">
<CalendarComponent
mode="single"
selected={endDate}
onSelect={(date) => {
if (date && startDate && date < startDate) {
return;
}
setEndDate(date);
if (date && startDate) {
setIsDatePickerOpen(false);
}
}}
disabled={(date) => {
if (startDate) {
return date < startDate;
}
return false;
}}
locale={fr}
/>
</div>
</div>
</div>
{endDate && (
<div className="px-3 pb-3 text-sm text-muted-foreground">
{startDate ? (
<>
{format(startDate, "PPP", { locale: fr })} -{" "}
{format(endDate, "PPP", { locale: fr })}
</>
) : (
<>Jusqu'au {format(endDate, "PPP", { locale: fr })}</>
)}
</div>
)}
</PopoverContent>
</Popover>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="w-full"
disabled={!canReconcile || isReconciling}
>
<CheckCircle2 className="w-4 h-4 mr-2" />
Pointer les opérations
{isReconciling && (
<span className="ml-auto text-xs text-muted-foreground">
En cours...
</span>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Pointer toutes les opérations ?
</AlertDialogTitle>
<AlertDialogDescription>
{endDate && (
<>
Cette action va marquer toutes les opérations non pointées{" "}
{startDate ? (
<>
entre {format(startDate, "PPP", { locale: fr })} et{" "}
{format(endDate, "PPP", { locale: fr })}
</>
) : (
<>jusqu'au {format(endDate, "PPP", { locale: fr })}</>
)}{" "}
comme pointées. Seules les opérations non encore pointées
seront modifiées.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isReconciling}>
Annuler
</AlertDialogCancel>
<AlertDialogAction
onClick={handleReconcile}
disabled={isReconciling}
className="bg-primary hover:bg-primary/90"
>
{isReconciling ? "Pointage..." : "Pointer"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</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

@@ -57,7 +57,7 @@ export function BalanceLineChart({
}; };
return ( return (
<Card> <Card className="card-hover">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Évolution du solde</CardTitle> <CardTitle>Évolution du solde</CardTitle>
<div className="flex gap-1"> <div className="flex gap-1">
@@ -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 (
<div
className="px-3 py-2 rounded-lg shadow-lg"
style={{
backgroundColor: "var(--popover)",
border: "1px solid var(--border)", border: "1px solid var(--border)",
borderRadius: "8px", 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

@@ -1,5 +1,6 @@
"use client"; "use client";
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 { import {
@@ -29,8 +30,58 @@ export function CategoryBarChart({
}: CategoryBarChartProps) { }: CategoryBarChartProps) {
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
const CustomYAxisTick = ({
x,
y,
payload,
}: {
x: number;
y: number;
payload: { value: string };
}) => {
const categoryName = payload.value;
const item = displayData.find((d) => d.name === categoryName);
if (!item) {
return ( return (
<Card> <text
x={x}
y={y}
fill="var(--muted-foreground)"
fontSize={12}
textAnchor="end"
dominantBaseline="middle"
>
{categoryName}
</text>
);
}
const href =
item.categoryId === null || item.categoryId === undefined
? "/transactions?includeUncategorized=true"
: `/transactions?categoryIds=${item.categoryId}`;
return (
<Link href={href}>
<text
x={x}
y={y}
fill="var(--muted-foreground)"
fontSize={12}
className="hover:opacity-80 transition-opacity cursor-pointer"
textAnchor="end"
dominantBaseline="middle"
>
{categoryName}
</text>
</Link>
);
};
return (
<Card className="card-hover">
<CardHeader> <CardHeader>
<CardTitle>{title}</CardTitle> <CardTitle>{title}</CardTitle>
</CardHeader> </CardHeader>
@@ -60,11 +111,7 @@ export function CategoryBarChart({
dataKey="name" dataKey="name"
className="text-xs" className="text-xs"
width={90} width={90}
tick={{ fill: "var(--muted-foreground)" }} tick={CustomYAxisTick}
tickFormatter={(value) => {
const item = displayData.find((d) => d.name === value);
return item ? value : "";
}}
/> />
<Tooltip <Tooltip
content={({ active, payload }) => { content={({ active, payload }) => {
@@ -115,3 +162,5 @@ export function CategoryBarChart({
</Card> </Card>
); );
} }
// Find the Card component in this file

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CategoryIcon } from "@/components/ui/category-icon"; import { CategoryIcon } from "@/components/ui/category-icon";
@@ -13,7 +14,8 @@ export interface CategoryChartData {
value: number; value: number;
color: string; color: string;
icon: string; icon: string;
[key: string]: string | number; categoryId?: string | null; // null for "Non catégorisé"
[key: string]: string | number | null | undefined;
} }
interface CategoryPieChartProps { interface CategoryPieChartProps {
@@ -49,7 +51,7 @@ export function CategoryPieChart({
const currentData = isExpanded ? baseData : baseData.slice(0, maxItems); const currentData = isExpanded ? baseData : baseData.slice(0, maxItems);
return ( return (
<Card> <Card className="card-hover">
<CardHeader className="flex flex-col md:flex-row md:items-center md:justify-between space-y-2 md:space-y-0 pb-2"> <CardHeader className="flex flex-col md:flex-row md:items-center md:justify-between space-y-2 md:space-y-0 pb-2">
<CardTitle className="text-sm md:text-base">{title}</CardTitle> <CardTitle className="text-sm md:text-base">{title}</CardTitle>
<div className="flex flex-col md:flex-row gap-2 w-full md:w-auto"> <div className="flex flex-col md:flex-row gap-2 w-full md:w-auto">
@@ -162,10 +164,17 @@ export function CategoryPieChart({
Légende Légende
</div> </div>
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden pr-2 space-y-2"> <div className="max-h-[300px] overflow-y-auto overflow-x-hidden pr-2 space-y-2">
{currentData.map((item, index) => ( {currentData.map((item, index) => {
<div const href =
item.categoryId === null || item.categoryId === undefined
? "/transactions?includeUncategorized=true"
: `/transactions?categoryIds=${item.categoryId}`;
return (
<Link
key={`legend-${index}`} key={`legend-${index}`}
className="flex items-center gap-2 text-sm" href={href}
className="flex items-center gap-2 text-sm hover:opacity-80 transition-opacity cursor-pointer"
> >
<CategoryIcon <CategoryIcon
icon={item.icon} icon={item.icon}
@@ -178,8 +187,9 @@ export function CategoryPieChart({
<span className="text-foreground font-semibold tabular-nums whitespace-nowrap"> <span className="text-foreground font-semibold tabular-nums whitespace-nowrap">
{formatCurrency(item.value)} {formatCurrency(item.value)}
</span> </span>
</div> </Link>
))} );
})}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -96,7 +96,7 @@ export function CategoryTrendChart({
}; };
return ( return (
<Card> <Card className="card-hover">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Évolution des dépenses par catégorie</CardTitle> <CardTitle>Évolution des dépenses par catégorie</CardTitle>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -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)",
// Filtrer seulement les catégories qui ont une valeur
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)", border: "1px solid var(--border)",
borderRadius: "8px", 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,7 +235,8 @@ 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">
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
{allCategoryIds.map((categoryId) => { {allCategoryIds.map((categoryId) => {
const categoryInfo = getCategoryInfo(categoryId); const categoryInfo = getCategoryInfo(categoryId);
const categoryName = getCategoryName(categoryId); const categoryName = getCategoryName(categoryId);
@@ -234,6 +291,7 @@ export function CategoryTrendChart({
); );
})} })}
</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

@@ -28,7 +28,7 @@ export function IncomeExpenseTrendChart({
formatCurrency, formatCurrency,
}: IncomeExpenseTrendChartProps) { }: IncomeExpenseTrendChartProps) {
return ( return (
<Card> <Card className="card-hover">
<CardHeader> <CardHeader>
<CardTitle>Tendances revenus et dépenses</CardTitle> <CardTitle>Tendances revenus et dépenses</CardTitle>
</CardHeader> </CardHeader>

View File

@@ -1,9 +1,12 @@
"use client"; "use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ChevronDown, ChevronUp } from "lucide-react";
import { import {
BarChart, LineChart,
Bar, Line,
XAxis, XAxis,
YAxis, YAxis,
CartesianGrid, CartesianGrid,
@@ -11,6 +14,11 @@ import {
ResponsiveContainer, ResponsiveContainer,
Legend, Legend,
} from "recharts"; } from "recharts";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
interface MonthlyChartData { interface MonthlyChartData {
month: string; month: string;
@@ -22,30 +30,67 @@ interface MonthlyChartData {
interface MonthlyChartProps { interface MonthlyChartProps {
data: MonthlyChartData[]; data: MonthlyChartData[];
formatCurrency: (amount: number) => string; formatCurrency: (amount: number) => string;
collapsible?: boolean;
defaultExpanded?: boolean;
showDots?: boolean;
} }
export function MonthlyChart({ data, formatCurrency }: MonthlyChartProps) { export function MonthlyChart({
return ( data,
<Card> formatCurrency,
<CardHeader> collapsible = false,
<CardTitle>Revenus vs Dépenses par mois</CardTitle> defaultExpanded = true,
</CardHeader> showDots = true,
<CardContent> }: MonthlyChartProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
// Calculer l'intervalle dynamiquement selon le nombre de données
const getXAxisInterval = () => {
if (data.length <= 6) return 0; // Afficher tous les labels pour 6 mois ou moins
if (data.length <= 12) return 1; // Afficher un label sur deux pour 7-12 mois
return Math.floor(data.length / 12); // Pour plus de 12 mois, afficher environ 12 labels
};
// Formater les labels de manière plus compacte
const formatMonthLabel = (month: string) => {
// Format: "janv. 24" -> "janv 24" (enlever le point)
return month.replace(".", "");
};
const chartContent = (
<>
{data.length > 0 ? ( {data.length > 0 ? (
<div className="h-[300px]"> <div className="h-[400px] sm:h-[300px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={data}> <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 dataKey="month" className="text-xs" /> <XAxis
dataKey="month"
className="text-xs"
angle={data.length > 6 ? -45 : 0}
textAnchor={data.length > 6 ? "end" : "middle"}
height={data.length > 6 ? 80 : 60}
interval={getXAxisInterval()}
tickFormatter={formatMonthLabel}
tick={{ fontSize: 11 }}
/>
<YAxis <YAxis
className="text-xs" className="text-xs"
width={80} width={60}
tickFormatter={(v) => { tickFormatter={(v) => {
// Format compact pour les grandes valeurs // Format compact pour les grandes valeurs
if (Math.abs(v) >= 1000) { if (Math.abs(v) >= 1000) {
return `${(v / 1000).toFixed(1)}k€`; return `${(v / 1000).toFixed(1)}k€`;
} }
return `${Math.round(v)}`; return `${v.toFixed(0)}`;
}} }}
tick={{ fill: "var(--muted-foreground)" }} tick={{ fill: "var(--muted-foreground)" }}
/> />
@@ -59,17 +104,73 @@ export function MonthlyChart({ data, formatCurrency }: MonthlyChartProps) {
}} }}
/> />
<Legend /> <Legend />
<Bar dataKey="revenus" fill="#22c55e" radius={[4, 4, 0, 0]} /> <Line
<Bar dataKey="depenses" fill="#ef4444" radius={[4, 4, 0, 0]} /> type="monotone"
</BarChart> dataKey="revenus"
name="Revenus"
stroke="#22c55e"
strokeWidth={2}
dot={showDots ? { fill: "#22c55e", r: 4 } : false}
activeDot={showDots ? { r: 6 } : false}
/>
<Line
type="monotone"
dataKey="depenses"
name="Dépenses"
stroke="#ef4444"
strokeWidth={2}
dot={showDots ? { fill: "#ef4444", r: 4 } : false}
activeDot={showDots ? { r: 6 } : false}
/>
</LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
) : ( ) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground"> <div className="h-[400px] sm:h-[300px] flex items-center justify-center text-muted-foreground">
Pas de données pour cette période Pas de données pour cette période
</div> </div>
)} )}
</CardContent> </>
);
if (!collapsible) {
return (
<Card className="card-hover">
<CardHeader>
<CardTitle>Revenus vs Dépenses par mois</CardTitle>
</CardHeader>
<CardContent>{chartContent}</CardContent>
</Card>
);
}
return (
<Card className="card-hover">
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 py-3 px-6">
<CardTitle className="text-base font-semibold">
Revenus vs Dépenses par mois
</CardTitle>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8">
{isExpanded ? (
<>
<ChevronUp className="w-4 h-4 mr-1" />
Réduire
</>
) : (
<>
<ChevronDown className="w-4 h-4 mr-1" />
Afficher
</>
)}
</Button>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="pt-0">{chartContent}</CardContent>
</CollapsibleContent>
</Collapsible>
</Card> </Card>
); );
} }

View File

@@ -31,18 +31,18 @@ export function SavingsTrendChart({
const isPositive = latestSavings >= 0; const isPositive = latestSavings >= 0;
return ( return (
<Card> <Card className="card-hover">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>É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

@@ -21,59 +21,86 @@ export function StatsSummaryCards({
return ( return (
<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> <Card className="stat-card-textured relative overflow-hidden">
<CardHeader className="pb-2"> {/* Icône en arrière-plan */}
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<TrendingUp className="w-3 h-3 md:w-4 md:h-4 text-emerald-600" /> <TrendingUp
className="h-16 w-16 md:h-20 md:w-20 text-success"
strokeWidth={1}
/>
</div>
<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">
Total Revenus Total Revenus
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="px-5 pb-5 pt-0 relative z-10">
<div className="text-lg md:text-2xl font-bold text-emerald-600"> <div className="text-xl md:text-2xl font-black text-success tracking-tight">
{formatCurrency(totalIncome)} {formatCurrency(totalIncome)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card className="stat-card-textured relative overflow-hidden">
<CardHeader className="pb-2"> {/* Icône en arrière-plan */}
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<TrendingDown className="w-3 h-3 md:w-4 md:h-4 text-red-600" /> <TrendingDown
className="h-16 w-16 md:h-20 md:w-20 text-destructive"
strokeWidth={1}
/>
</div>
<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">
Total Dépenses Total Dépenses
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="px-5 pb-5 pt-0 relative z-10">
<div className="text-lg md:text-2xl font-bold text-red-600"> <div className="text-xl md:text-2xl font-black text-destructive tracking-tight">
{formatCurrency(totalExpenses)} {formatCurrency(totalExpenses)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card className="stat-card-textured relative overflow-hidden">
<CardHeader className="pb-2"> {/* Icône en arrière-plan */}
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<ArrowRight className="w-3 h-3 md:w-4 md:h-4" /> <ArrowRight
className="h-16 w-16 md:h-20 md:w-20 text-muted-foreground/40"
strokeWidth={1}
/>
</div>
<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">
Moyenne mensuelle Moyenne mensuelle
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="px-5 pb-5 pt-0 relative z-10">
<div className="text-lg md:text-2xl font-bold"> <div className="text-xl md:text-2xl font-black tracking-tight">
{formatCurrency(avgMonthlyExpenses)} {formatCurrency(avgMonthlyExpenses)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card className="stat-card-textured relative overflow-hidden">
<CardHeader className="pb-2"> {/* Icône en arrière-plan */}
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground"> <div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
<div
className={cn(
"h-16 w-16 md:h-20 md:w-20 rounded-full border-2",
savings >= 0 ? "border-success" : "border-destructive",
)}
/>
</div>
<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">
Économies Économies
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="px-5 pb-5 pt-0 relative z-10">
<div <div
className={cn( className={cn(
"text-lg md:text-2xl font-bold", "text-xl md:text-2xl font-black tracking-tight",
savings >= 0 ? "text-emerald-600" : "text-red-600", savings >= 0 ? "text-success" : "text-destructive",
)} )}
> >
{formatCurrency(savings)} {formatCurrency(savings)}

View File

@@ -1,42 +1,324 @@
"use client"; "use client";
import { useState, useMemo, useEffect } from "react";
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 {
expensesByCategory: Array<{
categoryId: string | null;
expenses: Transaction[]; 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> <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 ( 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 (
<TabsContent key={tabValue} value={tabValue}>
<div className="space-y-2 md:space-y-3">
{expenses.map((expense, index) => (
<div <div
key={expense.id} key={expense.id}
className="flex items-start gap-2 md:gap-3" className="flex items-start gap-2 md:gap-3"
> >
<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"> <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">
{index + 1} {index + 1}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -44,40 +326,143 @@ export function TopExpensesList({
<p className="font-medium text-xs md:text-sm truncate flex-1"> <p className="font-medium text-xs md:text-sm truncate flex-1">
{expense.description} {expense.description}
</p> </p>
<div className="text-red-600 font-semibold tabular-nums text-xs md:text-sm shrink-0"> <div className="text-destructive font-semibold tabular-nums text-xs md:text-sm shrink-0">
{formatCurrency(expense.amount)} {formatCurrency(expense.amount)}
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap"> <div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
<span className="text-[10px] md:text-xs text-muted-foreground"> <span className="text-[10px] md:text-xs text-muted-foreground">
{new Date(expense.date).toLocaleDateString("fr-FR")} {new Date(expense.date).toLocaleDateString(
"fr-FR",
)}
</span> </span>
{category && ( {expense.categoryId &&
(() => {
const expenseCategory = categories.find(
(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 <Badge
variant="secondary" 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" 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={{ style={{
backgroundColor: `${category.color}20`, backgroundColor: `${expenseCategory.color}20`,
color: category.color, color: expenseCategory.color,
borderColor: `${category.color}30`, borderColor: `${expenseCategory.color}30`,
}} }}
> >
<CategoryIcon <CategoryIcon
icon={category.icon} icon={expenseCategory.icon}
color={category.color} color={expenseCategory.color}
size={isMobile ? 8 : 10} size={isMobile ? 8 : 10}
/> />
<span className="truncate max-w-[120px] md:max-w-none"> <span className="truncate max-w-[100px] md:max-w-none">
{category.name} {expenseCategory.name}
</span> </span>
</Badge> </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>
<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

@@ -33,7 +33,7 @@ export function YearOverYearChart({
previousYearLabel = "Année précédente", previousYearLabel = "Année précédente",
}: YearOverYearChartProps) { }: YearOverYearChartProps) {
return ( return (
<Card> <Card className="card-hover">
<CardHeader> <CardHeader>
<CardTitle>Comparaison année sur année</CardTitle> <CardTitle>Comparaison année sur année</CardTitle>
</CardHeader> </CardHeader>

View File

@@ -1,3 +1,5 @@
export { TransactionFilters } from "./transaction-filters"; export { TransactionFilters } from "./transaction-filters";
export { TransactionBulkActions } from "./transaction-bulk-actions"; export { TransactionBulkActions } from "./transaction-bulk-actions";
export { TransactionTable } from "./transaction-table"; export { TransactionTable } from "./transaction-table";
export { TransactionPagination } from "./transaction-pagination";
export { formatCurrency, formatDate } from "./transaction-utils";

View File

@@ -28,10 +28,12 @@ import {
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { Search, X, Filter, Wallet, Calendar } from "lucide-react"; import { Search, X, Filter, Wallet, Calendar, Copy } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import type { Account, Category, Folder, Transaction } from "@/lib/types"; import type { Account, Category, Folder, Transaction } from "@/lib/types";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all"; type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
@@ -53,6 +55,8 @@ interface TransactionFiltersProps {
onCustomEndDateChange: (date: Date | undefined) => void; onCustomEndDateChange: (date: Date | undefined) => void;
isCustomDatePickerOpen: boolean; isCustomDatePickerOpen: boolean;
onCustomDatePickerOpenChange: (open: boolean) => void; onCustomDatePickerOpenChange: (open: boolean) => void;
showDuplicates: boolean;
onShowDuplicatesChange: (show: boolean) => void;
accounts: Account[]; accounts: Account[];
folders: Folder[]; folders: Folder[];
categories: Category[]; categories: Category[];
@@ -77,6 +81,8 @@ export function TransactionFilters({
onCustomEndDateChange, onCustomEndDateChange,
isCustomDatePickerOpen, isCustomDatePickerOpen,
onCustomDatePickerOpenChange, onCustomDatePickerOpenChange,
showDuplicates,
onShowDuplicatesChange,
accounts, accounts,
folders, folders,
categories, categories,
@@ -153,6 +159,23 @@ export function TransactionFilters({
</SelectContent> </SelectContent>
</Select> </Select>
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-border bg-card">
<Checkbox
id="show-duplicates"
checked={showDuplicates}
onCheckedChange={(checked) =>
onShowDuplicatesChange(checked === true)
}
/>
<Label
htmlFor="show-duplicates"
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<Copy className="h-4 w-4 text-muted-foreground" />
Afficher les doublons
</Label>
</div>
{period === "custom" && ( {period === "custom" && (
<Popover <Popover
open={isCustomDatePickerOpen} open={isCustomDatePickerOpen}
@@ -179,9 +202,10 @@ export function TransactionFilters({
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
<div className="p-4 space-y-4"> <div className="p-3 flex gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Date de début</label> <label className="text-sm font-medium">Date de début</label>
<div className="scale-90 origin-top-left">
<CalendarComponent <CalendarComponent
mode="single" mode="single"
selected={customStartDate} selected={customStartDate}
@@ -194,8 +218,10 @@ export function TransactionFilters({
locale={fr} locale={fr}
/> />
</div> </div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Date de fin</label> <label className="text-sm font-medium">Date de fin</label>
<div className="scale-90 origin-top-left">
<CalendarComponent <CalendarComponent
mode="single" mode="single"
selected={customEndDate} selected={customEndDate}
@@ -215,8 +241,10 @@ export function TransactionFilters({
locale={fr} locale={fr}
/> />
</div> </div>
</div>
</div>
{customStartDate && customEndDate && ( {customStartDate && customEndDate && (
<div className="flex gap-2 pt-2 border-t"> <div className="flex gap-2 pt-2 border-t px-3 pb-3">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -237,7 +265,6 @@ export function TransactionFilters({
</Button> </Button>
</div> </div>
)} )}
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
)} )}
@@ -282,7 +309,8 @@ export function TransactionFilters({
(!selectedAccounts.includes("all") ? selectedAccounts.length : 0) + (!selectedAccounts.includes("all") ? selectedAccounts.length : 0) +
(!selectedCategories.includes("all") ? selectedCategories.length : 0) + (!selectedCategories.includes("all") ? selectedCategories.length : 0) +
(showReconciled !== "all" ? 1 : 0) + (showReconciled !== "all" ? 1 : 0) +
(period !== "all" ? 1 : 0); (period !== "all" ? 1 : 0) +
(showDuplicates ? 1 : 0);
return ( return (
<> <>

View File

@@ -0,0 +1,50 @@
"use client";
import { Button } from "@/components/ui/button";
interface TransactionPaginationProps {
page: number;
pageSize: number;
total: number;
hasMore: boolean;
onPageChange: (page: number) => void;
}
export function TransactionPagination({
page,
pageSize,
total,
hasMore,
onPageChange,
}: TransactionPaginationProps) {
if (total <= pageSize) {
return null;
}
return (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
Affichage de {page * pageSize + 1} à{" "}
{Math.min((page + 1) * pageSize, total)} sur {total}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(Math.max(0, page - 1))}
disabled={page === 0}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={!hasMore}
>
Suivant
</Button>
</div>
</div>
);
}

View File

@@ -25,6 +25,7 @@ import {
Wand2, Wand2,
Trash2, Trash2,
Loader2, Loader2,
AlertTriangle,
} from "lucide-react"; } from "lucide-react";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -52,6 +53,8 @@ interface TransactionTableProps {
formatCurrency: (amount: number) => string; formatCurrency: (amount: number) => string;
formatDate: (dateStr: string) => string; formatDate: (dateStr: string) => string;
updatingTransactionIds?: Set<string>; updatingTransactionIds?: Set<string>;
duplicateIds?: Set<string>;
highlightDuplicates?: boolean;
} }
const ROW_HEIGHT = 72; // Hauteur approximative d'une ligne const ROW_HEIGHT = 72; // Hauteur approximative d'une ligne
@@ -145,12 +148,14 @@ export function TransactionTable({
formatCurrency, formatCurrency,
formatDate, formatDate,
updatingTransactionIds = new Set(), updatingTransactionIds = new Set(),
duplicateIds = new Set(),
highlightDuplicates = false,
}: TransactionTableProps) { }: TransactionTableProps) {
const [focusedIndex, setFocusedIndex] = useState<number | null>(null); const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const MOBILE_ROW_HEIGHT = 120; const MOBILE_ROW_HEIGHT = 140;
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: transactions.length, count: transactions.length,
@@ -164,7 +169,7 @@ export function TransactionTable({
setFocusedIndex(index); setFocusedIndex(index);
onMarkReconciled(transactionId); onMarkReconciled(transactionId);
}, },
[onMarkReconciled] [onMarkReconciled],
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
@@ -193,7 +198,7 @@ export function TransactionTable({
} }
} }
}, },
[focusedIndex, transactions, onMarkReconciled, virtualizer] [focusedIndex, transactions, onMarkReconciled, virtualizer],
); );
useEffect(() => { useEffect(() => {
@@ -210,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(
@@ -218,22 +223,22 @@ 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"> <Card className="overflow-hidden w-full card-hover">
<CardContent className="p-0"> <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">
<p className="text-muted-foreground">Aucune transaction trouvée</p> <p className="text-muted-foreground">Aucune transaction trouvée</p>
</div> </div>
) : isMobile ? ( ) : isMobile ? (
<div className="divide-y divide-border"> <div className="divide-y divide-border w-full">
<div <div
ref={parentRef} ref={parentRef}
className="overflow-auto" className="overflow-auto w-full"
style={{ height: "calc(100vh - 300px)", minHeight: "400px" }} style={{ height: "calc(100vh - 200px)", minHeight: "600px" }}
> >
<div <div
style={{ style={{
@@ -247,6 +252,8 @@ export function TransactionTable({
const account = getAccount(transaction.accountId); const account = getAccount(transaction.accountId);
const _category = getCategory(transaction.categoryId); const _category = getCategory(transaction.categoryId);
const isFocused = focusedIndex === virtualRow.index; const isFocused = focusedIndex === virtualRow.index;
const isDuplicate =
highlightDuplicates && duplicateIds.has(transaction.id);
return ( return (
<div <div
@@ -259,6 +266,10 @@ export function TransactionTable({
left: 0, left: 0,
width: "100%", width: "100%",
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
...(isDuplicate && {
backgroundColor: "rgb(254 252 232)", // yellow-50
borderLeft: "4px solid rgb(234 179 8)", // yellow-500
}),
}} }}
onClick={() => { onClick={() => {
// Désactiver le pointage au clic sur mobile // Désactiver le pointage au clic sur mobile
@@ -267,26 +278,42 @@ export function TransactionTable({
} }
}} }}
className={cn( className={cn(
"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",
)} )}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0"> <div className="flex items-start gap-0 md:gap-3 flex-1 min-w-0">
<Checkbox <Checkbox
checked={selectedTransactions.has(transaction.id)} checked={selectedTransactions.has(transaction.id)}
onCheckedChange={() => { onCheckedChange={() => {
onToggleSelectTransaction(transaction.id); onToggleSelectTransaction(transaction.id);
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="mt-0.5 hidden md:flex"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-xs md:text-sm truncate"> <div className="flex items-center gap-2">
<p className="font-medium text-sm md:text-sm truncate leading-tight">
{transaction.description} {transaction.description}
</p> </p>
{isDuplicate && (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<AlertTriangle className="h-4 w-4 md:h-4 md:w-4 text-[var(--warning)] shrink-0" />
</TooltipTrigger>
<TooltipContent>
<p>
Transaction en double (même somme et date)
</p>
</TooltipContent>
</Tooltip>
)}
</div>
{transaction.memo && ( {transaction.memo && (
<p className="text-[10px] md:text-xs text-muted-foreground truncate mt-1"> <p className="text-xs md:text-xs text-muted-foreground truncate mt-1.5">
{transaction.memo} {transaction.memo}
</p> </p>
)} )}
@@ -294,10 +321,10 @@ export function TransactionTable({
</div> </div>
<div <div
className={cn( className={cn(
"font-semibold tabular-nums text-sm 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 ? "+" : ""}
@@ -305,18 +332,18 @@ export function TransactionTable({
</div> </div>
</div> </div>
<div className="flex items-center gap-2 md:gap-3 flex-wrap"> <div className="flex items-center gap-2.5 md:gap-3 flex-wrap">
<span className="text-[10px] md:text-xs text-muted-foreground"> <span className="text-xs md:text-xs text-muted-foreground">
{formatDate(transaction.date)} {formatDate(transaction.date)}
</span> </span>
{account && ( {account && (
<span className="text-[10px] md:text-xs text-muted-foreground"> <span className="text-xs md:text-xs text-muted-foreground">
{account.name} {account.name}
</span> </span>
)} )}
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="flex-1 relative" className="flex-1 relative min-w-[120px]"
> >
{updatingTransactionIds.has(transaction.id) && ( {updatingTransactionIds.has(transaction.id) && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10 rounded"> <div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10 rounded">
@@ -332,7 +359,7 @@ export function TransactionTable({
showBadge showBadge
align="start" align="start"
disabled={updatingTransactionIds.has( disabled={updatingTransactionIds.has(
transaction.id transaction.id,
)} )}
/> />
</div> </div>
@@ -344,9 +371,9 @@ export function TransactionTable({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 shrink-0" className="h-9 w-9 shrink-0"
> >
<MoreVertical className="w-4 h-4" /> <MoreVertical className="w-5 h-5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@@ -365,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
@@ -420,7 +447,7 @@ export function TransactionTable({
<div className="p-3 text-sm font-medium text-muted-foreground"> <div className="p-3 text-sm font-medium text-muted-foreground">
Compte Compte
</div> </div>
<div className="p-3 text-sm font-medium text-muted-foreground"> <div className="p-3 text-sm font-medium text-muted-foreground text-center">
Catégorie Catégorie
</div> </div>
<div className="p-3 text-right"> <div className="p-3 text-right">
@@ -438,11 +465,11 @@ export function TransactionTable({
<div className="p-3"></div> <div className="p-3"></div>
</div> </div>
</div> </div>
{/* Body virtualisé */} {/* Body virtualisé ou non selon le mode */}
<div <div
ref={parentRef} ref={parentRef}
className="overflow-auto" className="overflow-auto"
style={{ height: "calc(100vh - 400px)", minHeight: "400px" }} style={{ height: "calc(100vh - 200px)", minHeight: "700px" }}
> >
<div <div
style={{ style={{
@@ -455,6 +482,8 @@ export function TransactionTable({
const transaction = transactions[virtualRow.index]; const transaction = transactions[virtualRow.index];
const account = getAccount(transaction.accountId); const account = getAccount(transaction.accountId);
const isFocused = focusedIndex === virtualRow.index; const isFocused = focusedIndex === virtualRow.index;
const isDuplicate =
highlightDuplicates && duplicateIds.has(transaction.id);
return ( return (
<div <div
@@ -467,6 +496,10 @@ export function TransactionTable({
left: 0, left: 0,
width: "100%", width: "100%",
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
...(isDuplicate && {
backgroundColor: "rgb(254 252 232)", // yellow-50
borderLeft: "4px solid rgb(234 179 8)", // yellow-500
}),
}} }}
onClick={() => onClick={() =>
handleRowClick(virtualRow.index, transaction.id) handleRowClick(virtualRow.index, transaction.id)
@@ -474,10 +507,11 @@ export function TransactionTable({
className={cn( className={cn(
"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",
)} )}
> >
<div className="p-3"> <div className="p-3" onClick={(e) => e.stopPropagation()}>
<Checkbox <Checkbox
checked={selectedTransactions.has(transaction.id)} checked={selectedTransactions.has(transaction.id)}
onCheckedChange={() => onCheckedChange={() =>
@@ -492,9 +526,23 @@ export function TransactionTable({
className="p-3 min-w-0 overflow-hidden" className="p-3 min-w-0 overflow-hidden"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate"> <p className="font-medium text-sm truncate">
{transaction.description} {transaction.description}
</p> </p>
{isDuplicate && (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<AlertTriangle className="h-4 w-4 text-[var(--warning)] shrink-0" />
</TooltipTrigger>
<TooltipContent>
<p>
Transaction en double (même somme et date)
</p>
</TooltipContent>
</Tooltip>
)}
</div>
{transaction.memo && ( {transaction.memo && (
<DescriptionWithTooltip <DescriptionWithTooltip
description={transaction.memo} description={transaction.memo}
@@ -505,7 +553,7 @@ export function TransactionTable({
{account?.name || "-"} {account?.name || "-"}
</div> </div>
<div <div
className="p-3 relative" className="p-3 relative flex items-center justify-center"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{updatingTransactionIds.has(transaction.id) && ( {updatingTransactionIds.has(transaction.id) && (
@@ -528,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 ? "+" : ""}
@@ -544,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" />
)} )}
@@ -596,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

@@ -0,0 +1,18 @@
/**
* Utility functions for transaction formatting
*/
export const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(amount);
};
export const formatDate = (dateStr: string): string => {
return new Date(dateStr).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
});
};

View File

@@ -182,9 +182,7 @@ export function AccountFilterCombobox({
{isFolderPartiallySelected(folder.id) && ( {isFolderPartiallySelected(folder.id) && (
<div className="h-3 w-3 rounded-sm bg-primary/50 mr-1" /> <div className="h-3 w-3 rounded-sm bg-primary/50 mr-1" />
)} )}
{isFolderSelected(folder.id) && ( {isFolderSelected(folder.id) && <Check className="h-4 w-4" />}
<Check className="h-4 w-4" />
)}
</div> </div>
</CommandItem> </CommandItem>
@@ -306,9 +304,7 @@ export function AccountFilterCombobox({
) )
</span> </span>
)} )}
{isAll && ( {isAll && <Check className="ml-auto h-4 w-4" />}
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>

View File

@@ -54,7 +54,7 @@ function AlertDialogContent({
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
data-slot="alert-dialog-content" data-slot="alert-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 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "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 grid 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

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

@@ -43,7 +43,7 @@ function BreadcrumbLink({
return ( return (
<Comp <Comp
data-slot="breadcrumb-link" data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)} className={cn("hover:text-foreground", className)}
{...props} {...props}
/> />
); );

View File

@@ -5,21 +5,20 @@ 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 transition-all duration-300 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 hover:from-primary/95 hover:via-primary hover:to-primary/95 shadow-xl shadow-primary/30 hover:shadow-2xl hover:shadow-primary/40 hover:scale-[1.03] active:scale-[0.97] 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 hover:from-destructive/95 hover:via-destructive hover:to-destructive/95 shadow-xl shadow-destructive/30 hover:shadow-2xl hover:shadow-destructive/40 hover:scale-[1.03] focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 active:scale-[0.97] 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 hover:bg-accent hover:text-accent-foreground hover:border-primary/50 hover:shadow-lg hover:scale-[1.03] dark:bg-input/40 dark:border-input dark:hover:bg-input/60 active:scale-[0.97]", "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 hover:from-secondary/95 hover:via-secondary hover:to-secondary/95 hover:shadow-lg hover:scale-[1.03] active:scale-[0.97] backdrop-blur-sm", "bg-gradient-to-r from-secondary via-secondary/95 to-secondary/90 text-secondary-foreground backdrop-blur-sm",
ghost: ghost: "backdrop-blur-sm",
"hover:bg-accent/70 hover:text-accent-foreground dark:hover:bg-accent/50 hover:scale-[1.03] active:scale-[0.97] backdrop-blur-sm", link: "text-primary underline-offset-4 font-semibold",
link: "text-primary underline-offset-4 hover:underline font-semibold",
}, },
size: { size: {
default: "h-10 px-5 py-2.5 has-[>svg]:px-4", default: "h-10 px-5 py-2.5 has-[>svg]:px-4",

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

@@ -9,7 +9,6 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
className={cn( className={cn(
"fintech-card", "fintech-card",
"bg-card text-card-foreground flex flex-col", "bg-card text-card-foreground flex flex-col",
"transition-all duration-300 ease-out",
className, className,
)} )}
{...props} {...props}

View File

@@ -87,21 +87,55 @@ export function CategoryFilterCombobox({
return; return;
} }
// Check if this is a parent category
const isParentCategory = parentCategories.some((p) => p.id === newValue);
const childCategories = isParentCategory
? childrenByParent[newValue] || []
: [];
// Category selection - toggle // Category selection - toggle
let newSelection: string[]; let newSelection: string[];
if (isAll || isUncategorized) { if (isAll || isUncategorized) {
// Start fresh with just this category // Start fresh with this category and its children (if parent)
if (isParentCategory && childCategories.length > 0) {
newSelection = [newValue, ...childCategories.map((child) => child.id)];
} else {
newSelection = [newValue]; newSelection = [newValue];
}
} else if (value.includes(newValue)) { } else if (value.includes(newValue)) {
// Remove category // Remove category and its children (if parent)
if (isParentCategory && childCategories.length > 0) {
const childIds = childCategories.map((child) => child.id);
newSelection = value.filter(
(v) => v !== newValue && !childIds.includes(v),
);
} else {
newSelection = value.filter((v) => v !== newValue); newSelection = value.filter((v) => v !== newValue);
}
if (newSelection.length === 0) { if (newSelection.length === 0) {
newSelection = ["all"]; newSelection = ["all"];
} }
} else { } else {
// Add category // Add category and its children (if parent)
if (isParentCategory && childCategories.length > 0) {
const childIds = childCategories.map((child) => child.id);
newSelection = [
...value.filter((v) => !childIds.includes(v)), // Remove any existing children
newValue,
...childIds,
];
} else {
// Check if this child's parent is already selected
const category = categories.find((c) => c.id === newValue);
if (category?.parentId && value.includes(category.parentId)) {
// Parent is selected, so we're adding a child - keep parent
newSelection = [...value, newValue]; newSelection = [...value, newValue];
} else {
// Regular add
newSelection = [...value, newValue];
}
}
} }
onChange(newSelection); onChange(newSelection);
@@ -193,7 +227,11 @@ export function CategoryFilterCombobox({
align="start" align="start"
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<Command value={isAll ? "all" : isUncategorized ? "uncategorized" : value.join(",")}> <Command
value={
isAll ? "all" : isUncategorized ? "uncategorized" : value.join(",")
}
>
<CommandInput placeholder="Rechercher..." /> <CommandInput placeholder="Rechercher..." />
<CommandList className="max-h-[300px]"> <CommandList className="max-h-[300px]">
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty> <CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
@@ -212,9 +250,7 @@ export function CategoryFilterCombobox({
({filteredTransactions.length}) ({filteredTransactions.length})
</span> </span>
)} )}
{isAll && ( {isAll && <Check className="ml-auto h-4 w-4 shrink-0" />}
<Check className="ml-auto h-4 w-4 shrink-0" />
)}
</CommandItem> </CommandItem>
<CommandItem <CommandItem
value="uncategorized" value="uncategorized"

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 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 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}
@@ -69,7 +69,7 @@ function DialogContent({
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close <DialogPrimitive.Close
data-slot="dialog-close" data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
> >
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>

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

@@ -316,7 +316,7 @@ export function IconPicker({ value, onChange, color }: IconPickerProps) {
key={icon} key={icon}
onClick={() => handleSelect(icon)} onClick={() => handleSelect(icon)}
className={cn( className={cn(
"flex items-center justify-center p-2 rounded-md hover:bg-accent transition-colors", "flex items-center justify-center p-2 rounded-md hover:bg-accent",
value === icon && "bg-accent ring-2 ring-primary", value === icon && "bg-accent ring-2 ring-primary",
)} )}
title={icon} title={icon}

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

@@ -58,21 +58,21 @@ function SheetContent({
<SheetPrimitive.Content <SheetPrimitive.Content
data-slot="sheet-content" data-slot="sheet-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 overflow-y-auto",
side === "right" && side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm", "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" && side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm", "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" && side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 max-h-[calc(100vh-2rem)] border-b",
side === "bottom" && side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 max-h-[calc(100vh-2rem)] border-t",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" /> <XIcon className="size-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>

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

@@ -57,7 +57,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
<tr <tr
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", "hover:bg-muted/50 data-[state=selected]:bg-muted border-b",
className, className,
)} )}
{...props} {...props}

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: {

0
dev.db
View File

View File

@@ -1,19 +1,19 @@
services: services:
app: banking-app:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dummy-secret-for-build-only} - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dummy-secret-for-build-only}
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:4000} - NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:4000}
- DATABASE_URL=${DATABASE_URL:-file:./prisma/dev.db} - DATABASE_URL=file:/app/prisma/dev.db
ports: ports:
- "4000:3000" - "4000:3000"
volumes: volumes:
- ./prisma:/app/prisma - ${DATA_VOLUME_PATH:-./prisma}:/app/prisma
environment: environment:
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET} - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:4000} - NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:4000}
- DATABASE_URL=${DATABASE_URL:-file:./prisma/dev.db} - DATABASE_URL=file:/app/prisma/dev.db
env_file: labels:
- .env - "com.centurylinklabs.watchtower.enable=false"

View File

@@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
/**
* Hook pour gérer la persistance d'une valeur dans le localStorage
*/
export function useLocalStorage<T>(
key: string,
initialValue: T,
): [T, (value: T | ((val: T) => T)) => void] {
// État pour stocker la valeur
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Fonction pour mettre à jour la valeur
const setValue = (value: T | ((val: T) => T)) => {
try {
// Permet d'utiliser une fonction comme setState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue];
}

View File

@@ -4,7 +4,7 @@ const MOBILE_BREAKPOINT = 768;
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>( const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined undefined,
); );
React.useEffect(() => { React.useEffect(() => {

View File

@@ -0,0 +1,366 @@
"use client";
import { useState, useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { Transaction } from "@/lib/types";
import { getTransactionsQueryKey } from "@/lib/hooks";
import type { TransactionsPaginatedParams } from "@/services/banking.service";
import { invalidateAllTransactionQueries } from "@/lib/cache-utils";
interface UseTransactionMutationsProps {
transactionParams: TransactionsPaginatedParams;
transactionsData: { transactions: Transaction[]; total: number } | undefined;
}
export function useTransactionMutations({
transactionParams,
transactionsData,
}: UseTransactionMutationsProps) {
const queryClient = useQueryClient();
const [updatingTransactionIds, setUpdatingTransactionIds] = useState<
Set<string>
>(new Set());
const toggleReconciled = useCallback(
async (transactionId: string) => {
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId,
);
if (!transaction) return;
const newReconciledState = !transaction.isReconciled;
const updatedTransaction = {
...transaction,
isReconciled: newReconciledState,
};
// Optimistic cache update
const queryKey = getTransactionsQueryKey(transactionParams);
const previousData =
queryClient.getQueryData<typeof transactionsData>(queryKey);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
t.id === transactionId
? { ...t, isReconciled: newReconciledState }
: t,
),
};
});
try {
const response = await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTransaction),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// TOUJOURS revalider après succès pour garantir la cohérence
invalidateAllTransactionQueries(queryClient);
} catch (error) {
console.error("Failed to update transaction:", error);
// Rollback on error
if (previousData) {
queryClient.setQueryData(queryKey, previousData);
}
invalidateAllTransactionQueries(queryClient);
}
},
[transactionsData, transactionParams, queryClient],
);
const markReconciled = useCallback(
async (transactionId: string) => {
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId,
);
if (!transaction || transaction.isReconciled) return;
const updatedTransaction = {
...transaction,
isReconciled: true,
};
// Optimistic cache update
const queryKey = getTransactionsQueryKey(transactionParams);
const previousData =
queryClient.getQueryData<typeof transactionsData>(queryKey);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
t.id === transactionId ? { ...t, isReconciled: true } : t,
),
};
});
try {
const response = await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTransaction),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// TOUJOURS revalider après succès pour garantir la cohérence
invalidateAllTransactionQueries(queryClient);
} catch (error) {
console.error("Failed to update transaction:", error);
// Rollback on error
if (previousData) {
queryClient.setQueryData(queryKey, previousData);
}
invalidateAllTransactionQueries(queryClient);
}
},
[transactionsData, transactionParams, queryClient],
);
const setCategory = useCallback(
async (transactionId: string, categoryId: string | null) => {
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId,
);
if (!transaction) return;
setUpdatingTransactionIds((prev) => new Set(prev).add(transactionId));
// Optimistic cache update
const queryKey = getTransactionsQueryKey(transactionParams);
const previousData =
queryClient.getQueryData<typeof transactionsData>(queryKey);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
t.id === transactionId ? { ...t, categoryId } : t,
),
};
});
try {
const response = await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...transaction, categoryId }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// TOUJOURS revalider après succès pour garantir la cohérence
invalidateAllTransactionQueries(queryClient);
} catch (error) {
console.error("Failed to update transaction:", error);
// Rollback on error
if (previousData) {
queryClient.setQueryData(queryKey, previousData);
}
invalidateAllTransactionQueries(queryClient);
} finally {
setUpdatingTransactionIds((prev) => {
const next = new Set(prev);
next.delete(transactionId);
return next;
});
}
},
[transactionsData, transactionParams, queryClient],
);
const deleteTransaction = useCallback(
async (transactionId: string) => {
if (!transactionsData) return;
// Save current data for rollback
const queryKey = getTransactionsQueryKey(transactionParams);
const previousData =
queryClient.getQueryData<typeof transactionsData>(queryKey);
// Optimistic cache update
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.filter(
(t) => t.id !== transactionId,
),
total: oldData.total - 1,
};
});
try {
const response = await fetch(
`/api/banking/transactions?id=${transactionId}`,
{
method: "DELETE",
},
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.error ||
`Failed to delete transaction: ${response.status}`,
);
}
// TOUJOURS revalider après succès pour garantir la cohérence
invalidateAllTransactionQueries(queryClient);
} catch (error) {
console.error("Failed to delete transaction:", error);
// Rollback on error
if (previousData) {
queryClient.setQueryData(queryKey, previousData);
}
invalidateAllTransactionQueries(queryClient);
}
},
[transactionsData, transactionParams, queryClient],
);
const bulkReconcile = useCallback(
async (reconciled: boolean, selectedTransactionIds: Set<string>) => {
if (!transactionsData) return;
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
selectedTransactionIds.has(t.id),
);
const transactionIds = transactionsToUpdate.map((t) => t.id);
// Optimistic cache update
const queryKey = getTransactionsQueryKey(transactionParams);
const previousData =
queryClient.getQueryData<typeof transactionsData>(queryKey);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
transactionIds.includes(t.id)
? { ...t, isReconciled: reconciled }
: t,
),
};
});
try {
await Promise.all(
transactionsToUpdate.map((t) =>
fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, isReconciled: reconciled }),
}),
),
);
// TOUJOURS revalider après succès pour garantir la cohérence
invalidateAllTransactionQueries(queryClient);
} catch (error) {
console.error("Failed to update transactions:", error);
// Rollback on error
if (previousData) {
queryClient.setQueryData(queryKey, previousData);
}
invalidateAllTransactionQueries(queryClient);
}
},
[transactionsData, transactionParams, queryClient],
);
const bulkSetCategory = useCallback(
async (categoryId: string | null, selectedTransactionIds: Set<string>) => {
if (!transactionsData) return;
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
selectedTransactionIds.has(t.id),
);
const transactionIds = transactionsToUpdate.map((t) => t.id);
setUpdatingTransactionIds((prev) => {
const next = new Set(prev);
transactionIds.forEach((id) => next.add(id));
return next;
});
// Optimistic cache update
const queryKey = getTransactionsQueryKey(transactionParams);
const previousData =
queryClient.getQueryData<typeof transactionsData>(queryKey);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
transactionIds.includes(t.id) ? { ...t, categoryId } : t,
),
};
});
try {
await Promise.all(
transactionsToUpdate.map((t) =>
fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, categoryId }),
}),
),
);
// TOUJOURS revalider après succès pour garantir la cohérence
invalidateAllTransactionQueries(queryClient);
} catch (error) {
console.error("Failed to update transactions:", error);
// Rollback on error
if (previousData) {
queryClient.setQueryData(queryKey, previousData);
}
invalidateAllTransactionQueries(queryClient);
} finally {
setUpdatingTransactionIds((prev) => {
const next = new Set(prev);
transactionIds.forEach((id) => next.delete(id));
return next;
});
}
},
[transactionsData, transactionParams, queryClient],
);
return {
toggleReconciled,
markReconciled,
setCategory,
deleteTransaction,
bulkReconcile,
bulkSetCategory,
updatingTransactionIds,
};
}

View File

@@ -0,0 +1,116 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { Transaction, Category } from "@/lib/types";
import { updateCategory } from "@/lib/store-db";
import {
normalizeDescription,
suggestKeyword,
} from "@/components/rules/constants";
import {
invalidateAllTransactionQueries,
invalidateAllCategoryQueries,
} from "@/lib/cache-utils";
interface UseTransactionRulesProps {
transactionsData: { transactions: Transaction[] } | undefined;
metadata: {
categories: Category[];
} | null;
}
export function useTransactionRules({
transactionsData,
metadata,
}: UseTransactionRulesProps) {
const queryClient = useQueryClient();
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(
null,
);
const handleCreateRule = useCallback((transaction: Transaction) => {
setRuleTransaction(transaction);
setRuleDialogOpen(true);
}, []);
const ruleGroup = useMemo(() => {
if (!ruleTransaction || !transactionsData) return null;
const normalizedDesc = normalizeDescription(ruleTransaction.description);
const similarTransactions = transactionsData.transactions.filter(
(t) => normalizeDescription(t.description) === normalizedDesc,
);
if (similarTransactions.length === 0) return null;
return {
key: normalizedDesc,
displayName: ruleTransaction.description,
transactions: similarTransactions,
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
suggestedKeyword: suggestKeyword(
similarTransactions.map((t) => t.description),
),
};
}, [ruleTransaction, transactionsData]);
const handleSaveRule = useCallback(
async (ruleData: {
keyword: string;
categoryId: string;
applyToExisting: boolean;
transactionIds: string[];
}) => {
if (!metadata) return;
// Add keyword to category
const category = metadata.categories.find(
(c) => c.id === ruleData.categoryId,
);
if (!category) {
throw new Error("Category not found");
}
// Check if keyword already exists
const keywordExists = category.keywords.some(
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
);
if (!keywordExists) {
await updateCategory({
...category,
keywords: [...category.keywords, ruleData.keyword],
});
}
// Apply to existing transactions if requested
if (ruleData.applyToExisting) {
await Promise.all(
ruleData.transactionIds.map((id) =>
fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
}),
),
);
}
// Invalider toutes les queries liées
invalidateAllTransactionQueries(queryClient);
invalidateAllCategoryQueries(queryClient);
setRuleDialogOpen(false);
},
[metadata, queryClient],
);
return {
ruleDialogOpen,
setRuleDialogOpen,
ruleGroup,
handleCreateRule,
handleSaveRule,
};
}

View File

@@ -0,0 +1,328 @@
"use client";
import { useMemo } from "react";
import { useTransactions } from "@/lib/hooks";
import { useBankingMetadata } from "@/lib/hooks";
import type { TransactionsPaginatedParams } from "@/services/banking.service";
import type { Account } from "@/lib/types";
interface BalanceChartDataPoint {
date: string;
[key: string]: string | number;
}
interface UseTransactionsBalanceChartParams {
selectedAccounts: string[];
selectedCategories: string[];
period: "1month" | "3months" | "6months" | "12months" | "custom" | "all";
customStartDate?: Date;
customEndDate?: Date;
showReconciled: string;
searchQuery: string;
}
export function useTransactionsBalanceChart({
selectedAccounts,
selectedCategories,
period,
customStartDate,
customEndDate,
showReconciled,
searchQuery,
}: UseTransactionsBalanceChartParams) {
const { data: metadata } = useBankingMetadata();
// Calculate start date based on period
const startDate = useMemo(() => {
const now = new Date();
switch (period) {
case "1month":
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
case "3months":
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
case "6months":
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
case "12months":
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
case "custom":
return customStartDate || new Date(0);
default:
return new Date(0);
}
}, [period, customStartDate]);
// Calculate end date (only for custom period)
const endDate = useMemo(() => {
if (period === "custom" && customEndDate) {
return customEndDate;
}
return undefined;
}, [period, customEndDate]);
// Build params for fetching all transactions (no pagination)
const chartParams = useMemo<TransactionsPaginatedParams>(() => {
const params: TransactionsPaginatedParams = {
limit: 10000, // Large limit to get all transactions
offset: 0,
sortField: "date",
sortOrder: "asc", // Ascending for balance calculation
};
if (startDate && period !== "all") {
params.startDate = startDate.toISOString().split("T")[0];
}
if (endDate) {
params.endDate = endDate.toISOString().split("T")[0];
}
if (!selectedAccounts.includes("all")) {
params.accountIds = selectedAccounts;
}
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
params.includeUncategorized = true;
} else {
params.categoryIds = selectedCategories;
}
}
if (searchQuery) {
params.search = searchQuery;
}
if (showReconciled !== "all") {
params.isReconciled = showReconciled === "reconciled";
}
return params;
}, [
startDate,
endDate,
selectedAccounts,
selectedCategories,
searchQuery,
showReconciled,
period,
]);
// Build params for fetching transactions before startDate (for initial balance)
const beforeStartDateParams = useMemo<TransactionsPaginatedParams>(() => {
if (period === "all" || !startDate) {
return { limit: 0, offset: 0 }; // Don't fetch if no start date
}
const params: TransactionsPaginatedParams = {
limit: 10000,
offset: 0,
sortField: "date",
sortOrder: "asc",
endDate: new Date(startDate.getTime() - 1).toISOString().split("T")[0], // Day before startDate
};
if (!selectedAccounts.includes("all")) {
params.accountIds = selectedAccounts;
}
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
params.includeUncategorized = true;
} else {
params.categoryIds = selectedCategories;
}
}
if (searchQuery) {
params.search = searchQuery;
}
if (showReconciled !== "all") {
params.isReconciled = showReconciled === "reconciled";
}
return params;
}, [
startDate,
selectedAccounts,
selectedCategories,
searchQuery,
showReconciled,
period,
]);
// Fetch transactions before startDate for initial balance calculation
const { data: beforeStartDateData } = useTransactions(
beforeStartDateParams,
!!metadata && period !== "all" && !!startDate,
);
// Fetch all filtered transactions for chart
const { data: transactionsData, isLoading } = useTransactions(
chartParams,
!!metadata,
);
// Calculate balance chart data
const chartData = useMemo(() => {
if (!transactionsData || !metadata) {
return {
aggregatedData: [],
perAccountData: [],
};
}
const transactions = transactionsData.transactions;
const accounts = metadata.accounts;
// Sort transactions by date
const sortedTransactions = [...transactions].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
);
// Calculate starting balance: initialBalance + transactions before startDate
let runningBalance = 0;
const accountsToUse = selectedAccounts.includes("all")
? accounts
: accounts.filter((acc: Account) => selectedAccounts.includes(acc.id));
// Start with initial balances
runningBalance = accountsToUse.reduce(
(sum: number, acc: Account) => sum + (acc.initialBalance || 0),
0,
);
// Add transactions before startDate if we have them
if (beforeStartDateData?.transactions) {
const beforeStartTransactions = beforeStartDateData.transactions.filter(
(t) => {
const transactionDate = new Date(t.date);
return transactionDate < startDate;
},
);
beforeStartTransactions.forEach((t) => {
runningBalance += t.amount;
});
}
const aggregatedBalanceByDate = new Map<string, number>();
// Calculate balance evolution
sortedTransactions.forEach((t) => {
runningBalance += t.amount;
aggregatedBalanceByDate.set(t.date, runningBalance);
});
const aggregatedBalanceData: BalanceChartDataPoint[] = Array.from(
aggregatedBalanceByDate.entries(),
).map(([date, balance]) => ({
date: new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
}),
solde: Math.round(balance),
}));
// Per account balance calculation
const accountBalances = new Map<string, Map<string, number>>();
accounts.forEach((account: Account) => {
accountBalances.set(account.id, new Map());
});
// Calculate running balance per account
const accountRunningBalances = new Map<string, number>();
accounts.forEach((account: Account) => {
accountRunningBalances.set(account.id, account.initialBalance || 0);
});
// Add transactions before startDate per account
if (beforeStartDateData?.transactions) {
const beforeStartTransactions = beforeStartDateData.transactions.filter(
(t) => {
const transactionDate = new Date(t.date);
return transactionDate < startDate;
},
);
beforeStartTransactions.forEach((t) => {
const currentBalance = accountRunningBalances.get(t.accountId) || 0;
accountRunningBalances.set(t.accountId, currentBalance + t.amount);
});
}
// Filter transactions by account if needed
const transactionsForAccounts = selectedAccounts.includes("all")
? sortedTransactions
: sortedTransactions.filter((t) =>
selectedAccounts.includes(t.accountId),
);
transactionsForAccounts.forEach((t) => {
const currentBalance = accountRunningBalances.get(t.accountId) || 0;
const newBalance = currentBalance + t.amount;
accountRunningBalances.set(t.accountId, newBalance);
const accountDates = accountBalances.get(t.accountId);
if (accountDates) {
accountDates.set(t.date, newBalance);
}
});
// Merge all dates and create data points
const allDates = new Set<string>();
accountBalances.forEach((dates) => {
dates.forEach((_, date) => allDates.add(date));
});
const sortedDates = Array.from(allDates).sort();
const lastBalances = new Map<string, number>();
accounts.forEach((account: Account) => {
// Start with initial balance + transactions before startDate
let accountStartingBalance = account.initialBalance || 0;
if (beforeStartDateData?.transactions) {
const beforeStartTransactions = beforeStartDateData.transactions.filter(
(t) => {
const transactionDate = new Date(t.date);
return transactionDate < startDate && t.accountId === account.id;
},
);
beforeStartTransactions.forEach((t) => {
accountStartingBalance += t.amount;
});
}
lastBalances.set(account.id, accountStartingBalance);
});
const perAccountBalanceData: BalanceChartDataPoint[] = sortedDates.map(
(date) => {
const point: BalanceChartDataPoint = {
date: new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
}),
};
accounts.forEach((account: Account) => {
const accountDates = accountBalances.get(account.id);
if (accountDates?.has(date)) {
lastBalances.set(account.id, accountDates.get(date)!);
}
point[account.id] = Math.round(lastBalances.get(account.id) || 0);
});
return point;
},
);
return {
aggregatedData: aggregatedBalanceData,
perAccountData: perAccountBalanceData,
};
}, [
transactionsData,
beforeStartDateData,
metadata,
selectedAccounts,
startDate,
]);
return {
aggregatedData: chartData.aggregatedData,
perAccountData: chartData.perAccountData,
accounts: metadata?.accounts || [],
isLoading,
};
}

View File

@@ -0,0 +1,224 @@
"use client";
import { useMemo } from "react";
import { useTransactions } from "@/lib/hooks";
import { useBankingMetadata } from "@/lib/hooks";
import type { TransactionsPaginatedParams } from "@/services/banking.service";
import type { Category } from "@/lib/types";
interface MonthlyChartData {
month: string;
revenus: number;
depenses: number;
solde: number;
}
interface CategoryChartData {
name: string;
value: number;
color: string;
icon: string;
categoryId: string | null;
}
interface UseTransactionsChartDataParams {
selectedAccounts: string[];
selectedCategories: string[];
period: "1month" | "3months" | "6months" | "12months" | "custom" | "all";
customStartDate?: Date;
customEndDate?: Date;
showReconciled: string;
searchQuery: string;
}
export function useTransactionsChartData({
selectedAccounts,
selectedCategories,
period,
customStartDate,
customEndDate,
showReconciled,
searchQuery,
}: UseTransactionsChartDataParams) {
const { data: metadata } = useBankingMetadata();
// Calculate start date based on period
const startDate = useMemo(() => {
const now = new Date();
switch (period) {
case "1month":
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
case "3months":
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
case "6months":
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
case "12months":
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
case "custom":
return customStartDate || new Date(0);
default:
return new Date(0);
}
}, [period, customStartDate]);
// Calculate end date (only for custom period)
const endDate = useMemo(() => {
if (period === "custom" && customEndDate) {
return customEndDate;
}
return undefined;
}, [period, customEndDate]);
// Build params for fetching all transactions (no pagination)
const chartParams = useMemo<TransactionsPaginatedParams>(() => {
const params: TransactionsPaginatedParams = {
limit: 10000, // Large limit to get all transactions
offset: 0,
sortField: "date",
sortOrder: "asc",
};
if (startDate && period !== "all") {
params.startDate = startDate.toISOString().split("T")[0];
}
if (endDate) {
params.endDate = endDate.toISOString().split("T")[0];
}
if (!selectedAccounts.includes("all")) {
params.accountIds = selectedAccounts;
}
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
params.includeUncategorized = true;
} else {
params.categoryIds = selectedCategories;
}
}
if (searchQuery) {
params.search = searchQuery;
}
if (showReconciled !== "all") {
params.isReconciled = showReconciled === "reconciled";
}
return params;
}, [
startDate,
endDate,
selectedAccounts,
selectedCategories,
searchQuery,
showReconciled,
period,
]);
// Fetch all filtered transactions for chart
const { data: transactionsData, isLoading } = useTransactions(
chartParams,
!!metadata,
);
// Calculate monthly chart data
const monthlyData = useMemo(() => {
if (!transactionsData || !metadata) {
return [];
}
const transactions = transactionsData.transactions;
// Monthly breakdown
const monthlyMap = new Map<string, { income: number; expenses: number }>();
transactions.forEach((t) => {
const monthKey = t.date.substring(0, 7);
const current = monthlyMap.get(monthKey) || { income: 0, expenses: 0 };
if (t.amount >= 0) {
current.income += t.amount;
} else {
current.expenses += Math.abs(t.amount);
}
monthlyMap.set(monthKey, current);
});
// Format months with year: use short format for better readability
const sortedMonths = Array.from(monthlyMap.entries()).sort((a, b) =>
a[0].localeCompare(b[0]),
);
const monthlyChartData: MonthlyChartData[] = sortedMonths.map(
([monthKey, values]) => {
const date = new Date(monthKey + "-01");
// Format: "janv. 24" instead of "janv. 2024" for compactness
const monthLabel = date.toLocaleDateString("fr-FR", {
month: "short",
});
const yearShort = date.getFullYear().toString().slice(-2);
return {
month: `${monthLabel} ${yearShort}`,
revenus: values.income,
depenses: values.expenses,
solde: values.income - values.expenses,
};
},
);
return monthlyChartData;
}, [transactionsData, metadata]);
// Calculate category chart data (expenses only)
const categoryData = useMemo(() => {
if (!transactionsData || !metadata) {
return [];
}
const transactions = transactionsData.transactions;
const categoryTotals = new Map<string, number>();
transactions
.filter((t) => t.amount < 0)
.forEach((t) => {
const catId = t.categoryId || "uncategorized";
const current = categoryTotals.get(catId) || 0;
categoryTotals.set(catId, current + Math.abs(t.amount));
});
const categoryChartData: CategoryChartData[] = Array.from(
categoryTotals.entries(),
)
.map(([categoryId, total]) => {
const category = metadata.categories.find(
(c: Category) => c.id === categoryId,
);
return {
name: category?.name || "Non catégorisé",
value: Math.round(total),
color: category?.color || "#94a3b8",
icon: category?.icon || "HelpCircle",
categoryId: categoryId === "uncategorized" ? null : categoryId,
};
})
.sort((a, b) => b.value - a.value);
return categoryChartData;
}, [transactionsData, metadata]);
// Calculate total amount and count from all filtered transactions
const totalAmount = useMemo(() => {
if (!transactionsData) return 0;
return transactionsData.transactions.reduce((sum, t) => sum + t.amount, 0);
}, [transactionsData]);
const totalCount = useMemo(() => {
return transactionsData?.total || 0;
}, [transactionsData?.total]);
return {
monthlyData,
categoryData,
isLoading,
totalAmount,
totalCount,
transactions: transactionsData?.transactions || [],
};
}

View File

@@ -0,0 +1,108 @@
"use client";
import { useMemo } from "react";
import { useTransactions } from "@/lib/hooks";
import { useBankingMetadata } from "@/lib/hooks";
import type { TransactionsPaginatedParams } from "@/services/banking.service";
interface UseTransactionsForAccountFilterParams {
selectedCategories: string[];
period: "1month" | "3months" | "6months" | "12months" | "custom" | "all";
customStartDate?: Date;
customEndDate?: Date;
showReconciled: string;
searchQuery: string;
}
/**
* Hook to fetch transactions filtered by categories, period, search, reconciled
* but NOT by accounts. Used for displaying account totals in account filter.
*/
export function useTransactionsForAccountFilter({
selectedCategories,
period,
customStartDate,
customEndDate,
showReconciled,
searchQuery,
}: UseTransactionsForAccountFilterParams) {
const { data: metadata } = useBankingMetadata();
// Calculate start date based on period
const startDate = useMemo(() => {
const now = new Date();
switch (period) {
case "1month":
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
case "3months":
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
case "6months":
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
case "12months":
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
case "custom":
return customStartDate || new Date(0);
default:
return new Date(0);
}
}, [period, customStartDate]);
// Calculate end date (only for custom period)
const endDate = useMemo(() => {
if (period === "custom" && customEndDate) {
return customEndDate;
}
return undefined;
}, [period, customEndDate]);
// Build params for fetching all transactions (no pagination, no account filter)
const filterParams = useMemo<TransactionsPaginatedParams>(() => {
const params: TransactionsPaginatedParams = {
limit: 10000, // Large limit to get all transactions
offset: 0,
sortField: "date",
sortOrder: "asc",
};
if (startDate && period !== "all") {
params.startDate = startDate.toISOString().split("T")[0];
}
if (endDate) {
params.endDate = endDate.toISOString().split("T")[0];
}
// NOTE: We intentionally don't filter by accounts here
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
params.includeUncategorized = true;
} else {
params.categoryIds = selectedCategories;
}
}
if (searchQuery) {
params.search = searchQuery;
}
if (showReconciled !== "all") {
params.isReconciled = showReconciled === "reconciled";
}
return params;
}, [
startDate,
endDate,
selectedCategories,
searchQuery,
showReconciled,
period,
]);
// Fetch all filtered transactions (without account filter)
const { data: transactionsData, isLoading } = useTransactions(
filterParams,
!!metadata,
);
return {
transactions: transactionsData?.transactions || [],
isLoading,
};
}

Some files were not shown because too many files have changed in this diff Show More