Compare commits
10 Commits
34874aae86
...
a7f3433f5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f3433f5f | ||
|
|
ad8b936c7a | ||
|
|
b1a8f9cd60 | ||
|
|
e26eb0f039 | ||
|
|
c4fe288193 | ||
|
|
ec387d5e2b | ||
|
|
e715779de7 | ||
|
|
757b1b84ab | ||
|
|
b3b25412ad | ||
|
|
86236aeb04 |
19
.dockerignore
Normal file
19
.dockerignore
Normal 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
67
Dockerfile
Normal 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"]
|
||||||
|
|
||||||
@@ -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
|
Développé avec ❤️ en utilisant Next.js et React
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,16 @@ import {
|
|||||||
AccountEditDialog,
|
AccountEditDialog,
|
||||||
AccountBulkActions,
|
AccountBulkActions,
|
||||||
} from "@/components/accounts";
|
} from "@/components/accounts";
|
||||||
|
import { FolderEditDialog } from "@/components/folders";
|
||||||
|
import { useBankingMetadata, useAccountsWithStats } from "@/lib/hooks";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
FolderEditDialog,
|
updateAccount,
|
||||||
} from "@/components/folders";
|
deleteAccount,
|
||||||
import { useBankingData } from "@/lib/hooks";
|
addFolder,
|
||||||
import { updateAccount, deleteAccount, addFolder, updateFolder, deleteFolder } from "@/lib/store-db";
|
updateFolder,
|
||||||
|
deleteFolder,
|
||||||
|
} from "@/lib/store-db";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Building2, Folder, Plus, List, LayoutGrid } from "lucide-react";
|
import { Building2, Folder, Plus, List, LayoutGrid } from "lucide-react";
|
||||||
@@ -46,7 +51,7 @@ function FolderDropZone({
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn(
|
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}
|
{children}
|
||||||
@@ -55,7 +60,17 @@ function FolderDropZone({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AccountsPage() {
|
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 [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [selectedAccounts, setSelectedAccounts] = useState<Set<string>>(
|
const [selectedAccounts, setSelectedAccounts] = useState<Set<string>>(
|
||||||
@@ -85,13 +100,23 @@ export default function AccountsPage() {
|
|||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
distance: 8,
|
distance: 8,
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (
|
||||||
|
isLoadingMetadata ||
|
||||||
|
!metadata ||
|
||||||
|
isLoadingAccounts ||
|
||||||
|
!accountsWithStats
|
||||||
|
) {
|
||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert accountsWithStats to regular accounts for compatibility
|
||||||
|
const accounts = accountsWithStats.map(
|
||||||
|
({ transactionCount: _transactionCount, ...account }) => account,
|
||||||
|
);
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
@@ -124,7 +149,8 @@ export default function AccountsPage() {
|
|||||||
initialBalance: formData.initialBalance,
|
initialBalance: formData.initialBalance,
|
||||||
};
|
};
|
||||||
await updateAccount(updatedAccount);
|
await updateAccount(updatedAccount);
|
||||||
refresh();
|
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
setEditingAccount(null);
|
setEditingAccount(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -138,7 +164,8 @@ export default function AccountsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteAccount(accountId);
|
await deleteAccount(accountId);
|
||||||
refresh();
|
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting account:", error);
|
console.error("Error deleting account:", error);
|
||||||
alert("Erreur lors de la suppression du compte");
|
alert("Erreur lors de la suppression du compte");
|
||||||
@@ -166,7 +193,8 @@ export default function AccountsPage() {
|
|||||||
throw new Error("Failed to delete accounts");
|
throw new Error("Failed to delete accounts");
|
||||||
}
|
}
|
||||||
setSelectedAccounts(new Set());
|
setSelectedAccounts(new Set());
|
||||||
refresh();
|
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting accounts:", error);
|
console.error("Error deleting accounts:", error);
|
||||||
alert("Erreur lors de la suppression des comptes");
|
alert("Erreur lors de la suppression des comptes");
|
||||||
@@ -202,7 +230,9 @@ export default function AccountsPage() {
|
|||||||
|
|
||||||
const handleSaveFolder = async () => {
|
const handleSaveFolder = async () => {
|
||||||
const parentId =
|
const parentId =
|
||||||
folderFormData.parentId === "folder-root" ? null : folderFormData.parentId;
|
folderFormData.parentId === "folder-root"
|
||||||
|
? null
|
||||||
|
: folderFormData.parentId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingFolder) {
|
if (editingFolder) {
|
||||||
@@ -220,7 +250,8 @@ export default function AccountsPage() {
|
|||||||
icon: "folder",
|
icon: "folder",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
refresh();
|
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||||
setIsFolderDialogOpen(false);
|
setIsFolderDialogOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving folder:", error);
|
console.error("Error saving folder:", error);
|
||||||
@@ -231,14 +262,15 @@ export default function AccountsPage() {
|
|||||||
const handleDeleteFolder = async (folderId: string) => {
|
const handleDeleteFolder = async (folderId: string) => {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
"Supprimer ce dossier ? Les comptes seront déplacés à la racine."
|
"Supprimer ce dossier ? Les comptes seront déplacés à la racine.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteFolder(folderId);
|
await deleteFolder(folderId);
|
||||||
refresh();
|
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting folder:", error);
|
console.error("Error deleting folder:", error);
|
||||||
alert("Erreur lors de la suppression du dossier");
|
alert("Erreur lors de la suppression du dossier");
|
||||||
@@ -254,7 +286,7 @@ export default function AccountsPage() {
|
|||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
setActiveId(null);
|
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 activeId = active.id as string;
|
||||||
const overId = over.id as string;
|
const overId = over.id as string;
|
||||||
@@ -270,59 +302,60 @@ export default function AccountsPage() {
|
|||||||
} else if (overId.startsWith("account-")) {
|
} else if (overId.startsWith("account-")) {
|
||||||
// Déplacer vers le dossier du compte cible
|
// Déplacer vers le dossier du compte cible
|
||||||
const targetAccountId = overId.replace("account-", "");
|
const targetAccountId = overId.replace("account-", "");
|
||||||
const targetAccount = data.accounts.find((a) => a.id === targetAccountId);
|
const targetAccount = accountsWithStats.find(
|
||||||
|
(a) => a.id === targetAccountId,
|
||||||
|
);
|
||||||
if (targetAccount) {
|
if (targetAccount) {
|
||||||
targetFolderId = targetAccount.folderId;
|
targetFolderId = targetAccount.folderId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetFolderId !== undefined) {
|
if (targetFolderId !== undefined) {
|
||||||
const account = data.accounts.find((a) => a.id === accountId);
|
const account = accountsWithStats.find((a) => a.id === accountId);
|
||||||
if (!account) return;
|
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
|
// Optimistic update : mettre à jour immédiatement l'interface
|
||||||
const updatedAccount = {
|
const updatedAccount = {
|
||||||
...account,
|
...account,
|
||||||
folderId: targetFolderId,
|
folderId: targetFolderId,
|
||||||
};
|
};
|
||||||
const updatedAccounts = data.accounts.map((a) =>
|
// Update cache directly
|
||||||
a.id === accountId ? updatedAccount : a
|
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
|
// Faire la requête en arrière-plan
|
||||||
try {
|
try {
|
||||||
await updateAccount(updatedAccount);
|
await updateAccount(updatedAccount);
|
||||||
// Refresh silencieux pour synchroniser avec le serveur sans loader
|
// Refresh silencieux pour synchroniser avec le serveur sans loader
|
||||||
refreshSilent();
|
await refreshSilent();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error moving account:", error);
|
console.error("Error moving account:", error);
|
||||||
// Rollback en cas d'erreur
|
// Rollback en cas d'erreur - refresh data
|
||||||
update(previousData);
|
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||||
alert("Erreur lors du déplacement du compte");
|
alert("Erreur lors du déplacement du compte");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const getTransactionCount = (accountId: string) => {
|
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),
|
(sum, a) => sum + getAccountBalance(a),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Grouper les comptes par folder
|
// Grouper les comptes par folder
|
||||||
const accountsByFolder = data.accounts.reduce(
|
const accountsByFolder = accounts.reduce(
|
||||||
(acc, account) => {
|
(acc, account) => {
|
||||||
const folderId = account.folderId || "no-folder";
|
const folderId = account.folderId || "no-folder";
|
||||||
if (!acc[folderId]) {
|
if (!acc[folderId]) {
|
||||||
@@ -335,9 +368,9 @@ export default function AccountsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Obtenir les folders racine (sans parent) et les trier par nom
|
// Obtenir les folders racine (sans parent) et les trier par nom
|
||||||
const rootFolders = data.folders
|
const rootFolders = metadata.folders
|
||||||
.filter((f) => !f.parentId)
|
.filter((f: FolderType) => !f.parentId)
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a: FolderType, b: FolderType) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
@@ -370,7 +403,7 @@ export default function AccountsPage() {
|
|||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl font-bold",
|
"text-2xl font-bold",
|
||||||
totalBalance >= 0 ? "text-emerald-600" : "text-red-600"
|
totalBalance >= 0 ? "text-emerald-600" : "text-red-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(totalBalance)}
|
{formatCurrency(totalBalance)}
|
||||||
@@ -379,7 +412,7 @@ export default function AccountsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{data.accounts.length === 0 ? (
|
{accounts.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<Building2 className="w-16 h-16 text-muted-foreground mb-4" />
|
<Building2 className="w-16 h-16 text-muted-foreground mb-4" />
|
||||||
@@ -434,32 +467,32 @@ export default function AccountsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{accountsByFolder["no-folder"].map((account) => {
|
{accountsByFolder["no-folder"].map((account) => {
|
||||||
const folder = data.folders.find(
|
const folder = metadata.folders.find(
|
||||||
(f) => f.id === account.folderId,
|
(f: FolderType) => f.id === account.folderId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccountCard
|
<AccountCard
|
||||||
key={account.id}
|
key={account.id}
|
||||||
account={account}
|
account={account}
|
||||||
folder={folder}
|
folder={folder}
|
||||||
transactionCount={getTransactionCount(account.id)}
|
transactionCount={getTransactionCount(account.id)}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
isSelected={selectedAccounts.has(account.id)}
|
isSelected={selectedAccounts.has(account.id)}
|
||||||
onSelect={toggleSelectAccount}
|
onSelect={toggleSelectAccount}
|
||||||
draggableId={`account-${account.id}`}
|
draggableId={`account-${account.id}`}
|
||||||
compact={isCompactView}
|
compact={isCompactView}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</FolderDropZone>
|
</FolderDropZone>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Afficher les comptes groupés par folder */}
|
{/* Afficher les comptes groupés par folder */}
|
||||||
{rootFolders.map((folder) => {
|
{rootFolders.map((folder: FolderType) => {
|
||||||
const folderAccounts = accountsByFolder[folder.id] || [];
|
const folderAccounts = accountsByFolder[folder.id] || [];
|
||||||
const folderBalance = folderAccounts.reduce(
|
const folderBalance = folderAccounts.reduce(
|
||||||
(sum, a) => sum + getAccountBalance(a),
|
(sum, a) => sum + getAccountBalance(a),
|
||||||
@@ -514,8 +547,8 @@ export default function AccountsPage() {
|
|||||||
{folderAccounts.length > 0 ? (
|
{folderAccounts.length > 0 ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{folderAccounts.map((account) => {
|
{folderAccounts.map((account) => {
|
||||||
const accountFolder = data.folders.find(
|
const accountFolder = metadata.folders.find(
|
||||||
(f) => f.id === account.folderId,
|
(f: FolderType) => f.id === account.folderId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -558,8 +591,8 @@ export default function AccountsPage() {
|
|||||||
{activeId.startsWith("account-") ? (
|
{activeId.startsWith("account-") ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
{data.accounts.find(
|
{accounts.find(
|
||||||
(a) => a.id === activeId.replace("account-", "")
|
(a) => a.id === activeId.replace("account-", ""),
|
||||||
)?.name || ""}
|
)?.name || ""}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -576,7 +609,7 @@ export default function AccountsPage() {
|
|||||||
onOpenChange={setIsDialogOpen}
|
onOpenChange={setIsDialogOpen}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
onFormDataChange={setFormData}
|
onFormDataChange={setFormData}
|
||||||
folders={data.folders}
|
folders={metadata.folders}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -586,7 +619,7 @@ export default function AccountsPage() {
|
|||||||
editingFolder={editingFolder}
|
editingFolder={editingFolder}
|
||||||
formData={folderFormData}
|
formData={folderFormData}
|
||||||
onFormDataChange={setFolderFormData}
|
onFormDataChange={setFolderFormData}
|
||||||
folders={data.folders}
|
folders={metadata.folders}
|
||||||
onSave={handleSaveFolder}
|
onSave={handleSaveFolder}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ import { authOptions } from "@/lib/auth";
|
|||||||
const handler = NextAuth(authOptions);
|
const handler = NextAuth(authOptions);
|
||||||
|
|
||||||
export { handler as GET, handler as POST };
|
export { handler as GET, handler as POST };
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "Non authentifié" },
|
{ success: false, error: "Non authentifié" },
|
||||||
{ status: 401 }
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,14 +20,17 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!oldPassword || !newPassword) {
|
if (!oldPassword || !newPassword) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "Mot de passe requis" },
|
{ success: false, error: "Mot de passe requis" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 4) {
|
if (newPassword.length < 4) {
|
||||||
return NextResponse.json(
|
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) {
|
if (!result.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: result.error },
|
{ 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);
|
console.error("Error changing password:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "Erreur lors du changement de mot de passe" },
|
{ success: false, error: "Erreur lors du changement de mot de passe" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { requireAuth } from "@/lib/auth-utils";
|
|||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> | { id: string } }
|
{ params }: { params: Promise<{ id: string }> | { id: string } },
|
||||||
) {
|
) {
|
||||||
const authError = await requireAuth();
|
const authError = await requireAuth();
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
@@ -15,9 +15,12 @@ export async function POST(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error restoring backup:", error);
|
console.error("Error restoring backup:", error);
|
||||||
return NextResponse.json(
|
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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { requireAuth } from "@/lib/auth-utils";
|
|||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> | { id: string } }
|
{ params }: { params: Promise<{ id: string }> | { id: string } },
|
||||||
) {
|
) {
|
||||||
const authError = await requireAuth();
|
const authError = await requireAuth();
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
@@ -15,9 +15,12 @@ export async function DELETE(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting backup:", error);
|
console.error("Error deleting backup:", error);
|
||||||
return NextResponse.json(
|
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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,12 @@ export async function POST(_request: NextRequest) {
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export async function GET() {
|
|||||||
console.error("Error fetching backups:", error);
|
console.error("Error fetching backups:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "Failed to fetch backups" },
|
{ success: false, error: "Failed to fetch backups" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,9 +31,12 @@ export async function POST(request: NextRequest) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating backup:", error);
|
console.error("Error creating backup:", error);
|
||||||
return NextResponse.json(
|
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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function GET() {
|
|||||||
console.error("Error fetching backup settings:", error);
|
console.error("Error fetching backup settings:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "Failed to fetch settings" },
|
{ 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);
|
console.error("Error updating backup settings:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "Failed to update settings" },
|
{ success: false, error: "Failed to update settings" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { accountService } from "@/services/account.service";
|
import { accountService } from "@/services/account.service";
|
||||||
|
import { bankingService } from "@/services/banking.service";
|
||||||
import { requireAuth } from "@/lib/auth-utils";
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
import type { Account } from "@/lib/types";
|
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) {
|
export async function POST(request: Request) {
|
||||||
const authError = await requireAuth();
|
const authError = await requireAuth();
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { categoryService } from "@/services/category.service";
|
import { categoryService } from "@/services/category.service";
|
||||||
|
import { bankingService } from "@/services/banking.service";
|
||||||
import { requireAuth } from "@/lib/auth-utils";
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
import type { Category } from "@/lib/types";
|
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) {
|
export async function POST(request: Request) {
|
||||||
const authError = await requireAuth();
|
const authError = await requireAuth();
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { bankingService } from "@/services/banking.service";
|
import { bankingService } from "@/services/banking.service";
|
||||||
import { requireAuth } from "@/lib/auth-utils";
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
const authError = await requireAuth();
|
const authError = await requireAuth();
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
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();
|
const data = await bankingService.getAllData();
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching banking data:", error);
|
console.error("Error fetching banking data:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -27,5 +27,3 @@ export async function POST() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ export async function POST() {
|
|||||||
console.error("Error deduplicating transactions:", error);
|
console.error("Error deduplicating transactions:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to deduplicate transactions" },
|
{ error: "Failed to deduplicate transactions" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,73 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { transactionService } from "@/services/transaction.service";
|
import { transactionService } from "@/services/transaction.service";
|
||||||
|
import { bankingService } from "@/services/banking.service";
|
||||||
import { requireAuth } from "@/lib/auth-utils";
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
import type { Transaction } from "@/lib/types";
|
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) {
|
export async function POST(request: Request) {
|
||||||
const authError = await requireAuth();
|
const authError = await requireAuth();
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||||
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
||||||
import {
|
import {
|
||||||
CategoryCard,
|
CategoryCard,
|
||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
ParentCategoryRow,
|
ParentCategoryRow,
|
||||||
CategorySearchBar,
|
CategorySearchBar,
|
||||||
} from "@/components/categories";
|
} 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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -34,11 +35,13 @@ interface RecategorizationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
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 [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||||
const [expandedParents, setExpandedParents] = useState<Set<string>>(
|
const [expandedParents, setExpandedParents] = useState<Set<string>>(
|
||||||
new Set()
|
new Set(),
|
||||||
);
|
);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -48,28 +51,34 @@ export default function CategoriesPage() {
|
|||||||
parentId: null as string | null,
|
parentId: null as string | null,
|
||||||
});
|
});
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [recatResults, setRecatResults] = useState<RecategorizationResult[]>([]);
|
const [recatResults, setRecatResults] = useState<RecategorizationResult[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false);
|
const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false);
|
||||||
const [isRecategorizing, setIsRecategorizing] = useState(false);
|
const [isRecategorizing, setIsRecategorizing] = useState(false);
|
||||||
|
|
||||||
// Organiser les catégories par parent
|
// Organiser les catégories par parent
|
||||||
const { parentCategories, childrenByParent, orphanCategories } =
|
const { parentCategories, childrenByParent, orphanCategories } =
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (!data?.categories)
|
if (!metadata?.categories)
|
||||||
return {
|
return {
|
||||||
parentCategories: [],
|
parentCategories: [],
|
||||||
childrenByParent: {},
|
childrenByParent: {},
|
||||||
orphanCategories: [],
|
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 children: Record<string, Category[]> = {};
|
||||||
const orphans: Category[] = [];
|
const orphans: Category[] = [];
|
||||||
|
|
||||||
data.categories
|
metadata.categories
|
||||||
.filter((c) => c.parentId !== null)
|
.filter((c: Category) => c.parentId !== null)
|
||||||
.forEach((child) => {
|
.forEach((child: Category) => {
|
||||||
const parentExists = parents.some((p) => p.id === child.parentId);
|
const parentExists = parents.some(
|
||||||
|
(p: Category) => p.id === child.parentId,
|
||||||
|
);
|
||||||
if (parentExists) {
|
if (parentExists) {
|
||||||
if (!children[child.parentId!]) {
|
if (!children[child.parentId!]) {
|
||||||
children[child.parentId!] = [];
|
children[child.parentId!] = [];
|
||||||
@@ -85,16 +94,52 @@ export default function CategoriesPage() {
|
|||||||
childrenByParent: children,
|
childrenByParent: children,
|
||||||
orphanCategories: orphans,
|
orphanCategories: orphans,
|
||||||
};
|
};
|
||||||
}, [data?.categories]);
|
}, [metadata?.categories]);
|
||||||
|
|
||||||
// Initialiser tous les parents comme ouverts
|
// Initialiser tous les parents comme ouverts
|
||||||
useState(() => {
|
useEffect(() => {
|
||||||
if (parentCategories.length > 0 && expandedParents.size === 0) {
|
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) {
|
const refresh = useCallback(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["category-stats"] });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const getCategoryStats = useCallback(
|
||||||
|
(categoryId: string, includeChildren = false) => {
|
||||||
|
if (!categoryStats) return { total: 0, count: 0 };
|
||||||
|
|
||||||
|
let categoryIds = [categoryId];
|
||||||
|
|
||||||
|
if (includeChildren && childrenByParent[categoryId]) {
|
||||||
|
categoryIds = [
|
||||||
|
...categoryIds,
|
||||||
|
...childrenByParent[categoryId].map((c) => c.id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,27 +150,6 @@ export default function CategoriesPage() {
|
|||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCategoryStats = (categoryId: string, includeChildren = false) => {
|
|
||||||
let categoryIds = [categoryId];
|
|
||||||
|
|
||||||
if (includeChildren && childrenByParent[categoryId]) {
|
|
||||||
categoryIds = [
|
|
||||||
...categoryIds,
|
|
||||||
...childrenByParent[categoryId].map((c) => c.id),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
return { total, count };
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleExpanded = (parentId: string) => {
|
const toggleExpanded = (parentId: string) => {
|
||||||
const newExpanded = new Set(expandedParents);
|
const newExpanded = new Set(expandedParents);
|
||||||
if (newExpanded.has(parentId)) {
|
if (newExpanded.has(parentId)) {
|
||||||
@@ -137,7 +161,7 @@ export default function CategoriesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const expandAll = () => {
|
const expandAll = () => {
|
||||||
setExpandedParents(new Set(parentCategories.map((p) => p.id)));
|
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const collapseAll = () => {
|
const collapseAll = () => {
|
||||||
@@ -150,7 +174,13 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
const handleNewCategory = (parentId: string | null = null) => {
|
const handleNewCategory = (parentId: string | null = null) => {
|
||||||
setEditingCategory(null);
|
setEditingCategory(null);
|
||||||
setFormData({ name: "", color: "#22c55e", icon: "tag", keywords: [], parentId });
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
color: "#22c55e",
|
||||||
|
icon: "tag",
|
||||||
|
keywords: [],
|
||||||
|
parentId,
|
||||||
|
});
|
||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -216,16 +246,27 @@ export default function CategoriesPage() {
|
|||||||
const results: RecategorizationResult[] = [];
|
const results: RecategorizationResult[] = [];
|
||||||
|
|
||||||
try {
|
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 { updateTransaction } = await import("@/lib/store-db");
|
||||||
const uncategorized = data.transactions.filter((t) => !t.categoryId);
|
|
||||||
|
|
||||||
for (const transaction of uncategorized) {
|
for (const transaction of uncategorized) {
|
||||||
const categoryId = autoCategorize(
|
const categoryId = autoCategorize(
|
||||||
transaction.description + " " + (transaction.memo || ""),
|
transaction.description + " " + (transaction.memo || ""),
|
||||||
data.categories
|
metadata.categories,
|
||||||
);
|
);
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
const category = data.categories.find((c) => c.id === categoryId);
|
const category = metadata.categories.find(
|
||||||
|
(c: Category) => c.id === categoryId,
|
||||||
|
);
|
||||||
if (category) {
|
if (category) {
|
||||||
results.push({ transaction, category });
|
results.push({ transaction, category });
|
||||||
await updateTransaction({ ...transaction, categoryId });
|
await updateTransaction({ ...transaction, categoryId });
|
||||||
@@ -244,30 +285,30 @@ export default function CategoriesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const uncategorizedCount = data.transactions.filter(
|
const uncategorizedCount = categoryStats["uncategorized"]?.count || 0;
|
||||||
(t) => !t.categoryId
|
|
||||||
).length;
|
|
||||||
|
|
||||||
// Filtrer les catégories selon la recherche
|
// Filtrer les catégories selon la recherche
|
||||||
const filteredParentCategories = parentCategories.filter((parent) => {
|
const filteredParentCategories = parentCategories.filter(
|
||||||
if (!searchQuery.trim()) return true;
|
(parent: Category) => {
|
||||||
const query = searchQuery.toLowerCase();
|
if (!searchQuery.trim()) return true;
|
||||||
if (parent.name.toLowerCase().includes(query)) return true;
|
const query = searchQuery.toLowerCase();
|
||||||
if (parent.keywords.some((k) => k.toLowerCase().includes(query)))
|
if (parent.name.toLowerCase().includes(query)) return true;
|
||||||
return true;
|
if (parent.keywords.some((k: string) => k.toLowerCase().includes(query)))
|
||||||
const children = childrenByParent[parent.id] || [];
|
return true;
|
||||||
return children.some(
|
const children = childrenByParent[parent.id] || [];
|
||||||
(c) =>
|
return children.some(
|
||||||
c.name.toLowerCase().includes(query) ||
|
(c) =>
|
||||||
c.keywords.some((k) => k.toLowerCase().includes(query))
|
c.name.toLowerCase().includes(query) ||
|
||||||
);
|
c.keywords.some((k) => k.toLowerCase().includes(query)),
|
||||||
});
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Catégories"
|
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={
|
actions={
|
||||||
<>
|
<>
|
||||||
{uncategorizedCount > 0 && (
|
{uncategorizedCount > 0 && (
|
||||||
@@ -298,16 +339,16 @@ export default function CategoriesPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{filteredParentCategories.map((parent) => {
|
{filteredParentCategories.map((parent: Category) => {
|
||||||
const allChildren = childrenByParent[parent.id] || [];
|
const allChildren = childrenByParent[parent.id] || [];
|
||||||
const children = searchQuery.trim()
|
const children = searchQuery.trim()
|
||||||
? allChildren.filter(
|
? allChildren.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
c.keywords.some((k) =>
|
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;
|
: allChildren;
|
||||||
const stats = getCategoryStats(parent.id, true);
|
const stats = getCategoryStats(parent.id, true);
|
||||||
@@ -393,7 +434,9 @@ export default function CategoriesPage() {
|
|||||||
{result.transaction.description}
|
{result.transaction.description}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<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", {
|
{new Intl.NumberFormat("fr-FR", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
@@ -424,9 +467,7 @@ export default function CategoriesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end pt-4 border-t">
|
<div className="flex justify-end pt-4 border-t">
|
||||||
<Button onClick={() => setIsRecatDialogOpen(false)}>
|
<Button onClick={() => setIsRecatDialogOpen(false)}>Fermer</Button>
|
||||||
Fermer
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AuthSessionProvider } from "@/components/providers/session-provider";
|
import { AuthSessionProvider } from "@/components/providers/session-provider";
|
||||||
|
import { QueryProvider } from "@/components/providers/query-provider";
|
||||||
|
|
||||||
const _geist = Geist({ subsets: ["latin"] });
|
const _geist = Geist({ subsets: ["latin"] });
|
||||||
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
||||||
@@ -22,7 +23,9 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<body className="font-sans antialiased">
|
<body className="font-sans antialiased">
|
||||||
<AuthSessionProvider>{children}</AuthSessionProvider>
|
<QueryProvider>
|
||||||
|
<AuthSessionProvider>{children}</AuthSessionProvider>
|
||||||
|
</QueryProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,9 +53,7 @@ export default function LoginPage() {
|
|||||||
<div className="flex items-center justify-center mb-4">
|
<div className="flex items-center justify-center mb-4">
|
||||||
<Lock className="w-12 h-12 text-[var(--primary)]" />
|
<Lock className="w-12 h-12 text-[var(--primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-2xl text-center">
|
<CardTitle className="text-2xl text-center">Accès protégé</CardTitle>
|
||||||
Accès protégé
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-center">
|
<CardDescription className="text-center">
|
||||||
Entrez le mot de passe pour accéder à l'application
|
Entrez le mot de passe pour accéder à l'application
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
@@ -92,4 +90,3 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filteredAccounts = data.accounts.filter((a) =>
|
const filteredAccounts = data.accounts.filter((a) =>
|
||||||
selectedAccounts.includes(a.id)
|
selectedAccounts.includes(a.id),
|
||||||
);
|
);
|
||||||
const filteredAccountIds = new Set(filteredAccounts.map((a) => a.id));
|
const filteredAccountIds = new Set(filteredAccounts.map((a) => a.id));
|
||||||
const filteredTransactions = data.transactions.filter((t) =>
|
const filteredTransactions = data.transactions.filter((t) =>
|
||||||
filteredAccountIds.has(t.accountId)
|
filteredAccountIds.has(t.accountId),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -68,7 +68,7 @@ export default function DashboardPage() {
|
|||||||
folders={data.folders}
|
folders={data.folders}
|
||||||
value={selectedAccounts}
|
value={selectedAccounts}
|
||||||
onChange={setSelectedAccounts}
|
onChange={setSelectedAccounts}
|
||||||
className="w-[280px]"
|
className="w-full md:w-[280px]"
|
||||||
filteredTransactions={data.transactions}
|
filteredTransactions={data.transactions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import {
|
|||||||
RuleCreateDialog,
|
RuleCreateDialog,
|
||||||
RulesSearchBar,
|
RulesSearchBar,
|
||||||
} from "@/components/rules";
|
} 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 { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Sparkles, RefreshCw } from "lucide-react";
|
import { Sparkles, RefreshCw } from "lucide-react";
|
||||||
import { updateCategory, autoCategorize, updateTransaction } from "@/lib/store-db";
|
import { updateCategory, autoCategorize } from "@/lib/store-db";
|
||||||
import {
|
import {
|
||||||
normalizeDescription,
|
normalizeDescription,
|
||||||
suggestKeyword,
|
suggestKeyword,
|
||||||
@@ -27,22 +28,42 @@ interface TransactionGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RulesPage() {
|
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 [searchQuery, setSearchQuery] = useState("");
|
||||||
const [sortBy, setSortBy] = useState<"count" | "amount" | "name">("count");
|
const [sortBy, setSortBy] = useState<"count" | "amount" | "name">("count");
|
||||||
const [filterMinCount, setFilterMinCount] = useState(2);
|
const [filterMinCount, setFilterMinCount] = useState(2);
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
const [selectedGroup, setSelectedGroup] = useState<TransactionGroup | null>(
|
const [selectedGroup, setSelectedGroup] = useState<TransactionGroup | null>(
|
||||||
null
|
null,
|
||||||
);
|
);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [isAutoCategorizing, setIsAutoCategorizing] = useState(false);
|
const [isAutoCategorizing, setIsAutoCategorizing] = useState(false);
|
||||||
|
|
||||||
// Group uncategorized transactions by normalized description
|
// Group uncategorized transactions by normalized description
|
||||||
const transactionGroups = useMemo(() => {
|
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[]> = {};
|
const groups: Record<string, Transaction[]> = {};
|
||||||
|
|
||||||
uncategorized.forEach((transaction) => {
|
uncategorized.forEach((transaction) => {
|
||||||
@@ -64,7 +85,7 @@ export default function RulesPage() {
|
|||||||
totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0),
|
totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0),
|
||||||
suggestedKeyword: suggestKeyword(descriptions),
|
suggestedKeyword: suggestKeyword(descriptions),
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter by search query
|
// Filter by search query
|
||||||
@@ -75,7 +96,7 @@ export default function RulesPage() {
|
|||||||
(g) =>
|
(g) =>
|
||||||
g.displayName.toLowerCase().includes(query) ||
|
g.displayName.toLowerCase().includes(query) ||
|
||||||
g.key.includes(query) ||
|
g.key.includes(query) ||
|
||||||
g.suggestedKeyword.toLowerCase().includes(query)
|
g.suggestedKeyword.toLowerCase().includes(query),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,12 +118,9 @@ export default function RulesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [data?.transactions, searchQuery, sortBy, filterMinCount]);
|
}, [transactionsData?.transactions, searchQuery, sortBy, filterMinCount]);
|
||||||
|
|
||||||
const uncategorizedCount = useMemo(() => {
|
const uncategorizedCount = transactionsData?.total || 0;
|
||||||
if (!data?.transactions) return 0;
|
|
||||||
return data.transactions.filter((t) => !t.categoryId).length;
|
|
||||||
}, [data?.transactions]);
|
|
||||||
|
|
||||||
const formatCurrency = useCallback((amount: number) => {
|
const formatCurrency = useCallback((amount: number) => {
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
@@ -143,17 +161,19 @@ export default function RulesPage() {
|
|||||||
applyToExisting: boolean;
|
applyToExisting: boolean;
|
||||||
transactionIds: string[];
|
transactionIds: string[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!data) return;
|
if (!metadata) return;
|
||||||
|
|
||||||
// 1. Add keyword to category
|
// 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) {
|
if (!category) {
|
||||||
throw new Error("Category not found");
|
throw new Error("Category not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if keyword already exists
|
// Check if keyword already exists
|
||||||
const keywordExists = category.keywords.some(
|
const keywordExists = category.keywords.some(
|
||||||
(k) => k.toLowerCase() === ruleData.keyword.toLowerCase()
|
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!keywordExists) {
|
if (!keywordExists) {
|
||||||
@@ -165,60 +185,68 @@ export default function RulesPage() {
|
|||||||
|
|
||||||
// 2. Apply to existing transactions if requested
|
// 2. Apply to existing transactions if requested
|
||||||
if (ruleData.applyToExisting) {
|
if (ruleData.applyToExisting) {
|
||||||
const transactions = data.transactions.filter((t) =>
|
|
||||||
ruleData.transactionIds.includes(t.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
transactions.map((t) =>
|
ruleData.transactionIds.map((id) =>
|
||||||
updateTransaction({ ...t, categoryId: ruleData.categoryId })
|
fetch("/api/banking/transactions", {
|
||||||
)
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
[data, refresh]
|
[metadata, refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAutoCategorize = useCallback(async () => {
|
const handleAutoCategorize = useCallback(async () => {
|
||||||
if (!data) return;
|
if (!metadata || !transactionsData) return;
|
||||||
|
|
||||||
setIsAutoCategorizing(true);
|
setIsAutoCategorizing(true);
|
||||||
try {
|
try {
|
||||||
const uncategorized = data.transactions.filter((t) => !t.categoryId);
|
const uncategorized = transactionsData.transactions;
|
||||||
let categorizedCount = 0;
|
let categorizedCount = 0;
|
||||||
|
|
||||||
for (const transaction of uncategorized) {
|
for (const transaction of uncategorized) {
|
||||||
const categoryId = autoCategorize(
|
const categoryId = autoCategorize(
|
||||||
transaction.description + " " + (transaction.memo || ""),
|
transaction.description + " " + (transaction.memo || ""),
|
||||||
data.categories
|
metadata.categories,
|
||||||
);
|
);
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
await updateTransaction({ ...transaction, categoryId });
|
await fetch("/api/banking/transactions", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...transaction, categoryId }),
|
||||||
|
});
|
||||||
categorizedCount++;
|
categorizedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
alert(`${categorizedCount} transaction(s) catégorisée(s) automatiquement`);
|
alert(
|
||||||
|
`${categorizedCount} transaction(s) catégorisée(s) automatiquement`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error auto-categorizing:", error);
|
console.error("Error auto-categorizing:", error);
|
||||||
alert("Erreur lors de la catégorisation automatique");
|
alert("Erreur lors de la catégorisation automatique");
|
||||||
} finally {
|
} finally {
|
||||||
setIsAutoCategorizing(false);
|
setIsAutoCategorizing(false);
|
||||||
}
|
}
|
||||||
}, [data, refresh]);
|
}, [metadata, transactionsData, refresh]);
|
||||||
|
|
||||||
const handleCategorizeGroup = useCallback(
|
const handleCategorizeGroup = useCallback(
|
||||||
async (group: TransactionGroup, categoryId: string | null) => {
|
async (group: TransactionGroup, categoryId: string | null) => {
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
group.transactions.map((t) =>
|
group.transactions.map((t) =>
|
||||||
updateTransaction({ ...t, categoryId })
|
fetch("/api/banking/transactions", {
|
||||||
)
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...t, categoryId }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
refresh();
|
refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -226,10 +254,15 @@ export default function RulesPage() {
|
|||||||
alert("Erreur lors de la catégorisation");
|
alert("Erreur lors de la catégorisation");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[data, refresh]
|
[refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (
|
||||||
|
isLoadingMetadata ||
|
||||||
|
!metadata ||
|
||||||
|
isLoadingTransactions ||
|
||||||
|
!transactionsData
|
||||||
|
) {
|
||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,10 +271,15 @@ export default function RulesPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Règles de catégorisation"
|
title="Règles de catégorisation"
|
||||||
description={
|
description={
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2 flex-wrap">
|
||||||
{transactionGroups.length} groupe
|
<span className="text-xs md:text-base">
|
||||||
{transactionGroups.length > 1 ? "s" : ""} de transactions similaires
|
{transactionGroups.length} groupe
|
||||||
<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>
|
</span>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
@@ -272,14 +310,14 @@ export default function RulesPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{transactionGroups.length === 0 ? (
|
{transactionGroups.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-12 md:py-16 text-center px-4">
|
||||||
<Sparkles className="h-12 w-12 text-muted-foreground mb-4" />
|
<Sparkles className="h-8 w-8 md:h-12 md:w-12 text-muted-foreground mb-3 md:mb-4" />
|
||||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
<h3 className="text-base md:text-lg font-medium text-foreground mb-2">
|
||||||
{uncategorizedCount === 0
|
{uncategorizedCount === 0
|
||||||
? "Toutes les transactions sont catégorisées !"
|
? "Toutes les transactions sont catégorisées !"
|
||||||
: "Aucun groupe trouvé"}
|
: "Aucun groupe trouvé"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground max-w-md">
|
<p className="text-xs md:text-sm text-muted-foreground max-w-md">
|
||||||
{uncategorizedCount === 0
|
{uncategorizedCount === 0
|
||||||
? "Continuez à importer des transactions pour voir les suggestions de règles."
|
? "Continuez à importer des transactions pour voir les suggestions de règles."
|
||||||
: filterMinCount > 1
|
: filterMinCount > 1
|
||||||
@@ -288,7 +326,7 @@ export default function RulesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2 md:space-y-3">
|
||||||
{transactionGroups.map((group) => (
|
{transactionGroups.map((group) => (
|
||||||
<RuleGroupCard
|
<RuleGroupCard
|
||||||
key={group.key}
|
key={group.key}
|
||||||
@@ -299,7 +337,7 @@ export default function RulesPage() {
|
|||||||
onCategorize={(categoryId) =>
|
onCategorize={(categoryId) =>
|
||||||
handleCategorizeGroup(group, categoryId)
|
handleCategorizeGroup(group, categoryId)
|
||||||
}
|
}
|
||||||
categories={data.categories}
|
categories={metadata.categories}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
/>
|
/>
|
||||||
@@ -311,10 +349,9 @@ export default function RulesPage() {
|
|||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
onOpenChange={setIsDialogOpen}
|
onOpenChange={setIsDialogOpen}
|
||||||
group={selectedGroup}
|
group={selectedGroup}
|
||||||
categories={data.categories}
|
categories={metadata.categories}
|
||||||
onSave={handleSaveRule}
|
onSave={handleSaveRule}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function SettingsPage() {
|
|||||||
"/api/banking/transactions/clear-categories",
|
"/api/banking/transactions/clear-categories",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error("Erreur");
|
if (!response.ok) throw new Error("Erreur");
|
||||||
refresh();
|
refresh();
|
||||||
@@ -91,12 +91,9 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const deduplicateTransactions = async () => {
|
const deduplicateTransactions = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch("/api/banking/transactions/deduplicate", {
|
||||||
"/api/banking/transactions/deduplicate",
|
method: "POST",
|
||||||
{
|
});
|
||||||
method: "POST",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!response.ok) throw new Error("Erreur");
|
if (!response.ok) throw new Error("Erreur");
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
refresh();
|
refresh();
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
YearOverYearChart,
|
YearOverYearChart,
|
||||||
} from "@/components/statistics";
|
} from "@/components/statistics";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingData } from "@/lib/hooks";
|
||||||
import { getAccountBalance } from "@/lib/account-utils";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -30,11 +29,16 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Filter, X, Wallet, CircleSlash, Calendar } from "lucide-react";
|
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 { Calendar as CalendarComponent } from "@/components/ui/calendar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import type { Account, Category } from "@/lib/types";
|
import type { Account, Category } from "@/lib/types";
|
||||||
|
|
||||||
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
||||||
@@ -43,10 +47,17 @@ export default function StatisticsPage() {
|
|||||||
const { data, isLoading } = useBankingData();
|
const { data, isLoading } = useBankingData();
|
||||||
const [period, setPeriod] = useState<Period>("6months");
|
const [period, setPeriod] = useState<Period>("6months");
|
||||||
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
|
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([
|
||||||
const [excludeInternalTransfers, setExcludeInternalTransfers] = useState(true);
|
"all",
|
||||||
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined);
|
]);
|
||||||
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined);
|
const [excludeInternalTransfers, setExcludeInternalTransfers] =
|
||||||
|
useState(true);
|
||||||
|
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
|
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
|
||||||
|
|
||||||
// Get start date based on period
|
// Get start date based on period
|
||||||
@@ -80,7 +91,7 @@ export default function StatisticsPage() {
|
|||||||
const internalTransferCategory = useMemo(() => {
|
const internalTransferCategory = useMemo(() => {
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
return data.categories.find(
|
return data.categories.find(
|
||||||
(c) => c.name.toLowerCase() === "virement interne"
|
(c) => c.name.toLowerCase() === "virement interne",
|
||||||
);
|
);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -88,77 +99,98 @@ export default function StatisticsPage() {
|
|||||||
const transactionsForAccountFilter = useMemo(() => {
|
const transactionsForAccountFilter = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
return data.transactions.filter((t) => {
|
return data.transactions
|
||||||
const transactionDate = new Date(t.date);
|
.filter((t) => {
|
||||||
if (endDate) {
|
const transactionDate = new Date(t.date);
|
||||||
// Custom date range
|
if (endDate) {
|
||||||
const endOfDay = new Date(endDate);
|
// Custom date range
|
||||||
endOfDay.setHours(23, 59, 59, 999);
|
const endOfDay = new Date(endDate);
|
||||||
if (transactionDate < startDate || transactionDate > endOfDay) {
|
endOfDay.setHours(23, 59, 59, 999);
|
||||||
return false;
|
if (transactionDate < startDate || transactionDate > endOfDay) {
|
||||||
}
|
return false;
|
||||||
} else {
|
}
|
||||||
// Standard period
|
|
||||||
if (transactionDate < startDate) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}).filter((t) => {
|
|
||||||
if (!selectedCategories.includes("all")) {
|
|
||||||
if (selectedCategories.includes("uncategorized")) {
|
|
||||||
return !t.categoryId;
|
|
||||||
} else {
|
} else {
|
||||||
return t.categoryId && selectedCategories.includes(t.categoryId);
|
// Standard period
|
||||||
|
if (transactionDate < startDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return true;
|
||||||
return true;
|
})
|
||||||
}).filter((t) => {
|
.filter((t) => {
|
||||||
// Exclude "Virement interne" category if checkbox is checked
|
if (!selectedCategories.includes("all")) {
|
||||||
if (excludeInternalTransfers && internalTransferCategory) {
|
if (selectedCategories.includes("uncategorized")) {
|
||||||
return t.categoryId !== internalTransferCategory.id;
|
return !t.categoryId;
|
||||||
}
|
} else {
|
||||||
return true;
|
return t.categoryId && selectedCategories.includes(t.categoryId);
|
||||||
});
|
}
|
||||||
}, [data, startDate, endDate, selectedCategories, excludeInternalTransfers, internalTransferCategory]);
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.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,
|
||||||
|
]);
|
||||||
|
|
||||||
// Transactions filtered for category filter (by accounts, period - not categories)
|
// Transactions filtered for category filter (by accounts, period - not categories)
|
||||||
const transactionsForCategoryFilter = useMemo(() => {
|
const transactionsForCategoryFilter = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
return data.transactions.filter((t) => {
|
return data.transactions
|
||||||
const transactionDate = new Date(t.date);
|
.filter((t) => {
|
||||||
if (endDate) {
|
const transactionDate = new Date(t.date);
|
||||||
// Custom date range
|
if (endDate) {
|
||||||
const endOfDay = new Date(endDate);
|
// Custom date range
|
||||||
endOfDay.setHours(23, 59, 59, 999);
|
const endOfDay = new Date(endDate);
|
||||||
if (transactionDate < startDate || transactionDate > endOfDay) {
|
endOfDay.setHours(23, 59, 59, 999);
|
||||||
return false;
|
if (transactionDate < startDate || transactionDate > endOfDay) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Standard period
|
||||||
|
if (transactionDate < startDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
return true;
|
||||||
// Standard period
|
})
|
||||||
if (transactionDate < startDate) {
|
.filter((t) => {
|
||||||
return false;
|
if (!selectedAccounts.includes("all")) {
|
||||||
|
return selectedAccounts.includes(t.accountId);
|
||||||
}
|
}
|
||||||
}
|
return true;
|
||||||
return true;
|
})
|
||||||
}).filter((t) => {
|
.filter((t) => {
|
||||||
if (!selectedAccounts.includes("all")) {
|
// Exclude "Virement interne" category if checkbox is checked
|
||||||
return selectedAccounts.includes(t.accountId);
|
if (excludeInternalTransfers && internalTransferCategory) {
|
||||||
}
|
return t.categoryId !== internalTransferCategory.id;
|
||||||
return true;
|
}
|
||||||
}).filter((t) => {
|
return true;
|
||||||
// Exclude "Virement interne" category if checkbox is checked
|
});
|
||||||
if (excludeInternalTransfers && internalTransferCategory) {
|
}, [
|
||||||
return t.categoryId !== internalTransferCategory.id;
|
data,
|
||||||
}
|
startDate,
|
||||||
return true;
|
endDate,
|
||||||
});
|
selectedAccounts,
|
||||||
}, [data, startDate, endDate, selectedAccounts, excludeInternalTransfers, internalTransferCategory]);
|
excludeInternalTransfers,
|
||||||
|
internalTransferCategory,
|
||||||
|
]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
|
// Pre-filter transactions once
|
||||||
let transactions = data.transactions.filter((t) => {
|
let transactions = data.transactions.filter((t) => {
|
||||||
const transactionDate = new Date(t.date);
|
const transactionDate = new Date(t.date);
|
||||||
if (endDate) {
|
if (endDate) {
|
||||||
@@ -174,8 +206,8 @@ export default function StatisticsPage() {
|
|||||||
|
|
||||||
// Filter by accounts
|
// Filter by accounts
|
||||||
if (!selectedAccounts.includes("all")) {
|
if (!selectedAccounts.includes("all")) {
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter((t) =>
|
||||||
(t) => selectedAccounts.includes(t.accountId)
|
selectedAccounts.includes(t.accountId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +217,7 @@ export default function StatisticsPage() {
|
|||||||
transactions = transactions.filter((t) => !t.categoryId);
|
transactions = transactions.filter((t) => !t.categoryId);
|
||||||
} else {
|
} else {
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
(t) => t.categoryId && selectedCategories.includes(t.categoryId)
|
(t) => t.categoryId && selectedCategories.includes(t.categoryId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,7 +225,7 @@ export default function StatisticsPage() {
|
|||||||
// Exclude "Virement interne" category if checkbox is checked
|
// Exclude "Virement interne" category if checkbox is checked
|
||||||
if (excludeInternalTransfers && internalTransferCategory) {
|
if (excludeInternalTransfers && internalTransferCategory) {
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
(t) => t.categoryId !== internalTransferCategory.id
|
(t) => t.categoryId !== internalTransferCategory.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +296,9 @@ export default function StatisticsPage() {
|
|||||||
categoryTotalsByParent.set(groupId, current + Math.abs(t.amount));
|
categoryTotalsByParent.set(groupId, current + Math.abs(t.amount));
|
||||||
});
|
});
|
||||||
|
|
||||||
const categoryChartDataByParent = Array.from(categoryTotalsByParent.entries())
|
const categoryChartDataByParent = Array.from(
|
||||||
|
categoryTotalsByParent.entries(),
|
||||||
|
)
|
||||||
.map(([groupId, total]) => {
|
.map(([groupId, total]) => {
|
||||||
const category = data.categories.find((c) => c.id === groupId);
|
const category = data.categories.find((c) => c.id === groupId);
|
||||||
return {
|
return {
|
||||||
@@ -278,7 +312,7 @@ export default function StatisticsPage() {
|
|||||||
|
|
||||||
// Top expenses - deduplicate by ID and sort by amount (most negative first)
|
// Top expenses - deduplicate by ID and sort by amount (most negative first)
|
||||||
const uniqueTransactions = Array.from(
|
const uniqueTransactions = Array.from(
|
||||||
new Map(transactions.map((t) => [t.id, t])).values()
|
new Map(transactions.map((t) => [t.id, t])).values(),
|
||||||
);
|
);
|
||||||
const topExpenses = uniqueTransactions
|
const topExpenses = uniqueTransactions
|
||||||
.filter((t) => t.amount < 0)
|
.filter((t) => t.amount < 0)
|
||||||
@@ -304,7 +338,7 @@ export default function StatisticsPage() {
|
|||||||
|
|
||||||
// Balance evolution - Aggregated (using filtered transactions)
|
// Balance evolution - Aggregated (using filtered transactions)
|
||||||
const sortedFilteredTransactions = [...transactions].sort(
|
const sortedFilteredTransactions = [...transactions].sort(
|
||||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate starting balance: initialBalance + transactions before startDate
|
// Calculate starting balance: initialBalance + transactions before startDate
|
||||||
@@ -353,7 +387,7 @@ export default function StatisticsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const aggregatedBalanceData = Array.from(
|
const aggregatedBalanceData = Array.from(
|
||||||
aggregatedBalanceByDate.entries()
|
aggregatedBalanceByDate.entries(),
|
||||||
).map(([date, balance]) => ({
|
).map(([date, balance]) => ({
|
||||||
date: new Date(date).toLocaleDateString("fr-FR", {
|
date: new Date(date).toLocaleDateString("fr-FR", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
@@ -581,7 +615,15 @@ export default function StatisticsPage() {
|
|||||||
categoryTrendDataByParent,
|
categoryTrendDataByParent,
|
||||||
yearOverYearData,
|
yearOverYearData,
|
||||||
};
|
};
|
||||||
}, [data, startDate, endDate, selectedAccounts, selectedCategories, excludeInternalTransfers, internalTransferCategory]);
|
}, [
|
||||||
|
data,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
selectedAccounts,
|
||||||
|
selectedCategories,
|
||||||
|
excludeInternalTransfers,
|
||||||
|
internalTransferCategory,
|
||||||
|
]);
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
@@ -601,15 +643,15 @@ export default function StatisticsPage() {
|
|||||||
description="Analysez vos dépenses et revenus"
|
description="Analysez vos dépenses et revenus"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-4 md:mb-6">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-3 md:pt-4">
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-2 md:gap-4">
|
||||||
<AccountFilterCombobox
|
<AccountFilterCombobox
|
||||||
accounts={data.accounts}
|
accounts={data.accounts}
|
||||||
folders={data.folders}
|
folders={data.folders}
|
||||||
value={selectedAccounts}
|
value={selectedAccounts}
|
||||||
onChange={setSelectedAccounts}
|
onChange={setSelectedAccounts}
|
||||||
className="w-[280px]"
|
className="w-full md:w-[280px]"
|
||||||
filteredTransactions={transactionsForAccountFilter}
|
filteredTransactions={transactionsForAccountFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -617,7 +659,7 @@ export default function StatisticsPage() {
|
|||||||
categories={data.categories}
|
categories={data.categories}
|
||||||
value={selectedCategories}
|
value={selectedCategories}
|
||||||
onChange={setSelectedCategories}
|
onChange={setSelectedCategories}
|
||||||
className="w-[220px]"
|
className="w-full md:w-[220px]"
|
||||||
filteredTransactions={transactionsForCategoryFilter}
|
filteredTransactions={transactionsForCategoryFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -632,7 +674,7 @@ export default function StatisticsPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-full md:w-[150px]">
|
||||||
<SelectValue placeholder="Période" />
|
<SelectValue placeholder="Période" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -646,9 +688,15 @@ export default function StatisticsPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{period === "custom" && (
|
{period === "custom" && (
|
||||||
<Popover open={isCustomDatePickerOpen} onOpenChange={setIsCustomDatePickerOpen}>
|
<Popover
|
||||||
|
open={isCustomDatePickerOpen}
|
||||||
|
onOpenChange={setIsCustomDatePickerOpen}
|
||||||
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="w-[280px] justify-start text-left font-normal">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full md:w-[280px] justify-start text-left font-normal"
|
||||||
|
>
|
||||||
<Calendar className="mr-2 h-4 w-4" />
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
{customStartDate && customEndDate ? (
|
{customStartDate && customEndDate ? (
|
||||||
<>
|
<>
|
||||||
@@ -658,14 +706,18 @@ export default function StatisticsPage() {
|
|||||||
) : customStartDate ? (
|
) : customStartDate ? (
|
||||||
format(customStartDate, "PPP", { locale: fr })
|
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>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Date de début</label>
|
<label className="text-sm font-medium">
|
||||||
|
Date de début
|
||||||
|
</label>
|
||||||
<CalendarComponent
|
<CalendarComponent
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={customStartDate}
|
selected={customStartDate}
|
||||||
@@ -684,7 +736,11 @@ export default function StatisticsPage() {
|
|||||||
mode="single"
|
mode="single"
|
||||||
selected={customEndDate}
|
selected={customEndDate}
|
||||||
onSelect={(date) => {
|
onSelect={(date) => {
|
||||||
if (date && customStartDate && date < customStartDate) {
|
if (
|
||||||
|
date &&
|
||||||
|
customStartDate &&
|
||||||
|
date < customStartDate
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCustomEndDate(date);
|
setCustomEndDate(date);
|
||||||
@@ -727,15 +783,17 @@ export default function StatisticsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{internalTransferCategory && (
|
{internalTransferCategory && (
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border border-border rounded-md bg-[var(--card)]">
|
<div className="flex items-center gap-2 px-2 md:px-3 py-1.5 md:py-2 border border-border rounded-md bg-[var(--card)]">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="exclude-internal-transfers"
|
id="exclude-internal-transfers"
|
||||||
checked={excludeInternalTransfers}
|
checked={excludeInternalTransfers}
|
||||||
onCheckedChange={(checked) => setExcludeInternalTransfers(checked === true)}
|
onCheckedChange={(checked) =>
|
||||||
|
setExcludeInternalTransfers(checked === true)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="exclude-internal-transfers"
|
htmlFor="exclude-internal-transfers"
|
||||||
className="text-sm font-medium cursor-pointer select-none"
|
className="text-xs md:text-sm font-medium cursor-pointer select-none"
|
||||||
>
|
>
|
||||||
Exclure Virement interne
|
Exclure Virement interne
|
||||||
</label>
|
</label>
|
||||||
@@ -747,13 +805,17 @@ export default function StatisticsPage() {
|
|||||||
selectedAccounts={selectedAccounts}
|
selectedAccounts={selectedAccounts}
|
||||||
onRemoveAccount={(id) => {
|
onRemoveAccount={(id) => {
|
||||||
const newAccounts = selectedAccounts.filter((a) => a !== id);
|
const newAccounts = selectedAccounts.filter((a) => a !== id);
|
||||||
setSelectedAccounts(newAccounts.length > 0 ? newAccounts : ["all"]);
|
setSelectedAccounts(
|
||||||
|
newAccounts.length > 0 ? newAccounts : ["all"],
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onClearAccounts={() => setSelectedAccounts(["all"])}
|
onClearAccounts={() => setSelectedAccounts(["all"])}
|
||||||
selectedCategories={selectedCategories}
|
selectedCategories={selectedCategories}
|
||||||
onRemoveCategory={(id) => {
|
onRemoveCategory={(id) => {
|
||||||
const newCategories = selectedCategories.filter((c) => c !== id);
|
const newCategories = selectedCategories.filter((c) => c !== id);
|
||||||
setSelectedCategories(newCategories.length > 0 ? newCategories : ["all"]);
|
setSelectedCategories(
|
||||||
|
newCategories.length > 0 ? newCategories : ["all"],
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onClearCategories={() => setSelectedCategories(["all"])}
|
onClearCategories={() => setSelectedCategories(["all"])}
|
||||||
period={period}
|
period={period}
|
||||||
@@ -771,15 +833,17 @@ export default function StatisticsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Vue d'ensemble */}
|
{/* Vue d'ensemble */}
|
||||||
<section className="mb-8">
|
<section className="mb-4 md:mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">Vue d'ensemble</h2>
|
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">
|
||||||
|
Vue d'ensemble
|
||||||
|
</h2>
|
||||||
<StatsSummaryCards
|
<StatsSummaryCards
|
||||||
totalIncome={stats.totalIncome}
|
totalIncome={stats.totalIncome}
|
||||||
totalExpenses={stats.totalExpenses}
|
totalExpenses={stats.totalExpenses}
|
||||||
avgMonthlyExpenses={stats.avgMonthlyExpenses}
|
avgMonthlyExpenses={stats.avgMonthlyExpenses}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6">
|
<div className="mt-4 md:mt-6">
|
||||||
<BalanceLineChart
|
<BalanceLineChart
|
||||||
aggregatedData={stats.aggregatedBalanceData}
|
aggregatedData={stats.aggregatedBalanceData}
|
||||||
perAccountData={stats.perAccountBalanceData}
|
perAccountData={stats.perAccountBalanceData}
|
||||||
@@ -787,7 +851,7 @@ export default function StatisticsPage() {
|
|||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-4 md:mt-6">
|
||||||
<SavingsTrendChart
|
<SavingsTrendChart
|
||||||
data={stats.savingsTrendData}
|
data={stats.savingsTrendData}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
@@ -796,9 +860,11 @@ export default function StatisticsPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Revenus et Dépenses */}
|
{/* Revenus et Dépenses */}
|
||||||
<section className="mb-8">
|
<section className="mb-4 md:mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">Revenus et Dépenses</h2>
|
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
Revenus et Dépenses
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-4 md:gap-6 lg:grid-cols-2">
|
||||||
<MonthlyChart
|
<MonthlyChart
|
||||||
data={stats.monthlyChartData}
|
data={stats.monthlyChartData}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
@@ -813,7 +879,7 @@ export default function StatisticsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{stats.yearOverYearData.length > 0 && (
|
{stats.yearOverYearData.length > 0 && (
|
||||||
<div className="mt-6">
|
<div className="mt-4 md:mt-6">
|
||||||
<YearOverYearChart
|
<YearOverYearChart
|
||||||
data={stats.yearOverYearData}
|
data={stats.yearOverYearData}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
@@ -823,9 +889,11 @@ export default function StatisticsPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Analyse par Catégorie */}
|
{/* Analyse par Catégorie */}
|
||||||
<section className="mb-8">
|
<section className="mb-4 md:mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">Analyse par Catégorie</h2>
|
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">
|
||||||
<div className="grid gap-6">
|
Analyse par Catégorie
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-4 md:gap-6">
|
||||||
<CategoryPieChart
|
<CategoryPieChart
|
||||||
data={stats.categoryChartData}
|
data={stats.categoryChartData}
|
||||||
dataByParent={stats.categoryChartDataByParent}
|
dataByParent={stats.categoryChartDataByParent}
|
||||||
@@ -837,7 +905,7 @@ export default function StatisticsPage() {
|
|||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="hidden md:block mt-4 md:mt-6">
|
||||||
<CategoryTrendChart
|
<CategoryTrendChart
|
||||||
data={stats.categoryTrendData}
|
data={stats.categoryTrendData}
|
||||||
dataByParent={stats.categoryTrendDataByParent}
|
dataByParent={stats.categoryTrendDataByParent}
|
||||||
@@ -845,7 +913,7 @@ export default function StatisticsPage() {
|
|||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-4 md:mt-6">
|
||||||
<TopExpensesList
|
<TopExpensesList
|
||||||
expenses={stats.topExpenses}
|
expenses={stats.topExpenses}
|
||||||
categories={data.categories}
|
categories={data.categories}
|
||||||
@@ -853,7 +921,6 @@ export default function StatisticsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -885,6 +952,7 @@ function ActiveFilters({
|
|||||||
customStartDate?: Date;
|
customStartDate?: Date;
|
||||||
customEndDate?: Date;
|
customEndDate?: Date;
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const hasAccounts = !selectedAccounts.includes("all");
|
const hasAccounts = !selectedAccounts.includes("all");
|
||||||
const hasCategories = !selectedCategories.includes("all");
|
const hasCategories = !selectedCategories.includes("all");
|
||||||
const hasPeriod = period !== "all";
|
const hasPeriod = period !== "all";
|
||||||
@@ -894,7 +962,9 @@ function ActiveFilters({
|
|||||||
if (!hasActiveFilters) return null;
|
if (!hasActiveFilters) return null;
|
||||||
|
|
||||||
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
|
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 isUncategorized = selectedCategories.includes("uncategorized");
|
||||||
|
|
||||||
const getPeriodLabel = (p: Period) => {
|
const getPeriodLabel = (p: Period) => {
|
||||||
@@ -924,28 +994,38 @@ function ActiveFilters({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border flex-wrap">
|
<div className="flex items-center gap-1.5 md:gap-2 mt-2 md:mt-3 pt-2 md:pt-3 border-t border-border flex-wrap">
|
||||||
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
<Filter className="h-3 w-3 md:h-3.5 md:w-3.5 text-muted-foreground" />
|
||||||
|
|
||||||
{selectedAccs.map((acc) => (
|
{selectedAccs.map((acc) => (
|
||||||
<Badge key={acc.id} variant="secondary" className="gap-1 text-xs font-normal">
|
<Badge
|
||||||
<Wallet className="h-3 w-3" />
|
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}
|
{acc.name}
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemoveAccount(acc.id)}
|
onClick={() => onRemoveAccount(acc.id)}
|
||||||
className="ml-1 hover:text-foreground"
|
className="ml-0.5 md:ml-1 hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||||
</button>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isUncategorized && (
|
{isUncategorized && (
|
||||||
<Badge variant="secondary" className="gap-1 text-xs font-normal">
|
<Badge
|
||||||
<CircleSlash className="h-3 w-3" />
|
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é
|
Non catégorisé
|
||||||
<button onClick={onClearCategories} className="ml-1 hover:text-foreground">
|
<button
|
||||||
<X className="h-3 w-3" />
|
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>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -954,36 +1034,46 @@ function ActiveFilters({
|
|||||||
<Badge
|
<Badge
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="gap-1 text-xs font-normal"
|
className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${cat.color}15`,
|
backgroundColor: `${cat.color}15`,
|
||||||
borderColor: `${cat.color}30`,
|
borderColor: `${cat.color}30`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CategoryIcon icon={cat.icon} color={cat.color} size={12} />
|
<CategoryIcon
|
||||||
|
icon={cat.icon}
|
||||||
|
color={cat.color}
|
||||||
|
size={isMobile ? 10 : 12}
|
||||||
|
/>
|
||||||
{cat.name}
|
{cat.name}
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemoveCategory(cat.id)}
|
onClick={() => onRemoveCategory(cat.id)}
|
||||||
className="ml-1 hover:text-foreground"
|
className="ml-0.5 md:ml-1 hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||||
</button>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{hasPeriod && (
|
{hasPeriod && (
|
||||||
<Badge variant="secondary" className="gap-1 text-xs font-normal">
|
<Badge
|
||||||
<Calendar className="h-3 w-3" />
|
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)}
|
{getPeriodLabel(period)}
|
||||||
<button onClick={onClearPeriod} className="ml-1 hover:text-foreground">
|
<button
|
||||||
<X className="h-3 w-3" />
|
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>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground ml-auto"
|
className="text-[10px] md:text-xs text-muted-foreground hover:text-foreground ml-auto"
|
||||||
>
|
>
|
||||||
Effacer tout
|
Effacer tout
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -10,48 +10,71 @@ import {
|
|||||||
} from "@/components/transactions";
|
} from "@/components/transactions";
|
||||||
import { RuleCreateDialog } from "@/components/rules";
|
import { RuleCreateDialog } from "@/components/rules";
|
||||||
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingMetadata, useTransactions } from "@/lib/hooks";
|
||||||
import { updateCategory, updateTransaction } from "@/lib/store-db";
|
import { updateCategory } from "@/lib/store-db";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Upload } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@/lib/types";
|
||||||
|
import type { TransactionsPaginatedParams } from "@/services/banking.service";
|
||||||
import {
|
import {
|
||||||
normalizeDescription,
|
normalizeDescription,
|
||||||
suggestKeyword,
|
suggestKeyword,
|
||||||
} from "@/components/rules/constants";
|
} from "@/components/rules/constants";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { fr } from "date-fns/locale";
|
|
||||||
|
|
||||||
type SortField = "date" | "amount" | "description";
|
type SortField = "date" | "amount" | "description";
|
||||||
type SortOrder = "asc" | "desc";
|
type SortOrder = "asc" | "desc";
|
||||||
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
export default function TransactionsPage() {
|
export default function TransactionsPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { data, isLoading, refresh, update } = useBankingData();
|
const queryClient = useQueryClient();
|
||||||
|
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
|
||||||
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
|
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(() => {
|
useEffect(() => {
|
||||||
const accountId = searchParams.get("accountId");
|
const accountId = searchParams.get("accountId");
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
setSelectedAccounts([accountId]);
|
setSelectedAccounts([accountId]);
|
||||||
|
setPage(0);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([
|
||||||
|
"all",
|
||||||
|
]);
|
||||||
const [showReconciled, setShowReconciled] = useState<string>("all");
|
const [showReconciled, setShowReconciled] = useState<string>("all");
|
||||||
const [period, setPeriod] = useState<Period>("all");
|
const [period, setPeriod] = useState<Period>("all");
|
||||||
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined);
|
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
||||||
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined);
|
undefined,
|
||||||
|
);
|
||||||
|
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
|
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
|
||||||
const [sortField, setSortField] = useState<SortField>("date");
|
const [sortField, setSortField] = useState<SortField>("date");
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||||
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
|
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
|
||||||
new Set()
|
new Set(),
|
||||||
);
|
);
|
||||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
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
|
// Get start date based on period
|
||||||
const startDate = useMemo(() => {
|
const startDate = useMemo(() => {
|
||||||
@@ -80,207 +103,106 @@ export default function TransactionsPage() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [period, customEndDate]);
|
}, [period, customEndDate]);
|
||||||
|
|
||||||
// Transactions filtered for account filter (by categories, search, reconciled, period - not accounts)
|
// Build transaction query params
|
||||||
const transactionsForAccountFilter = useMemo(() => {
|
const transactionParams = useMemo(() => {
|
||||||
if (!data) return [];
|
const params: TransactionsPaginatedParams = {
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: page * PAGE_SIZE,
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
|
||||||
let transactions = [...data.transactions];
|
if (startDate && period !== "all") {
|
||||||
|
params.startDate = startDate.toISOString().split("T")[0];
|
||||||
// Filter by period
|
}
|
||||||
transactions = transactions.filter((t) => {
|
if (endDate) {
|
||||||
const transactionDate = new Date(t.date);
|
params.endDate = endDate.toISOString().split("T")[0];
|
||||||
if (endDate) {
|
}
|
||||||
// Custom date range
|
if (!selectedAccounts.includes("all")) {
|
||||||
const endOfDay = new Date(endDate);
|
params.accountIds = selectedAccounts;
|
||||||
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 (!selectedCategories.includes("all")) {
|
if (!selectedCategories.includes("all")) {
|
||||||
if (selectedCategories.includes("uncategorized")) {
|
if (selectedCategories.includes("uncategorized")) {
|
||||||
transactions = transactions.filter((t) => !t.categoryId);
|
params.includeUncategorized = true;
|
||||||
} else {
|
} else {
|
||||||
transactions = transactions.filter(
|
params.categoryIds = selectedCategories;
|
||||||
(t) => t.categoryId && selectedCategories.includes(t.categoryId)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (debouncedSearchQuery) {
|
||||||
|
params.search = debouncedSearchQuery;
|
||||||
|
}
|
||||||
if (showReconciled !== "all") {
|
if (showReconciled !== "all") {
|
||||||
const isReconciled = showReconciled === "reconciled";
|
params.isReconciled = showReconciled === "reconciled";
|
||||||
transactions = transactions.filter(
|
|
||||||
(t) => t.isReconciled === isReconciled
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return transactions;
|
return params;
|
||||||
}, [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;
|
|
||||||
}, [
|
}, [
|
||||||
data,
|
page,
|
||||||
searchQuery,
|
|
||||||
selectedAccounts,
|
|
||||||
selectedCategories,
|
|
||||||
showReconciled,
|
|
||||||
period,
|
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
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,
|
sortField,
|
||||||
sortOrder,
|
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) => {
|
const handleCreateRule = useCallback((transaction: Transaction) => {
|
||||||
setRuleTransaction(transaction);
|
setRuleTransaction(transaction);
|
||||||
setRuleDialogOpen(true);
|
setRuleDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Create a virtual group for the rule dialog based on selected transaction
|
// Create a virtual group for the rule dialog based on selected transaction
|
||||||
|
// Note: This requires fetching similar transactions - simplified for now
|
||||||
const ruleGroup = useMemo(() => {
|
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 normalizedDesc = normalizeDescription(ruleTransaction.description);
|
||||||
const similarTransactions = data.transactions.filter(
|
const similarTransactions = transactionsData.transactions.filter(
|
||||||
(t) => normalizeDescription(t.description) === normalizedDesc
|
(t) => normalizeDescription(t.description) === normalizedDesc,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (similarTransactions.length === 0) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: normalizedDesc,
|
key: normalizedDesc,
|
||||||
displayName: ruleTransaction.description,
|
displayName: ruleTransaction.description,
|
||||||
transactions: similarTransactions,
|
transactions: similarTransactions,
|
||||||
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
|
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(
|
const handleSaveRule = useCallback(
|
||||||
async (ruleData: {
|
async (ruleData: {
|
||||||
@@ -289,17 +211,19 @@ export default function TransactionsPage() {
|
|||||||
applyToExisting: boolean;
|
applyToExisting: boolean;
|
||||||
transactionIds: string[];
|
transactionIds: string[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!data) return;
|
if (!metadata) return;
|
||||||
|
|
||||||
// 1. Add keyword to category
|
// 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) {
|
if (!category) {
|
||||||
throw new Error("Category not found");
|
throw new Error("Category not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if keyword already exists
|
// Check if keyword already exists
|
||||||
const keywordExists = category.keywords.some(
|
const keywordExists = category.keywords.some(
|
||||||
(k) => k.toLowerCase() === ruleData.keyword.toLowerCase()
|
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!keywordExists) {
|
if (!keywordExists) {
|
||||||
@@ -311,24 +235,31 @@ export default function TransactionsPage() {
|
|||||||
|
|
||||||
// 2. Apply to existing transactions if requested
|
// 2. Apply to existing transactions if requested
|
||||||
if (ruleData.applyToExisting) {
|
if (ruleData.applyToExisting) {
|
||||||
const transactions = data.transactions.filter((t) =>
|
|
||||||
ruleData.transactionIds.includes(t.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
transactions.map((t) =>
|
ruleData.transactionIds.map((id) =>
|
||||||
updateTransaction({ ...t, categoryId: ruleData.categoryId })
|
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);
|
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 />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +279,11 @@ export default function TransactionsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleReconciled = async (transactionId: string) => {
|
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;
|
if (!transaction) return;
|
||||||
|
|
||||||
const updatedTransaction = {
|
const updatedTransaction = {
|
||||||
@@ -356,84 +291,75 @@ export default function TransactionsPage() {
|
|||||||
isReconciled: !transaction.isReconciled,
|
isReconciled: !transaction.isReconciled,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedTransactions = data.transactions.map((t) =>
|
|
||||||
t.id === transactionId ? updatedTransaction : t
|
|
||||||
);
|
|
||||||
update({ ...data, transactions: updatedTransactions });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch("/api/banking/transactions", {
|
await fetch("/api/banking/transactions", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(updatedTransaction),
|
body: JSON.stringify(updatedTransaction),
|
||||||
});
|
});
|
||||||
|
invalidateTransactions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update transaction:", error);
|
console.error("Failed to update transaction:", error);
|
||||||
refresh();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const markReconciled = async (transactionId: string) => {
|
const markReconciled = async (transactionId: string) => {
|
||||||
const transaction = data.transactions.find((t) => t.id === transactionId);
|
if (!transactionsData) return;
|
||||||
if (!transaction || transaction.isReconciled) return; // Skip if already reconciled
|
|
||||||
|
const transaction = transactionsData.transactions.find(
|
||||||
|
(t) => t.id === transactionId,
|
||||||
|
);
|
||||||
|
if (!transaction || transaction.isReconciled) return;
|
||||||
|
|
||||||
const updatedTransaction = {
|
const updatedTransaction = {
|
||||||
...transaction,
|
...transaction,
|
||||||
isReconciled: true,
|
isReconciled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedTransactions = data.transactions.map((t) =>
|
|
||||||
t.id === transactionId ? updatedTransaction : t
|
|
||||||
);
|
|
||||||
update({ ...data, transactions: updatedTransactions });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch("/api/banking/transactions", {
|
await fetch("/api/banking/transactions", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(updatedTransaction),
|
body: JSON.stringify(updatedTransaction),
|
||||||
});
|
});
|
||||||
|
invalidateTransactions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update transaction:", error);
|
console.error("Failed to update transaction:", error);
|
||||||
refresh();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setCategory = async (
|
const setCategory = async (
|
||||||
transactionId: string,
|
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;
|
if (!transaction) return;
|
||||||
|
|
||||||
const updatedTransaction = { ...transaction, categoryId };
|
const updatedTransaction = { ...transaction, categoryId };
|
||||||
|
|
||||||
const updatedTransactions = data.transactions.map((t) =>
|
|
||||||
t.id === transactionId ? updatedTransaction : t
|
|
||||||
);
|
|
||||||
update({ ...data, transactions: updatedTransactions });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch("/api/banking/transactions", {
|
await fetch("/api/banking/transactions", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(updatedTransaction),
|
body: JSON.stringify(updatedTransaction),
|
||||||
});
|
});
|
||||||
|
invalidateTransactions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update transaction:", error);
|
console.error("Failed to update transaction:", error);
|
||||||
refresh();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const bulkReconcile = async (reconciled: boolean) => {
|
const bulkReconcile = async (reconciled: boolean) => {
|
||||||
const transactionsToUpdate = data.transactions.filter((t) =>
|
if (!transactionsData) return;
|
||||||
selectedTransactions.has(t.id)
|
|
||||||
|
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());
|
setSelectedTransactions(new Set());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -443,24 +369,22 @@ export default function TransactionsPage() {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ...t, isReconciled: reconciled }),
|
body: JSON.stringify({ ...t, isReconciled: reconciled }),
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
invalidateTransactions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update transactions:", error);
|
console.error("Failed to update transactions:", error);
|
||||||
refresh();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const bulkSetCategory = async (categoryId: string | null) => {
|
const bulkSetCategory = async (categoryId: string | null) => {
|
||||||
const transactionsToUpdate = data.transactions.filter((t) =>
|
if (!transactionsData) return;
|
||||||
selectedTransactions.has(t.id)
|
|
||||||
|
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());
|
setSelectedTransactions(new Set());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -470,20 +394,23 @@ export default function TransactionsPage() {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ...t, categoryId }),
|
body: JSON.stringify({ ...t, categoryId }),
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
invalidateTransactions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update transactions:", error);
|
console.error("Failed to update transactions:", error);
|
||||||
refresh();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSelectAll = () => {
|
const toggleSelectAll = () => {
|
||||||
if (selectedTransactions.size === filteredTransactions.length) {
|
if (!transactionsData) return;
|
||||||
|
if (selectedTransactions.size === transactionsData.transactions.length) {
|
||||||
setSelectedTransactions(new Set());
|
setSelectedTransactions(new Set());
|
||||||
} else {
|
} 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);
|
setSortField(field);
|
||||||
setSortOrder(field === "date" ? "desc" : "asc");
|
setSortOrder(field === "date" ? "desc" : "asc");
|
||||||
}
|
}
|
||||||
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteTransaction = async (transactionId: string) => {
|
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
|
// Remove from selected if selected
|
||||||
const newSelected = new Set(selectedTransactions);
|
const newSelected = new Set(selectedTransactions);
|
||||||
newSelected.delete(transactionId);
|
newSelected.delete(transactionId);
|
||||||
@@ -523,22 +445,26 @@ export default function TransactionsPage() {
|
|||||||
`/api/banking/transactions?id=${transactionId}`,
|
`/api/banking/transactions?id=${transactionId}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error("Failed to delete transaction");
|
if (!response.ok) throw new Error("Failed to delete transaction");
|
||||||
|
invalidateTransactions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete transaction:", 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 (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Transactions"
|
title="Transactions"
|
||||||
description={`${filteredTransactions.length} transaction${filteredTransactions.length > 1 ? "s" : ""}`}
|
description={`${totalTransactions} transaction${totalTransactions > 1 ? "s" : ""}`}
|
||||||
actions={
|
actions={
|
||||||
<OFXImportDialog onImportComplete={refresh}>
|
<OFXImportDialog onImportComplete={invalidateAll}>
|
||||||
<Button>
|
<Button>
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
Importer OFX
|
Importer OFX
|
||||||
@@ -551,14 +477,24 @@ export default function TransactionsPage() {
|
|||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
selectedAccounts={selectedAccounts}
|
selectedAccounts={selectedAccounts}
|
||||||
onAccountsChange={setSelectedAccounts}
|
onAccountsChange={(accounts) => {
|
||||||
|
setSelectedAccounts(accounts);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
selectedCategories={selectedCategories}
|
selectedCategories={selectedCategories}
|
||||||
onCategoriesChange={setSelectedCategories}
|
onCategoriesChange={(categories) => {
|
||||||
|
setSelectedCategories(categories);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
showReconciled={showReconciled}
|
showReconciled={showReconciled}
|
||||||
onReconciledChange={setShowReconciled}
|
onReconciledChange={(value) => {
|
||||||
|
setShowReconciled(value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
period={period}
|
period={period}
|
||||||
onPeriodChange={(p) => {
|
onPeriodChange={(p) => {
|
||||||
setPeriod(p);
|
setPeriod(p);
|
||||||
|
setPage(0);
|
||||||
if (p !== "custom") {
|
if (p !== "custom") {
|
||||||
setIsCustomDatePickerOpen(false);
|
setIsCustomDatePickerOpen(false);
|
||||||
} else {
|
} else {
|
||||||
@@ -567,48 +503,89 @@ export default function TransactionsPage() {
|
|||||||
}}
|
}}
|
||||||
customStartDate={customStartDate}
|
customStartDate={customStartDate}
|
||||||
customEndDate={customEndDate}
|
customEndDate={customEndDate}
|
||||||
onCustomStartDateChange={setCustomStartDate}
|
onCustomStartDateChange={(date) => {
|
||||||
onCustomEndDateChange={setCustomEndDate}
|
setCustomStartDate(date);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
onCustomEndDateChange={(date) => {
|
||||||
|
setCustomEndDate(date);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
isCustomDatePickerOpen={isCustomDatePickerOpen}
|
isCustomDatePickerOpen={isCustomDatePickerOpen}
|
||||||
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen}
|
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen}
|
||||||
accounts={data.accounts}
|
accounts={metadata.accounts}
|
||||||
folders={data.folders}
|
folders={metadata.folders}
|
||||||
categories={data.categories}
|
categories={metadata.categories}
|
||||||
transactionsForAccountFilter={transactionsForAccountFilter}
|
transactionsForAccountFilter={transactionsForAccountFilter}
|
||||||
transactionsForCategoryFilter={transactionsForCategoryFilter}
|
transactionsForCategoryFilter={transactionsForCategoryFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TransactionBulkActions
|
<TransactionBulkActions
|
||||||
selectedCount={selectedTransactions.size}
|
selectedCount={selectedTransactions.size}
|
||||||
categories={data.categories}
|
categories={metadata.categories}
|
||||||
onReconcile={bulkReconcile}
|
onReconcile={bulkReconcile}
|
||||||
onSetCategory={bulkSetCategory}
|
onSetCategory={bulkSetCategory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TransactionTable
|
{isLoadingTransactions ? (
|
||||||
transactions={filteredTransactions}
|
<LoadingState />
|
||||||
accounts={data.accounts}
|
) : (
|
||||||
categories={data.categories}
|
<>
|
||||||
selectedTransactions={selectedTransactions}
|
<TransactionTable
|
||||||
sortField={sortField}
|
transactions={filteredTransactions}
|
||||||
sortOrder={sortOrder}
|
accounts={metadata.accounts}
|
||||||
onSortChange={handleSortChange}
|
categories={metadata.categories}
|
||||||
onToggleSelectAll={toggleSelectAll}
|
selectedTransactions={selectedTransactions}
|
||||||
onToggleSelectTransaction={toggleSelectTransaction}
|
sortField={sortField}
|
||||||
onToggleReconciled={toggleReconciled}
|
sortOrder={sortOrder}
|
||||||
onMarkReconciled={markReconciled}
|
onSortChange={handleSortChange}
|
||||||
onSetCategory={setCategory}
|
onToggleSelectAll={toggleSelectAll}
|
||||||
onCreateRule={handleCreateRule}
|
onToggleSelectTransaction={toggleSelectTransaction}
|
||||||
onDelete={deleteTransaction}
|
onToggleReconciled={toggleReconciled}
|
||||||
formatCurrency={formatCurrency}
|
onMarkReconciled={markReconciled}
|
||||||
formatDate={formatDate}
|
onSetCategory={setCategory}
|
||||||
/>
|
onCreateRule={handleCreateRule}
|
||||||
|
onDelete={deleteTransaction}
|
||||||
|
formatCurrency={formatCurrency}
|
||||||
|
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
|
<RuleCreateDialog
|
||||||
open={ruleDialogOpen}
|
open={ruleDialogOpen}
|
||||||
onOpenChange={setRuleDialogOpen}
|
onOpenChange={setRuleDialogOpen}
|
||||||
group={ruleGroup}
|
group={ruleGroup}
|
||||||
categories={data.categories}
|
categories={metadata.categories}
|
||||||
onSave={handleSaveRule}
|
onSave={handleSaveRule}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@@ -32,4 +32,3 @@ export function AccountBulkActions({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} 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 { cn } from "@/lib/utils";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Account, Folder } from "@/lib/types";
|
import type { Account, Folder } from "@/lib/types";
|
||||||
@@ -69,7 +75,13 @@ export function AccountCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cardContent = (
|
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">
|
<CardHeader className="pb-0">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-2 flex-1">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
@@ -96,7 +108,9 @@ export function AccountCard({
|
|||||||
<Icon className="w-4 h-4 text-primary" />
|
<Icon className="w-4 h-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<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 && (
|
{!compact && (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -140,7 +154,7 @@ export function AccountCard({
|
|||||||
compact ? "text-lg" : "text-xl",
|
compact ? "text-lg" : "text-xl",
|
||||||
"font-bold",
|
"font-bold",
|
||||||
!compact && "mb-1.5",
|
!compact && "mb-1.5",
|
||||||
realBalance >= 0 ? "text-emerald-600" : "text-red-600"
|
realBalance >= 0 ? "text-emerald-600" : "text-red-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(realBalance)}
|
{formatCurrency(realBalance)}
|
||||||
@@ -165,11 +179,12 @@ export function AccountCard({
|
|||||||
</Link>
|
</Link>
|
||||||
{folder && <span className="truncate ml-2">{folder.name}</span>}
|
{folder && <span className="truncate ml-2">{folder.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
{account.initialBalance !== undefined && account.initialBalance !== null && (
|
{account.initialBalance !== undefined &&
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
account.initialBalance !== null && (
|
||||||
Solde initial: {formatCurrency(account.initialBalance)}
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
</p>
|
Solde initial: {formatCurrency(account.initialBalance)}
|
||||||
)}
|
</p>
|
||||||
|
)}
|
||||||
{account.lastImport && (
|
{account.lastImport && (
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
Dernier import:{" "}
|
Dernier import:{" "}
|
||||||
@@ -203,4 +218,3 @@ export function AccountCard({
|
|||||||
|
|
||||||
return cardContent;
|
return cardContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,4 +142,3 @@ export function AccountEditDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,3 @@ export const accountTypeLabels = {
|
|||||||
CREDIT_CARD: "Carte de crédit",
|
CREDIT_CARD: "Carte de crédit",
|
||||||
OTHER: "Autre",
|
OTHER: "Autre",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,3 @@ export { AccountCard } from "./account-card";
|
|||||||
export { AccountEditDialog } from "./account-edit-dialog";
|
export { AccountEditDialog } from "./account-edit-dialog";
|
||||||
export { AccountBulkActions } from "./account-bulk-actions";
|
export { AccountBulkActions } from "./account-bulk-actions";
|
||||||
export { accountTypeIcons, accountTypeLabels } from "./constants";
|
export { accountTypeIcons, accountTypeLabels } from "./constants";
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
import { Pencil, Trash2 } from "lucide-react";
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import type { Category } from "@/lib/types";
|
import type { Category } from "@/lib/types";
|
||||||
|
|
||||||
interface CategoryCardProps {
|
interface CategoryCardProps {
|
||||||
@@ -21,39 +22,48 @@ export function CategoryCard({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: CategoryCardProps) {
|
}: CategoryCardProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group">
|
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
className="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
|
className="w-4 h-4 md:w-5 md:h-5 rounded-full flex items-center justify-center shrink-0"
|
||||||
style={{ backgroundColor: `${category.color}20` }}
|
style={{ backgroundColor: `${category.color}20` }}
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
icon={category.icon}
|
icon={category.icon}
|
||||||
color={category.color}
|
color={category.color}
|
||||||
size={12}
|
size={isMobile ? 10 : 12}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm truncate">{category.name}</span>
|
<span className="text-xs md:text-sm truncate">{category.name}</span>
|
||||||
<span className="text-sm text-muted-foreground shrink-0">
|
{!isMobile && (
|
||||||
{stats.count} opération{stats.count > 1 ? "s" : ""} •{" "}
|
<span className="text-xs md:text-sm text-muted-foreground shrink-0">
|
||||||
{formatCurrency(stats.total)}
|
{stats.count} opération{stats.count > 1 ? "s" : ""} •{" "}
|
||||||
</span>
|
{formatCurrency(stats.total)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isMobile && (
|
||||||
|
<span className="text-[10px] md:text-xs text-muted-foreground shrink-0">
|
||||||
|
{stats.count} 💳
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{category.keywords.length > 0 && (
|
{category.keywords.length > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-[10px] px-1.5 py-0 h-4 shrink-0"
|
className="text-[10px] px-1 md:px-1.5 py-0 h-3 md:h-4 shrink-0"
|
||||||
>
|
>
|
||||||
{category.keywords.length}
|
{category.keywords.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 md:opacity-100 transition-opacity">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6"
|
className="h-6 w-6 md:h-6 md:w-6"
|
||||||
onClick={() => onEdit(category)}
|
onClick={() => onEdit(category)}
|
||||||
>
|
>
|
||||||
<Pencil className="w-3 h-3" />
|
<Pencil className="w-3 h-3" />
|
||||||
@@ -61,7 +71,7 @@ export function CategoryCard({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
className="h-6 w-6 md:h-6 md:w-6 text-destructive hover:text-destructive"
|
||||||
onClick={() => onDelete(category.id)}
|
onClick={() => onDelete(category.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
@@ -70,4 +80,3 @@ export function CategoryCard({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function CategoryEditDialog({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"w-7 h-7 rounded-full transition-transform",
|
"w-7 h-7 rounded-full transition-transform",
|
||||||
formData.color === color &&
|
formData.color === color &&
|
||||||
"ring-2 ring-offset-2 ring-primary scale-110"
|
"ring-2 ring-offset-2 ring-primary scale-110",
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
/>
|
/>
|
||||||
@@ -201,4 +201,3 @@ export function CategoryEditDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,4 +43,3 @@ export function CategorySearchBar({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,4 +15,3 @@ export const categoryColors = [
|
|||||||
"#0891b2",
|
"#0891b2",
|
||||||
"#dc2626",
|
"#dc2626",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ export { CategoryEditDialog } from "./category-edit-dialog";
|
|||||||
export { ParentCategoryRow } from "./parent-category-row";
|
export { ParentCategoryRow } from "./parent-category-row";
|
||||||
export { CategorySearchBar } from "./category-search-bar";
|
export { CategorySearchBar } from "./category-search-bar";
|
||||||
export { categoryColors } from "./constants";
|
export { categoryColors } from "./constants";
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { CategoryCard } from "./category-card";
|
import { CategoryCard } from "./category-card";
|
||||||
import type { Category } from "@/lib/types";
|
import type { Category } from "@/lib/types";
|
||||||
|
|
||||||
@@ -49,51 +50,66 @@ export function ParentCategoryRow({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onNewCategory,
|
onNewCategory,
|
||||||
}: ParentCategoryRowProps) {
|
}: ParentCategoryRowProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg bg-card">
|
<div className="border rounded-lg bg-card">
|
||||||
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
|
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
|
||||||
<div className="flex items-center justify-between px-3 py-2">
|
<div className="flex items-center justify-between px-2 md:px-3 py-1.5 md:py-2">
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
|
<button className="flex items-center gap-1.5 md:gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
<ChevronDown className="w-3 h-3 md:w-4 md:h-4 text-muted-foreground shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
<ChevronRight className="w-3 h-3 md:w-4 md:h-4 text-muted-foreground shrink-0" />
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
|
className="w-5 h-5 md:w-7 md:h-7 rounded-full flex items-center justify-center shrink-0"
|
||||||
style={{ backgroundColor: `${parent.color}20` }}
|
style={{ backgroundColor: `${parent.color}20` }}
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
icon={parent.icon}
|
icon={parent.icon}
|
||||||
color={parent.color}
|
color={parent.color}
|
||||||
size={14}
|
size={isMobile ? 10 : 14}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-sm truncate">{parent.name}</span>
|
<span className="font-medium text-xs md:text-sm truncate">
|
||||||
<span className="text-sm text-muted-foreground shrink-0">
|
{parent.name}
|
||||||
{children.length} • {stats.count} opération
|
|
||||||
{stats.count > 1 ? "s" : ""} • {formatCurrency(stats.total)}
|
|
||||||
</span>
|
</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>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
<div className="flex items-center gap-0.5 md:gap-1 shrink-0 ml-1 md:ml-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-6 w-6 md:h-7 md:w-7"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onNewCategory(parent.id);
|
onNewCategory(parent.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
<Button
|
||||||
<MoreVertical className="w-4 h-4" />
|
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>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@@ -115,7 +131,7 @@ export function ParentCategoryRow({
|
|||||||
|
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
{children.length > 0 ? (
|
{children.length > 0 ? (
|
||||||
<div className="px-3 pb-2 space-y-1 ml-6 border-l-2 border-muted ml-5">
|
<div className="px-2 md:px-3 pb-2 space-y-1 ml-4 md:ml-6 border-l-2 border-muted md:ml-5">
|
||||||
{children.map((child) => (
|
{children.map((child) => (
|
||||||
<CategoryCard
|
<CategoryCard
|
||||||
key={child.id}
|
key={child.id}
|
||||||
@@ -137,4 +153,3 @@ export function ParentCategoryRow({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,12 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
|
|||||||
{/* Folder header */}
|
{/* Folder header */}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<FolderIcon className="w-4 h-4 text-muted-foreground" />
|
<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}
|
{folder.name}
|
||||||
</h3>
|
</h3>
|
||||||
{folderAccounts.length > 0 && (
|
{folderAccounts.length > 0 && (
|
||||||
@@ -122,9 +127,7 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-semibold tabular-nums",
|
"font-semibold tabular-nums",
|
||||||
realBalance >= 0
|
realBalance >= 0 ? "text-emerald-600" : "text-red-600",
|
||||||
? "text-emerald-600"
|
|
||||||
: "text-red-600",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(realBalance)}
|
{formatCurrency(realBalance)}
|
||||||
@@ -218,7 +221,9 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
|
|||||||
<Building2 className="w-4 h-4 text-primary" />
|
<Building2 className="w-4 h-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-xs text-muted-foreground">
|
||||||
{account.accountNumber}
|
{account.accountNumber}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function CategoryBreakdown({ data }: CategoryBreakdownProps) {
|
|||||||
const thisMonthStr = thisMonth.toISOString().slice(0, 7);
|
const thisMonthStr = thisMonth.toISOString().slice(0, 7);
|
||||||
|
|
||||||
const monthExpenses = data.transactions.filter(
|
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>();
|
const categoryTotals = new Map<string, number>();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { TrendingUp, TrendingDown, Wallet, CreditCard } from "lucide-react";
|
import { TrendingUp, TrendingDown, Wallet, CreditCard } from "lucide-react";
|
||||||
import type { BankingData } from "@/lib/types";
|
import type { BankingData } from "@/lib/types";
|
||||||
import { getAccountBalance } from "@/lib/account-utils";
|
import { getAccountBalance } from "@/lib/account-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface OverviewCardsProps {
|
interface OverviewCardsProps {
|
||||||
data: BankingData;
|
data: BankingData;
|
||||||
@@ -47,21 +48,21 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
|
||||||
Solde Total
|
Solde Total
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Wallet className="h-4 w-4 text-muted-foreground" />
|
<Wallet className="h-3 w-3 md:h-4 md:w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl font-bold",
|
"text-xl md:text-2xl font-bold",
|
||||||
totalBalance >= 0 ? "text-emerald-600" : "text-red-600",
|
totalBalance >= 0 ? "text-emerald-600" : "text-red-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(totalBalance)}
|
{formatCurrency(totalBalance)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
|
||||||
{data.accounts.length} compte{data.accounts.length > 1 ? "s" : ""}
|
{data.accounts.length} compte{data.accounts.length > 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -69,16 +70,16 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
|
||||||
Revenus du mois
|
Revenus du mois
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<TrendingUp className="h-4 w-4 text-emerald-600" />
|
<TrendingUp className="h-3 w-3 md:h-4 md:w-4 text-emerald-600" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-emerald-600">
|
<div className="text-xl md:text-2xl font-bold text-emerald-600">
|
||||||
{formatCurrency(income)}
|
{formatCurrency(income)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
|
||||||
{monthTransactions.filter((t) => t.amount > 0).length} opération
|
{monthTransactions.filter((t) => t.amount > 0).length} opération
|
||||||
{monthTransactions.filter((t) => t.amount > 0).length > 1
|
{monthTransactions.filter((t) => t.amount > 0).length > 1
|
||||||
? "s"
|
? "s"
|
||||||
@@ -89,16 +90,16 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
|
||||||
Dépenses du mois
|
Dépenses du mois
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<TrendingDown className="h-4 w-4 text-red-600" />
|
<TrendingDown className="h-3 w-3 md:h-4 md:w-4 text-red-600" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-red-600">
|
<div className="text-xl md:text-2xl font-bold text-red-600">
|
||||||
{formatCurrency(expenses)}
|
{formatCurrency(expenses)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
|
||||||
{monthTransactions.filter((t) => t.amount < 0).length} opération
|
{monthTransactions.filter((t) => t.amount < 0).length} opération
|
||||||
{monthTransactions.filter((t) => t.amount < 0).length > 1
|
{monthTransactions.filter((t) => t.amount < 0).length > 1
|
||||||
? "s"
|
? "s"
|
||||||
@@ -109,14 +110,16 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
|
||||||
Pointage
|
Pointage
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
<CreditCard className="h-3 w-3 md:h-4 md:w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{reconciledPercent}%</div>
|
<div className="text-xl md:text-2xl font-bold">
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
{reconciledPercent}%
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
|
||||||
{reconciled} / {total} opérations pointées
|
{reconciled} / {total} opérations pointées
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -124,5 +127,3 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|||||||
@@ -60,9 +60,11 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Transactions récentes</CardTitle>
|
<CardTitle className="text-sm md:text-base">
|
||||||
|
Transactions récentes
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="px-3 md:px-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{recentTransactions.map((transaction) => {
|
{recentTransactions.map((transaction) => {
|
||||||
const category = getCategory(transaction.categoryId);
|
const category = getCategory(transaction.categoryId);
|
||||||
@@ -71,59 +73,74 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={transaction.id}
|
key={transaction.id}
|
||||||
className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors"
|
className="rounded-lg bg-muted/50 hover:bg-muted transition-colors overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex items-start gap-2 md:gap-3 p-2 md:p-3">
|
||||||
{transaction.isReconciled ? (
|
<div className="flex-shrink-0 pt-0.5">
|
||||||
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
|
{transaction.isReconciled ? (
|
||||||
) : (
|
<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">
|
|
||||||
{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>
|
|
||||||
|
|
||||||
<div
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
className={cn(
|
<div className="flex items-start justify-between gap-2">
|
||||||
"font-semibold tabular-nums",
|
<p className="font-medium text-xs md:text-base truncate flex-1">
|
||||||
transaction.amount >= 0
|
{transaction.description}
|
||||||
? "text-emerald-600"
|
</p>
|
||||||
: "text-red-600",
|
<div
|
||||||
)}
|
className={cn(
|
||||||
>
|
"font-semibold tabular-nums text-xs md:text-base shrink-0 md:hidden",
|
||||||
{transaction.amount >= 0 ? "+" : ""}
|
transaction.amount >= 0
|
||||||
{formatCurrency(transaction.amount)}
|
? "text-emerald-600"
|
||||||
|
: "text-red-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{transaction.amount >= 0 ? "+" : ""}
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/", label: "Tableau de bord", icon: LayoutDashboard },
|
{ href: "/", label: "Tableau de bord", icon: LayoutDashboard },
|
||||||
@@ -29,10 +31,18 @@ const navItems = [
|
|||||||
{ href: "/statistics", label: "Statistiques", icon: BarChart3 },
|
{ href: "/statistics", label: "Statistiques", icon: BarChart3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
interface SidebarContentProps {
|
||||||
|
collapsed?: boolean;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({
|
||||||
|
collapsed = false,
|
||||||
|
onNavigate,
|
||||||
|
showHeader = false,
|
||||||
|
}: SidebarContentProps & { showHeader?: boolean }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -46,10 +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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col h-screen bg-card border-r border-border transition-all duration-300",
|
"hidden md:flex flex-col h-screen bg-card border-r border-border transition-all duration-300",
|
||||||
collapsed ? "w-16" : "w-64",
|
collapsed ? "w-16" : "w-64",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -76,51 +178,7 @@ export function Sidebar() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 p-2 space-y-1">
|
<SidebarContent collapsed={collapsed} showHeader={false} />
|
||||||
{navItems.map((item) => {
|
|
||||||
const isActive = pathname === item.href;
|
|
||||||
return (
|
|
||||||
<Link key={item.href} href={item.href}>
|
|
||||||
<Button
|
|
||||||
variant={isActive ? "secondary" : "ghost"}
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start gap-3",
|
|
||||||
collapsed && "justify-center px-2",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon className="w-5 h-5 shrink-0" />
|
|
||||||
{!collapsed && <span>{item.label}</span>}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="p-2 border-t border-border space-y-1">
|
|
||||||
<Link href="/settings">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start gap-3",
|
|
||||||
collapsed && "justify-center px-2",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Settings className="w-5 h-5 shrink-0" />
|
|
||||||
{!collapsed && <span>Paramètres</span>}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleSignOut}
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start gap-3 text-destructive hover:text-destructive hover:bg-destructive/10",
|
|
||||||
collapsed && "justify-center px-2",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<LogOut className="w-5 h-5 shrink-0" />
|
|
||||||
{!collapsed && <span>Déconnexion</span>}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,4 +115,3 @@ export function AccountFolderDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,3 @@ export const accountTypeLabels = {
|
|||||||
CREDIT_CARD: "Carte de crédit",
|
CREDIT_CARD: "Carte de crédit",
|
||||||
OTHER: "Autre",
|
OTHER: "Autre",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function DraggableAccountItem({
|
|||||||
style={style}
|
style={style}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group ml-12",
|
"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
|
<button
|
||||||
@@ -66,14 +66,15 @@ export function DraggableAccountItem({
|
|||||||
{account.name}
|
{account.name}
|
||||||
{account.accountNumber && (
|
{account.accountNumber && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{" "}({account.accountNumber})
|
{" "}
|
||||||
|
({account.accountNumber})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm tabular-nums",
|
"text-sm tabular-nums",
|
||||||
realBalance >= 0 ? "text-emerald-600" : "text-red-600"
|
realBalance >= 0 ? "text-emerald-600" : "text-red-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(realBalance)}
|
{formatCurrency(realBalance)}
|
||||||
@@ -89,4 +90,3 @@ export function DraggableAccountItem({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function DraggableFolderItem({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
|
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
|
||||||
level > 0 && "ml-6",
|
level > 0 && "ml-6",
|
||||||
isDragging && "bg-muted/80"
|
isDragging && "bg-muted/80",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -120,7 +120,7 @@ export function DraggableFolderItem({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-semibold tabular-nums",
|
"text-sm font-semibold tabular-nums",
|
||||||
folderTotal >= 0 ? "text-emerald-600" : "text-red-600"
|
folderTotal >= 0 ? "text-emerald-600" : "text-red-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(folderTotal)}
|
{formatCurrency(folderTotal)}
|
||||||
@@ -157,4 +157,3 @@ export function DraggableFolderItem({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,11 +96,13 @@ export function FolderEditDialog({
|
|||||||
{folderColors.map(({ value }) => (
|
{folderColors.map(({ value }) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
onClick={() => onFormDataChange({ ...formData, color: value })}
|
onClick={() =>
|
||||||
|
onFormDataChange({ ...formData, color: value })
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-8 h-8 rounded-full transition-transform",
|
"w-8 h-8 rounded-full transition-transform",
|
||||||
formData.color === value &&
|
formData.color === value &&
|
||||||
"ring-2 ring-offset-2 ring-primary scale-110"
|
"ring-2 ring-offset-2 ring-primary scale-110",
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: value }}
|
style={{ backgroundColor: value }}
|
||||||
/>
|
/>
|
||||||
@@ -120,4 +122,3 @@ export function FolderEditDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function FolderTreeItem({
|
|||||||
const folderAccounts = accounts.filter(
|
const folderAccounts = accounts.filter(
|
||||||
(a) =>
|
(a) =>
|
||||||
a.folderId === folder.id ||
|
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 childFolders = allFolders.filter((f) => f.parentId === folder.id);
|
||||||
const folderTotal = folderAccounts.reduce(
|
const folderTotal = folderAccounts.reduce(
|
||||||
@@ -88,4 +88,3 @@ export function FolderTreeItem({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ export { AccountFolderDialog } from "./account-folder-dialog";
|
|||||||
export { DraggableFolderItem } from "./draggable-folder-item";
|
export { DraggableFolderItem } from "./draggable-folder-item";
|
||||||
export { DraggableAccountItem } from "./draggable-account-item";
|
export { DraggableAccountItem } from "./draggable-account-item";
|
||||||
export { folderColors, accountTypeLabels } from "./constants";
|
export { folderColors, accountTypeLabels } from "./constants";
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export function OFXImportDialog({
|
|||||||
type: parsed.accountType as Account["type"],
|
type: parsed.accountType as Account["type"],
|
||||||
folderId: "folder-root",
|
folderId: "folder-root",
|
||||||
balance: parsed.balance,
|
balance: parsed.balance,
|
||||||
|
initialBalance: parsed.balance,
|
||||||
currency: parsed.currency,
|
currency: parsed.currency,
|
||||||
lastImport: new Date().toISOString(),
|
lastImport: new Date().toISOString(),
|
||||||
externalUrl: null,
|
externalUrl: null,
|
||||||
@@ -297,6 +298,7 @@ export function OFXImportDialog({
|
|||||||
type: parsedData.accountType as Account["type"],
|
type: parsedData.accountType as Account["type"],
|
||||||
folderId: selectedFolder,
|
folderId: selectedFolder,
|
||||||
balance: parsedData.balance,
|
balance: parsedData.balance,
|
||||||
|
initialBalance: parsedData.balance,
|
||||||
currency: parsedData.currency,
|
currency: parsedData.currency,
|
||||||
lastImport: new Date().toISOString(),
|
lastImport: new Date().toISOString(),
|
||||||
externalUrl: null,
|
externalUrl: null,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export { PageLayout } from "./page-layout";
|
export { PageLayout } from "./page-layout";
|
||||||
export { LoadingState } from "./loading-state";
|
export { LoadingState } from "./loading-state";
|
||||||
export { PageHeader } from "./page-header";
|
export { PageHeader } from "./page-header";
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,3 @@ export function LoadingState() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Menu } from "lucide-react";
|
||||||
|
import { useSidebarContext } from "@/components/layout/sidebar-context";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -15,17 +20,43 @@ export function PageHeader({
|
|||||||
actions,
|
actions,
|
||||||
rightContent,
|
rightContent,
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
|
const { setOpen } = useSidebarContext();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
{isMobile && (
|
||||||
{description && (
|
<Button
|
||||||
<div className="text-muted-foreground">{description}</div>
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg md:text-2xl font-bold text-foreground">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{description && (
|
||||||
|
<div className="text-xs md:text-base text-muted-foreground mt-1">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{rightContent}
|
{(rightContent || actions) && (
|
||||||
{actions && <div className="flex gap-2">{actions}</div>}
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{rightContent}
|
||||||
|
{actions && (
|
||||||
|
<div className={cn("flex gap-2", isMobile && "flex-wrap")}>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode, useState } from "react";
|
||||||
|
import { SidebarContext } from "@/components/layout/sidebar-context";
|
||||||
|
|
||||||
interface PageLayoutProps {
|
interface PageLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageLayout({ children }: PageLayoutProps) {
|
export function PageLayout({ children }: PageLayoutProps) {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<SidebarContext.Provider
|
||||||
<Sidebar />
|
value={{ open: sidebarOpen, setOpen: setSidebarOpen }}
|
||||||
<main className="flex-1 overflow-auto">
|
>
|
||||||
<div className="p-6 space-y-6">{children}</div>
|
<div className="flex h-screen bg-background overflow-hidden">
|
||||||
</main>
|
<Sidebar open={sidebarOpen} onOpenChange={setSidebarOpen} />
|
||||||
</div>
|
<main className="flex-1 overflow-auto overflow-x-hidden">
|
||||||
|
<div className="p-4 md:p-6 space-y-4 md:space-y-6 max-w-full">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</SidebarContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
components/layout/sidebar-context.tsx
Normal file
17
components/layout/sidebar-context.tsx
Normal 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);
|
||||||
|
}
|
||||||
24
components/providers/query-provider.tsx
Normal file
24
components/providers/query-provider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,4 +6,3 @@ import type { ReactNode } from "react";
|
|||||||
export function AuthSessionProvider({ children }: { children: ReactNode }) {
|
export function AuthSessionProvider({ children }: { children: ReactNode }) {
|
||||||
return <SessionProvider>{children}</SessionProvider>;
|
return <SessionProvider>{children}</SessionProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function suggestKeyword(descriptions: string[]): string {
|
|||||||
if (sorted.length > 0) {
|
if (sorted.length > 0) {
|
||||||
// Return the longest frequent keyword
|
// Return the longest frequent keyword
|
||||||
return sorted.reduce((best, current) =>
|
return sorted.reduce((best, current) =>
|
||||||
current[0].length > best[0].length ? current : best
|
current[0].length > best[0].length ? current : best,
|
||||||
)[0];
|
)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,4 +92,3 @@ export function suggestKeyword(descriptions: string[]): string {
|
|||||||
const firstKeywords = extractKeywords(descriptions[0]);
|
const firstKeywords = extractKeywords(descriptions[0]);
|
||||||
return firstKeywords[0] || descriptions[0].slice(0, 15);
|
return firstKeywords[0] || descriptions[0].slice(0, 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export { RuleGroupCard } from "./rule-group-card";
|
export { RuleGroupCard } from "./rule-group-card";
|
||||||
export { RuleCreateDialog } from "./rule-create-dialog";
|
export { RuleCreateDialog } from "./rule-create-dialog";
|
||||||
export { RulesSearchBar } from "./rules-search-bar";
|
export { RulesSearchBar } from "./rules-search-bar";
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function RuleCreateDialog({
|
|||||||
if (!keyword) return null;
|
if (!keyword) return null;
|
||||||
const lowerKeyword = keyword.toLowerCase();
|
const lowerKeyword = keyword.toLowerCase();
|
||||||
return categories.find((c) =>
|
return categories.find((c) =>
|
||||||
c.keywords.some((k) => k.toLowerCase() === lowerKeyword)
|
c.keywords.some((k) => k.toLowerCase() === lowerKeyword),
|
||||||
);
|
);
|
||||||
}, [keyword, categories]);
|
}, [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">
|
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
|
||||||
<AlertCircle className="h-3 w-3" />
|
<AlertCircle className="h-3 w-3" />
|
||||||
<span>
|
<span>
|
||||||
Ce mot-clé existe déjà dans "{existingCategory.name}"
|
Ce mot-clé existe déjà dans "{existingCategory.name}
|
||||||
|
"
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -202,8 +203,9 @@ export function RuleCreateDialog({
|
|||||||
<div className="flex items-center gap-2 text-sm text-success">
|
<div className="flex items-center gap-2 text-sm text-success">
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
Le mot-clé "<strong>{keyword}</strong>" sera ajouté à la
|
Le mot-clé "<strong>{keyword}</strong>" sera ajouté
|
||||||
catégorie "<strong>{selectedCategory?.name}</strong>"
|
à la catégorie "<strong>{selectedCategory?.name}</strong>
|
||||||
|
"
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,4 +227,3 @@ export function RuleCreateDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ChevronDown, ChevronRight, Plus, Tag } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { CategoryCombobox } from "@/components/ui/category-combobox";
|
import { CategoryCombobox } from "@/components/ui/category-combobox";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Transaction, Category } from "@/lib/types";
|
import type { Transaction, Category } from "@/lib/types";
|
||||||
|
|
||||||
@@ -37,7 +38,10 @@ export function RuleGroupCard({
|
|||||||
formatCurrency,
|
formatCurrency,
|
||||||
formatDate,
|
formatDate,
|
||||||
}: RuleGroupCardProps) {
|
}: RuleGroupCardProps) {
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const avgAmount =
|
const avgAmount =
|
||||||
group.transactions.reduce((sum, t) => sum + t.amount, 0) /
|
group.transactions.reduce((sum, t) => sum + t.amount, 0) /
|
||||||
@@ -53,59 +57,94 @@ export function RuleGroupCard({
|
|||||||
<div className="border border-border rounded-lg bg-card overflow-hidden">
|
<div className="border border-border rounded-lg bg-card overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3 p-4 cursor-pointer hover:bg-accent/5 transition-colors"
|
className="flex flex-col md:flex-row md:items-center gap-2 md:gap-3 p-3 md:p-4 cursor-pointer hover:bg-accent/5 transition-colors"
|
||||||
onClick={onToggleExpand}
|
onClick={onToggleExpand}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0">
|
<div className="flex items-center gap-2 md:gap-3 flex-1 min-w-0">
|
||||||
{isExpanded ? (
|
<Button
|
||||||
<ChevronDown className="h-4 w-4" />
|
variant="ghost"
|
||||||
) : (
|
size="icon"
|
||||||
<ChevronRight className="h-4 w-4" />
|
className="h-5 w-5 md:h-6 md:w-6 shrink-0"
|
||||||
)}
|
>
|
||||||
</Button>
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-3 w-3 md:h-4 md: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-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
|
||||||
<span className="font-medium text-foreground truncate">
|
<span className="font-medium text-xs md:text-base text-foreground truncate">
|
||||||
{group.displayName}
|
{group.displayName}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="secondary" className="shrink-0">
|
<Badge
|
||||||
{group.transactions.length} transaction
|
variant="secondary"
|
||||||
{group.transactions.length > 1 ? "s" : ""}
|
className="text-[10px] md:text-xs shrink-0"
|
||||||
</Badge>
|
>
|
||||||
</div>
|
{group.transactions.length} 💳
|
||||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
|
</Badge>
|
||||||
<Tag className="h-3 w-3" />
|
</div>
|
||||||
<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">
|
||||||
{group.suggestedKeyword}
|
<Tag className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
||||||
</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
{!isMobile && (
|
||||||
<div className="text-right">
|
<div className="flex items-center gap-4">
|
||||||
<div
|
<div className="text-right">
|
||||||
className={cn(
|
<div
|
||||||
"font-semibold tabular-nums",
|
className={cn(
|
||||||
isDebit ? "text-destructive" : "text-success"
|
"font-semibold tabular-nums text-sm",
|
||||||
)}
|
isDebit ? "text-destructive" : "text-success",
|
||||||
>
|
)}
|
||||||
{formatCurrency(group.totalAmount)}
|
>
|
||||||
|
{formatCurrency(group.totalAmount)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Moy: {formatCurrency(avgAmount)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Moy: {formatCurrency(avgAmount)}
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<CategoryCombobox
|
||||||
|
categories={categories}
|
||||||
|
value={selectedCategoryId}
|
||||||
|
onChange={handleCategorySelect}
|
||||||
|
placeholder="Catégoriser..."
|
||||||
|
width="w-[300px]"
|
||||||
|
buttonWidth="w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCreateRule();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Créer règle
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
{isMobile && (
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-2 shrink-0 ml-7">
|
||||||
|
<div onClick={(e) => e.stopPropagation()} className="flex-1">
|
||||||
<CategoryCombobox
|
<CategoryCombobox
|
||||||
categories={categories}
|
categories={categories}
|
||||||
value={selectedCategoryId}
|
value={selectedCategoryId}
|
||||||
onChange={handleCategorySelect}
|
onChange={handleCategorySelect}
|
||||||
placeholder="Catégoriser..."
|
placeholder="Catégoriser..."
|
||||||
width="w-[300px]"
|
width="w-full"
|
||||||
buttonWidth="w-auto"
|
buttonWidth="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -114,67 +153,100 @@ export function RuleGroupCard({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onCreateRule();
|
onCreateRule();
|
||||||
}}
|
}}
|
||||||
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-3 w-3 md:h-4 md:w-4" />
|
||||||
Créer règle
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded transactions list */}
|
{/* Expanded transactions list */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-border bg-muted/30">
|
<div className="border-t border-border bg-muted/30">
|
||||||
<div className="max-h-64 overflow-y-auto">
|
{isMobile ? (
|
||||||
<table className="w-full text-sm">
|
<div className="max-h-64 overflow-y-auto divide-y divide-border">
|
||||||
<thead className="bg-muted/50 sticky top-0">
|
{group.transactions.map((transaction) => (
|
||||||
<tr>
|
<div key={transaction.id} className="p-3 hover:bg-muted/50">
|
||||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground">
|
<div className="flex items-start justify-between gap-2">
|
||||||
Date
|
<div className="flex-1 min-w-0">
|
||||||
</th>
|
<p className="text-xs md:text-sm font-medium truncate">
|
||||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground">
|
{transaction.description}
|
||||||
Description
|
</p>
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2 text-right font-medium text-muted-foreground">
|
|
||||||
Montant
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{group.transactions.map((transaction) => (
|
|
||||||
<tr
|
|
||||||
key={transaction.id}
|
|
||||||
className="border-t border-border/50 hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-2 text-muted-foreground whitespace-nowrap">
|
|
||||||
{formatDate(transaction.date)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 truncate max-w-md">
|
|
||||||
{transaction.description}
|
|
||||||
{transaction.memo && (
|
{transaction.memo && (
|
||||||
<span className="text-muted-foreground ml-2">
|
<p className="text-[10px] md:text-xs text-muted-foreground truncate mt-0.5">
|
||||||
({transaction.memo})
|
{transaction.memo}
|
||||||
</span>
|
</p>
|
||||||
)}
|
)}
|
||||||
</td>
|
<p className="text-[10px] md:text-xs text-muted-foreground mt-1">
|
||||||
<td
|
{formatDate(transaction.date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-2 text-right tabular-nums whitespace-nowrap",
|
"text-xs md:text-sm font-semibold tabular-nums shrink-0",
|
||||||
transaction.amount < 0
|
transaction.amount < 0
|
||||||
? "text-destructive"
|
? "text-destructive"
|
||||||
: "text-success"
|
: "text-success",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(transaction.amount)}
|
{formatCurrency(transaction.amount)}
|
||||||
</td>
|
</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">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground">
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground">
|
||||||
|
Description
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium text-muted-foreground">
|
||||||
|
Montant
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{group.transactions.map((transaction) => (
|
||||||
</div>
|
<tr
|
||||||
|
key={transaction.id}
|
||||||
|
className="border-t border-border/50 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDate(transaction.date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 truncate max-w-md">
|
||||||
|
{transaction.description}
|
||||||
|
{transaction.memo && (
|
||||||
|
<span className="text-muted-foreground ml-2">
|
||||||
|
({transaction.memo})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-right tabular-nums whitespace-nowrap",
|
||||||
|
transaction.amount < 0
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-success",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(transaction.amount)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,24 +28,24 @@ export function RulesSearchBar({
|
|||||||
onFilterMinCountChange,
|
onFilterMinCountChange,
|
||||||
}: RulesSearchBarProps) {
|
}: RulesSearchBarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
<div className="flex flex-col gap-2 md:gap-3 mb-4 md:mb-6">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 md:h-4 md:w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Rechercher dans les descriptions..."
|
placeholder="Rechercher..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-9 md:pl-10 text-sm md:text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-2 md:gap-3 flex-wrap">
|
||||||
<Select
|
<Select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onValueChange={(v) => onSortChange(v as "count" | "amount" | "name")}
|
onValueChange={(v) => onSortChange(v as "count" | "amount" | "name")}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-44">
|
<SelectTrigger className="w-full md:w-44 text-sm">
|
||||||
<ArrowUpDown className="h-4 w-4 mr-2" />
|
<ArrowUpDown className="h-3.5 w-3.5 md:h-4 md:w-4 mr-2" />
|
||||||
<SelectValue placeholder="Trier par" />
|
<SelectValue placeholder="Trier par" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -59,8 +59,8 @@ export function RulesSearchBar({
|
|||||||
value={filterMinCount.toString()}
|
value={filterMinCount.toString()}
|
||||||
onValueChange={(v) => onFilterMinCountChange(parseInt(v))}
|
onValueChange={(v) => onFilterMinCountChange(parseInt(v))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-36">
|
<SelectTrigger className="w-full md:w-36 text-sm">
|
||||||
<Filter className="h-4 w-4 mr-2" />
|
<Filter className="h-3.5 w-3.5 md:h-4 md:w-4 mr-2" />
|
||||||
<SelectValue placeholder="Minimum" />
|
<SelectValue placeholder="Minimum" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -75,4 +75,3 @@ export function RulesSearchBar({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,13 +37,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import {
|
import { Database, Trash2, RotateCcw, Save, Clock } from "lucide-react";
|
||||||
Database,
|
|
||||||
Trash2,
|
|
||||||
RotateCcw,
|
|
||||||
Save,
|
|
||||||
Clock,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { fr } from "date-fns/locale/fr";
|
import { fr } from "date-fns/locale/fr";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -84,10 +78,17 @@ export function BackupCard() {
|
|||||||
|
|
||||||
if (backupsData.success) {
|
if (backupsData.success) {
|
||||||
setBackups(
|
setBackups(
|
||||||
backupsData.data.map((b: { id: string; filename: string; size: number; createdAt: string }) => ({
|
backupsData.data.map(
|
||||||
...b,
|
(b: {
|
||||||
createdAt: new Date(b.createdAt),
|
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.success) {
|
||||||
if (data.data.skipped) {
|
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 {
|
} else {
|
||||||
toast.success("Sauvegarde créée avec succès");
|
toast.success("Sauvegarde créée avec succès");
|
||||||
}
|
}
|
||||||
@@ -160,7 +163,9 @@ export function BackupCard() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
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(() => {
|
setTimeout(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@@ -258,9 +263,9 @@ export function BackupCard() {
|
|||||||
<Label htmlFor="backup-frequency">Fréquence</Label>
|
<Label htmlFor="backup-frequency">Fréquence</Label>
|
||||||
<Select
|
<Select
|
||||||
value={settings.frequency}
|
value={settings.frequency}
|
||||||
onValueChange={(value: "hourly" | "daily" | "weekly" | "monthly") =>
|
onValueChange={(
|
||||||
handleSettingsChange({ frequency: value })
|
value: "hourly" | "daily" | "weekly" | "monthly",
|
||||||
}
|
) => handleSettingsChange({ frequency: value })}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="backup-frequency">
|
<SelectTrigger id="backup-frequency">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -369,17 +374,16 @@ export function BackupCard() {
|
|||||||
Restaurer cette sauvegarde ?
|
Restaurer cette sauvegarde ?
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Cette action va remplacer votre base de données
|
Cette action va remplacer votre base de
|
||||||
actuelle par cette sauvegarde. Une sauvegarde
|
données actuelle par cette sauvegarde. Une
|
||||||
de sécurité sera créée avant la restauration.
|
sauvegarde de sécurité sera créée avant la
|
||||||
|
restauration.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() =>
|
onClick={() => handleRestoreBackup(backup.id)}
|
||||||
handleRestoreBackup(backup.id)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Restaurer
|
Restaurer
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
@@ -406,9 +410,7 @@ export function BackupCard() {
|
|||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() =>
|
onClick={() => handleDeleteBackup(backup.id)}
|
||||||
handleDeleteBackup(backup.id)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Supprimer
|
Supprimer
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
@@ -434,4 +436,3 @@ export function BackupCard() {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ interface DangerZoneCardProps {
|
|||||||
categorizedCount: number;
|
categorizedCount: number;
|
||||||
onClearCategories: () => void;
|
onClearCategories: () => void;
|
||||||
onResetData: () => void;
|
onResetData: () => void;
|
||||||
onDeduplicate: () => Promise<{ deletedCount: number; duplicatesFound: number }>;
|
onDeduplicate: () => Promise<{
|
||||||
|
deletedCount: number;
|
||||||
|
duplicatesFound: number;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DangerZoneCard({
|
export function DangerZoneCard({
|
||||||
@@ -42,7 +45,9 @@ export function DangerZoneCard({
|
|||||||
try {
|
try {
|
||||||
const result = await onDeduplicate();
|
const result = await onDeduplicate();
|
||||||
if (result.deletedCount > 0) {
|
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 {
|
} else {
|
||||||
alert("Aucun doublon trouvé");
|
alert("Aucun doublon trouvé");
|
||||||
}
|
}
|
||||||
@@ -88,10 +93,11 @@ export function DangerZoneCard({
|
|||||||
Dédoublonner les transactions ?
|
Dédoublonner les transactions ?
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Cette action va rechercher et supprimer les transactions en double
|
Cette action va rechercher et supprimer les transactions en
|
||||||
dans votre base de données. Les critères de dédoublonnage sont :
|
double dans votre base de données. Les critères de dédoublonnage
|
||||||
même compte, même date, même montant et même libellé. La première
|
sont : même compte, même date, même montant et même libellé. La
|
||||||
transaction trouvée sera conservée, les autres seront supprimées.
|
première transaction trouvée sera conservée, les autres seront
|
||||||
|
supprimées.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -131,8 +137,8 @@ export function DangerZoneCard({
|
|||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Cette action va retirer la catégorie de {categorizedCount}{" "}
|
Cette action va retirer la catégorie de {categorizedCount}{" "}
|
||||||
opération{categorizedCount > 1 ? "s" : ""}. Les catégories
|
opération{categorizedCount > 1 ? "s" : ""}. Les catégories
|
||||||
elles-mêmes ne seront pas supprimées, seulement leur
|
elles-mêmes ne seront pas supprimées, seulement leur affectation
|
||||||
affectation aux opérations.
|
aux opérations.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -179,4 +185,3 @@ export function DangerZoneCard({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,4 +70,3 @@ export function DataCard({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ export { DangerZoneCard } from "./danger-zone-card";
|
|||||||
export { OFXInfoCard } from "./ofx-info-card";
|
export { OFXInfoCard } from "./ofx-info-card";
|
||||||
export { BackupCard } from "./backup-card";
|
export { BackupCard } from "./backup-card";
|
||||||
export { PasswordCard } from "./password-card";
|
export { PasswordCard } from "./password-card";
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ export function OFXInfoCard() {
|
|||||||
<FileJson className="w-5 h-5" />
|
<FileJson className="w-5 h-5" />
|
||||||
Format OFX
|
Format OFX
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Informations sur l'import de fichiers</CardDescription>
|
||||||
Informations sur l'import de fichiers
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="prose prose-sm text-muted-foreground">
|
<div className="prose prose-sm text-muted-foreground">
|
||||||
@@ -29,13 +27,12 @@ export function OFXInfoCard() {
|
|||||||
l'espace client de votre banque.
|
l'espace client de votre banque.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
Lors de l'import, les transactions sont automatiquement
|
Lors de l'import, les transactions sont automatiquement catégorisées
|
||||||
catégorisées selon les mots-clés définis. Les doublons sont détectés
|
selon les mots-clés définis. Les doublons sont détectés et ignorés
|
||||||
et ignorés automatiquement.
|
automatiquement.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,9 @@ export function PasswordCard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="confirm-password"
|
id="confirm-password"
|
||||||
@@ -199,4 +201,3 @@ export function PasswordCard() {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,9 @@ export function BalanceLineChart({
|
|||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: entry.color,
|
backgroundColor: entry.color,
|
||||||
transform: isHovered ? "scale(1.2)" : "scale(1)",
|
transform: isHovered
|
||||||
|
? "scale(1.2)"
|
||||||
|
: "scale(1)",
|
||||||
transition: "transform 0.15s",
|
transition: "transform 0.15s",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -115,4 +115,3 @@ export function CategoryBarChart({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,7 @@ import { useState } from "react";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
import {
|
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts";
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Legend,
|
|
||||||
} from "recharts";
|
|
||||||
import { Layers, List, ChevronDown, ChevronUp } from "lucide-react";
|
import { Layers, List, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import type { Category } from "@/lib/types";
|
import type { Category } from "@/lib/types";
|
||||||
|
|
||||||
@@ -38,7 +31,7 @@ interface CategoryPieChartProps {
|
|||||||
export function CategoryPieChart({
|
export function CategoryPieChart({
|
||||||
data,
|
data,
|
||||||
dataByParent,
|
dataByParent,
|
||||||
categories = [],
|
categories: _categories = [],
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
title = "Répartition par catégorie",
|
title = "Répartition par catégorie",
|
||||||
height = 300,
|
height = 300,
|
||||||
@@ -49,7 +42,7 @@ export function CategoryPieChart({
|
|||||||
const [groupByParent, setGroupByParent] = useState(true);
|
const [groupByParent, setGroupByParent] = useState(true);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const hasParentData = dataByParent && dataByParent.length > 0;
|
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
|
// Limit to top 8 by default, show all if expanded
|
||||||
const maxItems = 8;
|
const maxItems = 8;
|
||||||
@@ -57,24 +50,29 @@ export function CategoryPieChart({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-col md:flex-row md:items-center md:justify-between space-y-2 md:space-y-0 pb-2">
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle className="text-sm md:text-base">{title}</CardTitle>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col md:flex-row gap-2 w-full md:w-auto">
|
||||||
{hasParentData && (
|
{hasParentData && (
|
||||||
<Button
|
<Button
|
||||||
variant={groupByParent ? "default" : "ghost"}
|
variant={groupByParent ? "default" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setGroupByParent(!groupByParent)}
|
onClick={() => setGroupByParent(!groupByParent)}
|
||||||
title={groupByParent ? "Afficher toutes les catégories" : "Regrouper par catégories parentes"}
|
title={
|
||||||
|
groupByParent
|
||||||
|
? "Afficher toutes les catégories"
|
||||||
|
: "Regrouper par catégories parentes"
|
||||||
|
}
|
||||||
|
className="w-full md:w-auto text-xs md:text-sm"
|
||||||
>
|
>
|
||||||
{groupByParent ? (
|
{groupByParent ? (
|
||||||
<>
|
<>
|
||||||
<List className="w-4 h-4 mr-1" />
|
<List className="w-3 h-3 md:w-4 md:h-4 mr-1" />
|
||||||
Par catégorie
|
Par catégorie
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Layers className="w-4 h-4 mr-1" />
|
<Layers className="w-3 h-3 md:w-4 md:h-4 mr-1" />
|
||||||
Par parent
|
Par parent
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -85,15 +83,16 @@ export function CategoryPieChart({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full md:w-auto text-xs md:text-sm"
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<>
|
<>
|
||||||
<ChevronUp className="w-4 h-4 mr-1" />
|
<ChevronUp className="w-3 h-3 md:w-4 md:h-4 mr-1" />
|
||||||
Réduire
|
Réduire
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ChevronDown className="w-4 h-4 mr-1" />
|
<ChevronDown className="w-3 h-3 md:w-4 md:h-4 mr-1" />
|
||||||
Voir tout ({baseData.length})
|
Voir tout ({baseData.length})
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -196,4 +195,3 @@ export function CategoryPieChart({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ export function CategoryTrendChart({
|
|||||||
variant={groupByParent ? "default" : "ghost"}
|
variant={groupByParent ? "default" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setGroupByParent(!groupByParent)}
|
onClick={() => setGroupByParent(!groupByParent)}
|
||||||
title={groupByParent ? "Afficher toutes les catégories" : "Regrouper par catégories parentes"}
|
title={
|
||||||
|
groupByParent
|
||||||
|
? "Afficher toutes les catégories"
|
||||||
|
: "Regrouper par catégories parentes"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{groupByParent ? (
|
{groupByParent ? (
|
||||||
<>
|
<>
|
||||||
@@ -179,9 +183,11 @@ export function CategoryTrendChart({
|
|||||||
{allCategoryIds.map((categoryId) => {
|
{allCategoryIds.map((categoryId) => {
|
||||||
const categoryInfo = getCategoryInfo(categoryId);
|
const categoryInfo = getCategoryInfo(categoryId);
|
||||||
const categoryName = getCategoryName(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 =
|
const isSelected =
|
||||||
selectedCategories.length === 0
|
selectedCategories.length === 0
|
||||||
? isInDisplayCategories
|
? isInDisplayCategories
|
||||||
@@ -198,8 +204,8 @@ export function CategoryTrendChart({
|
|||||||
if (selectedCategories.includes(categoryId)) {
|
if (selectedCategories.includes(categoryId)) {
|
||||||
setSelectedCategories(
|
setSelectedCategories(
|
||||||
selectedCategories.filter(
|
selectedCategories.filter(
|
||||||
(id) => id !== categoryId
|
(id) => id !== categoryId,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setSelectedCategories([
|
setSelectedCategories([
|
||||||
@@ -234,7 +240,8 @@ export function CategoryTrendChart({
|
|||||||
{categoriesToShow.map((categoryId, index) => {
|
{categoriesToShow.map((categoryId, index) => {
|
||||||
const categoryInfo = getCategoryInfo(categoryId);
|
const categoryInfo = getCategoryInfo(categoryId);
|
||||||
const categoryName = getCategoryName(categoryId);
|
const categoryName = getCategoryName(categoryId);
|
||||||
if (!categoryInfo && categoryId !== "uncategorized") return null;
|
if (!categoryInfo && categoryId !== "uncategorized")
|
||||||
|
return null;
|
||||||
|
|
||||||
const isSelected =
|
const isSelected =
|
||||||
selectedCategories.length === 0 ||
|
selectedCategories.length === 0 ||
|
||||||
@@ -245,7 +252,10 @@ export function CategoryTrendChart({
|
|||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={categoryId}
|
dataKey={categoryId}
|
||||||
name={categoryName}
|
name={categoryName}
|
||||||
stroke={categoryInfo?.color || CATEGORY_COLORS[index % CATEGORY_COLORS.length]}
|
stroke={
|
||||||
|
categoryInfo?.color ||
|
||||||
|
CATEGORY_COLORS[index % CATEGORY_COLORS.length]
|
||||||
|
}
|
||||||
strokeWidth={isSelected ? 2 : 1}
|
strokeWidth={isSelected ? 2 : 1}
|
||||||
strokeOpacity={isSelected ? 1 : 0.3}
|
strokeOpacity={isSelected ? 1 : 0.3}
|
||||||
dot={false}
|
dot={false}
|
||||||
@@ -265,4 +275,3 @@ export function CategoryTrendChart({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,4 +91,3 @@ export function IncomeExpenseTrendChart({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,4 +8,3 @@ export { CategoryTrendChart } from "./category-trend-chart";
|
|||||||
export { SavingsTrendChart } from "./savings-trend-chart";
|
export { SavingsTrendChart } from "./savings-trend-chart";
|
||||||
export { IncomeExpenseTrendChart } from "./income-expense-trend-chart";
|
export { IncomeExpenseTrendChart } from "./income-expense-trend-chart";
|
||||||
export { YearOverYearChart } from "./year-over-year-chart";
|
export { YearOverYearChart } from "./year-over-year-chart";
|
||||||
|
|
||||||
|
|||||||
@@ -73,4 +73,3 @@ export function MonthlyChart({ data, formatCurrency }: MonthlyChartProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,13 @@ export function SavingsTrendChart({
|
|||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={data}>
|
<AreaChart data={data}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="savingsGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient
|
||||||
|
id="savingsGradient"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
<stop
|
<stop
|
||||||
offset="5%"
|
offset="5%"
|
||||||
stopColor={isPositive ? "#22c55e" : "#ef4444"}
|
stopColor={isPositive ? "#22c55e" : "#ef4444"}
|
||||||
@@ -113,4 +119,3 @@ export function SavingsTrendChart({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,16 +20,16 @@ export function StatsSummaryCards({
|
|||||||
const savings = totalIncome - totalExpenses;
|
const savings = totalIncome - totalExpenses;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-3 md:gap-4 grid-cols-2 md:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2">
|
||||||
<TrendingUp className="w-4 h-4 text-emerald-600" />
|
<TrendingUp className="w-3 h-3 md:w-4 md:h-4 text-emerald-600" />
|
||||||
Total Revenus
|
Total Revenus
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-emerald-600">
|
<div className="text-lg md:text-2xl font-bold text-emerald-600">
|
||||||
{formatCurrency(totalIncome)}
|
{formatCurrency(totalIncome)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -37,13 +37,13 @@ export function StatsSummaryCards({
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2">
|
||||||
<TrendingDown className="w-4 h-4 text-red-600" />
|
<TrendingDown className="w-3 h-3 md:w-4 md:h-4 text-red-600" />
|
||||||
Total Dépenses
|
Total Dépenses
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-red-600">
|
<div className="text-lg md:text-2xl font-bold text-red-600">
|
||||||
{formatCurrency(totalExpenses)}
|
{formatCurrency(totalExpenses)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -51,13 +51,13 @@ export function StatsSummaryCards({
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2">
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
Moyenne mensuelle
|
Moyenne mensuelle
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-lg md:text-2xl font-bold">
|
||||||
{formatCurrency(avgMonthlyExpenses)}
|
{formatCurrency(avgMonthlyExpenses)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -65,15 +65,15 @@ export function StatsSummaryCards({
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
|
||||||
Économies
|
Économies
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl font-bold",
|
"text-lg md:text-2xl font-bold",
|
||||||
savings >= 0 ? "text-emerald-600" : "text-red-600"
|
savings >= 0 ? "text-emerald-600" : "text-red-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(savings)}
|
{formatCurrency(savings)}
|
||||||
@@ -83,4 +83,3 @@ export function StatsSummaryCards({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import type { Transaction, Category } from "@/lib/types";
|
import type { Transaction, Category } from "@/lib/types";
|
||||||
|
|
||||||
interface TopExpensesListProps {
|
interface TopExpensesListProps {
|
||||||
@@ -15,58 +17,69 @@ export function TopExpensesList({
|
|||||||
categories,
|
categories,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
}: TopExpensesListProps) {
|
}: TopExpensesListProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Top 5 dépenses</CardTitle>
|
<CardTitle className="text-sm md:text-base">Top 5 dépenses</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{expenses.length > 0 ? (
|
{expenses.length > 0 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3 md:space-y-4">
|
||||||
{expenses.map((expense, index) => {
|
{expenses.map((expense, index) => {
|
||||||
const category = categories.find(
|
const category = categories.find(
|
||||||
(c) => c.id === expense.categoryId
|
(c) => c.id === expense.categoryId,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div key={expense.id} className="flex items-center gap-3">
|
<div
|
||||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-sm font-semibold">
|
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}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium text-sm truncate">
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
{expense.description}
|
<p className="font-medium text-xs md:text-sm truncate flex-1">
|
||||||
</p>
|
{expense.description}
|
||||||
<div className="flex items-center gap-2">
|
</p>
|
||||||
<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")}
|
{new Date(expense.date).toLocaleDateString("fr-FR")}
|
||||||
</span>
|
</span>
|
||||||
{category && (
|
{category && (
|
||||||
<span
|
<Badge
|
||||||
className="text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1"
|
variant="secondary"
|
||||||
|
className="text-[10px] md:text-xs px-1.5 md:px-2 py-0.5 inline-flex items-center gap-1 shrink-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${category.color}20`,
|
backgroundColor: `${category.color}20`,
|
||||||
color: category.color,
|
color: category.color,
|
||||||
|
borderColor: `${category.color}30`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
icon={category.icon}
|
icon={category.icon}
|
||||||
color={category.color}
|
color={category.color}
|
||||||
size={10}
|
size={isMobile ? 8 : 10}
|
||||||
/>
|
/>
|
||||||
{category.name}
|
<span className="truncate max-w-[120px] md:max-w-none">
|
||||||
</span>
|
{category.name}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-red-600 font-semibold tabular-nums">
|
|
||||||
{formatCurrency(expense.amount)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[200px] flex items-center justify-center text-muted-foreground">
|
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-xs md:text-sm">
|
||||||
Pas de dépenses pour cette période
|
Pas de dépenses pour cette période
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -74,4 +87,3 @@ export function TopExpensesList({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,4 +88,3 @@ export function YearOverYearChart({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export { TransactionFilters } from "./transaction-filters";
|
export { TransactionFilters } from "./transaction-filters";
|
||||||
export { TransactionBulkActions } from "./transaction-bulk-actions";
|
export { TransactionBulkActions } from "./transaction-bulk-actions";
|
||||||
export { TransactionTable } from "./transaction-table";
|
export { TransactionTable } from "./transaction-table";
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export function TransactionBulkActions({
|
|||||||
onReconcile,
|
onReconcile,
|
||||||
onSetCategory,
|
onSetCategory,
|
||||||
}: TransactionBulkActionsProps) {
|
}: TransactionBulkActionsProps) {
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
if (selectedCount === 0) return null;
|
if (selectedCount === 0) return null;
|
||||||
|
|
||||||
@@ -61,4 +63,3 @@ export function TransactionBulkActions({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import {
|
|||||||
import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox";
|
import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox";
|
||||||
import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox";
|
import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
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 { Calendar as CalendarComponent } from "@/components/ui/calendar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Search, X, Filter, Wallet, Calendar } from "lucide-react";
|
import { Search, X, Filter, Wallet, Calendar } from "lucide-react";
|
||||||
@@ -91,7 +95,7 @@ export function TransactionFilters({
|
|||||||
folders={folders}
|
folders={folders}
|
||||||
value={selectedAccounts}
|
value={selectedAccounts}
|
||||||
onChange={onAccountsChange}
|
onChange={onAccountsChange}
|
||||||
className="w-[280px]"
|
className="w-full md:w-[280px]"
|
||||||
filteredTransactions={transactionsForAccountFilter}
|
filteredTransactions={transactionsForAccountFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -99,12 +103,12 @@ export function TransactionFilters({
|
|||||||
categories={categories}
|
categories={categories}
|
||||||
value={selectedCategories}
|
value={selectedCategories}
|
||||||
onChange={onCategoriesChange}
|
onChange={onCategoriesChange}
|
||||||
className="w-[220px]"
|
className="w-full md:w-[220px]"
|
||||||
filteredTransactions={transactionsForCategoryFilter}
|
filteredTransactions={transactionsForCategoryFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select value={showReconciled} onValueChange={onReconciledChange}>
|
<Select value={showReconciled} onValueChange={onReconciledChange}>
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectTrigger className="w-full md:w-[160px]">
|
||||||
<SelectValue placeholder="Pointage" />
|
<SelectValue placeholder="Pointage" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -125,7 +129,7 @@ export function TransactionFilters({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-full md:w-[150px]">
|
||||||
<SelectValue placeholder="Période" />
|
<SelectValue placeholder="Période" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -139,9 +143,15 @@ export function TransactionFilters({
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{period === "custom" && (
|
{period === "custom" && (
|
||||||
<Popover open={isCustomDatePickerOpen} onOpenChange={onCustomDatePickerOpenChange}>
|
<Popover
|
||||||
|
open={isCustomDatePickerOpen}
|
||||||
|
onOpenChange={onCustomDatePickerOpenChange}
|
||||||
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="w-[280px] justify-start text-left font-normal">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full md:w-[280px] justify-start text-left font-normal"
|
||||||
|
>
|
||||||
<Calendar className="mr-2 h-4 w-4" />
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
{customStartDate && customEndDate ? (
|
{customStartDate && customEndDate ? (
|
||||||
<>
|
<>
|
||||||
@@ -151,7 +161,9 @@ export function TransactionFilters({
|
|||||||
) : customStartDate ? (
|
) : customStartDate ? (
|
||||||
format(customStartDate, "PPP", { locale: fr })
|
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>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -232,7 +244,9 @@ export function TransactionFilters({
|
|||||||
selectedCategories={selectedCategories}
|
selectedCategories={selectedCategories}
|
||||||
onRemoveCategory={(id) => {
|
onRemoveCategory={(id) => {
|
||||||
const newCategories = selectedCategories.filter((c) => c !== id);
|
const newCategories = selectedCategories.filter((c) => c !== id);
|
||||||
onCategoriesChange(newCategories.length > 0 ? newCategories : ["all"]);
|
onCategoriesChange(
|
||||||
|
newCategories.length > 0 ? newCategories : ["all"],
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onClearCategories={() => onCategoriesChange(["all"])}
|
onClearCategories={() => onCategoriesChange(["all"])}
|
||||||
showReconciled={showReconciled}
|
showReconciled={showReconciled}
|
||||||
@@ -294,12 +308,15 @@ function ActiveFilters({
|
|||||||
const hasReconciled = showReconciled !== "all";
|
const hasReconciled = showReconciled !== "all";
|
||||||
const hasPeriod = period !== "all";
|
const hasPeriod = period !== "all";
|
||||||
|
|
||||||
const hasActiveFilters = hasSearch || hasAccounts || hasCategories || hasReconciled || hasPeriod;
|
const hasActiveFilters =
|
||||||
|
hasSearch || hasAccounts || hasCategories || hasReconciled || hasPeriod;
|
||||||
|
|
||||||
if (!hasActiveFilters) return null;
|
if (!hasActiveFilters) return null;
|
||||||
|
|
||||||
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
|
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 isUncategorized = selectedCategories.includes("uncategorized");
|
||||||
|
|
||||||
const clearAll = () => {
|
const clearAll = () => {
|
||||||
@@ -317,14 +334,21 @@ function ActiveFilters({
|
|||||||
{hasSearch && (
|
{hasSearch && (
|
||||||
<Badge variant="secondary" className="gap-1 text-xs font-normal">
|
<Badge variant="secondary" className="gap-1 text-xs font-normal">
|
||||||
Recherche: "{searchQuery}"
|
Recherche: "{searchQuery}"
|
||||||
<button onClick={onClearSearch} className="ml-1 hover:text-foreground">
|
<button
|
||||||
|
onClick={onClearSearch}
|
||||||
|
className="ml-1 hover:text-foreground"
|
||||||
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedAccs.map((acc) => (
|
{selectedAccs.map((acc) => (
|
||||||
<Badge key={acc.id} variant="secondary" className="gap-1 text-xs font-normal">
|
<Badge
|
||||||
|
key={acc.id}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-1 text-xs font-normal"
|
||||||
|
>
|
||||||
<Wallet className="h-3 w-3" />
|
<Wallet className="h-3 w-3" />
|
||||||
{acc.name}
|
{acc.name}
|
||||||
<button
|
<button
|
||||||
@@ -339,7 +363,10 @@ function ActiveFilters({
|
|||||||
{isUncategorized && (
|
{isUncategorized && (
|
||||||
<Badge variant="secondary" className="gap-1 text-xs font-normal">
|
<Badge variant="secondary" className="gap-1 text-xs font-normal">
|
||||||
Non catégorisé
|
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" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -369,7 +396,10 @@ function ActiveFilters({
|
|||||||
{hasReconciled && (
|
{hasReconciled && (
|
||||||
<Badge variant="secondary" className="gap-1 text-xs font-normal">
|
<Badge variant="secondary" className="gap-1 text-xs font-normal">
|
||||||
{showReconciled === "reconciled" ? "Pointées" : "Non pointées"}
|
{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" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -381,15 +411,18 @@ function ActiveFilters({
|
|||||||
{period === "custom" && customStartDate && customEndDate
|
{period === "custom" && customStartDate && customEndDate
|
||||||
? `${format(customStartDate, "d MMM", { locale: fr })} - ${format(customEndDate, "d MMM yyyy", { locale: fr })}`
|
? `${format(customStartDate, "d MMM", { locale: fr })} - ${format(customEndDate, "d MMM yyyy", { locale: fr })}`
|
||||||
: period === "1month"
|
: period === "1month"
|
||||||
? "1 mois"
|
? "1 mois"
|
||||||
: period === "3months"
|
: period === "3months"
|
||||||
? "3 mois"
|
? "3 mois"
|
||||||
: period === "6months"
|
: period === "6months"
|
||||||
? "6 mois"
|
? "6 mois"
|
||||||
: period === "12months"
|
: period === "12months"
|
||||||
? "12 mois"
|
? "12 mois"
|
||||||
: "Période"}
|
: "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" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -404,4 +437,3 @@ function ActiveFilters({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import type { Transaction, Account, Category } from "@/lib/types";
|
import type { Transaction, Account, Category } from "@/lib/types";
|
||||||
|
|
||||||
type SortField = "date" | "amount" | "description";
|
type SortField = "date" | "amount" | "description";
|
||||||
@@ -111,9 +112,7 @@ function DescriptionWithTooltip({ description }: { description: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={200}>
|
<Tooltip delayDuration={200}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||||
{content}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="top"
|
side="top"
|
||||||
align="start"
|
align="start"
|
||||||
@@ -146,11 +145,14 @@ export function TransactionTable({
|
|||||||
}: TransactionTableProps) {
|
}: TransactionTableProps) {
|
||||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const MOBILE_ROW_HEIGHT = 120;
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: transactions.length,
|
count: transactions.length,
|
||||||
getScrollElement: () => parentRef.current,
|
getScrollElement: () => parentRef.current,
|
||||||
estimateSize: () => ROW_HEIGHT,
|
estimateSize: () => (isMobile ? MOBILE_ROW_HEIGHT : ROW_HEIGHT),
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,7 +161,7 @@ export function TransactionTable({
|
|||||||
setFocusedIndex(index);
|
setFocusedIndex(index);
|
||||||
onMarkReconciled(transactionId);
|
onMarkReconciled(transactionId);
|
||||||
},
|
},
|
||||||
[onMarkReconciled]
|
[onMarkReconciled],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
@@ -188,7 +190,7 @@ export function TransactionTable({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[focusedIndex, transactions, onMarkReconciled, virtualizer]
|
[focusedIndex, transactions, onMarkReconciled, virtualizer],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -201,17 +203,177 @@ export function TransactionTable({
|
|||||||
setFocusedIndex(null);
|
setFocusedIndex(null);
|
||||||
}, [transactions.length]);
|
}, [transactions.length]);
|
||||||
|
|
||||||
const getAccount = (accountId: string) => {
|
const getAccount = useCallback(
|
||||||
return accounts.find((a) => a.id === accountId);
|
(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 (
|
return (
|
||||||
<Card>
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{transactions.length === 0 ? (
|
{transactions.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
<p className="text-muted-foreground">Aucune transaction trouvée</p>
|
<p className="text-muted-foreground">Aucune transaction trouvée</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : isMobile ? (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<div
|
||||||
|
ref={parentRef}
|
||||||
|
className="overflow-auto"
|
||||||
|
style={{ height: "calc(100vh - 300px)", minHeight: "400px" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
|
width: "100%",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const transaction = transactions[virtualRow.index];
|
||||||
|
const account = getAccount(transaction.accountId);
|
||||||
|
const _category = getCategory(transaction.categoryId);
|
||||||
|
const isFocused = focusedIndex === virtualRow.index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={transaction.id}
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
// Désactiver le pointage au clic sur mobile
|
||||||
|
if (!isMobile) {
|
||||||
|
handleRowClick(virtualRow.index, transaction.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border",
|
||||||
|
transaction.isReconciled && "bg-emerald-500/5",
|
||||||
|
isFocused && "bg-primary/10 ring-1 ring-primary/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedTransactions.has(transaction.id)}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
onToggleSelectTransaction(transaction.id);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-xs md:text-sm truncate">
|
||||||
|
{transaction.description}
|
||||||
|
</p>
|
||||||
|
{transaction.memo && (
|
||||||
|
<p className="text-[10px] md:text-xs text-muted-foreground truncate mt-1">
|
||||||
|
{transaction.memo}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"font-semibold tabular-nums text-sm md:text-base shrink-0",
|
||||||
|
transaction.amount >= 0
|
||||||
|
? "text-emerald-600"
|
||||||
|
: "text-red-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{transaction.amount >= 0 ? "+" : ""}
|
||||||
|
{formatCurrency(transaction.amount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 md:gap-3 flex-wrap">
|
||||||
|
<span className="text-[10px] md:text-xs text-muted-foreground">
|
||||||
|
{formatDate(transaction.date)}
|
||||||
|
</span>
|
||||||
|
{account && (
|
||||||
|
<span className="text-[10px] md:text-xs text-muted-foreground">
|
||||||
|
• {account.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<CategoryCombobox
|
||||||
|
categories={categories}
|
||||||
|
value={transaction.categoryId}
|
||||||
|
onChange={(categoryId) =>
|
||||||
|
onSetCategory(transaction.id, categoryId)
|
||||||
|
}
|
||||||
|
showBadge
|
||||||
|
align="start"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
asChild
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCreateRule(transaction);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Wand2 className="w-4 h-4 mr-2" />
|
||||||
|
Créer une règle
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (
|
||||||
|
confirm(
|
||||||
|
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
onDelete(transaction.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-red-600 focus:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Supprimer
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
{/* Header fixe */}
|
{/* Header fixe */}
|
||||||
@@ -295,11 +457,13 @@ export function TransactionTable({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
}}
|
}}
|
||||||
onClick={() => handleRowClick(virtualRow.index, transaction.id)}
|
onClick={() =>
|
||||||
|
handleRowClick(virtualRow.index, transaction.id)
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer",
|
"grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer",
|
||||||
transaction.isReconciled && "bg-emerald-500/5",
|
transaction.isReconciled && "bg-emerald-500/5",
|
||||||
isFocused && "bg-primary/10 ring-1 ring-primary/30"
|
isFocused && "bg-primary/10 ring-1 ring-primary/30",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
@@ -313,12 +477,17 @@ export function TransactionTable({
|
|||||||
<div className="p-3 text-sm text-muted-foreground whitespace-nowrap">
|
<div className="p-3 text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{formatDate(transaction.date)}
|
{formatDate(transaction.date)}
|
||||||
</div>
|
</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">
|
<p className="font-medium text-sm truncate">
|
||||||
{transaction.description}
|
{transaction.description}
|
||||||
</p>
|
</p>
|
||||||
{transaction.memo && (
|
{transaction.memo && (
|
||||||
<DescriptionWithTooltip description={transaction.memo} />
|
<DescriptionWithTooltip
|
||||||
|
description={transaction.memo}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 text-sm text-muted-foreground">
|
<div className="p-3 text-sm text-muted-foreground">
|
||||||
@@ -340,13 +509,16 @@ export function TransactionTable({
|
|||||||
"p-3 text-right font-semibold tabular-nums",
|
"p-3 text-right font-semibold tabular-nums",
|
||||||
transaction.amount >= 0
|
transaction.amount >= 0
|
||||||
? "text-emerald-600"
|
? "text-emerald-600"
|
||||||
: "text-red-600"
|
: "text-red-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{transaction.amount >= 0 ? "+" : ""}
|
{transaction.amount >= 0 ? "+" : ""}
|
||||||
{formatCurrency(transaction.amount)}
|
{formatCurrency(transaction.amount)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 text-center" onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
|
className="p-3 text-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggleReconciled(transaction.id)}
|
onClick={() => onToggleReconciled(transaction.id)}
|
||||||
className="p-1 hover:bg-muted rounded"
|
className="p-1 hover:bg-muted rounded"
|
||||||
@@ -404,7 +576,7 @@ export function TransactionTable({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (
|
if (
|
||||||
confirm(
|
confirm(
|
||||||
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`
|
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
onDelete(transaction.id);
|
onDelete(transaction.id);
|
||||||
@@ -429,4 +601,3 @@ export function TransactionTable({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function AccountFilterCombobox({
|
|||||||
// Get root folders (folders without parent) - same as folders/page.tsx
|
// Get root folders (folders without parent) - same as folders/page.tsx
|
||||||
const rootFolders = useMemo(
|
const rootFolders = useMemo(
|
||||||
() => folders.filter((f) => f.parentId === null),
|
() => folders.filter((f) => f.parentId === null),
|
||||||
[folders]
|
[folders],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get child folders for a given parent - same as FolderTreeItem
|
// Get child folders for a given parent - same as FolderTreeItem
|
||||||
@@ -78,7 +78,7 @@ export function AccountFilterCombobox({
|
|||||||
// Get accounts without folder
|
// Get accounts without folder
|
||||||
const orphanAccounts = useMemo(
|
const orphanAccounts = useMemo(
|
||||||
() => accounts.filter((a) => !a.folderId),
|
() => accounts.filter((a) => !a.folderId),
|
||||||
[accounts]
|
[accounts],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedAccounts = accounts.filter((a) => value.includes(a.id));
|
const selectedAccounts = accounts.filter((a) => value.includes(a.id));
|
||||||
@@ -89,7 +89,7 @@ export function AccountFilterCombobox({
|
|||||||
const directAccounts = getFolderAccounts(folderId);
|
const directAccounts = getFolderAccounts(folderId);
|
||||||
const childFoldersList = getChildFolders(folderId);
|
const childFoldersList = getChildFolders(folderId);
|
||||||
const childAccounts = childFoldersList.flatMap((cf) =>
|
const childAccounts = childFoldersList.flatMap((cf) =>
|
||||||
getAllAccountsInFolder(cf.id)
|
getAllAccountsInFolder(cf.id),
|
||||||
);
|
);
|
||||||
return [...directAccounts, ...childAccounts];
|
return [...directAccounts, ...childAccounts];
|
||||||
};
|
};
|
||||||
@@ -126,7 +126,7 @@ export function AccountFilterCombobox({
|
|||||||
|
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
const newSelection = value.filter(
|
const newSelection = value.filter(
|
||||||
(v) => !allFolderAccountIds.includes(v)
|
(v) => !allFolderAccountIds.includes(v),
|
||||||
);
|
);
|
||||||
onChange(newSelection.length > 0 ? newSelection : ["all"]);
|
onChange(newSelection.length > 0 ? newSelection : ["all"]);
|
||||||
} else {
|
} else {
|
||||||
@@ -153,7 +153,7 @@ export function AccountFilterCombobox({
|
|||||||
const folderAccounts = getAllAccountsInFolder(folderId);
|
const folderAccounts = getAllAccountsInFolder(folderId);
|
||||||
if (folderAccounts.length === 0) return false;
|
if (folderAccounts.length === 0) return false;
|
||||||
const selectedCount = folderAccounts.filter((a) =>
|
const selectedCount = folderAccounts.filter((a) =>
|
||||||
value.includes(a.id)
|
value.includes(a.id),
|
||||||
).length;
|
).length;
|
||||||
return selectedCount > 0 && selectedCount < folderAccounts.length;
|
return selectedCount > 0 && selectedCount < folderAccounts.length;
|
||||||
};
|
};
|
||||||
@@ -162,7 +162,9 @@ export function AccountFilterCombobox({
|
|||||||
const renderFolder = (folder: Folder, depth: number, parentPath: string) => {
|
const renderFolder = (folder: Folder, depth: number, parentPath: string) => {
|
||||||
const folderAccounts = getFolderAccounts(folder.id);
|
const folderAccounts = getFolderAccounts(folder.id);
|
||||||
const childFoldersList = getChildFolders(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;
|
const paddingLeft = depth * 16 + 8;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -183,7 +185,7 @@ export function AccountFilterCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4",
|
"h-4 w-4",
|
||||||
isFolderSelected(folder.id) ? "opacity-100" : "opacity-0"
|
isFolderSelected(folder.id) ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +213,7 @@ export function AccountFilterCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4 shrink-0",
|
"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>
|
</CommandItem>
|
||||||
@@ -220,7 +222,7 @@ export function AccountFilterCombobox({
|
|||||||
|
|
||||||
{/* Child folders - recursive */}
|
{/* Child folders - recursive */}
|
||||||
{childFoldersList.map((childFolder) =>
|
{childFoldersList.map((childFolder) =>
|
||||||
renderFolder(childFolder, depth + 1, currentPath)
|
renderFolder(childFolder, depth + 1, currentPath),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -239,10 +241,15 @@ export function AccountFilterCombobox({
|
|||||||
{selectedAccounts.length === 1 ? (
|
{selectedAccounts.length === 1 ? (
|
||||||
<>
|
<>
|
||||||
{(() => {
|
{(() => {
|
||||||
const AccountIcon = accountTypeIcons[selectedAccounts[0].type];
|
const AccountIcon =
|
||||||
return <AccountIcon className="h-4 w-4 text-muted-foreground shrink-0" />;
|
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 ? (
|
) : selectedAccounts.length > 1 ? (
|
||||||
<>
|
<>
|
||||||
@@ -254,7 +261,9 @@ export function AccountFilterCombobox({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Wallet className="h-4 w-4 text-muted-foreground shrink-0" />
|
<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>
|
</div>
|
||||||
@@ -290,15 +299,20 @@ export function AccountFilterCombobox({
|
|||||||
<span>Tous les comptes</span>
|
<span>Tous les comptes</span>
|
||||||
{filteredTransactions && (
|
{filteredTransactions && (
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
isAll ? "opacity-100" : "opacity-0"
|
isAll ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -321,7 +335,9 @@ export function AccountFilterCombobox({
|
|||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
>
|
>
|
||||||
<AccountIcon className="h-4 w-4 text-muted-foreground shrink-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 && (
|
{total !== undefined && (
|
||||||
<span className="text-xs text-muted-foreground ml-1 shrink-0">
|
<span className="text-xs text-muted-foreground ml-1 shrink-0">
|
||||||
({formatCurrency(total)})
|
({formatCurrency(total)})
|
||||||
@@ -330,7 +346,9 @@ export function AccountFilterCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4 shrink-0",
|
"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>
|
</CommandItem>
|
||||||
|
|||||||
@@ -115,11 +115,13 @@ export function CategoryCombobox({
|
|||||||
onSelect={() => handleSelect(null)}
|
onSelect={() => handleSelect(null)}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 text-muted-foreground" />
|
<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
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
value === null ? "opacity-100" : "opacity-0"
|
value === null ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -140,7 +142,7 @@ export function CategoryCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
value === parent.id ? "opacity-100" : "opacity-0"
|
value === parent.id ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -160,7 +162,7 @@ export function CategoryCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
value === child.id ? "opacity-100" : "opacity-0"
|
value === child.id ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -183,10 +185,7 @@ export function CategoryCombobox({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className={cn(
|
className={cn("justify-between", buttonWidth || "w-full")}
|
||||||
"justify-between",
|
|
||||||
buttonWidth || "w-full"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{selectedCategory ? (
|
{selectedCategory ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -213,16 +212,13 @@ export function CategoryCombobox({
|
|||||||
<CommandList className="max-h-[250px]">
|
<CommandList className="max-h-[250px]">
|
||||||
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
|
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<CommandItem
|
<CommandItem value="__none__" onSelect={() => handleSelect(null)}>
|
||||||
value="__none__"
|
|
||||||
onSelect={() => handleSelect(null)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4 text-muted-foreground" />
|
<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
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
value === null ? "opacity-100" : "opacity-0"
|
value === null ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -243,7 +239,7 @@ export function CategoryCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
value === parent.id ? "opacity-100" : "opacity-0"
|
value === parent.id ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -263,7 +259,7 @@ export function CategoryCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
value === child.id ? "opacity-100" : "opacity-0"
|
value === child.id ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -277,4 +273,3 @@ export function CategoryCombobox({
|
|||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ export function CategoryFilterCombobox({
|
|||||||
if (isAll) return "Toutes catégories";
|
if (isAll) return "Toutes catégories";
|
||||||
if (isUncategorized) return "Non catégorisé";
|
if (isUncategorized) return "Non catégorisé";
|
||||||
if (selectedCategories.length === 1) return selectedCategories[0].name;
|
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";
|
return "Catégorie";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,7 +138,9 @@ export function CategoryFilterCombobox({
|
|||||||
size={16}
|
size={16}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="truncate text-left">{selectedCategories[0].name}</span>
|
<span className="truncate text-left">
|
||||||
|
{selectedCategories[0].name}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : selectedCategories.length > 1 ? (
|
) : selectedCategories.length > 1 ? (
|
||||||
<>
|
<>
|
||||||
@@ -150,7 +153,9 @@ export function CategoryFilterCombobox({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="truncate text-left">{selectedCategories.length} catégories</span>
|
<span className="truncate text-left">
|
||||||
|
{selectedCategories.length} catégories
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : isUncategorized ? (
|
) : isUncategorized ? (
|
||||||
<>
|
<>
|
||||||
@@ -160,7 +165,9 @@ export function CategoryFilterCombobox({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Tags className="h-4 w-4 text-muted-foreground shrink-0" />
|
<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>
|
</div>
|
||||||
@@ -191,9 +198,15 @@ export function CategoryFilterCombobox({
|
|||||||
<CommandList className="max-h-[300px]">
|
<CommandList className="max-h-[300px]">
|
||||||
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
|
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
|
||||||
<CommandGroup>
|
<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" />
|
<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 && (
|
{filteredTransactions && (
|
||||||
<span className="text-xs text-muted-foreground ml-1 shrink-0">
|
<span className="text-xs text-muted-foreground ml-1 shrink-0">
|
||||||
({filteredTransactions.length})
|
({filteredTransactions.length})
|
||||||
@@ -202,7 +215,7 @@ export function CategoryFilterCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4 shrink-0",
|
"ml-auto h-4 w-4 shrink-0",
|
||||||
isAll ? "opacity-100" : "opacity-0"
|
isAll ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -221,7 +234,7 @@ export function CategoryFilterCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4 shrink-0",
|
"ml-auto h-4 w-4 shrink-0",
|
||||||
isUncategorized ? "opacity-100" : "opacity-0"
|
isUncategorized ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -240,7 +253,9 @@ export function CategoryFilterCombobox({
|
|||||||
size={16}
|
size={16}
|
||||||
className="shrink-0"
|
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 && (
|
{categoryCounts[parent.id] !== undefined && (
|
||||||
<span className="text-xs text-muted-foreground ml-1 shrink-0">
|
<span className="text-xs text-muted-foreground ml-1 shrink-0">
|
||||||
({categoryCounts[parent.id]})
|
({categoryCounts[parent.id]})
|
||||||
@@ -249,7 +264,7 @@ export function CategoryFilterCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4 shrink-0",
|
"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>
|
</CommandItem>
|
||||||
@@ -266,7 +281,9 @@ export function CategoryFilterCombobox({
|
|||||||
size={16}
|
size={16}
|
||||||
className="shrink-0"
|
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 && (
|
{categoryCounts[child.id] !== undefined && (
|
||||||
<span className="text-xs text-muted-foreground ml-1 shrink-0">
|
<span className="text-xs text-muted-foreground ml-1 shrink-0">
|
||||||
({categoryCounts[child.id]})
|
({categoryCounts[child.id]})
|
||||||
@@ -275,7 +292,9 @@ export function CategoryFilterCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4 shrink-0",
|
"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>
|
</CommandItem>
|
||||||
|
|||||||
@@ -20,64 +20,225 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
// Group icons by category for better organization
|
// Group icons by category for better organization
|
||||||
const iconGroups: Record<string, string[]> = {
|
const iconGroups: Record<string, string[]> = {
|
||||||
"Alimentation": [
|
Alimentation: [
|
||||||
"shopping-cart", "utensils", "croissant", "coffee", "wine", "beer",
|
"shopping-cart",
|
||||||
"pizza", "apple", "cherry", "salad", "sandwich", "ice-cream",
|
"utensils",
|
||||||
"cake", "cup-soda", "milk", "egg", "fish", "beef"
|
"croissant",
|
||||||
|
"coffee",
|
||||||
|
"wine",
|
||||||
|
"beer",
|
||||||
|
"pizza",
|
||||||
|
"apple",
|
||||||
|
"cherry",
|
||||||
|
"salad",
|
||||||
|
"sandwich",
|
||||||
|
"ice-cream",
|
||||||
|
"cake",
|
||||||
|
"cup-soda",
|
||||||
|
"milk",
|
||||||
|
"egg",
|
||||||
|
"fish",
|
||||||
|
"beef",
|
||||||
],
|
],
|
||||||
"Transport": [
|
Transport: [
|
||||||
"fuel", "train", "car", "parking", "bike", "plane", "bus",
|
"fuel",
|
||||||
"ship", "sailboat", "truck", "car-front", "circle-parking",
|
"train",
|
||||||
"train-front"
|
"car",
|
||||||
|
"parking",
|
||||||
|
"bike",
|
||||||
|
"plane",
|
||||||
|
"bus",
|
||||||
|
"ship",
|
||||||
|
"sailboat",
|
||||||
|
"truck",
|
||||||
|
"car-front",
|
||||||
|
"circle-parking",
|
||||||
|
"train-front",
|
||||||
],
|
],
|
||||||
"Logement": [
|
Logement: [
|
||||||
"home", "zap", "droplet", "hammer", "sofa", "refrigerator",
|
"home",
|
||||||
"washing-machine", "lamp", "lamp-desk", "armchair", "bath",
|
"zap",
|
||||||
"shower-head", "door-open", "fence", "trees", "flower",
|
"droplet",
|
||||||
"leaf", "sun", "snowflake", "wind", "thermometer"
|
"hammer",
|
||||||
|
"sofa",
|
||||||
|
"refrigerator",
|
||||||
|
"washing-machine",
|
||||||
|
"lamp",
|
||||||
|
"lamp-desk",
|
||||||
|
"armchair",
|
||||||
|
"bath",
|
||||||
|
"shower-head",
|
||||||
|
"door-open",
|
||||||
|
"fence",
|
||||||
|
"trees",
|
||||||
|
"flower",
|
||||||
|
"leaf",
|
||||||
|
"sun",
|
||||||
|
"snowflake",
|
||||||
|
"wind",
|
||||||
|
"thermometer",
|
||||||
],
|
],
|
||||||
"Santé": [
|
Santé: [
|
||||||
"pill", "stethoscope", "hospital", "glasses", "dumbbell", "sparkles",
|
"pill",
|
||||||
"heart", "heart-pulse", "activity", "syringe", "bandage", "brain",
|
"stethoscope",
|
||||||
"eye", "ear", "hand", "footprints", "person-standing"
|
"hospital",
|
||||||
|
"glasses",
|
||||||
|
"dumbbell",
|
||||||
|
"sparkles",
|
||||||
|
"heart",
|
||||||
|
"heart-pulse",
|
||||||
|
"activity",
|
||||||
|
"syringe",
|
||||||
|
"bandage",
|
||||||
|
"brain",
|
||||||
|
"eye",
|
||||||
|
"ear",
|
||||||
|
"hand",
|
||||||
|
"footprints",
|
||||||
|
"person-standing",
|
||||||
],
|
],
|
||||||
"Loisirs": [
|
Loisirs: [
|
||||||
"tv", "music", "film", "gamepad", "book", "ticket", "clapperboard",
|
"tv",
|
||||||
"headphones", "speaker", "radio", "camera", "image", "palette",
|
"music",
|
||||||
"brush", "pen-tool", "scissors", "drama", "party-popper"
|
"film",
|
||||||
|
"gamepad",
|
||||||
|
"book",
|
||||||
|
"ticket",
|
||||||
|
"clapperboard",
|
||||||
|
"headphones",
|
||||||
|
"speaker",
|
||||||
|
"radio",
|
||||||
|
"camera",
|
||||||
|
"image",
|
||||||
|
"palette",
|
||||||
|
"brush",
|
||||||
|
"pen-tool",
|
||||||
|
"scissors",
|
||||||
|
"drama",
|
||||||
|
"party-popper",
|
||||||
],
|
],
|
||||||
"Sport": ["trophy", "medal", "target", "volleyball"],
|
Sport: ["trophy", "medal", "target", "volleyball"],
|
||||||
"Shopping": [
|
Shopping: [
|
||||||
"shirt", "smartphone", "package", "shopping-bag", "store", "gem",
|
"shirt",
|
||||||
"watch", "sunglasses", "crown", "laptop", "monitor", "keyboard",
|
"smartphone",
|
||||||
"mouse", "printer", "tablet-smartphone", "headset"
|
"package",
|
||||||
|
"shopping-bag",
|
||||||
|
"store",
|
||||||
|
"gem",
|
||||||
|
"watch",
|
||||||
|
"sunglasses",
|
||||||
|
"crown",
|
||||||
|
"laptop",
|
||||||
|
"monitor",
|
||||||
|
"keyboard",
|
||||||
|
"mouse",
|
||||||
|
"printer",
|
||||||
|
"tablet-smartphone",
|
||||||
|
"headset",
|
||||||
],
|
],
|
||||||
"Services": [
|
Services: [
|
||||||
"wifi", "repeat", "landmark", "shield", "receipt", "file-text",
|
"wifi",
|
||||||
"mail", "phone", "message-square", "send", "globe", "cloud",
|
"repeat",
|
||||||
"server", "lock", "unlock", "settings", "wrench"
|
"landmark",
|
||||||
|
"shield",
|
||||||
|
"receipt",
|
||||||
|
"file-text",
|
||||||
|
"mail",
|
||||||
|
"phone",
|
||||||
|
"message-square",
|
||||||
|
"send",
|
||||||
|
"globe",
|
||||||
|
"cloud",
|
||||||
|
"server",
|
||||||
|
"lock",
|
||||||
|
"unlock",
|
||||||
|
"settings",
|
||||||
|
"wrench",
|
||||||
],
|
],
|
||||||
"Finance": [
|
Finance: [
|
||||||
"piggy-bank", "banknote", "wallet", "hand-coins", "undo", "coins",
|
"piggy-bank",
|
||||||
"credit-card", "building", "building2", "trending-up", "trending-down",
|
"banknote",
|
||||||
"bar-chart", "pie-chart", "line-chart", "calculator", "percent",
|
"wallet",
|
||||||
"dollar-sign", "euro"
|
"hand-coins",
|
||||||
|
"undo",
|
||||||
|
"coins",
|
||||||
|
"credit-card",
|
||||||
|
"building",
|
||||||
|
"building2",
|
||||||
|
"trending-up",
|
||||||
|
"trending-down",
|
||||||
|
"bar-chart",
|
||||||
|
"pie-chart",
|
||||||
|
"line-chart",
|
||||||
|
"calculator",
|
||||||
|
"percent",
|
||||||
|
"dollar-sign",
|
||||||
|
"euro",
|
||||||
],
|
],
|
||||||
"Voyage": [
|
Voyage: [
|
||||||
"bed", "luggage", "map", "map-pin", "compass", "mountain",
|
"bed",
|
||||||
"tent", "palmtree", "umbrella", "globe2", "flag"
|
"luggage",
|
||||||
|
"map",
|
||||||
|
"map-pin",
|
||||||
|
"compass",
|
||||||
|
"mountain",
|
||||||
|
"tent",
|
||||||
|
"palmtree",
|
||||||
|
"umbrella",
|
||||||
|
"globe2",
|
||||||
|
"flag",
|
||||||
],
|
],
|
||||||
"Famille": [
|
Famille: [
|
||||||
"graduation-cap", "baby", "paw-print", "users", "user", "user-plus",
|
"graduation-cap",
|
||||||
"dog", "cat", "bird", "rabbit"
|
"baby",
|
||||||
|
"paw-print",
|
||||||
|
"users",
|
||||||
|
"user",
|
||||||
|
"user-plus",
|
||||||
|
"dog",
|
||||||
|
"cat",
|
||||||
|
"bird",
|
||||||
|
"rabbit",
|
||||||
],
|
],
|
||||||
"Autre": [
|
Autre: [
|
||||||
"heart-handshake", "gift", "cigarette", "arrow-right-left",
|
"heart-handshake",
|
||||||
"help-circle", "tag", "folder", "key", "star", "bookmark", "clock",
|
"gift",
|
||||||
"calendar", "bell", "alert-triangle", "info", "check-circle", "x-circle",
|
"cigarette",
|
||||||
"plus", "minus", "search", "trash", "edit", "download", "upload",
|
"arrow-right-left",
|
||||||
"share", "link", "paperclip", "archive", "box", "boxes", "container",
|
"help-circle",
|
||||||
"briefcase", "education", "award", "lightbulb", "flame", "rocket", "atom"
|
"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(
|
const filtered = icons.filter(
|
||||||
(icon) =>
|
(icon) =>
|
||||||
icon.toLowerCase().includes(query) ||
|
icon.toLowerCase().includes(query) ||
|
||||||
group.toLowerCase().includes(query)
|
group.toLowerCase().includes(query),
|
||||||
);
|
);
|
||||||
if (filtered.length > 0) {
|
if (filtered.length > 0) {
|
||||||
result[group] = filtered;
|
result[group] = filtered;
|
||||||
@@ -156,7 +317,7 @@ export function IconPicker({ value, onChange, color }: IconPickerProps) {
|
|||||||
onClick={() => handleSelect(icon)}
|
onClick={() => handleSelect(icon)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center p-2 rounded-md hover:bg-accent transition-colors",
|
"flex items-center justify-center p-2 rounded-md hover:bg-accent transition-colors",
|
||||||
value === icon && "bg-accent ring-2 ring-primary"
|
value === icon && "bg-accent ring-2 ring-primary",
|
||||||
)}
|
)}
|
||||||
title={icon}
|
title={icon}
|
||||||
>
|
>
|
||||||
@@ -172,4 +333,3 @@ export function IconPicker({ value, onChange, color }: IconPickerProps) {
|
|||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal 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
|
||||||
@@ -37,12 +37,14 @@ Si vous déployez sur Vercel, le fichier `vercel.json` configure automatiquement
|
|||||||
Pour exécuter les sauvegardes automatiques, vous pouvez :
|
Pour exécuter les sauvegardes automatiques, vous pouvez :
|
||||||
|
|
||||||
1. **Utiliser un cron job système** :
|
1. **Utiliser un cron job système** :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Exécuter tous les jours à 2h du matin
|
# Exécuter tous les jours à 2h du matin
|
||||||
0 2 * * * cd /chemin/vers/projet && tsx scripts/run-backup.ts
|
0 2 * * * cd /chemin/vers/projet && tsx scripts/run-backup.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Appeler l'endpoint API directement** :
|
2. **Appeler l'endpoint API directement** :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:3000/api/backups/auto
|
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.
|
⚠️ **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 :
|
Pour restaurer une sauvegarde :
|
||||||
|
|
||||||
1. Allez dans **Paramètres** → **Sauvegardes automatiques**
|
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
|
2. Cliquez sur l'icône de restauration (flèche circulaire) à côté de la sauvegarde souhaitée
|
||||||
3. Confirmez la restauration
|
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
|
- 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 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
|
- Les métadonnées (nom, taille, date) sont stockées dans la table `Backup` de la base de données
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,22 @@ export function useIsMobile() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
const checkMobile = () => {
|
||||||
const onChange = () => {
|
const mobile = window.innerWidth < MOBILE_BREAKPOINT;
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
setIsMobile((prev) => {
|
||||||
|
// Éviter les re-renders inutiles si la valeur n'a pas changé
|
||||||
|
if (prev === mobile) return prev;
|
||||||
|
return mobile;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
mql.addEventListener("change", onChange);
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
// Vérification initiale
|
||||||
return () => mql.removeEventListener("change", onChange);
|
checkMobile();
|
||||||
|
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||||
|
mql.addEventListener("change", checkMobile);
|
||||||
|
|
||||||
|
return () => mql.removeEventListener("change", checkMobile);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return !!isMobile;
|
return !!isMobile;
|
||||||
|
|||||||
@@ -6,4 +6,3 @@ import type { Account } from "./types";
|
|||||||
export function getAccountBalance(account: Account): number {
|
export function getAccountBalance(account: Account): number {
|
||||||
return (account.initialBalance || 0) + account.balance;
|
return (account.initialBalance || 0) + account.balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,8 @@ export async function requireAuth(): Promise<NextResponse | null> {
|
|||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
{ error: "Non authentifié" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
lib/auth.ts
30
lib/auth.ts
@@ -3,11 +3,17 @@ import CredentialsProvider from "next-auth/providers/credentials";
|
|||||||
import { authService } from "@/services/auth.service";
|
import { authService } from "@/services/auth.service";
|
||||||
|
|
||||||
// Get secret with fallback for development
|
// 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)
|
// Debug: log secret status (remove in production)
|
||||||
if (process.env.NODE_ENV === "development") {
|
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") {
|
if (!process.env.NEXTAUTH_SECRET && process.env.NODE_ENV === "production") {
|
||||||
@@ -29,7 +35,9 @@ export const authOptions: NextAuthOptions = {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValid = await authService.verifyPassword(credentials.password);
|
const isValid = await authService.verifyPassword(
|
||||||
|
credentials.password
|
||||||
|
);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -51,16 +59,29 @@ export const authOptions: NextAuthOptions = {
|
|||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
strategy: "jwt",
|
strategy: "jwt",
|
||||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
maxAge: 24 * 60 * 60, // 24 hours
|
||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, user }) {
|
async jwt({ token, user }) {
|
||||||
|
// On first sign in, set expiration time
|
||||||
if (user) {
|
if (user) {
|
||||||
token.id = user.id;
|
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;
|
return token;
|
||||||
},
|
},
|
||||||
async session({ session, 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) {
|
if (session.user && token.id) {
|
||||||
session.user.id = token.id;
|
session.user.id = token.id;
|
||||||
}
|
}
|
||||||
@@ -69,4 +90,3 @@ export const authOptions: NextAuthOptions = {
|
|||||||
},
|
},
|
||||||
secret,
|
secret,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
106
lib/hooks.ts
106
lib/hooks.ts
@@ -1,8 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
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 { loadData } from "./store-db";
|
||||||
|
import type {
|
||||||
|
TransactionsPaginatedParams,
|
||||||
|
TransactionsPaginatedResult,
|
||||||
|
} from "@/services/banking.service";
|
||||||
|
|
||||||
export function useBankingData() {
|
export function useBankingData() {
|
||||||
const [data, setData] = useState<BankingData | null>(null);
|
const [data, setData] = useState<BankingData | null>(null);
|
||||||
@@ -75,3 +80,102 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
|||||||
|
|
||||||
return [storedValue, setValue] as const;
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,4 +19,3 @@ export const config = {
|
|||||||
"/((?!api/auth|login|_next/static|_next/image|favicon.ico).*)",
|
"/((?!api/auth|login|_next/static|_next/image|favicon.ico).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const nextConfig = {
|
|||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"@radix-ui/react-toggle": "1.1.1",
|
"@radix-ui/react-toggle": "1.1.1",
|
||||||
"@radix-ui/react-toggle-group": "1.1.1",
|
"@radix-ui/react-toggle-group": "1.1.1",
|
||||||
"@radix-ui/react-tooltip": "1.1.6",
|
"@radix-ui/react-tooltip": "1.1.6",
|
||||||
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@vercel/analytics": "1.3.1",
|
"@vercel/analytics": "1.3.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
@@ -60,7 +63,7 @@
|
|||||||
"embla-carousel-react": "8.5.1",
|
"embla-carousel-react": "8.5.1",
|
||||||
"input-otp": "1.4.1",
|
"input-otp": "1.4.1",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"next": "16.0.3",
|
"next": "16.0.7",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
|
|||||||
6760
pnpm-lock.yaml
generated
6760
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,8 @@ model Transaction {
|
|||||||
@@index([accountId])
|
@@index([accountId])
|
||||||
@@index([categoryId])
|
@@index([categoryId])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
|
@@index([accountId, date])
|
||||||
|
@@index([isReconciled])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Folder {
|
model Folder {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from "fs";
|
||||||
import * as path from 'path';
|
import * as path from "path";
|
||||||
import { prisma } from '../lib/prisma';
|
import { prisma } from "../lib/prisma";
|
||||||
import { transactionService } from '../services/transaction.service';
|
import { transactionService } from "../services/transaction.service";
|
||||||
import { generateId } from '../lib/store-db';
|
import { generateId } from "../lib/store-db";
|
||||||
|
|
||||||
interface CSVTransaction {
|
interface CSVTransaction {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -22,7 +22,7 @@ interface CSVTransaction {
|
|||||||
|
|
||||||
function parseCSVLine(line: string): string[] {
|
function parseCSVLine(line: string): string[] {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
let current = '';
|
let current = "";
|
||||||
let inQuotes = false;
|
let inQuotes = false;
|
||||||
|
|
||||||
for (let i = 0; i < line.length; i++) {
|
for (let i = 0; i < line.length; i++) {
|
||||||
@@ -30,9 +30,9 @@ function parseCSVLine(line: string): string[] {
|
|||||||
|
|
||||||
if (char === '"') {
|
if (char === '"') {
|
||||||
inQuotes = !inQuotes;
|
inQuotes = !inQuotes;
|
||||||
} else if (char === ',' && !inQuotes) {
|
} else if (char === "," && !inQuotes) {
|
||||||
result.push(current.trim());
|
result.push(current.trim());
|
||||||
current = '';
|
current = "";
|
||||||
} else {
|
} else {
|
||||||
current += char;
|
current += char;
|
||||||
}
|
}
|
||||||
@@ -43,8 +43,8 @@ function parseCSVLine(line: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseCSV(csvPath: string): CSVTransaction[] {
|
function parseCSV(csvPath: string): CSVTransaction[] {
|
||||||
const content = fs.readFileSync(csvPath, 'utf-8');
|
const content = fs.readFileSync(csvPath, "utf-8");
|
||||||
const lines = content.split('\n');
|
const lines = content.split("\n");
|
||||||
|
|
||||||
// Skip header lines (first 8 lines)
|
// Skip header lines (first 8 lines)
|
||||||
const dataLines = lines.slice(8);
|
const dataLines = lines.slice(8);
|
||||||
@@ -82,62 +82,72 @@ function parseCSV(csvPath: string): CSVTransaction[] {
|
|||||||
|
|
||||||
function parseDate(dateStr: string): string {
|
function parseDate(dateStr: string): string {
|
||||||
// Format: DD/MM/YYYY -> YYYY-MM-DD
|
// Format: DD/MM/YYYY -> YYYY-MM-DD
|
||||||
const [day, month, year] = dateStr.split('/');
|
const [day, month, year] = dateStr.split("/");
|
||||||
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAmount(amountStr: string): number {
|
function parseAmount(amountStr: string): number {
|
||||||
if (!amountStr || amountStr.trim() === '' || amountStr === '""') {
|
if (!amountStr || amountStr.trim() === "" || amountStr === '""') {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
// Remove quotes, spaces (including non-breaking spaces), and replace comma with dot
|
// 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);
|
const parsed = parseFloat(cleaned);
|
||||||
return isNaN(parsed) ? 0 : parsed;
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateFITID(transaction: CSVTransaction, index: number): string {
|
function generateFITID(transaction: CSVTransaction, index: number): string {
|
||||||
const date = parseDate(transaction.date);
|
const date = parseDate(transaction.date);
|
||||||
const dateStr = date.replace(/-/g, '');
|
const dateStr = date.replace(/-/g, "");
|
||||||
const amountStr = Math.abs(parseAmount(transaction.amount)).toFixed(2).replace('.', '');
|
const amountStr = Math.abs(parseAmount(transaction.amount))
|
||||||
const libelleHash = transaction.libelle.substring(0, 20).replace(/[^A-Z0-9]/gi, '');
|
.toFixed(2)
|
||||||
|
.replace(".", "");
|
||||||
|
const libelleHash = transaction.libelle
|
||||||
|
.substring(0, 20)
|
||||||
|
.replace(/[^A-Z0-9]/gi, "");
|
||||||
return `${dateStr}-${amountStr}-${libelleHash}-${index}`;
|
return `${dateStr}-${amountStr}-${libelleHash}-${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAccountPrefix(accountName: string): string {
|
function removeAccountPrefix(accountName: string): string {
|
||||||
// Remove prefixes: LivretA, LDDS, CCP, PEL (case insensitive)
|
// 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;
|
let cleaned = accountName;
|
||||||
|
|
||||||
for (const prefix of prefixes) {
|
for (const prefix of prefixes) {
|
||||||
// Remove prefix followed by optional spaces and dashes
|
// Remove prefix followed by optional spaces and dashes
|
||||||
const regex = new RegExp(`^${prefix}\\s*-?\\s*`, 'i');
|
const regex = new RegExp(`^${prefix}\\s*-?\\s*`, "i");
|
||||||
cleaned = cleaned.replace(regex, '');
|
cleaned = cleaned.replace(regex, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleaned.trim();
|
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();
|
const upper = accountName.toUpperCase();
|
||||||
if (upper.includes('LIVRET') || upper.includes('LDDS') || upper.includes('PEL')) {
|
if (
|
||||||
return 'SAVINGS';
|
upper.includes("LIVRET") ||
|
||||||
|
upper.includes("LDDS") ||
|
||||||
|
upper.includes("PEL")
|
||||||
|
) {
|
||||||
|
return "SAVINGS";
|
||||||
}
|
}
|
||||||
if (upper.includes('CCP') || upper.includes('COMPTE COURANT')) {
|
if (upper.includes("CCP") || upper.includes("COMPTE COURANT")) {
|
||||||
return 'CHECKING';
|
return "CHECKING";
|
||||||
}
|
}
|
||||||
return 'OTHER';
|
return "OTHER";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
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)) {
|
if (!fs.existsSync(csvPath)) {
|
||||||
console.error(`Fichier CSV introuvable: ${csvPath}`);
|
console.error(`Fichier CSV introuvable: ${csvPath}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Lecture du fichier CSV...');
|
console.log("Lecture du fichier CSV...");
|
||||||
const csvTransactions = parseCSV(csvPath);
|
const csvTransactions = parseCSV(csvPath);
|
||||||
console.log(`✓ ${csvTransactions.length} transactions trouvées`);
|
console.log(`✓ ${csvTransactions.length} transactions trouvées`);
|
||||||
|
|
||||||
@@ -167,8 +177,10 @@ async function main() {
|
|||||||
|
|
||||||
// Remove prefixes and extract account number from account name
|
// Remove prefixes and extract account number from account name
|
||||||
const cleanedAccountName = removeAccountPrefix(accountName);
|
const cleanedAccountName = removeAccountPrefix(accountName);
|
||||||
const accountNumber = cleanedAccountName.replace(/[^A-Z0-9]/gi, '').substring(0, 22);
|
const accountNumber = cleanedAccountName
|
||||||
const bankId = transactions[0]?.codeBanque || 'FR';
|
.replace(/[^A-Z0-9]/gi, "")
|
||||||
|
.substring(0, 22);
|
||||||
|
const bankId = transactions[0]?.codeBanque || "FR";
|
||||||
|
|
||||||
console.log(` Numéro de compte extrait: ${accountNumber}`);
|
console.log(` Numéro de compte extrait: ${accountNumber}`);
|
||||||
|
|
||||||
@@ -203,7 +215,7 @@ async function main() {
|
|||||||
// Try to find exact match in accountNumber (after cleaning)
|
// Try to find exact match in accountNumber (after cleaning)
|
||||||
for (const acc of allAccounts) {
|
for (const acc of allAccounts) {
|
||||||
const cleanedExisting = removeAccountPrefix(acc.accountNumber);
|
const cleanedExisting = removeAccountPrefix(acc.accountNumber);
|
||||||
const existingNumber = cleanedExisting.replace(/[^A-Z0-9]/gi, '');
|
const existingNumber = cleanedExisting.replace(/[^A-Z0-9]/gi, "");
|
||||||
if (existingNumber === accountNumber) {
|
if (existingNumber === accountNumber) {
|
||||||
account = acc;
|
account = acc;
|
||||||
break;
|
break;
|
||||||
@@ -221,14 +233,16 @@ async function main() {
|
|||||||
type: determineAccountType(accountName),
|
type: determineAccountType(accountName),
|
||||||
folderId: null,
|
folderId: null,
|
||||||
balance: 0,
|
balance: 0,
|
||||||
currency: 'EUR',
|
currency: "EUR",
|
||||||
lastImport: null,
|
lastImport: null,
|
||||||
externalUrl: null,
|
externalUrl: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
totalAccountsCreated++;
|
totalAccountsCreated++;
|
||||||
} else {
|
} else {
|
||||||
console.log(` → Compte existant trouvé: ${account.name} (${account.accountNumber})`);
|
console.log(
|
||||||
|
` → Compte existant trouvé: ${account.name} (${account.accountNumber})`,
|
||||||
|
);
|
||||||
totalAccountsUpdated++;
|
totalAccountsUpdated++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,11 +275,16 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (duplicatesCount > 0) {
|
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
|
// 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
|
// Prepare transactions for insertion
|
||||||
const dbTransactions = uniqueTransactions.map((transaction, index) => {
|
const dbTransactions = uniqueTransactions.map((transaction, index) => {
|
||||||
@@ -290,7 +309,7 @@ async function main() {
|
|||||||
date: date,
|
date: date,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
description: transaction.libelle.substring(0, 255),
|
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
|
categoryId: null, // Will be auto-categorized later if needed
|
||||||
isReconciled: false,
|
isReconciled: false,
|
||||||
fitId: generateFITID(transaction, index),
|
fitId: generateFITID(transaction, index),
|
||||||
@@ -316,17 +335,16 @@ async function main() {
|
|||||||
console.log(` ✓ Solde mis à jour: ${balance.toFixed(2)} EUR\n`);
|
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 créés: ${totalAccountsCreated}`);
|
||||||
console.log(`Comptes mis à jour: ${totalAccountsUpdated}`);
|
console.log(`Comptes mis à jour: ${totalAccountsUpdated}`);
|
||||||
console.log(`Transactions insérées: ${totalTransactionsCreated}`);
|
console.log(`Transactions insérées: ${totalTransactionsCreated}`);
|
||||||
console.log('\n✓ Import terminé!');
|
console.log("\n✓ Import terminé!");
|
||||||
|
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error('Erreur:', error);
|
console.error("Erreur:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,3 @@ console.log(" - parentId: null");
|
|||||||
console.log(" - color: #6366f1");
|
console.log(" - color: #6366f1");
|
||||||
console.log(" - icon: folder");
|
console.log(" - icon: folder");
|
||||||
console.log("3. Créez les catégories par défaut via l'interface web");
|
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
Reference in New Issue
Block a user