Compare commits

..

10 Commits

Author SHA1 Message Date
Julien Froidefond
a7f3433f5f chore: standardize quotes in pnpm-lock.yaml and clean up formatting in various files for improved readability 2025-12-06 12:38:45 +01:00
Julien Froidefond
ad8b936c7a feat: implement token expiration handling in authentication flow and update session management for improved security 2025-12-06 12:38:36 +01:00
Julien Froidefond
b1a8f9cd60 feat: integrate React Query for improved data fetching and state management across banking and transactions components 2025-12-06 09:36:06 +01:00
Julien Froidefond
e26eb0f039 chore: add formatting scripts to package.json and standardize pnpm-lock.yaml quotes 2025-12-06 09:16:04 +01:00
Julien Froidefond
c4fe288193 chore: update Next.js version to 16.0.7 and standardize package.json and pnpm-lock.yaml formatting 2025-12-05 08:29:36 +01:00
Julien Froidefond
ec387d5e2b chore: update .dockerignore and refactor createBackup function signature for improved readability 2025-12-05 08:28:36 +01:00
Julien Froidefond
e715779de7 chore: clean up code by removing trailing whitespace and ensuring consistent formatting across various files = prettier 2025-12-01 08:37:30 +01:00
Julien Froidefond
757b1b84ab refactor: remove unused imports and improve variable naming for clarity in statistics and transactions components 2025-12-01 08:35:33 +01:00
Julien Froidefond
b3b25412ad feat: enhance responsive design and layout consistency across various components, including dashboard, statistics, and rules pages 2025-12-01 08:34:28 +01:00
Julien Froidefond
86236aeb04 feat: docker and compose 2025-12-01 08:14:04 +01:00
110 changed files with 7275 additions and 3792 deletions

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
node_modules
.next
.git
.gitignore
.env
.env.local
.env*.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
*.pem
README.md
.vscode
.idea

67
Dockerfile Normal file
View File

@@ -0,0 +1,67 @@
FROM node:20-alpine AS base
# Install OpenSSL and libc6-compat for Prisma
RUN apk add --no-cache openssl3 libc6-compat
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build args for environment variables needed during build
ARG NEXTAUTH_SECRET=dummy-secret-for-build-only
ARG NEXTAUTH_URL=http://localhost:3000
ARG DATABASE_URL=file:./prisma/dev.db
# Set environment variables for build
ENV NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
ENV NEXTAUTH_URL=${NEXTAUTH_URL}
ENV DATABASE_URL=${DATABASE_URL}
ENV NODE_ENV=production
# Generate Prisma Client
RUN pnpm prisma generate
# Build Next.js
RUN pnpm build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV DATABASE_URL=file:./prisma/dev.db
ENV NEXTAUTH_URL=http://localhost:3000
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Copy prisma schema (will be mounted as volume in dev)
COPY --from=builder /app/prisma ./prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -243,5 +243,3 @@ Ce projet est en développement actif. Les suggestions et améliorations sont le
---
Développé avec ❤️ en utilisant Next.js et React

View File

@@ -18,11 +18,16 @@ import {
AccountEditDialog,
AccountBulkActions,
} from "@/components/accounts";
import { FolderEditDialog } from "@/components/folders";
import { useBankingMetadata, useAccountsWithStats } from "@/lib/hooks";
import { useQueryClient } from "@tanstack/react-query";
import {
FolderEditDialog,
} from "@/components/folders";
import { useBankingData } from "@/lib/hooks";
import { updateAccount, deleteAccount, addFolder, updateFolder, deleteFolder } from "@/lib/store-db";
updateAccount,
deleteAccount,
addFolder,
updateFolder,
deleteFolder,
} from "@/lib/store-db";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Building2, Folder, Plus, List, LayoutGrid } from "lucide-react";
@@ -46,7 +51,7 @@ function FolderDropZone({
<div
ref={setNodeRef}
className={cn(
isOver && "ring-2 ring-primary ring-offset-2 rounded-lg p-2"
isOver && "ring-2 ring-primary ring-offset-2 rounded-lg p-2",
)}
>
{children}
@@ -55,7 +60,17 @@ function FolderDropZone({
}
export default function AccountsPage() {
const { data, isLoading, refresh, refreshSilent, update } = useBankingData();
const queryClient = useQueryClient();
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
const { data: accountsWithStats, isLoading: isLoadingAccounts } =
useAccountsWithStats();
// refresh function is not used directly, invalidations are done inline
const refreshSilent = async () => {
await queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
await queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
};
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedAccounts, setSelectedAccounts] = useState<Set<string>>(
@@ -85,13 +100,23 @@ export default function AccountsPage() {
activationConstraint: {
distance: 8,
},
})
}),
);
if (isLoading || !data) {
if (
isLoadingMetadata ||
!metadata ||
isLoadingAccounts ||
!accountsWithStats
) {
return <LoadingState />;
}
// Convert accountsWithStats to regular accounts for compatibility
const accounts = accountsWithStats.map(
({ transactionCount: _transactionCount, ...account }) => account,
);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
@@ -124,7 +149,8 @@ export default function AccountsPage() {
initialBalance: formData.initialBalance,
};
await updateAccount(updatedAccount);
refresh();
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
setIsDialogOpen(false);
setEditingAccount(null);
} catch (error) {
@@ -138,7 +164,8 @@ export default function AccountsPage() {
try {
await deleteAccount(accountId);
refresh();
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) {
console.error("Error deleting account:", error);
alert("Erreur lors de la suppression du compte");
@@ -166,7 +193,8 @@ export default function AccountsPage() {
throw new Error("Failed to delete accounts");
}
setSelectedAccounts(new Set());
refresh();
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) {
console.error("Error deleting accounts:", error);
alert("Erreur lors de la suppression des comptes");
@@ -202,7 +230,9 @@ export default function AccountsPage() {
const handleSaveFolder = async () => {
const parentId =
folderFormData.parentId === "folder-root" ? null : folderFormData.parentId;
folderFormData.parentId === "folder-root"
? null
: folderFormData.parentId;
try {
if (editingFolder) {
@@ -220,7 +250,8 @@ export default function AccountsPage() {
icon: "folder",
});
}
refresh();
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
setIsFolderDialogOpen(false);
} catch (error) {
console.error("Error saving folder:", error);
@@ -231,14 +262,15 @@ export default function AccountsPage() {
const handleDeleteFolder = async (folderId: string) => {
if (
!confirm(
"Supprimer ce dossier ? Les comptes seront déplacés à la racine."
"Supprimer ce dossier ? Les comptes seront déplacés à la racine.",
)
)
return;
try {
await deleteFolder(folderId);
refresh();
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) {
console.error("Error deleting folder:", error);
alert("Erreur lors de la suppression du dossier");
@@ -254,7 +286,7 @@ export default function AccountsPage() {
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id || !data) return;
if (!over || active.id === over.id || !accountsWithStats) return;
const activeId = active.id as string;
const overId = over.id as string;
@@ -270,59 +302,60 @@ export default function AccountsPage() {
} else if (overId.startsWith("account-")) {
// Déplacer vers le dossier du compte cible
const targetAccountId = overId.replace("account-", "");
const targetAccount = data.accounts.find((a) => a.id === targetAccountId);
const targetAccount = accountsWithStats.find(
(a) => a.id === targetAccountId,
);
if (targetAccount) {
targetFolderId = targetAccount.folderId;
}
}
if (targetFolderId !== undefined) {
const account = data.accounts.find((a) => a.id === accountId);
const account = accountsWithStats.find((a) => a.id === accountId);
if (!account) return;
// Sauvegarder l'état précédent pour rollback en cas d'erreur
const previousData = data;
// Optimistic update : mettre à jour immédiatement l'interface
const updatedAccount = {
...account,
folderId: targetFolderId,
};
const updatedAccounts = data.accounts.map((a) =>
a.id === accountId ? updatedAccount : a
// Update cache directly
queryClient.setQueryData(
["accounts-with-stats"],
(old: Array<Account & { transactionCount: number }> | undefined) => {
if (!old) return old;
return old.map((a) => (a.id === accountId ? updatedAccount : a));
},
);
update({
...data,
accounts: updatedAccounts,
});
// Faire la requête en arrière-plan
try {
await updateAccount(updatedAccount);
// Refresh silencieux pour synchroniser avec le serveur sans loader
refreshSilent();
await refreshSilent();
} catch (error) {
console.error("Error moving account:", error);
// Rollback en cas d'erreur
update(previousData);
// Rollback en cas d'erreur - refresh data
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
alert("Erreur lors du déplacement du compte");
}
}
}
};
const getTransactionCount = (accountId: string) => {
return data.transactions.filter((t) => t.accountId === accountId).length;
const account = accountsWithStats.find((a) => a.id === accountId);
return account?.transactionCount || 0;
};
const totalBalance = data.accounts.reduce(
const totalBalance = accounts.reduce(
(sum, a) => sum + getAccountBalance(a),
0,
);
// Grouper les comptes par folder
const accountsByFolder = data.accounts.reduce(
const accountsByFolder = accounts.reduce(
(acc, account) => {
const folderId = account.folderId || "no-folder";
if (!acc[folderId]) {
@@ -335,9 +368,9 @@ export default function AccountsPage() {
);
// Obtenir les folders racine (sans parent) et les trier par nom
const rootFolders = data.folders
.filter((f) => !f.parentId)
.sort((a, b) => a.name.localeCompare(b.name));
const rootFolders = metadata.folders
.filter((f: FolderType) => !f.parentId)
.sort((a: FolderType, b: FolderType) => a.name.localeCompare(b.name));
return (
<PageLayout>
@@ -370,7 +403,7 @@ export default function AccountsPage() {
<p
className={cn(
"text-2xl font-bold",
totalBalance >= 0 ? "text-emerald-600" : "text-red-600"
totalBalance >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(totalBalance)}
@@ -379,7 +412,7 @@ export default function AccountsPage() {
}
/>
{data.accounts.length === 0 ? (
{accounts.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Building2 className="w-16 h-16 text-muted-foreground mb-4" />
@@ -434,8 +467,8 @@ export default function AccountsPage() {
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{accountsByFolder["no-folder"].map((account) => {
const folder = data.folders.find(
(f) => f.id === account.folderId,
const folder = metadata.folders.find(
(f: FolderType) => f.id === account.folderId,
);
return (
@@ -459,7 +492,7 @@ export default function AccountsPage() {
)}
{/* Afficher les comptes groupés par folder */}
{rootFolders.map((folder) => {
{rootFolders.map((folder: FolderType) => {
const folderAccounts = accountsByFolder[folder.id] || [];
const folderBalance = folderAccounts.reduce(
(sum, a) => sum + getAccountBalance(a),
@@ -514,8 +547,8 @@ export default function AccountsPage() {
{folderAccounts.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{folderAccounts.map((account) => {
const accountFolder = data.folders.find(
(f) => f.id === account.folderId,
const accountFolder = metadata.folders.find(
(f: FolderType) => f.id === account.folderId,
);
return (
@@ -558,8 +591,8 @@ export default function AccountsPage() {
{activeId.startsWith("account-") ? (
<Card>
<CardContent className="p-4">
{data.accounts.find(
(a) => a.id === activeId.replace("account-", "")
{accounts.find(
(a) => a.id === activeId.replace("account-", ""),
)?.name || ""}
</CardContent>
</Card>
@@ -576,7 +609,7 @@ export default function AccountsPage() {
onOpenChange={setIsDialogOpen}
formData={formData}
onFormDataChange={setFormData}
folders={data.folders}
folders={metadata.folders}
onSave={handleSave}
/>
@@ -586,7 +619,7 @@ export default function AccountsPage() {
editingFolder={editingFolder}
formData={folderFormData}
onFormDataChange={setFolderFormData}
folders={data.folders}
folders={metadata.folders}
onSave={handleSaveFolder}
/>
</PageLayout>

View File

@@ -4,4 +4,3 @@ import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
if (!session) {
return NextResponse.json(
{ success: false, error: "Non authentifié" },
{ status: 401 }
{ status: 401 },
);
}
@@ -20,14 +20,17 @@ export async function POST(request: NextRequest) {
if (!oldPassword || !newPassword) {
return NextResponse.json(
{ success: false, error: "Mot de passe requis" },
{ status: 400 }
{ status: 400 },
);
}
if (newPassword.length < 4) {
return NextResponse.json(
{ success: false, error: "Le mot de passe doit contenir au moins 4 caractères" },
{ status: 400 }
{
success: false,
error: "Le mot de passe doit contenir au moins 4 caractères",
},
{ status: 400 },
);
}
@@ -36,7 +39,7 @@ export async function POST(request: NextRequest) {
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: 400 }
{ status: 400 },
);
}
@@ -45,8 +48,7 @@ export async function POST(request: NextRequest) {
console.error("Error changing password:", error);
return NextResponse.json(
{ success: false, error: "Erreur lors du changement de mot de passe" },
{ status: 500 }
{ status: 500 },
);
}
}

View File

@@ -4,7 +4,7 @@ import { requireAuth } from "@/lib/auth-utils";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> | { id: string } }
{ params }: { params: Promise<{ id: string }> | { id: string } },
) {
const authError = await requireAuth();
if (authError) return authError;
@@ -15,9 +15,12 @@ export async function POST(
} catch (error) {
console.error("Error restoring backup:", error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "Failed to restore backup" },
{ status: 500 }
{
success: false,
error:
error instanceof Error ? error.message : "Failed to restore backup",
},
{ status: 500 },
);
}
}

View File

@@ -4,7 +4,7 @@ import { requireAuth } from "@/lib/auth-utils";
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> | { id: string } }
{ params }: { params: Promise<{ id: string }> | { id: string } },
) {
const authError = await requireAuth();
if (authError) return authError;
@@ -15,9 +15,12 @@ export async function DELETE(
} catch (error) {
console.error("Error deleting backup:", error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "Failed to delete backup" },
{ status: 500 }
{
success: false,
error:
error instanceof Error ? error.message : "Failed to delete backup",
},
{ status: 500 },
);
}
}

View File

@@ -32,10 +32,12 @@ export async function POST(_request: NextRequest) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Failed to create automatic backup",
error:
error instanceof Error
? error.message
: "Failed to create automatic backup",
},
{ status: 500 }
{ status: 500 },
);
}
}

View File

@@ -13,7 +13,7 @@ export async function GET() {
console.error("Error fetching backups:", error);
return NextResponse.json(
{ success: false, error: "Failed to fetch backups" },
{ status: 500 }
{ status: 500 },
);
}
}
@@ -31,9 +31,12 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error("Error creating backup:", error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "Failed to create backup" },
{ status: 500 }
{
success: false,
error:
error instanceof Error ? error.message : "Failed to create backup",
},
{ status: 500 },
);
}
}

View File

@@ -12,7 +12,7 @@ export async function GET() {
console.error("Error fetching backup settings:", error);
return NextResponse.json(
{ success: false, error: "Failed to fetch settings" },
{ status: 500 }
{ status: 500 },
);
}
}
@@ -29,8 +29,7 @@ export async function PUT(request: NextRequest) {
console.error("Error updating backup settings:", error);
return NextResponse.json(
{ success: false, error: "Failed to update settings" },
{ status: 500 }
{ status: 500 },
);
}
}

View File

@@ -1,8 +1,36 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { accountService } from "@/services/account.service";
import { bankingService } from "@/services/banking.service";
import { requireAuth } from "@/lib/auth-utils";
import type { Account } from "@/lib/types";
export async function GET(request: NextRequest) {
const authError = await requireAuth();
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const withStats = searchParams.get("withStats") === "true";
if (withStats) {
const accountsWithStats = await bankingService.getAccountsWithStats();
return NextResponse.json(accountsWithStats, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
}
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
} catch (error) {
console.error("Error fetching accounts:", error);
return NextResponse.json(
{ error: "Failed to fetch accounts" },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
const authError = await requireAuth();
if (authError) return authError;

View File

@@ -1,8 +1,36 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { categoryService } from "@/services/category.service";
import { bankingService } from "@/services/banking.service";
import { requireAuth } from "@/lib/auth-utils";
import type { Category } from "@/lib/types";
export async function GET(request: NextRequest) {
const authError = await requireAuth();
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const statsOnly = searchParams.get("statsOnly") === "true";
if (statsOnly) {
const stats = await bankingService.getCategoryStats();
return NextResponse.json(stats, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
}
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
} catch (error) {
console.error("Error fetching category stats:", error);
return NextResponse.json(
{ error: "Failed to fetch category stats" },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
const authError = await requireAuth();
if (authError) return authError;

View File

@@ -1,14 +1,30 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { bankingService } from "@/services/banking.service";
import { requireAuth } from "@/lib/auth-utils";
export async function GET() {
export async function GET(request: NextRequest) {
const authError = await requireAuth();
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const metadataOnly = searchParams.get("metadataOnly") === "true";
if (metadataOnly) {
const metadata = await bankingService.getMetadata();
return NextResponse.json(metadata, {
headers: {
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
},
});
}
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) {
console.error("Error fetching banking data:", error);
return NextResponse.json(

View File

@@ -27,5 +27,3 @@ export async function POST() {
);
}
}

View File

@@ -13,8 +13,7 @@ export async function POST() {
console.error("Error deduplicating transactions:", error);
return NextResponse.json(
{ error: "Failed to deduplicate transactions" },
{ status: 500 }
{ status: 500 },
);
}
}

View File

@@ -1,8 +1,73 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { transactionService } from "@/services/transaction.service";
import { bankingService } from "@/services/banking.service";
import { requireAuth } from "@/lib/auth-utils";
import type { Transaction } from "@/lib/types";
export async function GET(request: NextRequest) {
const authError = await requireAuth();
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
// Parse pagination params
const limit = parseInt(searchParams.get("limit") || "50", 10);
const offset = parseInt(searchParams.get("offset") || "0", 10);
// Parse filter params
const startDate = searchParams.get("startDate") || undefined;
const endDate = searchParams.get("endDate") || undefined;
const accountIds = searchParams.get("accountIds")
? searchParams.get("accountIds")!.split(",")
: undefined;
const categoryIds = searchParams.get("categoryIds")
? searchParams.get("categoryIds")!.split(",")
: undefined;
const includeUncategorized =
searchParams.get("includeUncategorized") === "true";
const search = searchParams.get("search") || undefined;
const isReconciledParam = searchParams.get("isReconciled");
const isReconciled =
isReconciledParam === "true"
? true
: isReconciledParam === "false"
? false
: "all";
const sortField =
(searchParams.get("sortField") as "date" | "amount" | "description") ||
"date";
const sortOrder =
(searchParams.get("sortOrder") as "asc" | "desc") || "desc";
const result = await bankingService.getTransactionsPaginated({
limit,
offset,
startDate,
endDate,
accountIds,
categoryIds,
includeUncategorized,
search,
isReconciled,
sortField,
sortOrder,
});
return NextResponse.json(result, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
} catch (error) {
console.error("Error fetching transactions:", error);
return NextResponse.json(
{ error: "Failed to fetch transactions" },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
const authError = await requireAuth();
if (authError) return authError;

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import {
CategoryCard,
@@ -8,7 +8,8 @@ import {
ParentCategoryRow,
CategorySearchBar,
} from "@/components/categories";
import { useBankingData } from "@/lib/hooks";
import { useBankingMetadata, useCategoryStats } from "@/lib/hooks";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -34,11 +35,13 @@ interface RecategorizationResult {
}
export default function CategoriesPage() {
const { data, isLoading, refresh } = useBankingData();
const queryClient = useQueryClient();
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
const { data: categoryStats, isLoading: isLoadingStats } = useCategoryStats();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [expandedParents, setExpandedParents] = useState<Set<string>>(
new Set()
new Set(),
);
const [formData, setFormData] = useState({
name: "",
@@ -48,28 +51,34 @@ export default function CategoriesPage() {
parentId: null as string | null,
});
const [searchQuery, setSearchQuery] = useState("");
const [recatResults, setRecatResults] = useState<RecategorizationResult[]>([]);
const [recatResults, setRecatResults] = useState<RecategorizationResult[]>(
[],
);
const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false);
const [isRecategorizing, setIsRecategorizing] = useState(false);
// Organiser les catégories par parent
const { parentCategories, childrenByParent, orphanCategories } =
useMemo(() => {
if (!data?.categories)
if (!metadata?.categories)
return {
parentCategories: [],
childrenByParent: {},
orphanCategories: [],
};
const parents = data.categories.filter((c) => c.parentId === null);
const parents = metadata.categories.filter(
(c: Category) => c.parentId === null,
);
const children: Record<string, Category[]> = {};
const orphans: Category[] = [];
data.categories
.filter((c) => c.parentId !== null)
.forEach((child) => {
const parentExists = parents.some((p) => p.id === child.parentId);
metadata.categories
.filter((c: Category) => c.parentId !== null)
.forEach((child: Category) => {
const parentExists = parents.some(
(p: Category) => p.id === child.parentId,
);
if (parentExists) {
if (!children[child.parentId!]) {
children[child.parentId!] = [];
@@ -85,27 +94,25 @@ export default function CategoriesPage() {
childrenByParent: children,
orphanCategories: orphans,
};
}, [data?.categories]);
}, [metadata?.categories]);
// Initialiser tous les parents comme ouverts
useState(() => {
useEffect(() => {
if (parentCategories.length > 0 && expandedParents.size === 0) {
setExpandedParents(new Set(parentCategories.map((p) => p.id)));
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentCategories.length]);
if (isLoading || !data) {
return <LoadingState />;
}
const refresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
queryClient.invalidateQueries({ queryKey: ["category-stats"] });
}, [queryClient]);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(amount);
};
const getCategoryStats = useCallback(
(categoryId: string, includeChildren = false) => {
if (!categoryStats) return { total: 0, count: 0 };
const getCategoryStats = (categoryId: string, includeChildren = false) => {
let categoryIds = [categoryId];
if (includeChildren && childrenByParent[categoryId]) {
@@ -115,15 +122,32 @@ export default function CategoriesPage() {
];
}
const categoryTransactions = data.transactions.filter((t) =>
categoryIds.includes(t.categoryId || "")
);
const total = categoryTransactions.reduce(
(sum, t) => sum + Math.abs(t.amount),
0
);
const count = categoryTransactions.length;
// Sum stats from all category IDs
let total = 0;
let count = 0;
categoryIds.forEach((id) => {
const stats = categoryStats[id];
if (stats) {
total += stats.total;
count += stats.count;
}
});
return { total, count };
},
[categoryStats, childrenByParent],
);
if (isLoadingMetadata || !metadata || isLoadingStats || !categoryStats) {
return <LoadingState />;
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(amount);
};
const toggleExpanded = (parentId: string) => {
@@ -137,7 +161,7 @@ export default function CategoriesPage() {
};
const expandAll = () => {
setExpandedParents(new Set(parentCategories.map((p) => p.id)));
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
};
const collapseAll = () => {
@@ -150,7 +174,13 @@ export default function CategoriesPage() {
const handleNewCategory = (parentId: string | null = null) => {
setEditingCategory(null);
setFormData({ name: "", color: "#22c55e", icon: "tag", keywords: [], parentId });
setFormData({
name: "",
color: "#22c55e",
icon: "tag",
keywords: [],
parentId,
});
setIsDialogOpen(true);
};
@@ -216,16 +246,27 @@ export default function CategoriesPage() {
const results: RecategorizationResult[] = [];
try {
// Fetch uncategorized transactions
const uncategorizedResponse = await fetch(
"/api/banking/transactions?limit=1000&offset=0&includeUncategorized=true",
);
if (!uncategorizedResponse.ok) {
throw new Error("Failed to fetch uncategorized transactions");
}
const { transactions: uncategorized } =
await uncategorizedResponse.json();
const { updateTransaction } = await import("@/lib/store-db");
const uncategorized = data.transactions.filter((t) => !t.categoryId);
for (const transaction of uncategorized) {
const categoryId = autoCategorize(
transaction.description + " " + (transaction.memo || ""),
data.categories
metadata.categories,
);
if (categoryId) {
const category = data.categories.find((c) => c.id === categoryId);
const category = metadata.categories.find(
(c: Category) => c.id === categoryId,
);
if (category) {
results.push({ transaction, category });
await updateTransaction({ ...transaction, categoryId });
@@ -244,30 +285,30 @@ export default function CategoriesPage() {
}
};
const uncategorizedCount = data.transactions.filter(
(t) => !t.categoryId
).length;
const uncategorizedCount = categoryStats["uncategorized"]?.count || 0;
// Filtrer les catégories selon la recherche
const filteredParentCategories = parentCategories.filter((parent) => {
const filteredParentCategories = parentCategories.filter(
(parent: Category) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
if (parent.name.toLowerCase().includes(query)) return true;
if (parent.keywords.some((k) => k.toLowerCase().includes(query)))
if (parent.keywords.some((k: string) => k.toLowerCase().includes(query)))
return true;
const children = childrenByParent[parent.id] || [];
return children.some(
(c) =>
c.name.toLowerCase().includes(query) ||
c.keywords.some((k) => k.toLowerCase().includes(query))
c.keywords.some((k) => k.toLowerCase().includes(query)),
);
},
);
});
return (
<PageLayout>
<PageHeader
title="Catégories"
description={`${parentCategories.length} catégories principales • ${data.categories.length - parentCategories.length} sous-catégories`}
description={`${parentCategories.length} catégories principales • ${metadata.categories.length - parentCategories.length} sous-catégories`}
actions={
<>
{uncategorizedCount > 0 && (
@@ -298,16 +339,16 @@ export default function CategoriesPage() {
/>
<div className="space-y-1">
{filteredParentCategories.map((parent) => {
{filteredParentCategories.map((parent: Category) => {
const allChildren = childrenByParent[parent.id] || [];
const children = searchQuery.trim()
? allChildren.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.keywords.some((k) =>
k.toLowerCase().includes(searchQuery.toLowerCase())
k.toLowerCase().includes(searchQuery.toLowerCase()),
) ||
parent.name.toLowerCase().includes(searchQuery.toLowerCase())
parent.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
: allChildren;
const stats = getCategoryStats(parent.id, true);
@@ -393,7 +434,9 @@ export default function CategoriesPage() {
{result.transaction.description}
</p>
<p className="text-xs text-muted-foreground truncate">
{new Date(result.transaction.date).toLocaleDateString("fr-FR")}
{new Date(result.transaction.date).toLocaleDateString(
"fr-FR",
)}
{" • "}
{new Intl.NumberFormat("fr-FR", {
style: "currency",
@@ -424,9 +467,7 @@ export default function CategoriesPage() {
)}
<div className="flex justify-end pt-4 border-t">
<Button onClick={() => setIsRecatDialogOpen(false)}>
Fermer
</Button>
<Button onClick={() => setIsRecatDialogOpen(false)}>Fermer</Button>
</div>
</DialogContent>
</Dialog>

View File

@@ -3,6 +3,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthSessionProvider } from "@/components/providers/session-provider";
import { QueryProvider } from "@/components/providers/query-provider";
const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] });
@@ -22,7 +23,9 @@ export default function RootLayout({
return (
<html lang="fr">
<body className="font-sans antialiased">
<QueryProvider>
<AuthSessionProvider>{children}</AuthSessionProvider>
</QueryProvider>
</body>
</html>
);

View File

@@ -53,9 +53,7 @@ export default function LoginPage() {
<div className="flex items-center justify-center mb-4">
<Lock className="w-12 h-12 text-[var(--primary)]" />
</div>
<CardTitle className="text-2xl text-center">
Accès protégé
</CardTitle>
<CardTitle className="text-2xl text-center">Accès protégé</CardTitle>
<CardDescription className="text-center">
Entrez le mot de passe pour accéder à l'application
</CardDescription>
@@ -92,4 +90,3 @@ export default function LoginPage() {
</div>
);
}

View File

@@ -27,11 +27,11 @@ export default function DashboardPage() {
}
const filteredAccounts = data.accounts.filter((a) =>
selectedAccounts.includes(a.id)
selectedAccounts.includes(a.id),
);
const filteredAccountIds = new Set(filteredAccounts.map((a) => a.id));
const filteredTransactions = data.transactions.filter((t) =>
filteredAccountIds.has(t.accountId)
filteredAccountIds.has(t.accountId),
);
return {
@@ -68,7 +68,7 @@ export default function DashboardPage() {
folders={data.folders}
value={selectedAccounts}
onChange={setSelectedAccounts}
className="w-[280px]"
className="w-full md:w-[280px]"
filteredTransactions={data.transactions}
/>
</div>

View File

@@ -7,11 +7,12 @@ import {
RuleCreateDialog,
RulesSearchBar,
} from "@/components/rules";
import { useBankingData } from "@/lib/hooks";
import { useBankingMetadata, useTransactions } from "@/lib/hooks";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Sparkles, RefreshCw } from "lucide-react";
import { updateCategory, autoCategorize, updateTransaction } from "@/lib/store-db";
import { updateCategory, autoCategorize } from "@/lib/store-db";
import {
normalizeDescription,
suggestKeyword,
@@ -27,22 +28,42 @@ interface TransactionGroup {
}
export default function RulesPage() {
const { data, isLoading, refresh } = useBankingData();
const queryClient = useQueryClient();
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
// Fetch uncategorized transactions only
const {
data: transactionsData,
isLoading: isLoadingTransactions,
invalidate: invalidateTransactions,
} = useTransactions(
{
limit: 10000, // Large limit to get all uncategorized
offset: 0,
includeUncategorized: true,
},
!!metadata,
);
const refresh = useCallback(() => {
invalidateTransactions();
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
}, [invalidateTransactions, queryClient]);
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<"count" | "amount" | "name">("count");
const [filterMinCount, setFilterMinCount] = useState(2);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [selectedGroup, setSelectedGroup] = useState<TransactionGroup | null>(
null
null,
);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isAutoCategorizing, setIsAutoCategorizing] = useState(false);
// Group uncategorized transactions by normalized description
const transactionGroups = useMemo(() => {
if (!data?.transactions) return [];
if (!transactionsData?.transactions) return [];
const uncategorized = data.transactions.filter((t) => !t.categoryId);
const uncategorized = transactionsData.transactions;
const groups: Record<string, Transaction[]> = {};
uncategorized.forEach((transaction) => {
@@ -64,7 +85,7 @@ export default function RulesPage() {
totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0),
suggestedKeyword: suggestKeyword(descriptions),
};
}
},
);
// Filter by search query
@@ -75,7 +96,7 @@ export default function RulesPage() {
(g) =>
g.displayName.toLowerCase().includes(query) ||
g.key.includes(query) ||
g.suggestedKeyword.toLowerCase().includes(query)
g.suggestedKeyword.toLowerCase().includes(query),
);
}
@@ -97,12 +118,9 @@ export default function RulesPage() {
});
return filtered;
}, [data?.transactions, searchQuery, sortBy, filterMinCount]);
}, [transactionsData?.transactions, searchQuery, sortBy, filterMinCount]);
const uncategorizedCount = useMemo(() => {
if (!data?.transactions) return 0;
return data.transactions.filter((t) => !t.categoryId).length;
}, [data?.transactions]);
const uncategorizedCount = transactionsData?.total || 0;
const formatCurrency = useCallback((amount: number) => {
return new Intl.NumberFormat("fr-FR", {
@@ -143,17 +161,19 @@ export default function RulesPage() {
applyToExisting: boolean;
transactionIds: string[];
}) => {
if (!data) return;
if (!metadata) return;
// 1. Add keyword to category
const category = data.categories.find((c) => c.id === ruleData.categoryId);
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) => k.toLowerCase() === ruleData.keyword.toLowerCase()
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
);
if (!keywordExists) {
@@ -165,60 +185,68 @@ export default function RulesPage() {
// 2. Apply to existing transactions if requested
if (ruleData.applyToExisting) {
const transactions = data.transactions.filter((t) =>
ruleData.transactionIds.includes(t.id)
);
await Promise.all(
transactions.map((t) =>
updateTransaction({ ...t, categoryId: ruleData.categoryId })
)
ruleData.transactionIds.map((id) =>
fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
}),
),
);
}
refresh();
},
[data, refresh]
[metadata, refresh],
);
const handleAutoCategorize = useCallback(async () => {
if (!data) return;
if (!metadata || !transactionsData) return;
setIsAutoCategorizing(true);
try {
const uncategorized = data.transactions.filter((t) => !t.categoryId);
const uncategorized = transactionsData.transactions;
let categorizedCount = 0;
for (const transaction of uncategorized) {
const categoryId = autoCategorize(
transaction.description + " " + (transaction.memo || ""),
data.categories
metadata.categories,
);
if (categoryId) {
await updateTransaction({ ...transaction, categoryId });
await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...transaction, categoryId }),
});
categorizedCount++;
}
}
refresh();
alert(`${categorizedCount} transaction(s) catégorisée(s) automatiquement`);
alert(
`${categorizedCount} transaction(s) catégorisée(s) automatiquement`,
);
} catch (error) {
console.error("Error auto-categorizing:", error);
alert("Erreur lors de la catégorisation automatique");
} finally {
setIsAutoCategorizing(false);
}
}, [data, refresh]);
}, [metadata, transactionsData, refresh]);
const handleCategorizeGroup = useCallback(
async (group: TransactionGroup, categoryId: string | null) => {
if (!data) return;
try {
await Promise.all(
group.transactions.map((t) =>
updateTransaction({ ...t, categoryId })
)
fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, categoryId }),
}),
),
);
refresh();
} catch (error) {
@@ -226,10 +254,15 @@ export default function RulesPage() {
alert("Erreur lors de la catégorisation");
}
},
[data, refresh]
[refresh],
);
if (isLoading || !data) {
if (
isLoadingMetadata ||
!metadata ||
isLoadingTransactions ||
!transactionsData
) {
return <LoadingState />;
}
@@ -238,10 +271,15 @@ export default function RulesPage() {
<PageHeader
title="Règles de catégorisation"
description={
<span className="flex items-center gap-2">
<span className="flex items-center gap-2 flex-wrap">
<span className="text-xs md:text-base">
{transactionGroups.length} groupe
{transactionGroups.length > 1 ? "s" : ""} de transactions similaires
<Badge variant="secondary">{uncategorizedCount} non catégorisées</Badge>
{transactionGroups.length > 1 ? "s" : ""} de transactions
similaires
</span>
<Badge variant="secondary" className="text-[10px] md:text-xs">
{uncategorizedCount} non catégorisées
</Badge>
</span>
}
actions={
@@ -272,14 +310,14 @@ export default function RulesPage() {
/>
{transactionGroups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Sparkles className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
<div className="flex flex-col items-center justify-center py-12 md:py-16 text-center px-4">
<Sparkles className="h-8 w-8 md:h-12 md:w-12 text-muted-foreground mb-3 md:mb-4" />
<h3 className="text-base md:text-lg font-medium text-foreground mb-2">
{uncategorizedCount === 0
? "Toutes les transactions sont catégorisées !"
: "Aucun groupe trouvé"}
</h3>
<p className="text-sm text-muted-foreground max-w-md">
<p className="text-xs md:text-sm text-muted-foreground max-w-md">
{uncategorizedCount === 0
? "Continuez à importer des transactions pour voir les suggestions de règles."
: filterMinCount > 1
@@ -288,7 +326,7 @@ export default function RulesPage() {
</p>
</div>
) : (
<div className="space-y-3">
<div className="space-y-2 md:space-y-3">
{transactionGroups.map((group) => (
<RuleGroupCard
key={group.key}
@@ -299,7 +337,7 @@ export default function RulesPage() {
onCategorize={(categoryId) =>
handleCategorizeGroup(group, categoryId)
}
categories={data.categories}
categories={metadata.categories}
formatCurrency={formatCurrency}
formatDate={formatDate}
/>
@@ -311,10 +349,9 @@ export default function RulesPage() {
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
group={selectedGroup}
categories={data.categories}
categories={metadata.categories}
onSave={handleSaveRule}
/>
</PageLayout>
);
}

View File

@@ -78,7 +78,7 @@ export default function SettingsPage() {
"/api/banking/transactions/clear-categories",
{
method: "POST",
}
},
);
if (!response.ok) throw new Error("Erreur");
refresh();
@@ -91,12 +91,9 @@ export default function SettingsPage() {
const deduplicateTransactions = async () => {
try {
const response = await fetch(
"/api/banking/transactions/deduplicate",
{
const response = await fetch("/api/banking/transactions/deduplicate", {
method: "POST",
}
);
});
if (!response.ok) throw new Error("Erreur");
const result = await response.json();
refresh();

View File

@@ -15,7 +15,6 @@ import {
YearOverYearChart,
} from "@/components/statistics";
import { useBankingData } from "@/lib/hooks";
import { getAccountBalance } from "@/lib/account-utils";
import {
Select,
SelectContent,
@@ -30,11 +29,16 @@ import { Badge } from "@/components/ui/badge";
import { CategoryIcon } from "@/components/ui/category-icon";
import { Checkbox } from "@/components/ui/checkbox";
import { Filter, X, Wallet, CircleSlash, Calendar } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { Button } from "@/components/ui/button";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { useIsMobile } from "@/hooks/use-mobile";
import type { Account, Category } from "@/lib/types";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
@@ -43,10 +47,17 @@ export default function StatisticsPage() {
const { data, isLoading } = useBankingData();
const [period, setPeriod] = useState<Period>("6months");
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
const [excludeInternalTransfers, setExcludeInternalTransfers] = useState(true);
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined);
const [selectedCategories, setSelectedCategories] = useState<string[]>([
"all",
]);
const [excludeInternalTransfers, setExcludeInternalTransfers] =
useState(true);
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
undefined,
);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
undefined,
);
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
// Get start date based on period
@@ -80,7 +91,7 @@ export default function StatisticsPage() {
const internalTransferCategory = useMemo(() => {
if (!data) return null;
return data.categories.find(
(c) => c.name.toLowerCase() === "virement interne"
(c) => c.name.toLowerCase() === "virement interne",
);
}, [data]);
@@ -88,7 +99,8 @@ export default function StatisticsPage() {
const transactionsForAccountFilter = useMemo(() => {
if (!data) return [];
return data.transactions.filter((t) => {
return data.transactions
.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
@@ -104,7 +116,8 @@ export default function StatisticsPage() {
}
}
return true;
}).filter((t) => {
})
.filter((t) => {
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
return !t.categoryId;
@@ -113,20 +126,29 @@ export default function StatisticsPage() {
}
}
return true;
}).filter((t) => {
})
.filter((t) => {
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
return t.categoryId !== internalTransferCategory.id;
}
return true;
});
}, [data, startDate, endDate, selectedCategories, excludeInternalTransfers, internalTransferCategory]);
}, [
data,
startDate,
endDate,
selectedCategories,
excludeInternalTransfers,
internalTransferCategory,
]);
// Transactions filtered for category filter (by accounts, period - not categories)
const transactionsForCategoryFilter = useMemo(() => {
if (!data) return [];
return data.transactions.filter((t) => {
return data.transactions
.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
@@ -142,23 +164,33 @@ export default function StatisticsPage() {
}
}
return true;
}).filter((t) => {
})
.filter((t) => {
if (!selectedAccounts.includes("all")) {
return selectedAccounts.includes(t.accountId);
}
return true;
}).filter((t) => {
})
.filter((t) => {
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
return t.categoryId !== internalTransferCategory.id;
}
return true;
});
}, [data, startDate, endDate, selectedAccounts, excludeInternalTransfers, internalTransferCategory]);
}, [
data,
startDate,
endDate,
selectedAccounts,
excludeInternalTransfers,
internalTransferCategory,
]);
const stats = useMemo(() => {
if (!data) return null;
// Pre-filter transactions once
let transactions = data.transactions.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
@@ -174,8 +206,8 @@ export default function StatisticsPage() {
// Filter by accounts
if (!selectedAccounts.includes("all")) {
transactions = transactions.filter(
(t) => selectedAccounts.includes(t.accountId)
transactions = transactions.filter((t) =>
selectedAccounts.includes(t.accountId),
);
}
@@ -185,7 +217,7 @@ export default function StatisticsPage() {
transactions = transactions.filter((t) => !t.categoryId);
} else {
transactions = transactions.filter(
(t) => t.categoryId && selectedCategories.includes(t.categoryId)
(t) => t.categoryId && selectedCategories.includes(t.categoryId),
);
}
}
@@ -193,7 +225,7 @@ export default function StatisticsPage() {
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
transactions = transactions.filter(
(t) => t.categoryId !== internalTransferCategory.id
(t) => t.categoryId !== internalTransferCategory.id,
);
}
@@ -264,7 +296,9 @@ export default function StatisticsPage() {
categoryTotalsByParent.set(groupId, current + Math.abs(t.amount));
});
const categoryChartDataByParent = Array.from(categoryTotalsByParent.entries())
const categoryChartDataByParent = Array.from(
categoryTotalsByParent.entries(),
)
.map(([groupId, total]) => {
const category = data.categories.find((c) => c.id === groupId);
return {
@@ -278,7 +312,7 @@ export default function StatisticsPage() {
// Top expenses - deduplicate by ID and sort by amount (most negative first)
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
.filter((t) => t.amount < 0)
@@ -304,7 +338,7 @@ export default function StatisticsPage() {
// Balance evolution - Aggregated (using filtered transactions)
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
@@ -353,7 +387,7 @@ export default function StatisticsPage() {
});
const aggregatedBalanceData = Array.from(
aggregatedBalanceByDate.entries()
aggregatedBalanceByDate.entries(),
).map(([date, balance]) => ({
date: new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
@@ -581,7 +615,15 @@ export default function StatisticsPage() {
categoryTrendDataByParent,
yearOverYearData,
};
}, [data, startDate, endDate, selectedAccounts, selectedCategories, excludeInternalTransfers, internalTransferCategory]);
}, [
data,
startDate,
endDate,
selectedAccounts,
selectedCategories,
excludeInternalTransfers,
internalTransferCategory,
]);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
@@ -601,15 +643,15 @@ export default function StatisticsPage() {
description="Analysez vos dépenses et revenus"
/>
<Card className="mb-6">
<CardContent className="pt-4">
<div className="flex flex-wrap gap-4">
<Card className="mb-4 md:mb-6">
<CardContent className="pt-3 md:pt-4">
<div className="flex flex-wrap gap-2 md:gap-4">
<AccountFilterCombobox
accounts={data.accounts}
folders={data.folders}
value={selectedAccounts}
onChange={setSelectedAccounts}
className="w-[280px]"
className="w-full md:w-[280px]"
filteredTransactions={transactionsForAccountFilter}
/>
@@ -617,7 +659,7 @@ export default function StatisticsPage() {
categories={data.categories}
value={selectedCategories}
onChange={setSelectedCategories}
className="w-[220px]"
className="w-full md:w-[220px]"
filteredTransactions={transactionsForCategoryFilter}
/>
@@ -632,7 +674,7 @@ export default function StatisticsPage() {
}
}}
>
<SelectTrigger className="w-[150px]">
<SelectTrigger className="w-full md:w-[150px]">
<SelectValue placeholder="Période" />
</SelectTrigger>
<SelectContent>
@@ -646,9 +688,15 @@ export default function StatisticsPage() {
</Select>
{period === "custom" && (
<Popover open={isCustomDatePickerOpen} onOpenChange={setIsCustomDatePickerOpen}>
<Popover
open={isCustomDatePickerOpen}
onOpenChange={setIsCustomDatePickerOpen}
>
<PopoverTrigger asChild>
<Button variant="outline" className="w-[280px] justify-start text-left font-normal">
<Button
variant="outline"
className="w-full md:w-[280px] justify-start text-left font-normal"
>
<Calendar className="mr-2 h-4 w-4" />
{customStartDate && customEndDate ? (
<>
@@ -658,14 +706,18 @@ export default function StatisticsPage() {
) : customStartDate ? (
format(customStartDate, "PPP", { locale: fr })
) : (
<span className="text-muted-foreground">Sélectionner les dates</span>
<span className="text-muted-foreground">
Sélectionner les dates
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Date de début</label>
<label className="text-sm font-medium">
Date de début
</label>
<CalendarComponent
mode="single"
selected={customStartDate}
@@ -684,7 +736,11 @@ export default function StatisticsPage() {
mode="single"
selected={customEndDate}
onSelect={(date) => {
if (date && customStartDate && date < customStartDate) {
if (
date &&
customStartDate &&
date < customStartDate
) {
return;
}
setCustomEndDate(date);
@@ -727,15 +783,17 @@ export default function StatisticsPage() {
)}
{internalTransferCategory && (
<div className="flex items-center gap-2 px-3 py-2 border border-border rounded-md bg-[var(--card)]">
<div className="flex items-center gap-2 px-2 md:px-3 py-1.5 md:py-2 border border-border rounded-md bg-[var(--card)]">
<Checkbox
id="exclude-internal-transfers"
checked={excludeInternalTransfers}
onCheckedChange={(checked) => setExcludeInternalTransfers(checked === true)}
onCheckedChange={(checked) =>
setExcludeInternalTransfers(checked === true)
}
/>
<label
htmlFor="exclude-internal-transfers"
className="text-sm font-medium cursor-pointer select-none"
className="text-xs md:text-sm font-medium cursor-pointer select-none"
>
Exclure Virement interne
</label>
@@ -747,13 +805,17 @@ export default function StatisticsPage() {
selectedAccounts={selectedAccounts}
onRemoveAccount={(id) => {
const newAccounts = selectedAccounts.filter((a) => a !== id);
setSelectedAccounts(newAccounts.length > 0 ? newAccounts : ["all"]);
setSelectedAccounts(
newAccounts.length > 0 ? newAccounts : ["all"],
);
}}
onClearAccounts={() => setSelectedAccounts(["all"])}
selectedCategories={selectedCategories}
onRemoveCategory={(id) => {
const newCategories = selectedCategories.filter((c) => c !== id);
setSelectedCategories(newCategories.length > 0 ? newCategories : ["all"]);
setSelectedCategories(
newCategories.length > 0 ? newCategories : ["all"],
);
}}
onClearCategories={() => setSelectedCategories(["all"])}
period={period}
@@ -771,15 +833,17 @@ export default function StatisticsPage() {
</Card>
{/* Vue d'ensemble */}
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Vue d'ensemble</h2>
<section className="mb-4 md:mb-8">
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">
Vue d'ensemble
</h2>
<StatsSummaryCards
totalIncome={stats.totalIncome}
totalExpenses={stats.totalExpenses}
avgMonthlyExpenses={stats.avgMonthlyExpenses}
formatCurrency={formatCurrency}
/>
<div className="mt-6">
<div className="mt-4 md:mt-6">
<BalanceLineChart
aggregatedData={stats.aggregatedBalanceData}
perAccountData={stats.perAccountBalanceData}
@@ -787,7 +851,7 @@ export default function StatisticsPage() {
formatCurrency={formatCurrency}
/>
</div>
<div className="mt-6">
<div className="mt-4 md:mt-6">
<SavingsTrendChart
data={stats.savingsTrendData}
formatCurrency={formatCurrency}
@@ -796,9 +860,11 @@ export default function StatisticsPage() {
</section>
{/* Revenus et Dépenses */}
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Revenus et Dépenses</h2>
<div className="grid gap-6 lg:grid-cols-2">
<section className="mb-4 md:mb-8">
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">
Revenus et Dépenses
</h2>
<div className="grid gap-4 md:gap-6 lg:grid-cols-2">
<MonthlyChart
data={stats.monthlyChartData}
formatCurrency={formatCurrency}
@@ -813,7 +879,7 @@ export default function StatisticsPage() {
/>
</div>
{stats.yearOverYearData.length > 0 && (
<div className="mt-6">
<div className="mt-4 md:mt-6">
<YearOverYearChart
data={stats.yearOverYearData}
formatCurrency={formatCurrency}
@@ -823,9 +889,11 @@ export default function StatisticsPage() {
</section>
{/* Analyse par Catégorie */}
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Analyse par Catégorie</h2>
<div className="grid gap-6">
<section className="mb-4 md:mb-8">
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">
Analyse par Catégorie
</h2>
<div className="grid gap-4 md:gap-6">
<CategoryPieChart
data={stats.categoryChartData}
dataByParent={stats.categoryChartDataByParent}
@@ -837,7 +905,7 @@ export default function StatisticsPage() {
formatCurrency={formatCurrency}
/>
</div>
<div className="mt-6">
<div className="hidden md:block mt-4 md:mt-6">
<CategoryTrendChart
data={stats.categoryTrendData}
dataByParent={stats.categoryTrendDataByParent}
@@ -845,7 +913,7 @@ export default function StatisticsPage() {
formatCurrency={formatCurrency}
/>
</div>
<div className="mt-6">
<div className="mt-4 md:mt-6">
<TopExpensesList
expenses={stats.topExpenses}
categories={data.categories}
@@ -853,7 +921,6 @@ export default function StatisticsPage() {
/>
</div>
</section>
</PageLayout>
);
}
@@ -885,6 +952,7 @@ function ActiveFilters({
customStartDate?: Date;
customEndDate?: Date;
}) {
const isMobile = useIsMobile();
const hasAccounts = !selectedAccounts.includes("all");
const hasCategories = !selectedCategories.includes("all");
const hasPeriod = period !== "all";
@@ -894,7 +962,9 @@ function ActiveFilters({
if (!hasActiveFilters) return null;
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
const selectedCats = categories.filter((c) => selectedCategories.includes(c.id));
const selectedCats = categories.filter((c) =>
selectedCategories.includes(c.id),
);
const isUncategorized = selectedCategories.includes("uncategorized");
const getPeriodLabel = (p: Period) => {
@@ -924,28 +994,38 @@ function ActiveFilters({
};
return (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border flex-wrap">
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
<div className="flex items-center gap-1.5 md:gap-2 mt-2 md:mt-3 pt-2 md:pt-3 border-t border-border flex-wrap">
<Filter className="h-3 w-3 md:h-3.5 md:w-3.5 text-muted-foreground" />
{selectedAccs.map((acc) => (
<Badge key={acc.id} variant="secondary" className="gap-1 text-xs font-normal">
<Wallet className="h-3 w-3" />
<Badge
key={acc.id}
variant="secondary"
className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"
>
<Wallet className="h-2.5 w-2.5 md:h-3 md:w-3" />
{acc.name}
<button
onClick={() => onRemoveAccount(acc.id)}
className="ml-1 hover:text-foreground"
className="ml-0.5 md:ml-1 hover:text-foreground"
>
<X className="h-3 w-3" />
<X className="h-2.5 w-2.5 md:h-3 md:w-3" />
</button>
</Badge>
))}
{isUncategorized && (
<Badge variant="secondary" className="gap-1 text-xs font-normal">
<CircleSlash className="h-3 w-3" />
<Badge
variant="secondary"
className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"
>
<CircleSlash className="h-2.5 w-2.5 md:h-3 md:w-3" />
Non catégorisé
<button onClick={onClearCategories} className="ml-1 hover:text-foreground">
<X className="h-3 w-3" />
<button
onClick={onClearCategories}
className="ml-0.5 md:ml-1 hover:text-foreground"
>
<X className="h-2.5 w-2.5 md:h-3 md:w-3" />
</button>
</Badge>
)}
@@ -954,36 +1034,46 @@ function ActiveFilters({
<Badge
key={cat.id}
variant="secondary"
className="gap-1 text-xs font-normal"
className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"
style={{
backgroundColor: `${cat.color}15`,
borderColor: `${cat.color}30`,
}}
>
<CategoryIcon icon={cat.icon} color={cat.color} size={12} />
<CategoryIcon
icon={cat.icon}
color={cat.color}
size={isMobile ? 10 : 12}
/>
{cat.name}
<button
onClick={() => onRemoveCategory(cat.id)}
className="ml-1 hover:text-foreground"
className="ml-0.5 md:ml-1 hover:text-foreground"
>
<X className="h-3 w-3" />
<X className="h-2.5 w-2.5 md:h-3 md:w-3" />
</button>
</Badge>
))}
{hasPeriod && (
<Badge variant="secondary" className="gap-1 text-xs font-normal">
<Calendar className="h-3 w-3" />
<Badge
variant="secondary"
className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"
>
<Calendar className="h-2.5 w-2.5 md:h-3 md:w-3" />
{getPeriodLabel(period)}
<button onClick={onClearPeriod} className="ml-1 hover:text-foreground">
<X className="h-3 w-3" />
<button
onClick={onClearPeriod}
className="ml-0.5 md:ml-1 hover:text-foreground"
>
<X className="h-2.5 w-2.5 md:h-3 md:w-3" />
</button>
</Badge>
)}
<button
onClick={clearAll}
className="text-xs text-muted-foreground hover:text-foreground ml-auto"
className="text-[10px] md:text-xs text-muted-foreground hover:text-foreground ml-auto"
>
Effacer tout
</button>

View File

@@ -10,48 +10,71 @@ import {
} from "@/components/transactions";
import { RuleCreateDialog } from "@/components/rules";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import { useBankingData } from "@/lib/hooks";
import { updateCategory, updateTransaction } from "@/lib/store-db";
import { useBankingMetadata, useTransactions } from "@/lib/hooks";
import { updateCategory } from "@/lib/store-db";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Upload } from "lucide-react";
import type { Transaction } from "@/lib/types";
import type { TransactionsPaginatedParams } from "@/services/banking.service";
import {
normalizeDescription,
suggestKeyword,
} from "@/components/rules/constants";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
type SortField = "date" | "amount" | "description";
type SortOrder = "asc" | "desc";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
const PAGE_SIZE = 100;
export default function TransactionsPage() {
const searchParams = useSearchParams();
const { data, isLoading, refresh, update } = useBankingData();
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
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
setPage(0); // Reset to first page when search changes
}, 300);
return () => clearTimeout(timer);
}, [searchQuery]);
useEffect(() => {
const accountId = searchParams.get("accountId");
if (accountId) {
setSelectedAccounts([accountId]);
setPage(0);
}
}, [searchParams]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
const [selectedCategories, setSelectedCategories] = useState<string[]>([
"all",
]);
const [showReconciled, setShowReconciled] = useState<string>("all");
const [period, setPeriod] = useState<Period>("all");
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined);
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
undefined,
);
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()
new Set(),
);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(null);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(
null,
);
// Get start date based on period
const startDate = useMemo(() => {
@@ -80,207 +103,106 @@ export default function TransactionsPage() {
return undefined;
}, [period, customEndDate]);
// Transactions filtered for account filter (by categories, search, reconciled, period - not accounts)
const transactionsForAccountFilter = useMemo(() => {
if (!data) return [];
// Build transaction query params
const transactionParams = useMemo(() => {
const params: TransactionsPaginatedParams = {
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
sortField,
sortOrder,
};
let transactions = [...data.transactions];
// Filter by period
transactions = transactions.filter((t) => {
const transactionDate = new Date(t.date);
if (startDate && period !== "all") {
params.startDate = startDate.toISOString().split("T")[0];
}
if (endDate) {
// Custom date range
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
return transactionDate >= startDate && transactionDate <= endOfDay;
} else if (period !== "all") {
// Standard period
return transactionDate >= startDate;
params.endDate = endDate.toISOString().split("T")[0];
}
return true;
});
if (searchQuery) {
const query = searchQuery.toLowerCase();
transactions = transactions.filter(
(t) =>
t.description.toLowerCase().includes(query) ||
t.memo?.toLowerCase().includes(query)
);
if (!selectedAccounts.includes("all")) {
params.accountIds = selectedAccounts;
}
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
transactions = transactions.filter((t) => !t.categoryId);
params.includeUncategorized = true;
} else {
transactions = transactions.filter(
(t) => t.categoryId && selectedCategories.includes(t.categoryId)
);
params.categoryIds = selectedCategories;
}
}
if (debouncedSearchQuery) {
params.search = debouncedSearchQuery;
}
if (showReconciled !== "all") {
const isReconciled = showReconciled === "reconciled";
transactions = transactions.filter(
(t) => t.isReconciled === isReconciled
);
params.isReconciled = showReconciled === "reconciled";
}
return transactions;
}, [data, searchQuery, selectedCategories, showReconciled, period, startDate, endDate]);
// Transactions filtered for category filter (by accounts, search, reconciled, period - not categories)
const transactionsForCategoryFilter = useMemo(() => {
if (!data) return [];
let transactions = [...data.transactions];
// Filter by period
transactions = transactions.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
return transactionDate >= startDate && transactionDate <= endOfDay;
} else if (period !== "all") {
// Standard period
return transactionDate >= startDate;
}
return true;
});
if (searchQuery) {
const query = searchQuery.toLowerCase();
transactions = transactions.filter(
(t) =>
t.description.toLowerCase().includes(query) ||
t.memo?.toLowerCase().includes(query)
);
}
if (!selectedAccounts.includes("all")) {
transactions = transactions.filter(
(t) => selectedAccounts.includes(t.accountId)
);
}
if (showReconciled !== "all") {
const isReconciled = showReconciled === "reconciled";
transactions = transactions.filter(
(t) => t.isReconciled === isReconciled
);
}
return transactions;
}, [data, searchQuery, selectedAccounts, showReconciled, period, startDate, endDate]);
const filteredTransactions = useMemo(() => {
if (!data) return [];
let transactions = [...data.transactions];
// Filter by period
transactions = transactions.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
return transactionDate >= startDate && transactionDate <= endOfDay;
} else if (period !== "all") {
// Standard period
return transactionDate >= startDate;
}
return true;
});
if (searchQuery) {
const query = searchQuery.toLowerCase();
transactions = transactions.filter(
(t) =>
t.description.toLowerCase().includes(query) ||
t.memo?.toLowerCase().includes(query)
);
}
if (!selectedAccounts.includes("all")) {
transactions = transactions.filter(
(t) => selectedAccounts.includes(t.accountId)
);
}
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
transactions = transactions.filter((t) => !t.categoryId);
} else {
transactions = transactions.filter(
(t) => t.categoryId && selectedCategories.includes(t.categoryId)
);
}
}
if (showReconciled !== "all") {
const isReconciled = showReconciled === "reconciled";
transactions = transactions.filter(
(t) => t.isReconciled === isReconciled
);
}
transactions.sort((a, b) => {
let comparison = 0;
switch (sortField) {
case "date":
comparison = new Date(a.date).getTime() - new Date(b.date).getTime();
break;
case "amount":
comparison = a.amount - b.amount;
break;
case "description":
comparison = a.description.localeCompare(b.description);
break;
}
return sortOrder === "asc" ? comparison : -comparison;
});
return transactions;
return params;
}, [
data,
searchQuery,
selectedAccounts,
selectedCategories,
showReconciled,
period,
page,
startDate,
endDate,
selectedAccounts,
selectedCategories,
debouncedSearchQuery,
showReconciled,
sortField,
sortOrder,
period,
]);
// Fetch transactions with pagination
const {
data: transactionsData,
isLoading: isLoadingTransactions,
invalidate: invalidateTransactions,
} = useTransactions(transactionParams, !!metadata);
// Reset page when filters change
useEffect(() => {
setPage(0);
}, [
startDate,
endDate,
selectedAccounts,
selectedCategories,
debouncedSearchQuery,
showReconciled,
sortField,
sortOrder,
]);
// 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 || !data) return null;
if (!ruleTransaction || !transactionsData) return null;
// Find similar transactions (same normalized description)
// Use transactions from current page to find similar ones
const normalizedDesc = normalizeDescription(ruleTransaction.description);
const similarTransactions = data.transactions.filter(
(t) => normalizeDescription(t.description) === normalizedDesc
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)),
suggestedKeyword: suggestKeyword(
similarTransactions.map((t) => t.description),
),
};
}, [ruleTransaction, data]);
}, [ruleTransaction, transactionsData]);
const handleSaveRule = useCallback(
async (ruleData: {
@@ -289,17 +211,19 @@ export default function TransactionsPage() {
applyToExisting: boolean;
transactionIds: string[];
}) => {
if (!data) return;
if (!metadata) return;
// 1. Add keyword to category
const category = data.categories.find((c) => c.id === ruleData.categoryId);
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) => k.toLowerCase() === ruleData.keyword.toLowerCase()
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
);
if (!keywordExists) {
@@ -311,24 +235,31 @@ export default function TransactionsPage() {
// 2. Apply to existing transactions if requested
if (ruleData.applyToExisting) {
const transactions = data.transactions.filter((t) =>
ruleData.transactionIds.includes(t.id)
);
await Promise.all(
transactions.map((t) =>
updateTransaction({ ...t, categoryId: ruleData.categoryId })
)
ruleData.transactionIds.map((id) =>
fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
}),
),
);
}
refresh();
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["transactions"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
setRuleDialogOpen(false);
},
[data, refresh]
[metadata, queryClient],
);
if (isLoading || !data) {
const invalidateAll = useCallback(() => {
invalidateTransactions();
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
}, [invalidateTransactions, queryClient]);
if (isLoadingMetadata || !metadata) {
return <LoadingState />;
}
@@ -348,7 +279,11 @@ export default function TransactionsPage() {
};
const toggleReconciled = async (transactionId: string) => {
const transaction = data.transactions.find((t) => t.id === transactionId);
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId,
);
if (!transaction) return;
const updatedTransaction = {
@@ -356,84 +291,75 @@ export default function TransactionsPage() {
isReconciled: !transaction.isReconciled,
};
const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t
);
update({ ...data, transactions: updatedTransactions });
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);
refresh();
}
};
const markReconciled = async (transactionId: string) => {
const transaction = data.transactions.find((t) => t.id === transactionId);
if (!transaction || transaction.isReconciled) return; // Skip if already reconciled
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId,
);
if (!transaction || transaction.isReconciled) return;
const updatedTransaction = {
...transaction,
isReconciled: true,
};
const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t
);
update({ ...data, transactions: updatedTransactions });
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);
refresh();
}
};
const setCategory = async (
transactionId: string,
categoryId: string | null
categoryId: string | null,
) => {
const transaction = data.transactions.find((t) => t.id === transactionId);
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId,
);
if (!transaction) return;
const updatedTransaction = { ...transaction, categoryId };
const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t
);
update({ ...data, transactions: updatedTransactions });
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);
refresh();
}
};
const bulkReconcile = async (reconciled: boolean) => {
const transactionsToUpdate = data.transactions.filter((t) =>
selectedTransactions.has(t.id)
if (!transactionsData) return;
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
selectedTransactions.has(t.id),
);
const updatedTransactions = data.transactions.map((t) =>
selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t
);
update({ ...data, transactions: updatedTransactions });
setSelectedTransactions(new Set());
try {
@@ -443,24 +369,22 @@ export default function TransactionsPage() {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, isReconciled: reconciled }),
})
)
}),
),
);
invalidateTransactions();
} catch (error) {
console.error("Failed to update transactions:", error);
refresh();
}
};
const bulkSetCategory = async (categoryId: string | null) => {
const transactionsToUpdate = data.transactions.filter((t) =>
selectedTransactions.has(t.id)
if (!transactionsData) return;
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
selectedTransactions.has(t.id),
);
const updatedTransactions = data.transactions.map((t) =>
selectedTransactions.has(t.id) ? { ...t, categoryId } : t
);
update({ ...data, transactions: updatedTransactions });
setSelectedTransactions(new Set());
try {
@@ -470,20 +394,23 @@ export default function TransactionsPage() {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, categoryId }),
})
)
}),
),
);
invalidateTransactions();
} catch (error) {
console.error("Failed to update transactions:", error);
refresh();
}
};
const toggleSelectAll = () => {
if (selectedTransactions.size === filteredTransactions.length) {
if (!transactionsData) return;
if (selectedTransactions.size === transactionsData.transactions.length) {
setSelectedTransactions(new Set());
} else {
setSelectedTransactions(new Set(filteredTransactions.map((t) => t.id)));
setSelectedTransactions(
new Set(transactionsData.transactions.map((t) => t.id)),
);
}
};
@@ -504,15 +431,10 @@ export default function TransactionsPage() {
setSortField(field);
setSortOrder(field === "date" ? "desc" : "asc");
}
setPage(0);
};
const deleteTransaction = async (transactionId: string) => {
// Optimistic update
const updatedTransactions = data.transactions.filter(
(t) => t.id !== transactionId
);
update({ ...data, transactions: updatedTransactions });
// Remove from selected if selected
const newSelected = new Set(selectedTransactions);
newSelected.delete(transactionId);
@@ -523,22 +445,26 @@ export default function TransactionsPage() {
`/api/banking/transactions?id=${transactionId}`,
{
method: "DELETE",
}
},
);
if (!response.ok) throw new Error("Failed to delete transaction");
invalidateTransactions();
} catch (error) {
console.error("Failed to delete transaction:", error);
refresh(); // Revert on error
}
};
const filteredTransactions = transactionsData?.transactions || [];
const totalTransactions = transactionsData?.total || 0;
const hasMore = transactionsData?.hasMore || false;
return (
<PageLayout>
<PageHeader
title="Transactions"
description={`${filteredTransactions.length} transaction${filteredTransactions.length > 1 ? "s" : ""}`}
description={`${totalTransactions} transaction${totalTransactions > 1 ? "s" : ""}`}
actions={
<OFXImportDialog onImportComplete={refresh}>
<OFXImportDialog onImportComplete={invalidateAll}>
<Button>
<Upload className="w-4 h-4 mr-2" />
Importer OFX
@@ -551,14 +477,24 @@ export default function TransactionsPage() {
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
selectedAccounts={selectedAccounts}
onAccountsChange={setSelectedAccounts}
onAccountsChange={(accounts) => {
setSelectedAccounts(accounts);
setPage(0);
}}
selectedCategories={selectedCategories}
onCategoriesChange={setSelectedCategories}
onCategoriesChange={(categories) => {
setSelectedCategories(categories);
setPage(0);
}}
showReconciled={showReconciled}
onReconciledChange={setShowReconciled}
onReconciledChange={(value) => {
setShowReconciled(value);
setPage(0);
}}
period={period}
onPeriodChange={(p) => {
setPeriod(p);
setPage(0);
if (p !== "custom") {
setIsCustomDatePickerOpen(false);
} else {
@@ -567,28 +503,38 @@ export default function TransactionsPage() {
}}
customStartDate={customStartDate}
customEndDate={customEndDate}
onCustomStartDateChange={setCustomStartDate}
onCustomEndDateChange={setCustomEndDate}
onCustomStartDateChange={(date) => {
setCustomStartDate(date);
setPage(0);
}}
onCustomEndDateChange={(date) => {
setCustomEndDate(date);
setPage(0);
}}
isCustomDatePickerOpen={isCustomDatePickerOpen}
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen}
accounts={data.accounts}
folders={data.folders}
categories={data.categories}
accounts={metadata.accounts}
folders={metadata.folders}
categories={metadata.categories}
transactionsForAccountFilter={transactionsForAccountFilter}
transactionsForCategoryFilter={transactionsForCategoryFilter}
/>
<TransactionBulkActions
selectedCount={selectedTransactions.size}
categories={data.categories}
categories={metadata.categories}
onReconcile={bulkReconcile}
onSetCategory={bulkSetCategory}
/>
{isLoadingTransactions ? (
<LoadingState />
) : (
<>
<TransactionTable
transactions={filteredTransactions}
accounts={data.accounts}
categories={data.categories}
accounts={metadata.accounts}
categories={metadata.categories}
selectedTransactions={selectedTransactions}
sortField={sortField}
sortOrder={sortOrder}
@@ -604,11 +550,42 @@ export default function TransactionsPage() {
formatDate={formatDate}
/>
{/* Pagination controls */}
{totalTransactions > PAGE_SIZE && (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
Affichage de {page * PAGE_SIZE + 1} à{" "}
{Math.min((page + 1) * PAGE_SIZE, totalTransactions)} sur{" "}
{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>
)}
</>
)}
<RuleCreateDialog
open={ruleDialogOpen}
onOpenChange={setRuleDialogOpen}
group={ruleGroup}
categories={data.categories}
categories={metadata.categories}
onSave={handleSaveRule}
/>
</PageLayout>

View File

@@ -32,4 +32,3 @@ export function AccountBulkActions({
</Card>
);
}

View File

@@ -10,7 +10,13 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreVertical, Pencil, Trash2, ExternalLink, GripVertical } from "lucide-react";
import {
MoreVertical,
Pencil,
Trash2,
ExternalLink,
GripVertical,
} from "lucide-react";
import { cn } from "@/lib/utils";
import Link from "next/link";
import type { Account, Folder } from "@/lib/types";
@@ -69,7 +75,13 @@ export function AccountCard({
};
const cardContent = (
<Card className={cn("relative", isSelected && "ring-2 ring-primary", isDragging && "bg-muted/80")}>
<Card
className={cn(
"relative",
isSelected && "ring-2 ring-primary",
isDragging && "bg-muted/80",
)}
>
<CardHeader className="pb-0">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2 flex-1">
@@ -96,7 +108,9 @@ export function AccountCard({
<Icon className="w-4 h-4 text-primary" />
</div>
<div className="min-w-0">
<CardTitle className="text-sm font-semibold truncate">{account.name}</CardTitle>
<CardTitle className="text-sm font-semibold truncate">
{account.name}
</CardTitle>
{!compact && (
<>
<p className="text-xs text-muted-foreground">
@@ -140,7 +154,7 @@ export function AccountCard({
compact ? "text-lg" : "text-xl",
"font-bold",
!compact && "mb-1.5",
realBalance >= 0 ? "text-emerald-600" : "text-red-600"
realBalance >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(realBalance)}
@@ -165,7 +179,8 @@ export function AccountCard({
</Link>
{folder && <span className="truncate ml-2">{folder.name}</span>}
</div>
{account.initialBalance !== undefined && account.initialBalance !== null && (
{account.initialBalance !== undefined &&
account.initialBalance !== null && (
<p className="text-xs text-muted-foreground mt-1.5">
Solde initial: {formatCurrency(account.initialBalance)}
</p>
@@ -203,4 +218,3 @@ export function AccountCard({
return cardContent;
}

View File

@@ -142,4 +142,3 @@ export function AccountEditDialog({
</Dialog>
);
}

View File

@@ -13,4 +13,3 @@ export const accountTypeLabels = {
CREDIT_CARD: "Carte de crédit",
OTHER: "Autre",
};

View File

@@ -2,4 +2,3 @@ export { AccountCard } from "./account-card";
export { AccountEditDialog } from "./account-edit-dialog";
export { AccountBulkActions } from "./account-bulk-actions";
export { accountTypeIcons, accountTypeLabels } from "./constants";

View File

@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { CategoryIcon } from "@/components/ui/category-icon";
import { Pencil, Trash2 } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import type { Category } from "@/lib/types";
interface CategoryCardProps {
@@ -21,39 +22,48 @@ export function CategoryCard({
onEdit,
onDelete,
}: CategoryCardProps) {
const isMobile = useIsMobile();
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 gap-2 min-w-0 flex-1">
<div
className="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
className="w-4 h-4 md:w-5 md:h-5 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: `${category.color}20` }}
>
<CategoryIcon
icon={category.icon}
color={category.color}
size={12}
size={isMobile ? 10 : 12}
/>
</div>
<span className="text-sm truncate">{category.name}</span>
<span className="text-sm text-muted-foreground shrink-0">
<span className="text-xs md:text-sm truncate">{category.name}</span>
{!isMobile && (
<span className="text-xs md:text-sm text-muted-foreground shrink-0">
{stats.count} opération{stats.count > 1 ? "s" : ""} {" "}
{formatCurrency(stats.total)}
</span>
)}
{isMobile && (
<span className="text-[10px] md:text-xs text-muted-foreground shrink-0">
{stats.count} 💳
</span>
)}
{category.keywords.length > 0 && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4 shrink-0"
className="text-[10px] px-1 md:px-1.5 py-0 h-3 md:h-4 shrink-0"
>
{category.keywords.length}
</Badge>
)}
</div>
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 md:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
className="h-6 w-6 md:h-6 md:w-6"
onClick={() => onEdit(category)}
>
<Pencil className="w-3 h-3" />
@@ -61,7 +71,7 @@ export function CategoryCard({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
className="h-6 w-6 md:h-6 md:w-6 text-destructive hover:text-destructive"
onClick={() => onDelete(category.id)}
>
<Trash2 className="w-3 h-3" />
@@ -70,4 +80,3 @@ export function CategoryCard({
</div>
);
}

View File

@@ -142,7 +142,7 @@ export function CategoryEditDialog({
className={cn(
"w-7 h-7 rounded-full transition-transform",
formData.color === color &&
"ring-2 ring-offset-2 ring-primary scale-110"
"ring-2 ring-offset-2 ring-primary scale-110",
)}
style={{ backgroundColor: color }}
/>
@@ -201,4 +201,3 @@ export function CategoryEditDialog({
</Dialog>
);
}

View File

@@ -43,4 +43,3 @@ export function CategorySearchBar({
</div>
);
}

View File

@@ -15,4 +15,3 @@ export const categoryColors = [
"#0891b2",
"#dc2626",
];

View File

@@ -3,4 +3,3 @@ export { CategoryEditDialog } from "./category-edit-dialog";
export { ParentCategoryRow } from "./parent-category-row";
export { CategorySearchBar } from "./category-search-bar";
export { categoryColors } from "./constants";

View File

@@ -21,6 +21,7 @@ import {
ChevronDown,
ChevronRight,
} from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { CategoryCard } from "./category-card";
import type { Category } from "@/lib/types";
@@ -49,51 +50,66 @@ export function ParentCategoryRow({
onDelete,
onNewCategory,
}: ParentCategoryRowProps) {
const isMobile = useIsMobile();
return (
<div className="border rounded-lg bg-card">
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center justify-between px-2 md:px-3 py-1.5 md:py-2">
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
<button className="flex items-center gap-1.5 md:gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
<ChevronDown className="w-3 h-3 md:w-4 md:h-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
<ChevronRight className="w-3 h-3 md:w-4 md:h-4 text-muted-foreground shrink-0" />
)}
<div
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
className="w-5 h-5 md:w-7 md:h-7 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: `${parent.color}20` }}
>
<CategoryIcon
icon={parent.icon}
color={parent.color}
size={14}
size={isMobile ? 10 : 14}
/>
</div>
<span className="font-medium text-sm truncate">{parent.name}</span>
<span className="text-sm text-muted-foreground shrink-0">
<span className="font-medium text-xs md:text-sm truncate">
{parent.name}
</span>
{!isMobile && (
<span className="text-xs md:text-sm text-muted-foreground shrink-0">
{children.length} {stats.count} opération
{stats.count > 1 ? "s" : ""} {formatCurrency(stats.total)}
</span>
)}
{isMobile && (
<span className="text-[10px] md:text-xs text-muted-foreground shrink-0">
{children.length} {stats.count} 💳
</span>
)}
</button>
</CollapsibleTrigger>
<div className="flex items-center gap-1 shrink-0 ml-2">
<div className="flex items-center gap-0.5 md:gap-1 shrink-0 ml-1 md:ml-2">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-6 w-6 md:h-7 md:w-7"
onClick={(e) => {
e.stopPropagation();
onNewCategory(parent.id);
}}
>
<Plus className="w-4 h-4" />
<Plus className="w-3 h-3 md:w-4 md:h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreVertical className="w-4 h-4" />
<Button
variant="ghost"
size="icon"
className="h-6 w-6 md:h-7 md:w-7"
>
<MoreVertical className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -115,7 +131,7 @@ export function ParentCategoryRow({
<CollapsibleContent>
{children.length > 0 ? (
<div className="px-3 pb-2 space-y-1 ml-6 border-l-2 border-muted ml-5">
<div className="px-2 md:px-3 pb-2 space-y-1 ml-4 md:ml-6 border-l-2 border-muted md:ml-5">
{children.map((child) => (
<CategoryCard
key={child.id}
@@ -137,4 +153,3 @@ export function ParentCategoryRow({
</div>
);
}

View File

@@ -72,7 +72,12 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
{/* Folder header */}
<div className="flex items-center gap-2 mb-3">
<FolderIcon className="w-4 h-4 text-muted-foreground" />
<h3 className={cn("font-semibold text-sm", level > 0 && "text-muted-foreground")}>
<h3
className={cn(
"font-semibold text-sm",
level > 0 && "text-muted-foreground",
)}
>
{folder.name}
</h3>
{folderAccounts.length > 0 && (
@@ -122,9 +127,7 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<span
className={cn(
"font-semibold tabular-nums",
realBalance >= 0
? "text-emerald-600"
: "text-red-600",
realBalance >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(realBalance)}
@@ -218,7 +221,9 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<Building2 className="w-4 h-4 text-primary" />
</div>
<div>
<p className="font-medium text-sm">{account.name}</p>
<p className="font-medium text-sm">
{account.name}
</p>
<p className="text-xs text-muted-foreground">
{account.accountNumber}
</p>

View File

@@ -17,7 +17,7 @@ export function CategoryBreakdown({ data }: CategoryBreakdownProps) {
const thisMonthStr = thisMonth.toISOString().slice(0, 7);
const monthExpenses = data.transactions.filter(
(t) => t.date.startsWith(thisMonthStr) && t.amount < 0
(t) => t.date.startsWith(thisMonthStr) && t.amount < 0,
);
const categoryTotals = new Map<string, number>();

View File

@@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown, Wallet, CreditCard } from "lucide-react";
import type { BankingData } from "@/lib/types";
import { getAccountBalance } from "@/lib/account-utils";
import { cn } from "@/lib/utils";
interface OverviewCardsProps {
data: BankingData;
@@ -47,21 +48,21 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
Solde Total
</CardTitle>
<Wallet className="h-4 w-4 text-muted-foreground" />
<Wallet className="h-3 w-3 md:h-4 md:w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div
className={cn(
"text-2xl font-bold",
"text-xl md:text-2xl font-bold",
totalBalance >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(totalBalance)}
</div>
<p className="text-xs text-muted-foreground mt-1">
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
{data.accounts.length} compte{data.accounts.length > 1 ? "s" : ""}
</p>
</CardContent>
@@ -69,16 +70,16 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
Revenus du mois
</CardTitle>
<TrendingUp className="h-4 w-4 text-emerald-600" />
<TrendingUp className="h-3 w-3 md:h-4 md:w-4 text-emerald-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-600">
<div className="text-xl md:text-2xl font-bold text-emerald-600">
{formatCurrency(income)}
</div>
<p className="text-xs text-muted-foreground mt-1">
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
{monthTransactions.filter((t) => t.amount > 0).length} opération
{monthTransactions.filter((t) => t.amount > 0).length > 1
? "s"
@@ -89,16 +90,16 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
Dépenses du mois
</CardTitle>
<TrendingDown className="h-4 w-4 text-red-600" />
<TrendingDown className="h-3 w-3 md:h-4 md:w-4 text-red-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
<div className="text-xl md:text-2xl font-bold text-red-600">
{formatCurrency(expenses)}
</div>
<p className="text-xs text-muted-foreground mt-1">
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
{monthTransactions.filter((t) => t.amount < 0).length} opération
{monthTransactions.filter((t) => t.amount < 0).length > 1
? "s"
@@ -109,14 +110,16 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
Pointage
</CardTitle>
<CreditCard className="h-4 w-4 text-muted-foreground" />
<CreditCard className="h-3 w-3 md:h-4 md:w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{reconciledPercent}%</div>
<p className="text-xs text-muted-foreground mt-1">
<div className="text-xl md:text-2xl font-bold">
{reconciledPercent}%
</div>
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
{reconciled} / {total} opérations pointées
</p>
</CardContent>
@@ -124,5 +127,3 @@ export function OverviewCards({ data }: OverviewCardsProps) {
</div>
);
}
import { cn } from "@/lib/utils";

View File

@@ -60,9 +60,11 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
return (
<Card>
<CardHeader>
<CardTitle>Transactions récentes</CardTitle>
<CardTitle className="text-sm md:text-base">
Transactions récentes
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="px-3 md:px-6">
<div className="space-y-3">
{recentTransactions.map((transaction) => {
const category = getCategory(transaction.categoryId);
@@ -71,52 +73,25 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
return (
<div
key={transaction.id}
className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors"
className="rounded-lg bg-muted/50 hover:bg-muted transition-colors overflow-hidden"
>
<div className="flex-shrink-0">
<div className="flex items-start gap-2 md:gap-3 p-2 md:p-3">
<div className="flex-shrink-0 pt-0.5">
{transaction.isReconciled ? (
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
<CheckCircle2 className="w-4 h-4 md:w-5 md:h-5 text-emerald-600" />
) : (
<Circle className="w-5 h-5 text-muted-foreground" />
<Circle className="w-4 h-4 md:w-5 md:h-5 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-xs md:text-base truncate flex-1">
{transaction.description}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">
{formatDate(transaction.date)}
</span>
{account && (
<span className="text-xs text-muted-foreground">
{account.name}
</span>
)}
{category && (
<Badge
variant="secondary"
className="text-xs gap-1"
style={{
backgroundColor: `${category.color}20`,
color: category.color,
}}
>
<CategoryIcon
icon={category.icon}
color={category.color}
size={12}
/>
{category.name}
</Badge>
)}
</div>
</div>
<div
className={cn(
"font-semibold tabular-nums",
"font-semibold tabular-nums text-xs md:text-base shrink-0 md:hidden",
transaction.amount >= 0
? "text-emerald-600"
: "text-red-600",
@@ -126,6 +101,48 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
{formatCurrency(transaction.amount)}
</div>
</div>
<div className="flex items-center gap-1.5 md:gap-2 mt-1 flex-wrap">
<span className="text-[10px] md:text-xs text-muted-foreground whitespace-nowrap">
{formatDate(transaction.date)}
</span>
{account && (
<span className="text-[10px] md:text-xs text-muted-foreground truncate">
{account.name}
</span>
)}
{category && (
<Badge
variant="secondary"
className="text-[10px] md:text-xs gap-1 shrink-0"
style={{
backgroundColor: `${category.color}20`,
color: category.color,
}}
>
<CategoryIcon
icon={category.icon}
color={category.color}
size={10}
/>
<span className="truncate">{category.name}</span>
</Badge>
)}
</div>
</div>
<div
className={cn(
"font-semibold tabular-nums text-sm md:text-base shrink-0 hidden md:block",
transaction.amount >= 0
? "text-emerald-600"
: "text-red-600",
)}
>
{transaction.amount >= 0 ? "+" : ""}
{formatCurrency(transaction.amount)}
</div>
</div>
</div>
);
})}
</div>

View File

@@ -19,6 +19,8 @@ import {
LogOut,
} from "lucide-react";
import { toast } from "sonner";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { useIsMobile } from "@/hooks/use-mobile";
const navItems = [
{ href: "/", label: "Tableau de bord", icon: LayoutDashboard },
@@ -29,10 +31,18 @@ const navItems = [
{ href: "/statistics", label: "Statistiques", icon: BarChart3 },
];
export function Sidebar() {
interface SidebarContentProps {
collapsed?: boolean;
onNavigate?: () => void;
}
function SidebarContent({
collapsed = false,
onNavigate,
showHeader = false,
}: SidebarContentProps & { showHeader?: boolean }) {
const pathname = usePathname();
const router = useRouter();
const [collapsed, setCollapsed] = useState(false);
const handleSignOut = async () => {
try {
@@ -46,10 +56,102 @@ export function Sidebar() {
}
};
const handleLinkClick = () => {
onNavigate?.();
};
return (
<>
{showHeader && (
<div className="flex items-center justify-between p-4 border-b border-border">
{!collapsed && (
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Wallet className="w-5 h-5 text-primary-foreground" />
</div>
<span className="font-semibold text-foreground">FinTrack</span>
</div>
)}
</div>
)}
<nav className="flex-1 p-2 space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link key={item.href} href={item.href} onClick={handleLinkClick}>
<Button
variant={isActive ? "secondary" : "ghost"}
className={cn(
"w-full justify-start gap-3",
collapsed && "justify-center px-2",
)}
>
<item.icon className="w-5 h-5 shrink-0" />
{!collapsed && <span>{item.label}</span>}
</Button>
</Link>
);
})}
</nav>
<div className="p-2 border-t border-border space-y-1">
<Link href="/settings" onClick={handleLinkClick}>
<Button
variant="ghost"
className={cn(
"w-full justify-start gap-3",
collapsed && "justify-center px-2",
)}
>
<Settings className="w-5 h-5 shrink-0" />
{!collapsed && <span>Paramètres</span>}
</Button>
</Link>
<Button
variant="ghost"
onClick={handleSignOut}
className={cn(
"w-full justify-start gap-3 text-destructive hover:text-destructive hover:bg-destructive/10",
collapsed && "justify-center px-2",
)}
>
<LogOut className="w-5 h-5 shrink-0" />
{!collapsed && <span>Déconnexion</span>}
</Button>
</div>
</>
);
}
interface SidebarProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function Sidebar({ open, onOpenChange }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false);
const isMobile = useIsMobile();
if (isMobile) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-64 p-0">
<div className="flex flex-col h-full">
<SidebarContent
showHeader
onNavigate={() => onOpenChange?.(false)}
/>
</div>
</SheetContent>
</Sheet>
);
}
return (
<aside
className={cn(
"flex flex-col h-screen bg-card border-r border-border transition-all duration-300",
"hidden md:flex flex-col h-screen bg-card border-r border-border transition-all duration-300",
collapsed ? "w-16" : "w-64",
)}
>
@@ -76,51 +178,7 @@ export function Sidebar() {
</Button>
</div>
<nav className="flex-1 p-2 space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link key={item.href} href={item.href}>
<Button
variant={isActive ? "secondary" : "ghost"}
className={cn(
"w-full justify-start gap-3",
collapsed && "justify-center px-2",
)}
>
<item.icon className="w-5 h-5 shrink-0" />
{!collapsed && <span>{item.label}</span>}
</Button>
</Link>
);
})}
</nav>
<div className="p-2 border-t border-border space-y-1">
<Link href="/settings">
<Button
variant="ghost"
className={cn(
"w-full justify-start gap-3",
collapsed && "justify-center px-2",
)}
>
<Settings className="w-5 h-5 shrink-0" />
{!collapsed && <span>Paramètres</span>}
</Button>
</Link>
<Button
variant="ghost"
onClick={handleSignOut}
className={cn(
"w-full justify-start gap-3 text-destructive hover:text-destructive hover:bg-destructive/10",
collapsed && "justify-center px-2",
)}
>
<LogOut className="w-5 h-5 shrink-0" />
{!collapsed && <span>Déconnexion</span>}
</Button>
</div>
<SidebarContent collapsed={collapsed} showHeader={false} />
</aside>
);
}

View File

@@ -115,4 +115,3 @@ export function AccountFolderDialog({
</Dialog>
);
}

View File

@@ -13,4 +13,3 @@ export const accountTypeLabels = {
CREDIT_CARD: "Carte de crédit",
OTHER: "Autre",
};

View File

@@ -48,7 +48,7 @@ export function DraggableAccountItem({
style={style}
className={cn(
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group ml-12",
isDragging && "bg-muted/80"
isDragging && "bg-muted/80",
)}
>
<button
@@ -66,14 +66,15 @@ export function DraggableAccountItem({
{account.name}
{account.accountNumber && (
<span className="text-muted-foreground">
{" "}({account.accountNumber})
{" "}
({account.accountNumber})
</span>
)}
</Link>
<span
className={cn(
"text-sm tabular-nums",
realBalance >= 0 ? "text-emerald-600" : "text-red-600"
realBalance >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(realBalance)}
@@ -89,4 +90,3 @@ export function DraggableAccountItem({
</div>
);
}

View File

@@ -77,7 +77,7 @@ export function DraggableFolderItem({
className={cn(
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
level > 0 && "ml-6",
isDragging && "bg-muted/80"
isDragging && "bg-muted/80",
)}
>
<button
@@ -120,7 +120,7 @@ export function DraggableFolderItem({
<span
className={cn(
"text-sm font-semibold tabular-nums",
folderTotal >= 0 ? "text-emerald-600" : "text-red-600"
folderTotal >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(folderTotal)}
@@ -157,4 +157,3 @@ export function DraggableFolderItem({
</div>
);
}

View File

@@ -96,11 +96,13 @@ export function FolderEditDialog({
{folderColors.map(({ value }) => (
<button
key={value}
onClick={() => onFormDataChange({ ...formData, color: value })}
onClick={() =>
onFormDataChange({ ...formData, color: value })
}
className={cn(
"w-8 h-8 rounded-full transition-transform",
formData.color === value &&
"ring-2 ring-offset-2 ring-primary scale-110"
"ring-2 ring-offset-2 ring-primary scale-110",
)}
style={{ backgroundColor: value }}
/>
@@ -120,4 +122,3 @@ export function FolderEditDialog({
</Dialog>
);
}

View File

@@ -33,7 +33,7 @@ export function FolderTreeItem({
const folderAccounts = accounts.filter(
(a) =>
a.folderId === folder.id ||
(folder.id === "folder-root" && a.folderId === null)
(folder.id === "folder-root" && a.folderId === null),
);
const childFolders = allFolders.filter((f) => f.parentId === folder.id);
const folderTotal = folderAccounts.reduce(
@@ -88,4 +88,3 @@ export function FolderTreeItem({
</div>
);
}

View File

@@ -4,4 +4,3 @@ export { AccountFolderDialog } from "./account-folder-dialog";
export { DraggableFolderItem } from "./draggable-folder-item";
export { DraggableAccountItem } from "./draggable-account-item";
export { folderColors, accountTypeLabels } from "./constants";

View File

@@ -121,6 +121,7 @@ export function OFXImportDialog({
type: parsed.accountType as Account["type"],
folderId: "folder-root",
balance: parsed.balance,
initialBalance: parsed.balance,
currency: parsed.currency,
lastImport: new Date().toISOString(),
externalUrl: null,
@@ -297,6 +298,7 @@ export function OFXImportDialog({
type: parsedData.accountType as Account["type"],
folderId: selectedFolder,
balance: parsedData.balance,
initialBalance: parsedData.balance,
currency: parsedData.currency,
lastImport: new Date().toISOString(),
externalUrl: null,

View File

@@ -1,4 +1,3 @@
export { PageLayout } from "./page-layout";
export { LoadingState } from "./loading-state";
export { PageHeader } from "./page-header";

View File

@@ -13,4 +13,3 @@ export function LoadingState() {
</div>
);
}

View File

@@ -1,6 +1,11 @@
"use client";
import { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Menu } from "lucide-react";
import { useSidebarContext } from "@/components/layout/sidebar-context";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
interface PageHeaderProps {
title: string;
@@ -15,17 +20,43 @@ export function PageHeader({
actions,
rightContent,
}: PageHeaderProps) {
const { setOpen } = useSidebarContext();
const isMobile = useIsMobile();
return (
<div className="flex items-center justify-between">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
{isMobile && (
<Button
variant="ghost"
size="icon"
onClick={() => setOpen(true)}
className="shrink-0"
>
<Menu className="w-5 h-5" />
</Button>
)}
<div>
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
<h1 className="text-lg md:text-2xl font-bold text-foreground">
{title}
</h1>
{description && (
<div className="text-muted-foreground">{description}</div>
<div className="text-xs md:text-base text-muted-foreground mt-1">
{description}
</div>
)}
</div>
</div>
{(rightContent || actions) && (
<div className="flex items-center gap-2 flex-wrap">
{rightContent}
{actions && <div className="flex gap-2">{actions}</div>}
{actions && (
<div className={cn("flex gap-2", isMobile && "flex-wrap")}>
{actions}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,20 +1,28 @@
"use client";
import { Sidebar } from "@/components/dashboard/sidebar";
import { ReactNode } from "react";
import { ReactNode, useState } from "react";
import { SidebarContext } from "@/components/layout/sidebar-context";
interface PageLayoutProps {
children: ReactNode;
}
export function PageLayout({ children }: PageLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-auto">
<div className="p-6 space-y-6">{children}</div>
<SidebarContext.Provider
value={{ open: sidebarOpen, setOpen: setSidebarOpen }}
>
<div className="flex h-screen bg-background overflow-hidden">
<Sidebar open={sidebarOpen} onOpenChange={setSidebarOpen} />
<main className="flex-1 overflow-auto overflow-x-hidden">
<div className="p-4 md:p-6 space-y-4 md:space-y-6 max-w-full">
{children}
</div>
</main>
</div>
</SidebarContext.Provider>
);
}

View File

@@ -0,0 +1,17 @@
"use client";
import { createContext, useContext } from "react";
interface SidebarContextType {
open: boolean;
setOpen: (open: boolean) => void;
}
export const SidebarContext = createContext<SidebarContextType>({
open: false,
setOpen: () => {},
});
export function useSidebarContext() {
return useContext(SidebarContext);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
refetchOnWindowFocus: false,
retry: 1,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -6,4 +6,3 @@ import type { ReactNode } from "react";
export function AuthSessionProvider({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@@ -84,7 +84,7 @@ export function suggestKeyword(descriptions: string[]): string {
if (sorted.length > 0) {
// Return the longest frequent keyword
return sorted.reduce((best, current) =>
current[0].length > best[0].length ? current : best
current[0].length > best[0].length ? current : best,
)[0];
}
@@ -92,4 +92,3 @@ export function suggestKeyword(descriptions: string[]): string {
const firstKeywords = extractKeywords(descriptions[0]);
return firstKeywords[0] || descriptions[0].slice(0, 15);
}

View File

@@ -1,4 +1,3 @@
export { RuleGroupCard } from "./rule-group-card";
export { RuleCreateDialog } from "./rule-create-dialog";
export { RulesSearchBar } from "./rules-search-bar";

View File

@@ -65,7 +65,7 @@ export function RuleCreateDialog({
if (!keyword) return null;
const lowerKeyword = keyword.toLowerCase();
return categories.find((c) =>
c.keywords.some((k) => k.toLowerCase() === lowerKeyword)
c.keywords.some((k) => k.toLowerCase() === lowerKeyword),
);
}, [keyword, categories]);
@@ -136,7 +136,8 @@ export function RuleCreateDialog({
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
<AlertCircle className="h-3 w-3" />
<span>
Ce mot-clé existe déjà dans &quot;{existingCategory.name}&quot;
Ce mot-clé existe déjà dans &quot;{existingCategory.name}
&quot;
</span>
</div>
)}
@@ -202,8 +203,9 @@ export function RuleCreateDialog({
<div className="flex items-center gap-2 text-sm text-success">
<CheckCircle2 className="h-4 w-4" />
<span>
Le mot-clé &quot;<strong>{keyword}</strong>&quot; sera ajouté à la
catégorie &quot;<strong>{selectedCategory?.name}</strong>&quot;
Le mot-clé &quot;<strong>{keyword}</strong>&quot; sera ajouté
à la catégorie &quot;<strong>{selectedCategory?.name}</strong>
&quot;
</span>
</div>
</div>
@@ -225,4 +227,3 @@ export function RuleCreateDialog({
</Dialog>
);
}

View File

@@ -5,6 +5,7 @@ import { ChevronDown, ChevronRight, Plus, Tag } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { CategoryCombobox } from "@/components/ui/category-combobox";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import type { Transaction, Category } from "@/lib/types";
@@ -37,7 +38,10 @@ export function RuleGroupCard({
formatCurrency,
formatDate,
}: RuleGroupCardProps) {
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(
null,
);
const isMobile = useIsMobile();
const avgAmount =
group.transactions.reduce((sum, t) => sum + t.amount, 0) /
@@ -53,41 +57,50 @@ export function RuleGroupCard({
<div className="border border-border rounded-lg bg-card overflow-hidden">
{/* Header */}
<div
className="flex items-center gap-3 p-4 cursor-pointer hover:bg-accent/5 transition-colors"
className="flex flex-col md:flex-row md:items-center gap-2 md:gap-3 p-3 md:p-4 cursor-pointer hover:bg-accent/5 transition-colors"
onClick={onToggleExpand}
>
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0">
<div className="flex items-center gap-2 md:gap-3 flex-1 min-w-0">
<Button
variant="ghost"
size="icon"
className="h-5 w-5 md:h-6 md:w-6 shrink-0"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
<ChevronDown className="h-3 w-3 md:h-4 md:w-4" />
) : (
<ChevronRight className="h-4 w-4" />
<ChevronRight className="h-3 w-3 md:h-4 md:w-4" />
)}
</Button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground truncate">
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
<span className="font-medium text-xs md:text-base text-foreground truncate">
{group.displayName}
</span>
<Badge variant="secondary" className="shrink-0">
{group.transactions.length} transaction
{group.transactions.length > 1 ? "s" : ""}
<Badge
variant="secondary"
className="text-[10px] md:text-xs shrink-0"
>
{group.transactions.length} 💳
</Badge>
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
<Tag className="h-3 w-3" />
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
<div className="flex items-center gap-1.5 md:gap-2 mt-1 text-[10px] md:text-sm text-muted-foreground">
<Tag className="h-2.5 w-2.5 md:h-3 md:w-3" />
<span className="font-mono text-[10px] md:text-xs bg-muted px-1 md:px-1.5 py-0.5 rounded">
{group.suggestedKeyword}
</span>
</div>
</div>
</div>
{!isMobile && (
<div className="flex items-center gap-4">
<div className="text-right">
<div
className={cn(
"font-semibold tabular-nums",
isDebit ? "text-destructive" : "text-success"
"font-semibold tabular-nums text-sm",
isDebit ? "text-destructive" : "text-success",
)}
>
{formatCurrency(group.totalAmount)}
@@ -120,11 +133,70 @@ export function RuleGroupCard({
</Button>
</div>
</div>
)}
{isMobile && (
<div className="flex items-center gap-2 shrink-0 ml-7">
<div onClick={(e) => e.stopPropagation()} className="flex-1">
<CategoryCombobox
categories={categories}
value={selectedCategoryId}
onChange={handleCategorySelect}
placeholder="Catégoriser..."
width="w-full"
buttonWidth="w-full"
/>
</div>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
onCreateRule();
}}
className="shrink-0"
>
<Plus className="h-3 w-3 md:h-4 md:w-4" />
</Button>
</div>
)}
</div>
{/* Expanded transactions list */}
{isExpanded && (
<div className="border-t border-border bg-muted/30">
{isMobile ? (
<div className="max-h-64 overflow-y-auto divide-y divide-border">
{group.transactions.map((transaction) => (
<div key={transaction.id} className="p-3 hover:bg-muted/50">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-xs md:text-sm font-medium truncate">
{transaction.description}
</p>
{transaction.memo && (
<p className="text-[10px] md:text-xs text-muted-foreground truncate mt-0.5">
{transaction.memo}
</p>
)}
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
{formatDate(transaction.date)}
</p>
</div>
<div
className={cn(
"text-xs md:text-sm font-semibold tabular-nums shrink-0",
transaction.amount < 0
? "text-destructive"
: "text-success",
)}
>
{formatCurrency(transaction.amount)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="max-h-64 overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50 sticky top-0">
@@ -162,7 +234,7 @@ export function RuleGroupCard({
"px-4 py-2 text-right tabular-nums whitespace-nowrap",
transaction.amount < 0
? "text-destructive"
: "text-success"
: "text-success",
)}
>
{formatCurrency(transaction.amount)}
@@ -172,9 +244,9 @@ export function RuleGroupCard({
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -28,24 +28,24 @@ export function RulesSearchBar({
onFilterMinCountChange,
}: RulesSearchBarProps) {
return (
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<div className="flex flex-col gap-2 md:gap-3 mb-4 md:mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 md:h-4 md:w-4 text-muted-foreground" />
<Input
placeholder="Rechercher dans les descriptions..."
placeholder="Rechercher..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
className="pl-9 md:pl-10 text-sm md:text-base"
/>
</div>
<div className="flex gap-3">
<div className="flex gap-2 md:gap-3 flex-wrap">
<Select
value={sortBy}
onValueChange={(v) => onSortChange(v as "count" | "amount" | "name")}
>
<SelectTrigger className="w-44">
<ArrowUpDown className="h-4 w-4 mr-2" />
<SelectTrigger className="w-full md:w-44 text-sm">
<ArrowUpDown className="h-3.5 w-3.5 md:h-4 md:w-4 mr-2" />
<SelectValue placeholder="Trier par" />
</SelectTrigger>
<SelectContent>
@@ -59,8 +59,8 @@ export function RulesSearchBar({
value={filterMinCount.toString()}
onValueChange={(v) => onFilterMinCountChange(parseInt(v))}
>
<SelectTrigger className="w-36">
<Filter className="h-4 w-4 mr-2" />
<SelectTrigger className="w-full md:w-36 text-sm">
<Filter className="h-3.5 w-3.5 md:h-4 md:w-4 mr-2" />
<SelectValue placeholder="Minimum" />
</SelectTrigger>
<SelectContent>
@@ -75,4 +75,3 @@ export function RulesSearchBar({
</div>
);
}

View File

@@ -37,13 +37,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Database,
Trash2,
RotateCcw,
Save,
Clock,
} from "lucide-react";
import { Database, Trash2, RotateCcw, Save, Clock } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale/fr";
import { toast } from "sonner";
@@ -84,10 +78,17 @@ export function BackupCard() {
if (backupsData.success) {
setBackups(
backupsData.data.map((b: { id: string; filename: string; size: number; createdAt: string }) => ({
backupsData.data.map(
(b: {
id: string;
filename: string;
size: number;
createdAt: string;
}) => ({
...b,
createdAt: new Date(b.createdAt),
}))
}),
),
);
}
@@ -116,7 +117,9 @@ export function BackupCard() {
if (data.success) {
if (data.data.skipped) {
toast.info("Aucun changement détecté. La dernière sauvegarde a été mise à jour.");
toast.info(
"Aucun changement détecté. La dernière sauvegarde a été mise à jour.",
);
} else {
toast.success("Sauvegarde créée avec succès");
}
@@ -160,7 +163,9 @@ export function BackupCard() {
const data = await response.json();
if (data.success) {
toast.success("Sauvegarde restaurée avec succès. Rechargement de la page...");
toast.success(
"Sauvegarde restaurée avec succès. Rechargement de la page...",
);
setTimeout(() => {
window.location.reload();
}, 2000);
@@ -258,9 +263,9 @@ export function BackupCard() {
<Label htmlFor="backup-frequency">Fréquence</Label>
<Select
value={settings.frequency}
onValueChange={(value: "hourly" | "daily" | "weekly" | "monthly") =>
handleSettingsChange({ frequency: value })
}
onValueChange={(
value: "hourly" | "daily" | "weekly" | "monthly",
) => handleSettingsChange({ frequency: value })}
>
<SelectTrigger id="backup-frequency">
<SelectValue />
@@ -369,17 +374,16 @@ export function BackupCard() {
Restaurer cette sauvegarde ?
</AlertDialogTitle>
<AlertDialogDescription>
Cette action va remplacer votre base de données
actuelle par cette sauvegarde. Une sauvegarde
de sécurité sera créée avant la restauration.
Cette action va remplacer votre base de
données actuelle par cette sauvegarde. Une
sauvegarde de sécurité sera créée avant la
restauration.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
handleRestoreBackup(backup.id)
}
onClick={() => handleRestoreBackup(backup.id)}
>
Restaurer
</AlertDialogAction>
@@ -406,9 +410,7 @@ export function BackupCard() {
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
handleDeleteBackup(backup.id)
}
onClick={() => handleDeleteBackup(backup.id)}
>
Supprimer
</AlertDialogAction>
@@ -434,4 +436,3 @@ export function BackupCard() {
</Card>
);
}

View File

@@ -26,7 +26,10 @@ interface DangerZoneCardProps {
categorizedCount: number;
onClearCategories: () => void;
onResetData: () => void;
onDeduplicate: () => Promise<{ deletedCount: number; duplicatesFound: number }>;
onDeduplicate: () => Promise<{
deletedCount: number;
duplicatesFound: number;
}>;
}
export function DangerZoneCard({
@@ -42,7 +45,9 @@ export function DangerZoneCard({
try {
const result = await onDeduplicate();
if (result.deletedCount > 0) {
alert(`${result.deletedCount} transaction${result.deletedCount > 1 ? "s" : ""} en double supprimée${result.deletedCount > 1 ? "s" : ""}`);
alert(
`${result.deletedCount} transaction${result.deletedCount > 1 ? "s" : ""} en double supprimée${result.deletedCount > 1 ? "s" : ""}`,
);
} else {
alert("Aucun doublon trouvé");
}
@@ -88,10 +93,11 @@ export function DangerZoneCard({
Dédoublonner les transactions ?
</AlertDialogTitle>
<AlertDialogDescription>
Cette action va rechercher et supprimer les transactions en double
dans votre base de données. Les critères de dédoublonnage sont :
même compte, même date, même montant et même libellé. La première
transaction trouvée sera conservée, les autres seront supprimées.
Cette action va rechercher et supprimer les transactions en
double dans votre base de données. Les critères de dédoublonnage
sont : même compte, même date, même montant et même libellé. La
première transaction trouvée sera conservée, les autres seront
supprimées.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -131,8 +137,8 @@ export function DangerZoneCard({
<AlertDialogDescription>
Cette action va retirer la catégorie de {categorizedCount}{" "}
opération{categorizedCount > 1 ? "s" : ""}. Les catégories
elles-mêmes ne seront pas supprimées, seulement leur
affectation aux opérations.
elles-mêmes ne seront pas supprimées, seulement leur affectation
aux opérations.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -179,4 +185,3 @@ export function DangerZoneCard({
</Card>
);
}

View File

@@ -70,4 +70,3 @@ export function DataCard({
</Card>
);
}

View File

@@ -3,4 +3,3 @@ export { DangerZoneCard } from "./danger-zone-card";
export { OFXInfoCard } from "./ofx-info-card";
export { BackupCard } from "./backup-card";
export { PasswordCard } from "./password-card";

View File

@@ -17,9 +17,7 @@ export function OFXInfoCard() {
<FileJson className="w-5 h-5" />
Format OFX
</CardTitle>
<CardDescription>
Informations sur l'import de fichiers
</CardDescription>
<CardDescription>Informations sur l'import de fichiers</CardDescription>
</CardHeader>
<CardContent>
<div className="prose prose-sm text-muted-foreground">
@@ -29,13 +27,12 @@ export function OFXInfoCard() {
l'espace client de votre banque.
</p>
<p className="mt-2">
Lors de l'import, les transactions sont automatiquement
catégorisées selon les mots-clés définis. Les doublons sont détectés
et ignorés automatiquement.
Lors de l'import, les transactions sont automatiquement catégorisées
selon les mots-clés définis. Les doublons sont détectés et ignorés
automatiquement.
</p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -158,7 +158,9 @@ export function PasswordCard() {
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirmer le mot de passe</Label>
<Label htmlFor="confirm-password">
Confirmer le mot de passe
</Label>
<div className="relative">
<Input
id="confirm-password"
@@ -199,4 +201,3 @@ export function PasswordCard() {
</Card>
);
}

View File

@@ -158,7 +158,9 @@ export function BalanceLineChart({
className="w-3 h-3 rounded-full"
style={{
backgroundColor: entry.color,
transform: isHovered ? "scale(1.2)" : "scale(1)",
transform: isHovered
? "scale(1.2)"
: "scale(1)",
transition: "transform 0.15s",
}}
/>

View File

@@ -115,4 +115,3 @@ export function CategoryBarChart({
</Card>
);
}

View File

@@ -4,14 +4,7 @@ import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CategoryIcon } from "@/components/ui/category-icon";
import {
PieChart,
Pie,
Cell,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts";
import { Layers, List, ChevronDown, ChevronUp } from "lucide-react";
import type { Category } from "@/lib/types";
@@ -38,7 +31,7 @@ interface CategoryPieChartProps {
export function CategoryPieChart({
data,
dataByParent,
categories = [],
categories: _categories = [],
formatCurrency,
title = "Répartition par catégorie",
height = 300,
@@ -49,7 +42,7 @@ export function CategoryPieChart({
const [groupByParent, setGroupByParent] = useState(true);
const [isExpanded, setIsExpanded] = useState(false);
const hasParentData = dataByParent && dataByParent.length > 0;
const baseData = (groupByParent && hasParentData) ? dataByParent : data;
const baseData = groupByParent && hasParentData ? dataByParent : data;
// Limit to top 8 by default, show all if expanded
const maxItems = 8;
@@ -57,24 +50,29 @@ export function CategoryPieChart({
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>{title}</CardTitle>
<div className="flex gap-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>
<div className="flex flex-col md:flex-row gap-2 w-full md:w-auto">
{hasParentData && (
<Button
variant={groupByParent ? "default" : "ghost"}
size="sm"
onClick={() => setGroupByParent(!groupByParent)}
title={groupByParent ? "Afficher toutes les catégories" : "Regrouper par catégories parentes"}
title={
groupByParent
? "Afficher toutes les catégories"
: "Regrouper par catégories parentes"
}
className="w-full md:w-auto text-xs md:text-sm"
>
{groupByParent ? (
<>
<List className="w-4 h-4 mr-1" />
<List className="w-3 h-3 md:w-4 md:h-4 mr-1" />
Par catégorie
</>
) : (
<>
<Layers className="w-4 h-4 mr-1" />
<Layers className="w-3 h-3 md:w-4 md:h-4 mr-1" />
Par parent
</>
)}
@@ -85,15 +83,16 @@ export function CategoryPieChart({
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full md:w-auto text-xs md:text-sm"
>
{isExpanded ? (
<>
<ChevronUp className="w-4 h-4 mr-1" />
<ChevronUp className="w-3 h-3 md:w-4 md:h-4 mr-1" />
Réduire
</>
) : (
<>
<ChevronDown className="w-4 h-4 mr-1" />
<ChevronDown className="w-3 h-3 md:w-4 md:h-4 mr-1" />
Voir tout ({baseData.length})
</>
)}
@@ -196,4 +195,3 @@ export function CategoryPieChart({
</Card>
);
}

View File

@@ -104,7 +104,11 @@ export function CategoryTrendChart({
variant={groupByParent ? "default" : "ghost"}
size="sm"
onClick={() => setGroupByParent(!groupByParent)}
title={groupByParent ? "Afficher toutes les catégories" : "Regrouper par catégories parentes"}
title={
groupByParent
? "Afficher toutes les catégories"
: "Regrouper par catégories parentes"
}
>
{groupByParent ? (
<>
@@ -179,9 +183,11 @@ export function CategoryTrendChart({
{allCategoryIds.map((categoryId) => {
const categoryInfo = getCategoryInfo(categoryId);
const categoryName = getCategoryName(categoryId);
if (!categoryInfo && categoryId !== "uncategorized") return null;
if (!categoryInfo && categoryId !== "uncategorized")
return null;
const isInDisplayCategories = displayCategories.includes(categoryId);
const isInDisplayCategories =
displayCategories.includes(categoryId);
const isSelected =
selectedCategories.length === 0
? isInDisplayCategories
@@ -198,8 +204,8 @@ export function CategoryTrendChart({
if (selectedCategories.includes(categoryId)) {
setSelectedCategories(
selectedCategories.filter(
(id) => id !== categoryId
)
(id) => id !== categoryId,
),
);
} else {
setSelectedCategories([
@@ -234,7 +240,8 @@ export function CategoryTrendChart({
{categoriesToShow.map((categoryId, index) => {
const categoryInfo = getCategoryInfo(categoryId);
const categoryName = getCategoryName(categoryId);
if (!categoryInfo && categoryId !== "uncategorized") return null;
if (!categoryInfo && categoryId !== "uncategorized")
return null;
const isSelected =
selectedCategories.length === 0 ||
@@ -245,7 +252,10 @@ export function CategoryTrendChart({
type="monotone"
dataKey={categoryId}
name={categoryName}
stroke={categoryInfo?.color || CATEGORY_COLORS[index % CATEGORY_COLORS.length]}
stroke={
categoryInfo?.color ||
CATEGORY_COLORS[index % CATEGORY_COLORS.length]
}
strokeWidth={isSelected ? 2 : 1}
strokeOpacity={isSelected ? 1 : 0.3}
dot={false}
@@ -265,4 +275,3 @@ export function CategoryTrendChart({
</Card>
);
}

View File

@@ -91,4 +91,3 @@ export function IncomeExpenseTrendChart({
</Card>
);
}

View File

@@ -8,4 +8,3 @@ export { CategoryTrendChart } from "./category-trend-chart";
export { SavingsTrendChart } from "./savings-trend-chart";
export { IncomeExpenseTrendChart } from "./income-expense-trend-chart";
export { YearOverYearChart } from "./year-over-year-chart";

View File

@@ -73,4 +73,3 @@ export function MonthlyChart({ data, formatCurrency }: MonthlyChartProps) {
</Card>
);
}

View File

@@ -55,7 +55,13 @@ export function SavingsTrendChart({
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}>
<defs>
<linearGradient id="savingsGradient" x1="0" y1="0" x2="0" y2="1">
<linearGradient
id="savingsGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={isPositive ? "#22c55e" : "#ef4444"}
@@ -113,4 +119,3 @@ export function SavingsTrendChart({
</Card>
);
}

View File

@@ -20,16 +20,16 @@ export function StatsSummaryCards({
const savings = totalIncome - totalExpenses;
return (
<div className="grid gap-4 md:grid-cols-4">
<div className="grid gap-3 md:gap-4 grid-cols-2 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-emerald-600" />
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2">
<TrendingUp className="w-3 h-3 md:w-4 md:h-4 text-emerald-600" />
Total Revenus
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-600">
<div className="text-lg md:text-2xl font-bold text-emerald-600">
{formatCurrency(totalIncome)}
</div>
</CardContent>
@@ -37,13 +37,13 @@ export function StatsSummaryCards({
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-red-600" />
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2">
<TrendingDown className="w-3 h-3 md:w-4 md:h-4 text-red-600" />
Total Dépenses
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
<div className="text-lg md:text-2xl font-bold text-red-600">
{formatCurrency(totalExpenses)}
</div>
</CardContent>
@@ -51,13 +51,13 @@ export function StatsSummaryCards({
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<ArrowRight className="w-4 h-4" />
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2">
<ArrowRight className="w-3 h-3 md:w-4 md:h-4" />
Moyenne mensuelle
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
<div className="text-lg md:text-2xl font-bold">
{formatCurrency(avgMonthlyExpenses)}
</div>
</CardContent>
@@ -65,15 +65,15 @@ export function StatsSummaryCards({
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
Économies
</CardTitle>
</CardHeader>
<CardContent>
<div
className={cn(
"text-2xl font-bold",
savings >= 0 ? "text-emerald-600" : "text-red-600"
"text-lg md:text-2xl font-bold",
savings >= 0 ? "text-emerald-600" : "text-red-600",
)}
>
{formatCurrency(savings)}
@@ -83,4 +83,3 @@ export function StatsSummaryCards({
</div>
);
}

View File

@@ -2,6 +2,8 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CategoryIcon } from "@/components/ui/category-icon";
import { Badge } from "@/components/ui/badge";
import { useIsMobile } from "@/hooks/use-mobile";
import type { Transaction, Category } from "@/lib/types";
interface TopExpensesListProps {
@@ -15,58 +17,69 @@ export function TopExpensesList({
categories,
formatCurrency,
}: TopExpensesListProps) {
const isMobile = useIsMobile();
return (
<Card>
<CardHeader>
<CardTitle>Top 5 dépenses</CardTitle>
<CardTitle className="text-sm md:text-base">Top 5 dépenses</CardTitle>
</CardHeader>
<CardContent>
{expenses.length > 0 ? (
<div className="space-y-4">
<div className="space-y-3 md:space-y-4">
{expenses.map((expense, index) => {
const category = categories.find(
(c) => c.id === expense.categoryId
(c) => c.id === expense.categoryId,
);
return (
<div key={expense.id} className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-sm font-semibold">
<div
key={expense.id}
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">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
<div className="flex items-start justify-between gap-2 mb-1">
<p className="font-medium text-xs md:text-sm truncate flex-1">
{expense.description}
</p>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
<div className="text-red-600 font-semibold tabular-nums text-xs md:text-sm shrink-0">
{formatCurrency(expense.amount)}
</div>
</div>
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
<span className="text-[10px] md:text-xs text-muted-foreground">
{new Date(expense.date).toLocaleDateString("fr-FR")}
</span>
{category && (
<span
className="text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1"
<Badge
variant="secondary"
className="text-[10px] md:text-xs px-1.5 md:px-2 py-0.5 inline-flex items-center gap-1 shrink-0"
style={{
backgroundColor: `${category.color}20`,
color: category.color,
borderColor: `${category.color}30`,
}}
>
<CategoryIcon
icon={category.icon}
color={category.color}
size={10}
size={isMobile ? 8 : 10}
/>
<span className="truncate max-w-[120px] md:max-w-none">
{category.name}
</span>
</Badge>
)}
</div>
</div>
<div className="text-red-600 font-semibold tabular-nums">
{formatCurrency(expense.amount)}
</div>
</div>
);
})}
</div>
) : (
<div className="h-[200px] flex items-center justify-center text-muted-foreground">
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-xs md:text-sm">
Pas de dépenses pour cette période
</div>
)}
@@ -74,4 +87,3 @@ export function TopExpensesList({
</Card>
);
}

View File

@@ -88,4 +88,3 @@ export function YearOverYearChart({
</Card>
);
}

View File

@@ -1,4 +1,3 @@
export { TransactionFilters } from "./transaction-filters";
export { TransactionBulkActions } from "./transaction-bulk-actions";
export { TransactionTable } from "./transaction-table";

View File

@@ -20,7 +20,9 @@ export function TransactionBulkActions({
onReconcile,
onSetCategory,
}: TransactionBulkActionsProps) {
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(
null,
);
if (selectedCount === 0) return null;
@@ -61,4 +63,3 @@ export function TransactionBulkActions({
</Card>
);
}

View File

@@ -13,7 +13,11 @@ import {
import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox";
import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox";
import { CategoryIcon } from "@/components/ui/category-icon";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { Button } from "@/components/ui/button";
import { Search, X, Filter, Wallet, Calendar } from "lucide-react";
@@ -91,7 +95,7 @@ export function TransactionFilters({
folders={folders}
value={selectedAccounts}
onChange={onAccountsChange}
className="w-[280px]"
className="w-full md:w-[280px]"
filteredTransactions={transactionsForAccountFilter}
/>
@@ -99,12 +103,12 @@ export function TransactionFilters({
categories={categories}
value={selectedCategories}
onChange={onCategoriesChange}
className="w-[220px]"
className="w-full md:w-[220px]"
filteredTransactions={transactionsForCategoryFilter}
/>
<Select value={showReconciled} onValueChange={onReconciledChange}>
<SelectTrigger className="w-[160px]">
<SelectTrigger className="w-full md:w-[160px]">
<SelectValue placeholder="Pointage" />
</SelectTrigger>
<SelectContent>
@@ -125,7 +129,7 @@ export function TransactionFilters({
}
}}
>
<SelectTrigger className="w-[150px]">
<SelectTrigger className="w-full md:w-[150px]">
<SelectValue placeholder="Période" />
</SelectTrigger>
<SelectContent>
@@ -139,9 +143,15 @@ export function TransactionFilters({
</Select>
{period === "custom" && (
<Popover open={isCustomDatePickerOpen} onOpenChange={onCustomDatePickerOpenChange}>
<Popover
open={isCustomDatePickerOpen}
onOpenChange={onCustomDatePickerOpenChange}
>
<PopoverTrigger asChild>
<Button variant="outline" className="w-[280px] justify-start text-left font-normal">
<Button
variant="outline"
className="w-full md:w-[280px] justify-start text-left font-normal"
>
<Calendar className="mr-2 h-4 w-4" />
{customStartDate && customEndDate ? (
<>
@@ -151,7 +161,9 @@ export function TransactionFilters({
) : customStartDate ? (
format(customStartDate, "PPP", { locale: fr })
) : (
<span className="text-muted-foreground">Sélectionner les dates</span>
<span className="text-muted-foreground">
Sélectionner les dates
</span>
)}
</Button>
</PopoverTrigger>
@@ -232,7 +244,9 @@ export function TransactionFilters({
selectedCategories={selectedCategories}
onRemoveCategory={(id) => {
const newCategories = selectedCategories.filter((c) => c !== id);
onCategoriesChange(newCategories.length > 0 ? newCategories : ["all"]);
onCategoriesChange(
newCategories.length > 0 ? newCategories : ["all"],
);
}}
onClearCategories={() => onCategoriesChange(["all"])}
showReconciled={showReconciled}
@@ -294,12 +308,15 @@ function ActiveFilters({
const hasReconciled = showReconciled !== "all";
const hasPeriod = period !== "all";
const hasActiveFilters = hasSearch || hasAccounts || hasCategories || hasReconciled || hasPeriod;
const hasActiveFilters =
hasSearch || hasAccounts || hasCategories || hasReconciled || hasPeriod;
if (!hasActiveFilters) return null;
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
const selectedCats = categories.filter((c) => selectedCategories.includes(c.id));
const selectedCats = categories.filter((c) =>
selectedCategories.includes(c.id),
);
const isUncategorized = selectedCategories.includes("uncategorized");
const clearAll = () => {
@@ -317,14 +334,21 @@ function ActiveFilters({
{hasSearch && (
<Badge variant="secondary" className="gap-1 text-xs font-normal">
Recherche: &quot;{searchQuery}&quot;
<button onClick={onClearSearch} className="ml-1 hover:text-foreground">
<button
onClick={onClearSearch}
className="ml-1 hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</Badge>
)}
{selectedAccs.map((acc) => (
<Badge key={acc.id} variant="secondary" className="gap-1 text-xs font-normal">
<Badge
key={acc.id}
variant="secondary"
className="gap-1 text-xs font-normal"
>
<Wallet className="h-3 w-3" />
{acc.name}
<button
@@ -339,7 +363,10 @@ function ActiveFilters({
{isUncategorized && (
<Badge variant="secondary" className="gap-1 text-xs font-normal">
Non catégorisé
<button onClick={onClearCategories} className="ml-1 hover:text-foreground">
<button
onClick={onClearCategories}
className="ml-1 hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</Badge>
@@ -369,7 +396,10 @@ function ActiveFilters({
{hasReconciled && (
<Badge variant="secondary" className="gap-1 text-xs font-normal">
{showReconciled === "reconciled" ? "Pointées" : "Non pointées"}
<button onClick={onClearReconciled} className="ml-1 hover:text-foreground">
<button
onClick={onClearReconciled}
className="ml-1 hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</Badge>
@@ -389,7 +419,10 @@ function ActiveFilters({
: period === "12months"
? "12 mois"
: "Période"}
<button onClick={onClearPeriod} className="ml-1 hover:text-foreground">
<button
onClick={onClearPeriod}
className="ml-1 hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</Badge>
@@ -404,4 +437,3 @@ function ActiveFilters({
</div>
);
}

View File

@@ -27,6 +27,7 @@ import {
} from "lucide-react";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { useIsMobile } from "@/hooks/use-mobile";
import type { Transaction, Account, Category } from "@/lib/types";
type SortField = "date" | "amount" | "description";
@@ -111,9 +112,7 @@ function DescriptionWithTooltip({ description }: { description: string }) {
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
{content}
</TooltipTrigger>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent
side="top"
align="start"
@@ -146,11 +145,14 @@ export function TransactionTable({
}: TransactionTableProps) {
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const parentRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();
const MOBILE_ROW_HEIGHT = 120;
const virtualizer = useVirtualizer({
count: transactions.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
estimateSize: () => (isMobile ? MOBILE_ROW_HEIGHT : ROW_HEIGHT),
overscan: 10,
});
@@ -159,7 +161,7 @@ export function TransactionTable({
setFocusedIndex(index);
onMarkReconciled(transactionId);
},
[onMarkReconciled]
[onMarkReconciled],
);
const handleKeyDown = useCallback(
@@ -188,7 +190,7 @@ export function TransactionTable({
}
}
},
[focusedIndex, transactions, onMarkReconciled, virtualizer]
[focusedIndex, transactions, onMarkReconciled, virtualizer],
);
useEffect(() => {
@@ -201,17 +203,177 @@ export function TransactionTable({
setFocusedIndex(null);
}, [transactions.length]);
const getAccount = (accountId: string) => {
const getAccount = useCallback(
(accountId: string) => {
return accounts.find((a) => a.id === accountId);
};
},
[accounts],
);
const getCategory = useCallback(
(categoryId: string | null) => {
if (!categoryId) return null;
return categories.find((c) => c.id === categoryId);
},
[categories],
);
return (
<Card>
<Card className="overflow-hidden">
<CardContent className="p-0">
{transactions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">Aucune transaction trouvée</p>
</div>
) : isMobile ? (
<div className="divide-y divide-border">
<div
ref={parentRef}
className="overflow-auto"
style={{ height: "calc(100vh - 300px)", minHeight: "400px" }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const transaction = transactions[virtualRow.index];
const account = getAccount(transaction.accountId);
const _category = getCategory(transaction.categoryId);
const isFocused = focusedIndex === virtualRow.index;
return (
<div
key={transaction.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
onClick={() => {
// Désactiver le pointage au clic sur mobile
if (!isMobile) {
handleRowClick(virtualRow.index, transaction.id);
}
}}
className={cn(
"p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border",
transaction.isReconciled && "bg-emerald-500/5",
isFocused && "bg-primary/10 ring-1 ring-primary/30",
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3 flex-1 min-w-0">
<Checkbox
checked={selectedTransactions.has(transaction.id)}
onCheckedChange={() => {
onToggleSelectTransaction(transaction.id);
}}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-xs md:text-sm truncate">
{transaction.description}
</p>
{transaction.memo && (
<p className="text-[10px] md:text-xs text-muted-foreground truncate mt-1">
{transaction.memo}
</p>
)}
</div>
</div>
<div
className={cn(
"font-semibold tabular-nums text-sm md:text-base shrink-0",
transaction.amount >= 0
? "text-emerald-600"
: "text-red-600",
)}
>
{transaction.amount >= 0 ? "+" : ""}
{formatCurrency(transaction.amount)}
</div>
</div>
<div className="flex items-center gap-2 md:gap-3 flex-wrap">
<span className="text-[10px] md:text-xs text-muted-foreground">
{formatDate(transaction.date)}
</span>
{account && (
<span className="text-[10px] md:text-xs text-muted-foreground">
{account.name}
</span>
)}
<div
onClick={(e) => e.stopPropagation()}
className="flex-1"
>
<CategoryCombobox
categories={categories}
value={transaction.categoryId}
onChange={(categoryId) =>
onSetCategory(transaction.id, categoryId)
}
showBadge
align="start"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger
asChild
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onCreateRule(transaction);
}}
>
<Wand2 className="w-4 h-4 mr-2" />
Créer une règle
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
if (
confirm(
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
)
) {
onDelete(transaction.id);
}
}}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
</div>
</div>
</div>
) : (
<div className="overflow-x-auto">
{/* Header fixe */}
@@ -295,11 +457,13 @@ export function TransactionTable({
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
onClick={() => handleRowClick(virtualRow.index, transaction.id)}
onClick={() =>
handleRowClick(virtualRow.index, transaction.id)
}
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",
transaction.isReconciled && "bg-emerald-500/5",
isFocused && "bg-primary/10 ring-1 ring-primary/30"
isFocused && "bg-primary/10 ring-1 ring-primary/30",
)}
>
<div className="p-3">
@@ -313,12 +477,17 @@ export function TransactionTable({
<div className="p-3 text-sm text-muted-foreground whitespace-nowrap">
{formatDate(transaction.date)}
</div>
<div className="p-3 min-w-0 overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div
className="p-3 min-w-0 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<p className="font-medium text-sm truncate">
{transaction.description}
</p>
{transaction.memo && (
<DescriptionWithTooltip description={transaction.memo} />
<DescriptionWithTooltip
description={transaction.memo}
/>
)}
</div>
<div className="p-3 text-sm text-muted-foreground">
@@ -340,13 +509,16 @@ export function TransactionTable({
"p-3 text-right font-semibold tabular-nums",
transaction.amount >= 0
? "text-emerald-600"
: "text-red-600"
: "text-red-600",
)}
>
{transaction.amount >= 0 ? "+" : ""}
{formatCurrency(transaction.amount)}
</div>
<div className="p-3 text-center" onClick={(e) => e.stopPropagation()}>
<div
className="p-3 text-center"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => onToggleReconciled(transaction.id)}
className="p-1 hover:bg-muted rounded"
@@ -404,7 +576,7 @@ export function TransactionTable({
e.stopPropagation();
if (
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);
@@ -429,4 +601,3 @@ export function TransactionTable({
</Card>
);
}

View File

@@ -64,7 +64,7 @@ export function AccountFilterCombobox({
// Get root folders (folders without parent) - same as folders/page.tsx
const rootFolders = useMemo(
() => folders.filter((f) => f.parentId === null),
[folders]
[folders],
);
// Get child folders for a given parent - same as FolderTreeItem
@@ -78,7 +78,7 @@ export function AccountFilterCombobox({
// Get accounts without folder
const orphanAccounts = useMemo(
() => accounts.filter((a) => !a.folderId),
[accounts]
[accounts],
);
const selectedAccounts = accounts.filter((a) => value.includes(a.id));
@@ -89,7 +89,7 @@ export function AccountFilterCombobox({
const directAccounts = getFolderAccounts(folderId);
const childFoldersList = getChildFolders(folderId);
const childAccounts = childFoldersList.flatMap((cf) =>
getAllAccountsInFolder(cf.id)
getAllAccountsInFolder(cf.id),
);
return [...directAccounts, ...childAccounts];
};
@@ -126,7 +126,7 @@ export function AccountFilterCombobox({
if (allSelected) {
const newSelection = value.filter(
(v) => !allFolderAccountIds.includes(v)
(v) => !allFolderAccountIds.includes(v),
);
onChange(newSelection.length > 0 ? newSelection : ["all"]);
} else {
@@ -153,7 +153,7 @@ export function AccountFilterCombobox({
const folderAccounts = getAllAccountsInFolder(folderId);
if (folderAccounts.length === 0) return false;
const selectedCount = folderAccounts.filter((a) =>
value.includes(a.id)
value.includes(a.id),
).length;
return selectedCount > 0 && selectedCount < folderAccounts.length;
};
@@ -162,7 +162,9 @@ export function AccountFilterCombobox({
const renderFolder = (folder: Folder, depth: number, parentPath: string) => {
const folderAccounts = getFolderAccounts(folder.id);
const childFoldersList = getChildFolders(folder.id);
const currentPath = parentPath ? `${parentPath} ${folder.name}` : folder.name;
const currentPath = parentPath
? `${parentPath} ${folder.name}`
: folder.name;
const paddingLeft = depth * 16 + 8;
return (
@@ -183,7 +185,7 @@ export function AccountFilterCombobox({
<Check
className={cn(
"h-4 w-4",
isFolderSelected(folder.id) ? "opacity-100" : "opacity-0"
isFolderSelected(folder.id) ? "opacity-100" : "opacity-0",
)}
/>
</div>
@@ -211,7 +213,7 @@ export function AccountFilterCombobox({
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
value.includes(account.id) ? "opacity-100" : "opacity-0"
value.includes(account.id) ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
@@ -220,7 +222,7 @@ export function AccountFilterCombobox({
{/* Child folders - recursive */}
{childFoldersList.map((childFolder) =>
renderFolder(childFolder, depth + 1, currentPath)
renderFolder(childFolder, depth + 1, currentPath),
)}
</div>
);
@@ -239,10 +241,15 @@ export function AccountFilterCombobox({
{selectedAccounts.length === 1 ? (
<>
{(() => {
const AccountIcon = accountTypeIcons[selectedAccounts[0].type];
return <AccountIcon className="h-4 w-4 text-muted-foreground shrink-0" />;
const AccountIcon =
accountTypeIcons[selectedAccounts[0].type];
return (
<AccountIcon className="h-4 w-4 text-muted-foreground shrink-0" />
);
})()}
<span className="truncate text-left">{selectedAccounts[0].name}</span>
<span className="truncate text-left">
{selectedAccounts[0].name}
</span>
</>
) : selectedAccounts.length > 1 ? (
<>
@@ -254,7 +261,9 @@ export function AccountFilterCombobox({
) : (
<>
<Wallet className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground truncate text-left">Tous les comptes</span>
<span className="text-muted-foreground truncate text-left">
Tous les comptes
</span>
</>
)}
</div>
@@ -290,15 +299,20 @@ export function AccountFilterCombobox({
<span>Tous les comptes</span>
{filteredTransactions && (
<span className="text-xs text-muted-foreground ml-1">
({formatCurrency(
filteredTransactions.reduce((sum, t) => sum + t.amount, 0)
)})
(
{formatCurrency(
filteredTransactions.reduce(
(sum, t) => sum + t.amount,
0,
),
)}
)
</span>
)}
<Check
className={cn(
"ml-auto h-4 w-4",
isAll ? "opacity-100" : "opacity-0"
isAll ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
@@ -321,7 +335,9 @@ export function AccountFilterCombobox({
className="min-w-0"
>
<AccountIcon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate min-w-0 flex-1">{account.name}</span>
<span className="truncate min-w-0 flex-1">
{account.name}
</span>
{total !== undefined && (
<span className="text-xs text-muted-foreground ml-1 shrink-0">
({formatCurrency(total)})
@@ -330,7 +346,9 @@ export function AccountFilterCombobox({
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
value.includes(account.id) ? "opacity-100" : "opacity-0"
value.includes(account.id)
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>

View File

@@ -115,11 +115,13 @@ export function CategoryCombobox({
onSelect={() => handleSelect(null)}
>
<X className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Aucune catégorie</span>
<span className="text-muted-foreground">
Aucune catégorie
</span>
<Check
className={cn(
"ml-auto h-4 w-4",
value === null ? "opacity-100" : "opacity-0"
value === null ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
@@ -140,7 +142,7 @@ export function CategoryCombobox({
<Check
className={cn(
"ml-auto h-4 w-4",
value === parent.id ? "opacity-100" : "opacity-0"
value === parent.id ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
@@ -160,7 +162,7 @@ export function CategoryCombobox({
<Check
className={cn(
"ml-auto h-4 w-4",
value === child.id ? "opacity-100" : "opacity-0"
value === child.id ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
@@ -183,10 +185,7 @@ export function CategoryCombobox({
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"justify-between",
buttonWidth || "w-full"
)}
className={cn("justify-between", buttonWidth || "w-full")}
>
{selectedCategory ? (
<div className="flex items-center gap-2">
@@ -213,16 +212,13 @@ export function CategoryCombobox({
<CommandList className="max-h-[250px]">
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => handleSelect(null)}
>
<CommandItem value="__none__" onSelect={() => handleSelect(null)}>
<X className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Aucune catégorie</span>
<Check
className={cn(
"ml-auto h-4 w-4",
value === null ? "opacity-100" : "opacity-0"
value === null ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
@@ -243,7 +239,7 @@ export function CategoryCombobox({
<Check
className={cn(
"ml-auto h-4 w-4",
value === parent.id ? "opacity-100" : "opacity-0"
value === parent.id ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
@@ -263,7 +259,7 @@ export function CategoryCombobox({
<Check
className={cn(
"ml-auto h-4 w-4",
value === child.id ? "opacity-100" : "opacity-0"
value === child.id ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
@@ -277,4 +273,3 @@ export function CategoryCombobox({
</Popover>
);
}

View File

@@ -115,7 +115,8 @@ export function CategoryFilterCombobox({
if (isAll) return "Toutes catégories";
if (isUncategorized) return "Non catégorisé";
if (selectedCategories.length === 1) return selectedCategories[0].name;
if (selectedCategories.length > 1) return `${selectedCategories.length} catégories`;
if (selectedCategories.length > 1)
return `${selectedCategories.length} catégories`;
return "Catégorie";
};
@@ -137,7 +138,9 @@ export function CategoryFilterCombobox({
size={16}
className="shrink-0"
/>
<span className="truncate text-left">{selectedCategories[0].name}</span>
<span className="truncate text-left">
{selectedCategories[0].name}
</span>
</>
) : selectedCategories.length > 1 ? (
<>
@@ -150,7 +153,9 @@ export function CategoryFilterCombobox({
/>
))}
</div>
<span className="truncate text-left">{selectedCategories.length} catégories</span>
<span className="truncate text-left">
{selectedCategories.length} catégories
</span>
</>
) : isUncategorized ? (
<>
@@ -160,7 +165,9 @@ export function CategoryFilterCombobox({
) : (
<>
<Tags className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground truncate text-left">{getDisplayValue()}</span>
<span className="text-muted-foreground truncate text-left">
{getDisplayValue()}
</span>
</>
)}
</div>
@@ -191,9 +198,15 @@ export function CategoryFilterCombobox({
<CommandList className="max-h-[300px]">
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
<CommandGroup>
<CommandItem value="all" onSelect={() => handleSelect("all")} className="min-w-0">
<CommandItem
value="all"
onSelect={() => handleSelect("all")}
className="min-w-0"
>
<Tags className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate min-w-0 flex-1">Toutes catégories</span>
<span className="truncate min-w-0 flex-1">
Toutes catégories
</span>
{filteredTransactions && (
<span className="text-xs text-muted-foreground ml-1 shrink-0">
({filteredTransactions.length})
@@ -202,7 +215,7 @@ export function CategoryFilterCombobox({
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
isAll ? "opacity-100" : "opacity-0"
isAll ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
@@ -221,7 +234,7 @@ export function CategoryFilterCombobox({
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
isUncategorized ? "opacity-100" : "opacity-0"
isUncategorized ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
@@ -240,7 +253,9 @@ export function CategoryFilterCombobox({
size={16}
className="shrink-0"
/>
<span className="font-medium truncate min-w-0 flex-1">{parent.name}</span>
<span className="font-medium truncate min-w-0 flex-1">
{parent.name}
</span>
{categoryCounts[parent.id] !== undefined && (
<span className="text-xs text-muted-foreground ml-1 shrink-0">
({categoryCounts[parent.id]})
@@ -249,7 +264,7 @@ export function CategoryFilterCombobox({
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
value.includes(parent.id) ? "opacity-100" : "opacity-0"
value.includes(parent.id) ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
@@ -266,7 +281,9 @@ export function CategoryFilterCombobox({
size={16}
className="shrink-0"
/>
<span className="truncate min-w-0 flex-1">{child.name}</span>
<span className="truncate min-w-0 flex-1">
{child.name}
</span>
{categoryCounts[child.id] !== undefined && (
<span className="text-xs text-muted-foreground ml-1 shrink-0">
({categoryCounts[child.id]})
@@ -275,7 +292,9 @@ export function CategoryFilterCombobox({
<Check
className={cn(
"ml-auto h-4 w-4 shrink-0",
value.includes(child.id) ? "opacity-100" : "opacity-0"
value.includes(child.id)
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>

View File

@@ -20,64 +20,225 @@ import { cn } from "@/lib/utils";
// Group icons by category for better organization
const iconGroups: Record<string, string[]> = {
"Alimentation": [
"shopping-cart", "utensils", "croissant", "coffee", "wine", "beer",
"pizza", "apple", "cherry", "salad", "sandwich", "ice-cream",
"cake", "cup-soda", "milk", "egg", "fish", "beef"
Alimentation: [
"shopping-cart",
"utensils",
"croissant",
"coffee",
"wine",
"beer",
"pizza",
"apple",
"cherry",
"salad",
"sandwich",
"ice-cream",
"cake",
"cup-soda",
"milk",
"egg",
"fish",
"beef",
],
"Transport": [
"fuel", "train", "car", "parking", "bike", "plane", "bus",
"ship", "sailboat", "truck", "car-front", "circle-parking",
"train-front"
Transport: [
"fuel",
"train",
"car",
"parking",
"bike",
"plane",
"bus",
"ship",
"sailboat",
"truck",
"car-front",
"circle-parking",
"train-front",
],
"Logement": [
"home", "zap", "droplet", "hammer", "sofa", "refrigerator",
"washing-machine", "lamp", "lamp-desk", "armchair", "bath",
"shower-head", "door-open", "fence", "trees", "flower",
"leaf", "sun", "snowflake", "wind", "thermometer"
Logement: [
"home",
"zap",
"droplet",
"hammer",
"sofa",
"refrigerator",
"washing-machine",
"lamp",
"lamp-desk",
"armchair",
"bath",
"shower-head",
"door-open",
"fence",
"trees",
"flower",
"leaf",
"sun",
"snowflake",
"wind",
"thermometer",
],
"Santé": [
"pill", "stethoscope", "hospital", "glasses", "dumbbell", "sparkles",
"heart", "heart-pulse", "activity", "syringe", "bandage", "brain",
"eye", "ear", "hand", "footprints", "person-standing"
Santé: [
"pill",
"stethoscope",
"hospital",
"glasses",
"dumbbell",
"sparkles",
"heart",
"heart-pulse",
"activity",
"syringe",
"bandage",
"brain",
"eye",
"ear",
"hand",
"footprints",
"person-standing",
],
"Loisirs": [
"tv", "music", "film", "gamepad", "book", "ticket", "clapperboard",
"headphones", "speaker", "radio", "camera", "image", "palette",
"brush", "pen-tool", "scissors", "drama", "party-popper"
Loisirs: [
"tv",
"music",
"film",
"gamepad",
"book",
"ticket",
"clapperboard",
"headphones",
"speaker",
"radio",
"camera",
"image",
"palette",
"brush",
"pen-tool",
"scissors",
"drama",
"party-popper",
],
"Sport": ["trophy", "medal", "target", "volleyball"],
"Shopping": [
"shirt", "smartphone", "package", "shopping-bag", "store", "gem",
"watch", "sunglasses", "crown", "laptop", "monitor", "keyboard",
"mouse", "printer", "tablet-smartphone", "headset"
Sport: ["trophy", "medal", "target", "volleyball"],
Shopping: [
"shirt",
"smartphone",
"package",
"shopping-bag",
"store",
"gem",
"watch",
"sunglasses",
"crown",
"laptop",
"monitor",
"keyboard",
"mouse",
"printer",
"tablet-smartphone",
"headset",
],
"Services": [
"wifi", "repeat", "landmark", "shield", "receipt", "file-text",
"mail", "phone", "message-square", "send", "globe", "cloud",
"server", "lock", "unlock", "settings", "wrench"
Services: [
"wifi",
"repeat",
"landmark",
"shield",
"receipt",
"file-text",
"mail",
"phone",
"message-square",
"send",
"globe",
"cloud",
"server",
"lock",
"unlock",
"settings",
"wrench",
],
"Finance": [
"piggy-bank", "banknote", "wallet", "hand-coins", "undo", "coins",
"credit-card", "building", "building2", "trending-up", "trending-down",
"bar-chart", "pie-chart", "line-chart", "calculator", "percent",
"dollar-sign", "euro"
Finance: [
"piggy-bank",
"banknote",
"wallet",
"hand-coins",
"undo",
"coins",
"credit-card",
"building",
"building2",
"trending-up",
"trending-down",
"bar-chart",
"pie-chart",
"line-chart",
"calculator",
"percent",
"dollar-sign",
"euro",
],
"Voyage": [
"bed", "luggage", "map", "map-pin", "compass", "mountain",
"tent", "palmtree", "umbrella", "globe2", "flag"
Voyage: [
"bed",
"luggage",
"map",
"map-pin",
"compass",
"mountain",
"tent",
"palmtree",
"umbrella",
"globe2",
"flag",
],
"Famille": [
"graduation-cap", "baby", "paw-print", "users", "user", "user-plus",
"dog", "cat", "bird", "rabbit"
Famille: [
"graduation-cap",
"baby",
"paw-print",
"users",
"user",
"user-plus",
"dog",
"cat",
"bird",
"rabbit",
],
"Autre": [
"heart-handshake", "gift", "cigarette", "arrow-right-left",
"help-circle", "tag", "folder", "key", "star", "bookmark", "clock",
"calendar", "bell", "alert-triangle", "info", "check-circle", "x-circle",
"plus", "minus", "search", "trash", "edit", "download", "upload",
"share", "link", "paperclip", "archive", "box", "boxes", "container",
"briefcase", "education", "award", "lightbulb", "flame", "rocket", "atom"
Autre: [
"heart-handshake",
"gift",
"cigarette",
"arrow-right-left",
"help-circle",
"tag",
"folder",
"key",
"star",
"bookmark",
"clock",
"calendar",
"bell",
"alert-triangle",
"info",
"check-circle",
"x-circle",
"plus",
"minus",
"search",
"trash",
"edit",
"download",
"upload",
"share",
"link",
"paperclip",
"archive",
"box",
"boxes",
"container",
"briefcase",
"education",
"award",
"lightbulb",
"flame",
"rocket",
"atom",
],
};
@@ -102,7 +263,7 @@ export function IconPicker({ value, onChange, color }: IconPickerProps) {
const filtered = icons.filter(
(icon) =>
icon.toLowerCase().includes(query) ||
group.toLowerCase().includes(query)
group.toLowerCase().includes(query),
);
if (filtered.length > 0) {
result[group] = filtered;
@@ -156,7 +317,7 @@ export function IconPicker({ value, onChange, color }: IconPickerProps) {
onClick={() => handleSelect(icon)}
className={cn(
"flex items-center justify-center p-2 rounded-md hover:bg-accent transition-colors",
value === icon && "bg-accent ring-2 ring-primary"
value === icon && "bg-accent ring-2 ring-primary",
)}
title={icon}
>
@@ -172,4 +333,3 @@ export function IconPicker({ value, onChange, color }: IconPickerProps) {
</Popover>
);
}

19
docker-compose.yml Normal file
View File

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

View File

@@ -37,12 +37,14 @@ Si vous déployez sur Vercel, le fichier `vercel.json` configure automatiquement
Pour exécuter les sauvegardes automatiques, vous pouvez :
1. **Utiliser un cron job système** :
```bash
# Exécuter tous les jours à 2h du matin
0 2 * * * cd /chemin/vers/projet && tsx scripts/run-backup.ts
```
2. **Appeler l'endpoint API directement** :
```bash
curl -X POST http://localhost:3000/api/backups/auto
```
@@ -74,6 +76,7 @@ Le système garde automatiquement les 10 sauvegardes les plus récentes. Les sau
⚠️ **Attention** : La restauration d'une sauvegarde remplace complètement votre base de données actuelle. Une sauvegarde de sécurité est créée automatiquement avant la restauration.
Pour restaurer une sauvegarde :
1. Allez dans **Paramètres** → **Sauvegardes automatiques**
2. Cliquez sur l'icône de restauration (flèche circulaire) à côté de la sauvegarde souhaitée
3. Confirmez la restauration
@@ -85,4 +88,3 @@ Pour restaurer une sauvegarde :
- La taille des sauvegardes dépend de la taille de votre base de données
- Les sauvegardes sont stockées localement dans le dossier `prisma/backups/`
- Les métadonnées (nom, taille, date) sont stockées dans la table `Backup` de la base de données

View File

@@ -8,13 +8,22 @@ export function useIsMobile() {
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
const checkMobile = () => {
const mobile = window.innerWidth < MOBILE_BREAKPOINT;
setIsMobile((prev) => {
// Éviter les re-renders inutiles si la valeur n'a pas changé
if (prev === mobile) return prev;
return mobile;
});
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
// Vérification initiale
checkMobile();
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
mql.addEventListener("change", checkMobile);
return () => mql.removeEventListener("change", checkMobile);
}, []);
return !!isMobile;

View File

@@ -6,4 +6,3 @@ import type { Account } from "./types";
export function getAccountBalance(account: Account): number {
return (account.initialBalance || 0) + account.balance;
}

View File

@@ -10,12 +10,8 @@ export async function requireAuth(): Promise<NextResponse | null> {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: "Non authentifié" },
{ status: 401 }
);
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
return null;
}

View File

@@ -3,11 +3,17 @@ import CredentialsProvider from "next-auth/providers/credentials";
import { authService } from "@/services/auth.service";
// Get secret with fallback for development
const secret = process.env.NEXTAUTH_SECRET || "dev-secret-key-change-in-production";
const secret =
process.env.NEXTAUTH_SECRET || "dev-secret-key-change-in-production";
// Debug: log secret status (remove in production)
if (process.env.NODE_ENV === "development") {
console.log("🔐 NextAuth secret:", process.env.NEXTAUTH_SECRET ? "✅ Loaded from .env.local" : "⚠️ Using fallback");
console.log(
"🔐 NextAuth secret:",
process.env.NEXTAUTH_SECRET
? "✅ Loaded from .env.local"
: "⚠️ Using fallback"
);
}
if (!process.env.NEXTAUTH_SECRET && process.env.NODE_ENV === "production") {
@@ -29,7 +35,9 @@ export const authOptions: NextAuthOptions = {
return null;
}
const isValid = await authService.verifyPassword(credentials.password);
const isValid = await authService.verifyPassword(
credentials.password
);
if (!isValid) {
return null;
}
@@ -51,16 +59,29 @@ export const authOptions: NextAuthOptions = {
},
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
maxAge: 24 * 60 * 60, // 24 hours
},
callbacks: {
async jwt({ token, user }) {
// On first sign in, set expiration time
if (user) {
token.id = user.id;
token.exp = Math.floor(Date.now() / 1000) + 24 * 60 * 60; // 24 hours from now
}
// Check if token has expired
if (token.exp && Date.now() >= token.exp * 1000) {
return { ...token, error: "TokenExpired" };
}
return token;
},
async session({ session, token }) {
// If token is expired, return null session
if (token.error === "TokenExpired") {
return null as unknown as typeof session;
}
if (session.user && token.id) {
session.user.id = token.id;
}
@@ -69,4 +90,3 @@ export const authOptions: NextAuthOptions = {
},
secret,
};

View File

@@ -1,8 +1,13 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import type { BankingData } from "./types";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { BankingData, Account } from "./types";
import { loadData } from "./store-db";
import type {
TransactionsPaginatedParams,
TransactionsPaginatedResult,
} from "@/services/banking.service";
export function useBankingData() {
const [data, setData] = useState<BankingData | null>(null);
@@ -75,3 +80,102 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
return [storedValue, setValue] as const;
}
export function useTransactions(
params: TransactionsPaginatedParams = {},
enabled = true,
) {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["transactions", params],
queryFn: async (): Promise<TransactionsPaginatedResult> => {
const searchParams = new URLSearchParams();
if (params.limit) searchParams.set("limit", params.limit.toString());
if (params.offset) searchParams.set("offset", params.offset.toString());
if (params.startDate) searchParams.set("startDate", params.startDate);
if (params.endDate) searchParams.set("endDate", params.endDate);
if (params.accountIds && params.accountIds.length > 0) {
searchParams.set("accountIds", params.accountIds.join(","));
}
if (params.categoryIds && params.categoryIds.length > 0) {
searchParams.set("categoryIds", params.categoryIds.join(","));
}
if (params.includeUncategorized) {
searchParams.set("includeUncategorized", "true");
}
if (params.search) searchParams.set("search", params.search);
if (params.isReconciled !== undefined && params.isReconciled !== "all") {
searchParams.set(
"isReconciled",
params.isReconciled === true ? "true" : "false",
);
}
if (params.sortField) searchParams.set("sortField", params.sortField);
if (params.sortOrder) searchParams.set("sortOrder", params.sortOrder);
const response = await fetch(`/api/banking/transactions?${searchParams}`);
if (!response.ok) {
throw new Error("Failed to fetch transactions");
}
return response.json();
},
enabled,
staleTime: 30 * 1000, // 30 seconds
});
const invalidate = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["transactions"] });
}, [queryClient]);
return {
...query,
invalidate,
};
}
export function useCategoryStats() {
return useQuery({
queryKey: ["category-stats"],
queryFn: async (): Promise<
Record<string, { count: number; total: number }>
> => {
const response = await fetch("/api/banking/categories?statsOnly=true");
if (!response.ok) {
throw new Error("Failed to fetch category stats");
}
return response.json();
},
staleTime: 60 * 1000, // 1 minute
});
}
export function useBankingMetadata() {
return useQuery({
queryKey: ["banking-metadata"],
queryFn: async () => {
const response = await fetch("/api/banking?metadataOnly=true");
if (!response.ok) {
throw new Error("Failed to fetch banking metadata");
}
return response.json();
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useAccountsWithStats() {
return useQuery({
queryKey: ["accounts-with-stats"],
queryFn: async () => {
const response = await fetch("/api/banking/accounts?withStats=true");
if (!response.ok) {
throw new Error("Failed to fetch accounts with stats");
}
return response.json() as Promise<
Array<Account & { transactionCount: number }>
>;
},
staleTime: 60 * 1000, // 1 minute
});
}

View File

@@ -19,4 +19,3 @@ export const config = {
"/((?!api/auth|login|_next/static|_next/image|favicon.ico).*)",
],
};

View File

@@ -6,6 +6,7 @@ const nextConfig = {
images: {
unoptimized: true,
},
output: "standalone",
};
export default nextConfig;

View File

@@ -6,6 +6,8 @@
"build": "next build",
"dev": "next dev",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"start": "next start",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
@@ -49,6 +51,7 @@
"@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-virtual": "^3.13.12",
"@vercel/analytics": "1.3.1",
"autoprefixer": "^10.4.20",
@@ -60,7 +63,7 @@
"embla-carousel-react": "8.5.1",
"input-otp": "1.4.1",
"lucide-react": "^0.454.0",
"next": "16.0.3",
"next": "16.0.7",
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
"prisma": "^5.22.0",

6760
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,8 @@ model Transaction {
@@index([accountId])
@@index([categoryId])
@@index([date])
@@index([accountId, date])
@@index([isReconciled])
}
model Folder {

View File

@@ -1,8 +1,8 @@
import * as fs from 'fs';
import * as path from 'path';
import { prisma } from '../lib/prisma';
import { transactionService } from '../services/transaction.service';
import { generateId } from '../lib/store-db';
import * as fs from "fs";
import * as path from "path";
import { prisma } from "../lib/prisma";
import { transactionService } from "../services/transaction.service";
import { generateId } from "../lib/store-db";
interface CSVTransaction {
date: string;
@@ -22,7 +22,7 @@ interface CSVTransaction {
function parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = '';
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
@@ -30,9 +30,9 @@ function parseCSVLine(line: string): string[] {
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
} else if (char === "," && !inQuotes) {
result.push(current.trim());
current = '';
current = "";
} else {
current += char;
}
@@ -43,8 +43,8 @@ function parseCSVLine(line: string): string[] {
}
function parseCSV(csvPath: string): CSVTransaction[] {
const content = fs.readFileSync(csvPath, 'utf-8');
const lines = content.split('\n');
const content = fs.readFileSync(csvPath, "utf-8");
const lines = content.split("\n");
// Skip header lines (first 8 lines)
const dataLines = lines.slice(8);
@@ -82,62 +82,72 @@ function parseCSV(csvPath: string): CSVTransaction[] {
function parseDate(dateStr: string): string {
// Format: DD/MM/YYYY -> YYYY-MM-DD
const [day, month, year] = dateStr.split('/');
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
const [day, month, year] = dateStr.split("/");
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
}
function parseAmount(amountStr: string): number {
if (!amountStr || amountStr.trim() === '' || amountStr === '""') {
if (!amountStr || amountStr.trim() === "" || amountStr === '""') {
return 0;
}
// Remove quotes, spaces (including non-breaking spaces), and replace comma with dot
const cleaned = amountStr.replace(/["\s\u00A0]/g, '').replace(',', '.');
const cleaned = amountStr.replace(/["\s\u00A0]/g, "").replace(",", ".");
const parsed = parseFloat(cleaned);
return isNaN(parsed) ? 0 : parsed;
}
function generateFITID(transaction: CSVTransaction, index: number): string {
const date = parseDate(transaction.date);
const dateStr = date.replace(/-/g, '');
const amountStr = Math.abs(parseAmount(transaction.amount)).toFixed(2).replace('.', '');
const libelleHash = transaction.libelle.substring(0, 20).replace(/[^A-Z0-9]/gi, '');
const dateStr = date.replace(/-/g, "");
const amountStr = Math.abs(parseAmount(transaction.amount))
.toFixed(2)
.replace(".", "");
const libelleHash = transaction.libelle
.substring(0, 20)
.replace(/[^A-Z0-9]/gi, "");
return `${dateStr}-${amountStr}-${libelleHash}-${index}`;
}
function removeAccountPrefix(accountName: string): string {
// Remove prefixes: LivretA, LDDS, CCP, PEL (case insensitive)
const prefixes = ['LivretA', 'Livret A', 'LDDS', 'CCP', 'PEL'];
const prefixes = ["LivretA", "Livret A", "LDDS", "CCP", "PEL"];
let cleaned = accountName;
for (const prefix of prefixes) {
// Remove prefix followed by optional spaces and dashes
const regex = new RegExp(`^${prefix}\\s*-?\\s*`, 'i');
cleaned = cleaned.replace(regex, '');
const regex = new RegExp(`^${prefix}\\s*-?\\s*`, "i");
cleaned = cleaned.replace(regex, "");
}
return cleaned.trim();
}
function determineAccountType(accountName: string): "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER" {
function determineAccountType(
accountName: string,
): "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER" {
const upper = accountName.toUpperCase();
if (upper.includes('LIVRET') || upper.includes('LDDS') || upper.includes('PEL')) {
return 'SAVINGS';
if (
upper.includes("LIVRET") ||
upper.includes("LDDS") ||
upper.includes("PEL")
) {
return "SAVINGS";
}
if (upper.includes('CCP') || upper.includes('COMPTE COURANT')) {
return 'CHECKING';
if (upper.includes("CCP") || upper.includes("COMPTE COURANT")) {
return "CHECKING";
}
return 'OTHER';
return "OTHER";
}
async function main() {
const csvPath = path.join(__dirname, '../temp/all account.csv');
const csvPath = path.join(__dirname, "../temp/all account.csv");
if (!fs.existsSync(csvPath)) {
console.error(`Fichier CSV introuvable: ${csvPath}`);
process.exit(1);
}
console.log('Lecture du fichier CSV...');
console.log("Lecture du fichier CSV...");
const csvTransactions = parseCSV(csvPath);
console.log(`${csvTransactions.length} transactions trouvées`);
@@ -167,8 +177,10 @@ async function main() {
// Remove prefixes and extract account number from account name
const cleanedAccountName = removeAccountPrefix(accountName);
const accountNumber = cleanedAccountName.replace(/[^A-Z0-9]/gi, '').substring(0, 22);
const bankId = transactions[0]?.codeBanque || 'FR';
const accountNumber = cleanedAccountName
.replace(/[^A-Z0-9]/gi, "")
.substring(0, 22);
const bankId = transactions[0]?.codeBanque || "FR";
console.log(` Numéro de compte extrait: ${accountNumber}`);
@@ -203,7 +215,7 @@ async function main() {
// Try to find exact match in accountNumber (after cleaning)
for (const acc of allAccounts) {
const cleanedExisting = removeAccountPrefix(acc.accountNumber);
const existingNumber = cleanedExisting.replace(/[^A-Z0-9]/gi, '');
const existingNumber = cleanedExisting.replace(/[^A-Z0-9]/gi, "");
if (existingNumber === accountNumber) {
account = acc;
break;
@@ -221,14 +233,16 @@ async function main() {
type: determineAccountType(accountName),
folderId: null,
balance: 0,
currency: 'EUR',
currency: "EUR",
lastImport: null,
externalUrl: null,
},
});
totalAccountsCreated++;
} else {
console.log(` → Compte existant trouvé: ${account.name} (${account.accountNumber})`);
console.log(
` → Compte existant trouvé: ${account.name} (${account.accountNumber})`,
);
totalAccountsUpdated++;
}
@@ -261,11 +275,16 @@ async function main() {
}
if (duplicatesCount > 0) {
console.log(`${duplicatesCount} doublons détectés et ignorés (même date, montant, libellé)`);
console.log(
`${duplicatesCount} doublons détectés et ignorés (même date, montant, libellé)`,
);
}
// Calculate balance from unique transactions
const balance = uniqueTransactions.reduce((sum, t) => sum + parseAmount(t.amount), 0);
const balance = uniqueTransactions.reduce(
(sum, t) => sum + parseAmount(t.amount),
0,
);
// Prepare transactions for insertion
const dbTransactions = uniqueTransactions.map((transaction, index) => {
@@ -290,7 +309,7 @@ async function main() {
date: date,
amount: amount,
description: transaction.libelle.substring(0, 255),
type: amount >= 0 ? 'CREDIT' as const : 'DEBIT' as const,
type: amount >= 0 ? ("CREDIT" as const) : ("DEBIT" as const),
categoryId: null, // Will be auto-categorized later if needed
isReconciled: false,
fitId: generateFITID(transaction, index),
@@ -316,17 +335,16 @@ async function main() {
console.log(` ✓ Solde mis à jour: ${balance.toFixed(2)} EUR\n`);
}
console.log('\n=== Résumé ===');
console.log("\n=== Résumé ===");
console.log(`Comptes créés: ${totalAccountsCreated}`);
console.log(`Comptes mis à jour: ${totalAccountsUpdated}`);
console.log(`Transactions insérées: ${totalTransactionsCreated}`);
console.log('\n✓ Import terminé!');
console.log("\n✓ Import terminé!");
await prisma.$disconnect();
}
main().catch((error) => {
console.error('Erreur:', error);
console.error("Erreur:", error);
process.exit(1);
});

View File

@@ -10,5 +10,3 @@ console.log(" - parentId: null");
console.log(" - color: #6366f1");
console.log(" - icon: folder");
console.log("3. Créez les catégories par défaut via l'interface web");

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