Compare commits
52 Commits
11c0df1293
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a4f6d31b8 | ||
|
|
804b0f0aad | ||
|
|
f295e86fc2 | ||
|
|
c57daa9cc8 | ||
|
|
01c1f25de2 | ||
|
|
9de7d1a467 | ||
|
|
407486a109 | ||
|
|
e0597b0dcb | ||
|
|
b2eac21bdf | ||
|
|
7c3f522531 | ||
|
|
4f13134ef0 | ||
|
|
6c14484636 | ||
|
|
2452e30a0f | ||
|
|
b3ae6059ca | ||
|
|
6f78dca1f0 | ||
|
|
c4707e5511 | ||
|
|
2887a6a750 | ||
|
|
82e27524b5 | ||
|
|
8b62cd8385 | ||
|
|
a01345c1fb | ||
|
|
c358845033 | ||
|
|
b3e99a15d2 | ||
|
|
dbcf8e7abd | ||
|
|
55f0e5c625 | ||
|
|
b4dace0673 | ||
|
|
aa2c656c00 | ||
|
|
53798176a0 | ||
|
|
8b81dfe8c0 | ||
|
|
376bc8f84e | ||
|
|
4e1e623f93 | ||
|
|
4f7a80de1c | ||
|
|
d61d9181c7 | ||
|
|
dff2a9061f | ||
|
|
4445a38380 | ||
|
|
198bf44a96 | ||
|
|
c300e1d7a6 | ||
|
|
8e5c60a684 | ||
|
|
27d4612217 | ||
|
|
3f4381c26e | ||
|
|
b219ca8748 | ||
|
|
f2ad63852c | ||
|
|
0db4555257 | ||
|
|
30dc3e9732 | ||
|
|
c8988c42bd | ||
|
|
b282b477a2 | ||
|
|
385f68bbdf | ||
|
|
f8919b19b3 | ||
|
|
299a66e6ff | ||
|
|
8d947ad70f | ||
|
|
53bae084c4 | ||
|
|
ba4d112cb8 | ||
|
|
cb8628ce39 |
@@ -14,9 +14,3 @@ README.md
|
|||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
23
.gitea/workflows/deploy.yml
Normal file
23
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: Deploy with Docker Compose
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main # adapte la branche que tu veux déployer
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: mac-orbstack-runner # le nom que tu as donné au runner
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy stack
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: 1
|
||||||
|
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||||
|
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|
||||||
|
NEXTAUTH_URL: ${{ vars.NEXTAUTH_URL }}
|
||||||
|
DATA_VOLUME_PATH: ${{ vars.DATA_VOLUME_PATH }}
|
||||||
|
run: |
|
||||||
|
docker compose up -d --build
|
||||||
@@ -12,7 +12,8 @@ WORKDIR /app
|
|||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# Rebuild the source code only when needed
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
|||||||
import {
|
import {
|
||||||
AccountCard,
|
AccountCard,
|
||||||
AccountEditDialog,
|
AccountEditDialog,
|
||||||
|
AccountMergeSelectDialog,
|
||||||
AccountBulkActions,
|
AccountBulkActions,
|
||||||
} from "@/components/accounts";
|
} from "@/components/accounts";
|
||||||
import { FolderEditDialog } from "@/components/folders";
|
import { FolderEditDialog } from "@/components/folders";
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
updateFolder,
|
updateFolder,
|
||||||
deleteFolder,
|
deleteFolder,
|
||||||
} from "@/lib/store-db";
|
} from "@/lib/store-db";
|
||||||
|
import { invalidateAllAccountQueries } from "@/lib/cache-utils";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -85,8 +87,7 @@ export default function AccountsPage() {
|
|||||||
// refresh function is not used directly, invalidations are done inline
|
// refresh function is not used directly, invalidations are done inline
|
||||||
|
|
||||||
const refreshSilent = async () => {
|
const refreshSilent = async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
invalidateAllAccountQueries(queryClient);
|
||||||
await queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
|
||||||
};
|
};
|
||||||
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
@@ -99,6 +100,7 @@ export default function AccountsPage() {
|
|||||||
folderId: "folder-root",
|
folderId: "folder-root",
|
||||||
externalUrl: "",
|
externalUrl: "",
|
||||||
initialBalance: 0,
|
initialBalance: 0,
|
||||||
|
totalBalance: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Folder management state
|
// Folder management state
|
||||||
@@ -111,6 +113,7 @@ export default function AccountsPage() {
|
|||||||
});
|
});
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [isCompactView, setIsCompactView] = useState(false);
|
const [isCompactView, setIsCompactView] = useState(false);
|
||||||
|
const [isMergeDialogOpen, setIsMergeDialogOpen] = useState(false);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -131,7 +134,11 @@ export default function AccountsPage() {
|
|||||||
|
|
||||||
// Convert accountsWithStats to regular accounts for compatibility
|
// Convert accountsWithStats to regular accounts for compatibility
|
||||||
const accounts = accountsWithStats.map(
|
const accounts = accountsWithStats.map(
|
||||||
({ transactionCount: _transactionCount, ...account }) => account,
|
({
|
||||||
|
transactionCount: _transactionCount,
|
||||||
|
calculatedBalance: _calculatedBalance,
|
||||||
|
...account
|
||||||
|
}) => account,
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
@@ -143,12 +150,14 @@ export default function AccountsPage() {
|
|||||||
|
|
||||||
const handleEdit = (account: Account) => {
|
const handleEdit = (account: Account) => {
|
||||||
setEditingAccount(account);
|
setEditingAccount(account);
|
||||||
|
const totalBalance = getAccountBalance(account);
|
||||||
setFormData({
|
setFormData({
|
||||||
name: account.name,
|
name: account.name,
|
||||||
type: account.type,
|
type: account.type,
|
||||||
folderId: account.folderId || "folder-root",
|
folderId: account.folderId || "folder-root",
|
||||||
externalUrl: account.externalUrl || "",
|
externalUrl: account.externalUrl || "",
|
||||||
initialBalance: account.initialBalance || 0,
|
initialBalance: account.initialBalance || 0,
|
||||||
|
totalBalance: totalBalance,
|
||||||
});
|
});
|
||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
};
|
};
|
||||||
@@ -157,17 +166,25 @@ export default function AccountsPage() {
|
|||||||
if (!editingAccount) return;
|
if (!editingAccount) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Calculer le balance à partir du solde total et du solde initial
|
||||||
|
// balance = totalBalance - initialBalance
|
||||||
|
const balance = formData.totalBalance - formData.initialBalance;
|
||||||
|
|
||||||
|
// Convertir "folder-root" en null
|
||||||
|
const folderId =
|
||||||
|
formData.folderId === "folder-root" ? null : formData.folderId;
|
||||||
|
|
||||||
const updatedAccount = {
|
const updatedAccount = {
|
||||||
...editingAccount,
|
...editingAccount,
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
type: formData.type,
|
type: formData.type,
|
||||||
folderId: formData.folderId,
|
folderId: folderId,
|
||||||
externalUrl: formData.externalUrl || null,
|
externalUrl: formData.externalUrl || null,
|
||||||
initialBalance: formData.initialBalance,
|
initialBalance: formData.initialBalance,
|
||||||
|
balance: balance,
|
||||||
};
|
};
|
||||||
await updateAccount(updatedAccount);
|
await updateAccount(updatedAccount);
|
||||||
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
invalidateAllAccountQueries(queryClient);
|
||||||
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
setEditingAccount(null);
|
setEditingAccount(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -181,8 +198,7 @@ export default function AccountsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteAccount(accountId);
|
await deleteAccount(accountId);
|
||||||
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
invalidateAllAccountQueries(queryClient);
|
||||||
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting account:", error);
|
console.error("Error deleting account:", error);
|
||||||
alert("Erreur lors de la suppression du compte");
|
alert("Erreur lors de la suppression du compte");
|
||||||
@@ -210,8 +226,7 @@ export default function AccountsPage() {
|
|||||||
throw new Error("Failed to delete accounts");
|
throw new Error("Failed to delete accounts");
|
||||||
}
|
}
|
||||||
setSelectedAccounts(new Set());
|
setSelectedAccounts(new Set());
|
||||||
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
invalidateAllAccountQueries(queryClient);
|
||||||
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting accounts:", error);
|
console.error("Error deleting accounts:", error);
|
||||||
alert("Erreur lors de la suppression des comptes");
|
alert("Erreur lors de la suppression des comptes");
|
||||||
@@ -267,8 +282,7 @@ export default function AccountsPage() {
|
|||||||
icon: "folder",
|
icon: "folder",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
invalidateAllAccountQueries(queryClient);
|
||||||
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
|
||||||
setIsFolderDialogOpen(false);
|
setIsFolderDialogOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving folder:", error);
|
console.error("Error saving folder:", error);
|
||||||
@@ -286,14 +300,50 @@ export default function AccountsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteFolder(folderId);
|
await deleteFolder(folderId);
|
||||||
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
invalidateAllAccountQueries(queryClient);
|
||||||
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting folder:", error);
|
console.error("Error deleting folder:", error);
|
||||||
alert("Erreur lors de la suppression du dossier");
|
alert("Erreur lors de la suppression du dossier");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMergeAccounts = async (
|
||||||
|
sourceAccountId: string,
|
||||||
|
targetAccountId: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/banking/accounts/merge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourceAccountId,
|
||||||
|
targetAccountId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || "Failed to merge accounts");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
invalidateAllAccountQueries(queryClient);
|
||||||
|
|
||||||
|
// Réinitialiser la sélection
|
||||||
|
setSelectedAccounts(new Set());
|
||||||
|
|
||||||
|
// Afficher un message de succès
|
||||||
|
alert(
|
||||||
|
`Fusion réussie ! ${result.transactionCount} transactions déplacées vers le compte de destination.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error merging accounts:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Drag and drop handlers
|
// Drag and drop handlers
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
setActiveId(event.active.id as string);
|
setActiveId(event.active.id as string);
|
||||||
@@ -339,7 +389,16 @@ export default function AccountsPage() {
|
|||||||
// Update cache directly
|
// Update cache directly
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["accounts-with-stats"],
|
["accounts-with-stats"],
|
||||||
(old: Array<Account & { transactionCount: number }> | undefined) => {
|
(
|
||||||
|
old:
|
||||||
|
| Array<
|
||||||
|
Account & {
|
||||||
|
transactionCount: number;
|
||||||
|
calculatedBalance: number;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
| undefined,
|
||||||
|
) => {
|
||||||
if (!old) return old;
|
if (!old) return old;
|
||||||
return old.map((a) => (a.id === accountId ? updatedAccount : a));
|
return old.map((a) => (a.id === accountId ? updatedAccount : a));
|
||||||
},
|
},
|
||||||
@@ -353,8 +412,7 @@ export default function AccountsPage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error moving account:", error);
|
console.error("Error moving account:", error);
|
||||||
// Rollback en cas d'erreur - refresh data
|
// Rollback en cas d'erreur - refresh data
|
||||||
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
invalidateAllAccountQueries(queryClient);
|
||||||
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
|
||||||
alert("Erreur lors du déplacement du compte");
|
alert("Erreur lors du déplacement du compte");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -464,7 +522,13 @@ export default function AccountsPage() {
|
|||||||
<>
|
<>
|
||||||
<AccountBulkActions
|
<AccountBulkActions
|
||||||
selectedCount={selectedAccounts.size}
|
selectedCount={selectedAccounts.size}
|
||||||
|
selectedAccountIds={Array.from(selectedAccounts)}
|
||||||
onDelete={handleBulkDelete}
|
onDelete={handleBulkDelete}
|
||||||
|
onMerge={
|
||||||
|
selectedAccounts.size === 2
|
||||||
|
? () => setIsMergeDialogOpen(true)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
@@ -514,12 +578,18 @@ export default function AccountsPage() {
|
|||||||
(f: FolderType) => f.id === account.folderId,
|
(f: FolderType) => f.id === account.folderId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const accountWithStats = accountsWithStats.find(
|
||||||
|
(a) => a.id === account.id,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<AccountCard
|
<AccountCard
|
||||||
key={account.id}
|
key={account.id}
|
||||||
account={account}
|
account={account}
|
||||||
folder={folder}
|
folder={folder}
|
||||||
transactionCount={getTransactionCount(account.id)}
|
transactionCount={getTransactionCount(account.id)}
|
||||||
|
calculatedBalance={
|
||||||
|
accountWithStats?.calculatedBalance
|
||||||
|
}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
@@ -631,12 +701,18 @@ export default function AccountsPage() {
|
|||||||
(f: FolderType) => f.id === account.folderId,
|
(f: FolderType) => f.id === account.folderId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const accountWithStats = accountsWithStats.find(
|
||||||
|
(a) => a.id === account.id,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<AccountCard
|
<AccountCard
|
||||||
key={account.id}
|
key={account.id}
|
||||||
account={account}
|
account={account}
|
||||||
folder={accountFolder}
|
folder={accountFolder}
|
||||||
transactionCount={getTransactionCount(account.id)}
|
transactionCount={getTransactionCount(account.id)}
|
||||||
|
calculatedBalance={
|
||||||
|
accountWithStats?.calculatedBalance
|
||||||
|
}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
@@ -702,6 +778,21 @@ export default function AccountsPage() {
|
|||||||
folders={metadata.folders}
|
folders={metadata.folders}
|
||||||
onSave={handleSaveFolder}
|
onSave={handleSaveFolder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AccountMergeSelectDialog
|
||||||
|
open={isMergeDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsMergeDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
// Réinitialiser la sélection après fusion
|
||||||
|
setSelectedAccounts(new Set());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
accounts={accounts}
|
||||||
|
selectedAccountIds={Array.from(selectedAccounts)}
|
||||||
|
onMerge={handleMergeAccounts}
|
||||||
|
formatCurrency={formatCurrency}
|
||||||
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
128
app/api/banking/accounts/merge/route.ts
Normal file
128
app/api/banking/accounts/merge/route.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authError = await requireAuth();
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { sourceAccountId, targetAccountId } = await request.json();
|
||||||
|
|
||||||
|
if (!sourceAccountId || !targetAccountId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Source and target account IDs are required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceAccountId === targetAccountId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Source and target accounts must be different" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que les comptes existent
|
||||||
|
const [sourceAccount, targetAccount] = await Promise.all([
|
||||||
|
prisma.account.findUnique({
|
||||||
|
where: { id: sourceAccountId },
|
||||||
|
include: {
|
||||||
|
transactions: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.account.findUnique({
|
||||||
|
where: { id: targetAccountId },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!sourceAccount || !targetAccount) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "One or both accounts not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionCount = sourceAccount.transactions.length;
|
||||||
|
|
||||||
|
// Déplacer toutes les transactions vers le compte cible
|
||||||
|
await prisma.transaction.updateMany({
|
||||||
|
where: {
|
||||||
|
accountId: sourceAccountId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
accountId: targetAccountId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recalculer le solde du compte cible à partir de toutes ses transactions
|
||||||
|
const allTransactions = await prisma.transaction.findMany({
|
||||||
|
where: {
|
||||||
|
accountId: targetAccountId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
amount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const calculatedBalance = allTransactions.reduce(
|
||||||
|
(sum, t) => sum + t.amount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculer l'initialBalance total (somme des deux comptes)
|
||||||
|
const totalInitialBalance =
|
||||||
|
sourceAccount.initialBalance + targetAccount.initialBalance;
|
||||||
|
|
||||||
|
// Mettre à jour le compte cible
|
||||||
|
await prisma.account.update({
|
||||||
|
where: {
|
||||||
|
id: targetAccountId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
balance: calculatedBalance,
|
||||||
|
initialBalance: totalInitialBalance,
|
||||||
|
// Garder le bankId du compte cible (celui qui est conservé)
|
||||||
|
// Garder le dernier import le plus récent
|
||||||
|
lastImport:
|
||||||
|
sourceAccount.lastImport && targetAccount.lastImport
|
||||||
|
? sourceAccount.lastImport > targetAccount.lastImport
|
||||||
|
? sourceAccount.lastImport
|
||||||
|
: targetAccount.lastImport
|
||||||
|
: sourceAccount.lastImport || targetAccount.lastImport,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Supprimer le compte source
|
||||||
|
await prisma.account.delete({
|
||||||
|
where: {
|
||||||
|
id: sourceAccountId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revalider les caches
|
||||||
|
revalidatePath("/accounts", "page");
|
||||||
|
revalidatePath("/transactions", "page");
|
||||||
|
revalidatePath("/statistics", "page");
|
||||||
|
revalidatePath("/dashboard", "page");
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Fusion réussie : ${transactionCount} transactions déplacées`,
|
||||||
|
targetAccountId,
|
||||||
|
transactionCount,
|
||||||
|
calculatedBalance,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error merging accounts:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to merge accounts" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { accountService } from "@/services/account.service";
|
import { accountService } from "@/services/account.service";
|
||||||
import { bankingService } from "@/services/banking.service";
|
import { bankingService } from "@/services/banking.service";
|
||||||
import { requireAuth } from "@/lib/auth-utils";
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
@@ -14,11 +15,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (withStats) {
|
if (withStats) {
|
||||||
const accountsWithStats = await bankingService.getAccountsWithStats();
|
const accountsWithStats = await bankingService.getAccountsWithStats();
|
||||||
return NextResponse.json(accountsWithStats, {
|
return NextResponse.json(accountsWithStats);
|
||||||
headers: {
|
|
||||||
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||||
@@ -38,7 +35,18 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const data: Omit<Account, "id"> = await request.json();
|
const data: Omit<Account, "id"> = await request.json();
|
||||||
const created = await accountService.create(data);
|
const created = await accountService.create(data);
|
||||||
return NextResponse.json(created);
|
|
||||||
|
// Revalider le cache serveur
|
||||||
|
revalidatePath("/accounts", "page");
|
||||||
|
revalidatePath("/transactions", "page");
|
||||||
|
revalidatePath("/statistics", "page");
|
||||||
|
revalidatePath("/dashboard", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(created, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating account:", error);
|
console.error("Error creating account:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -55,7 +63,18 @@ export async function PUT(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const account: Account = await request.json();
|
const account: Account = await request.json();
|
||||||
const updated = await accountService.update(account.id, account);
|
const updated = await accountService.update(account.id, account);
|
||||||
return NextResponse.json(updated);
|
|
||||||
|
// Revalider le cache serveur
|
||||||
|
revalidatePath("/accounts", "page");
|
||||||
|
revalidatePath("/transactions", "page");
|
||||||
|
revalidatePath("/statistics", "page");
|
||||||
|
revalidatePath("/dashboard", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(updated, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating account:", error);
|
console.error("Error updating account:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -84,7 +103,21 @@ export async function DELETE(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await accountService.deleteMany(accountIds);
|
await accountService.deleteMany(accountIds);
|
||||||
return NextResponse.json({ success: true, count: accountIds.length });
|
|
||||||
|
// Revalider le cache serveur
|
||||||
|
revalidatePath("/accounts", "page");
|
||||||
|
revalidatePath("/transactions", "page");
|
||||||
|
revalidatePath("/statistics", "page");
|
||||||
|
revalidatePath("/dashboard", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true, count: accountIds.length },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -95,7 +128,21 @@ export async function DELETE(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await accountService.delete(id);
|
await accountService.delete(id);
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
|
// Revalider le cache serveur
|
||||||
|
revalidatePath("/accounts", "page");
|
||||||
|
revalidatePath("/transactions", "page");
|
||||||
|
revalidatePath("/statistics", "page");
|
||||||
|
revalidatePath("/dashboard", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting account:", error);
|
console.error("Error deleting account:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { categoryService } from "@/services/category.service";
|
import { categoryService } from "@/services/category.service";
|
||||||
import { bankingService } from "@/services/banking.service";
|
import { bankingService } from "@/services/banking.service";
|
||||||
import { requireAuth } from "@/lib/auth-utils";
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
@@ -14,11 +15,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (statsOnly) {
|
if (statsOnly) {
|
||||||
const stats = await bankingService.getCategoryStats();
|
const stats = await bankingService.getCategoryStats();
|
||||||
return NextResponse.json(stats, {
|
return NextResponse.json(stats);
|
||||||
headers: {
|
|
||||||
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||||
@@ -37,7 +34,18 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const data: Omit<Category, "id"> = await request.json();
|
const data: Omit<Category, "id"> = await request.json();
|
||||||
const created = await categoryService.create(data);
|
const created = await categoryService.create(data);
|
||||||
return NextResponse.json(created);
|
|
||||||
|
// Revalider le cache serveur
|
||||||
|
revalidatePath("/categories", "page");
|
||||||
|
revalidatePath("/transactions", "page");
|
||||||
|
revalidatePath("/statistics", "page");
|
||||||
|
revalidatePath("/rules", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(created, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating category:", error);
|
console.error("Error creating category:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -54,7 +62,18 @@ export async function PUT(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const category: Category = await request.json();
|
const category: Category = await request.json();
|
||||||
const updated = await categoryService.update(category.id, category);
|
const updated = await categoryService.update(category.id, category);
|
||||||
return NextResponse.json(updated);
|
|
||||||
|
// Revalider le cache serveur
|
||||||
|
revalidatePath("/categories", "page");
|
||||||
|
revalidatePath("/transactions", "page");
|
||||||
|
revalidatePath("/statistics", "page");
|
||||||
|
revalidatePath("/rules", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(updated, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating category:", error);
|
console.error("Error updating category:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -80,7 +99,21 @@ export async function DELETE(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await categoryService.delete(id);
|
await categoryService.delete(id);
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
|
// Revalider le cache serveur
|
||||||
|
revalidatePath("/categories", "page");
|
||||||
|
revalidatePath("/transactions", "page");
|
||||||
|
revalidatePath("/statistics", "page");
|
||||||
|
revalidatePath("/rules", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting category:", error);
|
console.error("Error deleting category:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
19
app/api/banking/duplicates/ids/route.ts
Normal file
19
app/api/banking/duplicates/ids/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { transactionService } from "@/services/transaction.service";
|
||||||
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const authError = await requireAuth();
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const duplicateIds = await transactionService.getDuplicateIds();
|
||||||
|
return NextResponse.json({ duplicateIds: Array.from(duplicateIds) });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error finding duplicate IDs:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to find duplicate IDs" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { folderService, FolderNotFoundError } from "@/services/folder.service";
|
import { folderService, FolderNotFoundError } from "@/services/folder.service";
|
||||||
import { requireAuth } from "@/lib/auth-utils";
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
import type { Folder } from "@/lib/types";
|
import type { Folder } from "@/lib/types";
|
||||||
@@ -9,7 +10,15 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const data: Omit<Folder, "id"> = await request.json();
|
const data: Omit<Folder, "id"> = await request.json();
|
||||||
const created = await folderService.create(data);
|
const created = await folderService.create(data);
|
||||||
return NextResponse.json(created);
|
|
||||||
|
// Revalider le cache des pages
|
||||||
|
revalidatePath("/accounts", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(created, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating folder:", error);
|
console.error("Error creating folder:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -26,7 +35,15 @@ export async function PUT(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const folder: Folder = await request.json();
|
const folder: Folder = await request.json();
|
||||||
const updated = await folderService.update(folder.id, folder);
|
const updated = await folderService.update(folder.id, folder);
|
||||||
return NextResponse.json(updated);
|
|
||||||
|
// Revalider le cache des pages
|
||||||
|
revalidatePath("/accounts", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(updated, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating folder:", error);
|
console.error("Error updating folder:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -52,7 +69,18 @@ export async function DELETE(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await folderService.delete(id);
|
await folderService.delete(id);
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
|
// Revalider le cache des pages
|
||||||
|
revalidatePath("/accounts", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof FolderNotFoundError) {
|
if (error instanceof FolderNotFoundError) {
|
||||||
return NextResponse.json({ error: "Folder not found" }, { status: 404 });
|
return NextResponse.json({ error: "Folder not found" }, { status: 404 });
|
||||||
|
|||||||
@@ -12,19 +12,11 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (metadataOnly) {
|
if (metadataOnly) {
|
||||||
const metadata = await bankingService.getMetadata();
|
const metadata = await bankingService.getMetadata();
|
||||||
return NextResponse.json(metadata, {
|
return NextResponse.json(metadata);
|
||||||
headers: {
|
|
||||||
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await bankingService.getAllData();
|
const data = await bankingService.getAllData();
|
||||||
return NextResponse.json(data, {
|
return NextResponse.json(data);
|
||||||
headers: {
|
|
||||||
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching banking data:", error);
|
console.error("Error fetching banking data:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireAuth } from "@/lib/auth-utils";
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
|
|
||||||
@@ -15,10 +16,23 @@ export async function POST() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
// Revalider le cache des pages
|
||||||
|
revalidatePath("/transactions", "page");
|
||||||
|
revalidatePath("/statistics", "page");
|
||||||
|
revalidatePath("/categories", "page");
|
||||||
|
revalidatePath("/dashboard", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
success: true,
|
success: true,
|
||||||
count: result.count,
|
count: result.count,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error clearing categories:", error);
|
console.error("Error clearing categories:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { transactionService } from "@/services/transaction.service";
|
import { transactionService } from "@/services/transaction.service";
|
||||||
import { requireAuth } from "@/lib/auth-utils";
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
|
|
||||||
@@ -8,7 +9,17 @@ export async function POST() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await transactionService.deduplicate();
|
const result = await transactionService.deduplicate();
|
||||||
return NextResponse.json(result);
|
|
||||||
|
// Revalider le cache des pages
|
||||||
|
revalidatePath("/transactions", "page");
|
||||||
|
revalidatePath("/statistics", "page");
|
||||||
|
revalidatePath("/dashboard", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(result, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deduplicating transactions:", error);
|
console.error("Error deduplicating transactions:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
68
app/api/banking/transactions/reconcile-date-range/route.ts
Normal file
68
app/api/banking/transactions/reconcile-date-range/route.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { transactionService } from "@/services/transaction.service";
|
||||||
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authError = await requireAuth();
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { startDate, endDate, reconciled = true } = body;
|
||||||
|
|
||||||
|
if (!endDate) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "endDate is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date format (YYYY-MM-DD)
|
||||||
|
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
if (startDate && !dateRegex.test(startDate)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid startDate format. Expected YYYY-MM-DD" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!dateRegex.test(endDate)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid endDate format. Expected YYYY-MM-DD" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate && startDate > endDate) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "startDate must be before or equal to endDate" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await transactionService.reconcileByDateRange(
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
reconciled,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Revalider le cache des pages
|
||||||
|
revalidatePath("/transactions", "page");
|
||||||
|
revalidatePath("/statistics", "page");
|
||||||
|
revalidatePath("/dashboard", "page");
|
||||||
|
revalidatePath("/settings", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(result, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reconciling transactions by date range:", error);
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to reconcile transactions";
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { transactionService } from "@/services/transaction.service";
|
import { transactionService } from "@/services/transaction.service";
|
||||||
import { bankingService } from "@/services/banking.service";
|
import { bankingService } from "@/services/banking.service";
|
||||||
import { requireAuth } from "@/lib/auth-utils";
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
@@ -28,7 +29,7 @@ export async function GET(request: NextRequest) {
|
|||||||
searchParams.get("includeUncategorized") === "true";
|
searchParams.get("includeUncategorized") === "true";
|
||||||
const search = searchParams.get("search") || undefined;
|
const search = searchParams.get("search") || undefined;
|
||||||
const isReconciledParam = searchParams.get("isReconciled");
|
const isReconciledParam = searchParams.get("isReconciled");
|
||||||
const isReconciled =
|
const isReconciled: boolean | "all" =
|
||||||
isReconciledParam === "true"
|
isReconciledParam === "true"
|
||||||
? true
|
? true
|
||||||
: isReconciledParam === "false"
|
: isReconciledParam === "false"
|
||||||
@@ -40,7 +41,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const sortOrder =
|
const sortOrder =
|
||||||
(searchParams.get("sortOrder") as "asc" | "desc") || "desc";
|
(searchParams.get("sortOrder") as "asc" | "desc") || "desc";
|
||||||
|
|
||||||
const result = await bankingService.getTransactionsPaginated({
|
const params = {
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -52,13 +53,13 @@ export async function GET(request: NextRequest) {
|
|||||||
isReconciled,
|
isReconciled,
|
||||||
sortField,
|
sortField,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
});
|
};
|
||||||
|
|
||||||
return NextResponse.json(result, {
|
// Pas de cache serveur pour garantir des données toujours à jour
|
||||||
headers: {
|
// Le cache client React Query gère déjà la mise en cache côté client
|
||||||
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
const result = await bankingService.getTransactionsPaginated(params);
|
||||||
},
|
|
||||||
});
|
return NextResponse.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching transactions:", error);
|
console.error("Error fetching transactions:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -74,7 +75,17 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const transactions: Transaction[] = await request.json();
|
const transactions: Transaction[] = await request.json();
|
||||||
const result = await transactionService.createMany(transactions);
|
const result = await transactionService.createMany(transactions);
|
||||||
return NextResponse.json(result);
|
|
||||||
|
// Revalider le cache des pages
|
||||||
|
revalidatePath("/transactions", "page");
|
||||||
|
revalidatePath("/statistics", "page");
|
||||||
|
revalidatePath("/dashboard", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(result, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating transactions:", error);
|
console.error("Error creating transactions:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -94,7 +105,17 @@ export async function PUT(request: Request) {
|
|||||||
transaction.id,
|
transaction.id,
|
||||||
transaction,
|
transaction,
|
||||||
);
|
);
|
||||||
return NextResponse.json(updated);
|
|
||||||
|
// Revalider le cache des pages
|
||||||
|
revalidatePath("/transactions", "page");
|
||||||
|
revalidatePath("/statistics", "page");
|
||||||
|
revalidatePath("/dashboard", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(updated, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating transaction:", error);
|
console.error("Error updating transaction:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -120,14 +141,24 @@ export async function DELETE(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await transactionService.delete(id);
|
await transactionService.delete(id);
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
|
// Revalider le cache des pages
|
||||||
|
revalidatePath("/transactions", "page");
|
||||||
|
revalidatePath("/statistics", "page");
|
||||||
|
revalidatePath("/dashboard", "page");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting transaction:", error);
|
console.error("Error deleting transaction:", error);
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : "Failed to delete transaction";
|
error instanceof Error ? error.message : "Failed to delete transaction";
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||||
{ error: errorMessage },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { useBankingMetadata, useCategoryStats } from "@/lib/hooks";
|
import { useBankingMetadata, useCategoryStats } from "@/lib/hooks";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -28,6 +29,8 @@ import {
|
|||||||
deleteCategory,
|
deleteCategory,
|
||||||
} from "@/lib/store-db";
|
} from "@/lib/store-db";
|
||||||
import type { Category, Transaction } from "@/lib/types";
|
import type { Category, Transaction } from "@/lib/types";
|
||||||
|
import { invalidateAllCategoryQueries } from "@/lib/cache-utils";
|
||||||
|
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||||
|
|
||||||
interface RecategorizationResult {
|
interface RecategorizationResult {
|
||||||
transaction: Transaction;
|
transaction: Transaction;
|
||||||
@@ -57,6 +60,12 @@ export default function CategoriesPage() {
|
|||||||
const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false);
|
const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false);
|
||||||
const [isRecategorizing, setIsRecategorizing] = useState(false);
|
const [isRecategorizing, setIsRecategorizing] = useState(false);
|
||||||
|
|
||||||
|
// Persister l'état "tout déplier" dans le localStorage
|
||||||
|
const [expandAllByDefault, setExpandAllByDefault] = useLocalStorage(
|
||||||
|
"categories-expand-all-by-default",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
// Organiser les catégories par parent
|
// Organiser les catégories par parent
|
||||||
const { parentCategories, childrenByParent, orphanCategories } =
|
const { parentCategories, childrenByParent, orphanCategories } =
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
@@ -96,17 +105,22 @@ export default function CategoriesPage() {
|
|||||||
};
|
};
|
||||||
}, [metadata?.categories]);
|
}, [metadata?.categories]);
|
||||||
|
|
||||||
// Initialiser tous les parents comme ouverts
|
// Initialiser tous les parents selon la préférence sauvegardée
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (parentCategories.length > 0 && expandedParents.size === 0) {
|
if (parentCategories.length > 0 && expandedParents.size === 0) {
|
||||||
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
|
if (expandAllByDefault) {
|
||||||
|
setExpandedParents(
|
||||||
|
new Set(parentCategories.map((p: Category) => p.id)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setExpandedParents(new Set());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [parentCategories.length]);
|
}, [parentCategories.length, expandAllByDefault]);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
invalidateAllCategoryQueries(queryClient);
|
||||||
queryClient.invalidateQueries({ queryKey: ["category-stats"] });
|
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
const getCategoryStats = useCallback(
|
const getCategoryStats = useCallback(
|
||||||
@@ -162,10 +176,12 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
const expandAll = () => {
|
const expandAll = () => {
|
||||||
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
|
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
|
||||||
|
setExpandAllByDefault(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const collapseAll = () => {
|
const collapseAll = () => {
|
||||||
setExpandedParents(new Set());
|
setExpandedParents(new Set());
|
||||||
|
setExpandAllByDefault(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const allExpanded =
|
const allExpanded =
|
||||||
@@ -338,7 +354,7 @@ export default function CategoriesPage() {
|
|||||||
onToggleAll={allExpanded ? collapseAll : expandAll}
|
onToggleAll={allExpanded ? collapseAll : expandAll}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-3 md:space-y-4">
|
||||||
{filteredParentCategories.map((parent: Category) => {
|
{filteredParentCategories.map((parent: Category) => {
|
||||||
const allChildren = childrenByParent[parent.id] || [];
|
const allChildren = childrenByParent[parent.id] || [];
|
||||||
const children = searchQuery.trim()
|
const children = searchQuery.trim()
|
||||||
@@ -374,8 +390,8 @@ export default function CategoriesPage() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{orphanCategories.length > 0 && (
|
{orphanCategories.length > 0 && (
|
||||||
<div className="border rounded-lg bg-card">
|
<Card className="card-hover">
|
||||||
<div className="px-3 py-2 border-b">
|
<div className="px-3 py-2 border-b border-border">
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
Catégories non classées ({orphanCategories.length})
|
Catégories non classées ({orphanCategories.length})
|
||||||
</span>
|
</span>
|
||||||
@@ -392,7 +408,7 @@ export default function CategoriesPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
1091
app/globals.css
1091
app/globals.css
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ import { Geist, Geist_Mono } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AuthSessionProvider } from "@/components/providers/session-provider";
|
import { AuthSessionProvider } from "@/components/providers/session-provider";
|
||||||
import { QueryProvider } from "@/components/providers/query-provider";
|
import { QueryProvider } from "@/components/providers/query-provider";
|
||||||
|
import { BackgroundProvider } from "@/components/providers/background-provider";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
|
||||||
const _geist = Geist({ subsets: ["latin"] });
|
const _geist = Geist({ subsets: ["latin"] });
|
||||||
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
||||||
@@ -21,11 +23,19 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="fr">
|
<html lang="fr" suppressHydrationWarning>
|
||||||
<body className="font-sans antialiased">
|
<body className="font-sans antialiased">
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="light"
|
||||||
|
enableSystem={false}
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<BackgroundProvider />
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<AuthSessionProvider>{children}</AuthSessionProvider>
|
<AuthSessionProvider>{children}</AuthSessionProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[var(--background)] p-4 page-background">
|
<div className="min-h-screen flex items-center justify-center bg-[var(--background)] p-4 page-background">
|
||||||
<Card className="w-full max-w-md page-content">
|
<Card className="w-full max-w-md page-content card-hover">
|
||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-1">
|
||||||
<div className="flex items-center justify-center mb-4">
|
<div className="flex items-center justify-center mb-4">
|
||||||
<Lock className="w-12 h-12 text-[var(--primary)]" />
|
<Lock className="w-12 h-12 text-[var(--primary)]" />
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { AccountsSummary } from "@/components/dashboard/accounts-summary";
|
|||||||
import { CategoryBreakdown } from "@/components/dashboard/category-breakdown";
|
import { CategoryBreakdown } from "@/components/dashboard/category-breakdown";
|
||||||
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
||||||
import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox";
|
import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingData } from "@/lib/hooks";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Upload } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
@@ -60,8 +59,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="mb-6 card-hover">
|
<div className="mb-6">
|
||||||
<CardContent className="px-5 py-5 sm:px-6 sm:py-6">
|
|
||||||
<AccountFilterCombobox
|
<AccountFilterCombobox
|
||||||
accounts={data.accounts}
|
accounts={data.accounts}
|
||||||
folders={data.folders}
|
folders={data.folders}
|
||||||
@@ -70,8 +68,7 @@ export default function DashboardPage() {
|
|||||||
className="w-full md:w-[320px]"
|
className="w-full md:w-[320px]"
|
||||||
filteredTransactions={data.transactions}
|
filteredTransactions={data.transactions}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<OverviewCards data={filteredData} />
|
<OverviewCards data={filteredData} />
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
suggestKeyword,
|
suggestKeyword,
|
||||||
} from "@/components/rules/constants";
|
} from "@/components/rules/constants";
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@/lib/types";
|
||||||
|
import {
|
||||||
|
invalidateAllTransactionQueries,
|
||||||
|
invalidateAllCategoryQueries,
|
||||||
|
} from "@/lib/cache-utils";
|
||||||
|
|
||||||
interface TransactionGroup {
|
interface TransactionGroup {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -32,31 +36,26 @@ export default function RulesPage() {
|
|||||||
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
|
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
|
||||||
|
|
||||||
// Fetch uncategorized transactions only
|
// Fetch uncategorized transactions only
|
||||||
const {
|
const { data: transactionsData, isLoading: isLoadingTransactions } =
|
||||||
data: transactionsData,
|
useTransactions(
|
||||||
isLoading: isLoadingTransactions,
|
|
||||||
invalidate: invalidateTransactions,
|
|
||||||
} = useTransactions(
|
|
||||||
{
|
{
|
||||||
limit: 10000, // Large limit to get all uncategorized
|
limit: 10000, // Large limit to get all uncategorized
|
||||||
offset: 0,
|
offset: 0,
|
||||||
includeUncategorized: true,
|
includeUncategorized: true,
|
||||||
},
|
},
|
||||||
!!metadata
|
!!metadata,
|
||||||
);
|
);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const _refresh = useCallback(() => {
|
||||||
invalidateTransactions();
|
invalidateAllTransactionQueries(queryClient);
|
||||||
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
invalidateAllCategoryQueries(queryClient);
|
||||||
queryClient.invalidateQueries({ queryKey: ["category-stats"] });
|
}, [queryClient]);
|
||||||
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
|
||||||
}, [invalidateTransactions, queryClient]);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [sortBy, setSortBy] = useState<"count" | "amount" | "name">("count");
|
const [sortBy, setSortBy] = useState<"count" | "amount" | "name">("count");
|
||||||
const [filterMinCount, setFilterMinCount] = useState(2);
|
const [filterMinCount, setFilterMinCount] = useState(2);
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
const [selectedGroup, setSelectedGroup] = useState<TransactionGroup | null>(
|
const [selectedGroup, setSelectedGroup] = useState<TransactionGroup | null>(
|
||||||
null
|
null,
|
||||||
);
|
);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [isAutoCategorizing, setIsAutoCategorizing] = useState(false);
|
const [isAutoCategorizing, setIsAutoCategorizing] = useState(false);
|
||||||
@@ -87,7 +86,7 @@ export default function RulesPage() {
|
|||||||
totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0),
|
totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0),
|
||||||
suggestedKeyword: suggestKeyword(descriptions),
|
suggestedKeyword: suggestKeyword(descriptions),
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter by search query
|
// Filter by search query
|
||||||
@@ -98,7 +97,7 @@ export default function RulesPage() {
|
|||||||
(g) =>
|
(g) =>
|
||||||
g.displayName.toLowerCase().includes(query) ||
|
g.displayName.toLowerCase().includes(query) ||
|
||||||
g.key.includes(query) ||
|
g.key.includes(query) ||
|
||||||
g.suggestedKeyword.toLowerCase().includes(query)
|
g.suggestedKeyword.toLowerCase().includes(query),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +166,7 @@ export default function RulesPage() {
|
|||||||
|
|
||||||
// 1. Add keyword to category
|
// 1. Add keyword to category
|
||||||
const category = metadata.categories.find(
|
const category = metadata.categories.find(
|
||||||
(c: { id: string }) => c.id === ruleData.categoryId
|
(c: { id: string }) => c.id === ruleData.categoryId,
|
||||||
);
|
);
|
||||||
if (!category) {
|
if (!category) {
|
||||||
throw new Error("Category not found");
|
throw new Error("Category not found");
|
||||||
@@ -175,7 +174,7 @@ export default function RulesPage() {
|
|||||||
|
|
||||||
// Check if keyword already exists
|
// Check if keyword already exists
|
||||||
const keywordExists = category.keywords.some(
|
const keywordExists = category.keywords.some(
|
||||||
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase()
|
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!keywordExists) {
|
if (!keywordExists) {
|
||||||
@@ -193,14 +192,16 @@ export default function RulesPage() {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
|
body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh();
|
// Invalider toutes les queries liées
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
invalidateAllCategoryQueries(queryClient);
|
||||||
},
|
},
|
||||||
[metadata, refresh]
|
[metadata, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAutoCategorize = useCallback(async () => {
|
const handleAutoCategorize = useCallback(async () => {
|
||||||
@@ -214,7 +215,7 @@ export default function RulesPage() {
|
|||||||
for (const transaction of uncategorized) {
|
for (const transaction of uncategorized) {
|
||||||
const categoryId = autoCategorize(
|
const categoryId = autoCategorize(
|
||||||
transaction.description + " " + (transaction.memo || ""),
|
transaction.description + " " + (transaction.memo || ""),
|
||||||
metadata.categories
|
metadata.categories,
|
||||||
);
|
);
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
await fetch("/api/banking/transactions", {
|
await fetch("/api/banking/transactions", {
|
||||||
@@ -226,9 +227,11 @@ export default function RulesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh();
|
// Invalider toutes les queries liées
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
invalidateAllCategoryQueries(queryClient);
|
||||||
alert(
|
alert(
|
||||||
`${categorizedCount} transaction(s) catégorisée(s) automatiquement`
|
`${categorizedCount} transaction(s) catégorisée(s) automatiquement`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error auto-categorizing:", error);
|
console.error("Error auto-categorizing:", error);
|
||||||
@@ -236,7 +239,7 @@ export default function RulesPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsAutoCategorizing(false);
|
setIsAutoCategorizing(false);
|
||||||
}
|
}
|
||||||
}, [metadata, transactionsData, refresh]);
|
}, [metadata, transactionsData, queryClient]);
|
||||||
|
|
||||||
const handleCategorizeGroup = useCallback(
|
const handleCategorizeGroup = useCallback(
|
||||||
async (group: TransactionGroup, categoryId: string | null) => {
|
async (group: TransactionGroup, categoryId: string | null) => {
|
||||||
@@ -247,16 +250,18 @@ export default function RulesPage() {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ...t, categoryId }),
|
body: JSON.stringify({ ...t, categoryId }),
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
refresh();
|
// Invalider toutes les queries liées
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
invalidateAllCategoryQueries(queryClient);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error categorizing group:", error);
|
console.error("Error categorizing group:", error);
|
||||||
alert("Erreur lors de la catégorisation");
|
alert("Erreur lors de la catégorisation");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refresh]
|
[queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
OFXInfoCard,
|
OFXInfoCard,
|
||||||
BackupCard,
|
BackupCard,
|
||||||
PasswordCard,
|
PasswordCard,
|
||||||
|
ReconcileDateRangeCard,
|
||||||
|
BackgroundCard,
|
||||||
|
ThemeCard,
|
||||||
} from "@/components/settings";
|
} from "@/components/settings";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingData } from "@/lib/hooks";
|
||||||
import type { BankingData } from "@/lib/types";
|
import type { BankingData } from "@/lib/types";
|
||||||
@@ -125,6 +128,12 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<PasswordCard />
|
<PasswordCard />
|
||||||
|
|
||||||
|
<ThemeCard />
|
||||||
|
|
||||||
|
<BackgroundCard />
|
||||||
|
|
||||||
|
<ReconcileDateRangeCard />
|
||||||
|
|
||||||
<DangerZoneCard
|
<DangerZoneCard
|
||||||
categorizedCount={categorizedCount}
|
categorizedCount={categorizedCount}
|
||||||
onClearCategories={clearAllCategories}
|
onClearCategories={clearAllCategories}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
||||||
import {
|
import {
|
||||||
StatsSummaryCards,
|
StatsSummaryCards,
|
||||||
@@ -46,6 +46,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||||
import type { Account, Category } from "@/lib/types";
|
import type { Account, Category } from "@/lib/types";
|
||||||
|
|
||||||
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
||||||
@@ -54,21 +55,65 @@ export default function StatisticsPage() {
|
|||||||
const { data, isLoading } = useBankingData();
|
const { data, isLoading } = useBankingData();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [period, setPeriod] = useState<Period>("6months");
|
|
||||||
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
|
// Persister les filtres dans le localStorage
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([
|
const [period, setPeriod] = useLocalStorage<Period>(
|
||||||
"all",
|
"statistics-period",
|
||||||
]);
|
"6months"
|
||||||
|
);
|
||||||
|
const [selectedAccounts, setSelectedAccounts] = useLocalStorage<string[]>(
|
||||||
|
"statistics-selected-accounts",
|
||||||
|
["all"]
|
||||||
|
);
|
||||||
|
const [selectedCategories, setSelectedCategories] = useLocalStorage<string[]>(
|
||||||
|
"statistics-selected-categories",
|
||||||
|
["all"]
|
||||||
|
);
|
||||||
const [excludeInternalTransfers, setExcludeInternalTransfers] =
|
const [excludeInternalTransfers, setExcludeInternalTransfers] =
|
||||||
useState(true);
|
useLocalStorage("statistics-exclude-internal-transfers", true);
|
||||||
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
|
||||||
undefined,
|
// Pour les dates, on stocke les ISO strings et on les convertit
|
||||||
|
const [customStartDateISO, setCustomStartDateISO] = useLocalStorage<
|
||||||
|
string | null
|
||||||
|
>("statistics-custom-start-date", null);
|
||||||
|
const [customEndDateISO, setCustomEndDateISO] = useLocalStorage<
|
||||||
|
string | null
|
||||||
|
>("statistics-custom-end-date", null);
|
||||||
|
|
||||||
|
// Convertir les ISO strings en Date
|
||||||
|
const customStartDate = useMemo(
|
||||||
|
() => (customStartDateISO ? new Date(customStartDateISO) : undefined),
|
||||||
|
[customStartDateISO]
|
||||||
);
|
);
|
||||||
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
|
const customEndDate = useMemo(
|
||||||
undefined,
|
() => (customEndDateISO ? new Date(customEndDateISO) : undefined),
|
||||||
|
[customEndDateISO]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fonctions pour mettre à jour les dates avec persistance
|
||||||
|
const setCustomStartDate = (date: Date | undefined) => {
|
||||||
|
setCustomStartDateISO(date ? date.toISOString() : null);
|
||||||
|
};
|
||||||
|
const setCustomEndDate = (date: Date | undefined) => {
|
||||||
|
setCustomEndDateISO(date ? date.toISOString() : null);
|
||||||
|
};
|
||||||
|
|
||||||
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
|
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
|
||||||
|
|
||||||
|
// Nettoyer les dates personnalisées quand on change de période (sauf si on passe à "custom")
|
||||||
|
useEffect(() => {
|
||||||
|
if (period !== "custom" && (customStartDateISO || customEndDateISO)) {
|
||||||
|
setCustomStartDateISO(null);
|
||||||
|
setCustomEndDateISO(null);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
period,
|
||||||
|
customStartDateISO,
|
||||||
|
customEndDateISO,
|
||||||
|
setCustomStartDateISO,
|
||||||
|
setCustomEndDateISO,
|
||||||
|
]);
|
||||||
|
|
||||||
// Get start date based on period
|
// Get start date based on period
|
||||||
const startDate = useMemo(() => {
|
const startDate = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -100,7 +145,7 @@ export default function StatisticsPage() {
|
|||||||
const internalTransferCategory = useMemo(() => {
|
const internalTransferCategory = useMemo(() => {
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
return data.categories.find(
|
return data.categories.find(
|
||||||
(c) => c.name.toLowerCase() === "virement interne",
|
(c) => c.name.toLowerCase() === "virement interne"
|
||||||
);
|
);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -216,7 +261,7 @@ export default function StatisticsPage() {
|
|||||||
// Filter by accounts
|
// Filter by accounts
|
||||||
if (!selectedAccounts.includes("all")) {
|
if (!selectedAccounts.includes("all")) {
|
||||||
transactions = transactions.filter((t) =>
|
transactions = transactions.filter((t) =>
|
||||||
selectedAccounts.includes(t.accountId),
|
selectedAccounts.includes(t.accountId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +271,7 @@ export default function StatisticsPage() {
|
|||||||
transactions = transactions.filter((t) => !t.categoryId);
|
transactions = transactions.filter((t) => !t.categoryId);
|
||||||
} else {
|
} else {
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
(t) => t.categoryId && selectedCategories.includes(t.categoryId),
|
(t) => t.categoryId && selectedCategories.includes(t.categoryId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,7 +279,7 @@ export default function StatisticsPage() {
|
|||||||
// Exclude "Virement interne" category if checkbox is checked
|
// Exclude "Virement interne" category if checkbox is checked
|
||||||
if (excludeInternalTransfers && internalTransferCategory) {
|
if (excludeInternalTransfers && internalTransferCategory) {
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
(t) => t.categoryId !== internalTransferCategory.id,
|
(t) => t.categoryId !== internalTransferCategory.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +326,7 @@ export default function StatisticsPage() {
|
|||||||
value: Math.round(total),
|
value: Math.round(total),
|
||||||
color: category?.color || "#94a3b8",
|
color: category?.color || "#94a3b8",
|
||||||
icon: category?.icon || "HelpCircle",
|
icon: category?.icon || "HelpCircle",
|
||||||
|
categoryId: categoryId === "uncategorized" ? null : categoryId,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.value - a.value);
|
.sort((a, b) => b.value - a.value);
|
||||||
@@ -306,7 +352,7 @@ export default function StatisticsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const categoryChartDataByParent = Array.from(
|
const categoryChartDataByParent = Array.from(
|
||||||
categoryTotalsByParent.entries(),
|
categoryTotalsByParent.entries()
|
||||||
)
|
)
|
||||||
.map(([groupId, total]) => {
|
.map(([groupId, total]) => {
|
||||||
const category = data.categories.find((c) => c.id === groupId);
|
const category = data.categories.find((c) => c.id === groupId);
|
||||||
@@ -315,16 +361,42 @@ export default function StatisticsPage() {
|
|||||||
value: Math.round(total),
|
value: Math.round(total),
|
||||||
color: category?.color || "#94a3b8",
|
color: category?.color || "#94a3b8",
|
||||||
icon: category?.icon || "HelpCircle",
|
icon: category?.icon || "HelpCircle",
|
||||||
|
categoryId: groupId === "uncategorized" ? null : groupId,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.value - a.value);
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
// Top expenses - deduplicate by ID and sort by amount (most negative first)
|
// Top expenses by top parent categories - deduplicate by ID
|
||||||
const uniqueTransactions = Array.from(
|
const uniqueTransactions = Array.from(
|
||||||
new Map(transactions.map((t) => [t.id, t])).values(),
|
new Map(transactions.map((t) => [t.id, t])).values()
|
||||||
);
|
);
|
||||||
const topExpenses = uniqueTransactions
|
const expenses = uniqueTransactions.filter((t) => t.amount < 0);
|
||||||
.filter((t) => t.amount < 0)
|
|
||||||
|
// Get top 5 parent categories by total expenses
|
||||||
|
const topParentCategories = Array.from(categoryTotalsByParent.entries())
|
||||||
|
.map(([groupId, total]) => ({
|
||||||
|
groupId: groupId === "uncategorized" ? null : groupId,
|
||||||
|
total,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
// Get top 10 expenses per top parent category (from all its subcategories)
|
||||||
|
const topExpensesByCategory = topParentCategories.map(({ groupId }) => {
|
||||||
|
const categoryExpenses = expenses
|
||||||
|
.filter((t) => {
|
||||||
|
if (groupId === null) {
|
||||||
|
return !t.categoryId;
|
||||||
|
}
|
||||||
|
// Check if transaction belongs to this parent category or its subcategories
|
||||||
|
const category = data.categories.find((c) => c.id === t.categoryId);
|
||||||
|
if (!category) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Use parent category ID if exists, otherwise use the category itself
|
||||||
|
const transactionGroupId = category.parentId || category.id;
|
||||||
|
return transactionGroupId === groupId;
|
||||||
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// Sort by amount (most negative first)
|
// Sort by amount (most negative first)
|
||||||
if (a.amount !== b.amount) {
|
if (a.amount !== b.amount) {
|
||||||
@@ -333,7 +405,13 @@ export default function StatisticsPage() {
|
|||||||
// If same amount, sort by date (most recent first) for stable sorting
|
// If same amount, sort by date (most recent first) for stable sorting
|
||||||
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||||
})
|
})
|
||||||
.slice(0, 5);
|
.slice(0, 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
categoryId: groupId,
|
||||||
|
expenses: categoryExpenses,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
const totalIncome = transactions
|
const totalIncome = transactions
|
||||||
@@ -347,7 +425,7 @@ export default function StatisticsPage() {
|
|||||||
|
|
||||||
// Balance evolution - Aggregated (using filtered transactions)
|
// Balance evolution - Aggregated (using filtered transactions)
|
||||||
const sortedFilteredTransactions = [...transactions].sort(
|
const sortedFilteredTransactions = [...transactions].sort(
|
||||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate starting balance: initialBalance + transactions before startDate
|
// Calculate starting balance: initialBalance + transactions before startDate
|
||||||
@@ -359,7 +437,7 @@ export default function StatisticsPage() {
|
|||||||
// Start with initial balances
|
// Start with initial balances
|
||||||
runningBalance = accountsToUse.reduce(
|
runningBalance = accountsToUse.reduce(
|
||||||
(sum, acc) => sum + (acc.initialBalance || 0),
|
(sum, acc) => sum + (acc.initialBalance || 0),
|
||||||
0,
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add all transactions before the start date for these accounts
|
// Add all transactions before the start date for these accounts
|
||||||
@@ -396,7 +474,7 @@ export default function StatisticsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const aggregatedBalanceData = Array.from(
|
const aggregatedBalanceData = Array.from(
|
||||||
aggregatedBalanceByDate.entries(),
|
aggregatedBalanceByDate.entries()
|
||||||
).map(([date, balance]) => ({
|
).map(([date, balance]) => ({
|
||||||
date: new Date(date).toLocaleDateString("fr-FR", {
|
date: new Date(date).toLocaleDateString("fr-FR", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
@@ -612,13 +690,14 @@ export default function StatisticsPage() {
|
|||||||
monthlyChartData,
|
monthlyChartData,
|
||||||
categoryChartData,
|
categoryChartData,
|
||||||
categoryChartDataByParent,
|
categoryChartDataByParent,
|
||||||
topExpenses,
|
topExpensesByCategory,
|
||||||
totalIncome,
|
totalIncome,
|
||||||
totalExpenses,
|
totalExpenses,
|
||||||
avgMonthlyExpenses,
|
avgMonthlyExpenses,
|
||||||
aggregatedBalanceData,
|
aggregatedBalanceData,
|
||||||
perAccountBalanceData,
|
perAccountBalanceData,
|
||||||
transactionCount: transactions.length,
|
transactionCount: transactions.length,
|
||||||
|
transactions, // Toutes les transactions filtrées pour le graphique
|
||||||
savingsTrendData,
|
savingsTrendData,
|
||||||
categoryTrendData,
|
categoryTrendData,
|
||||||
categoryTrendDataByParent,
|
categoryTrendDataByParent,
|
||||||
@@ -853,7 +932,7 @@ export default function StatisticsPage() {
|
|||||||
onRemoveAccount={(id) => {
|
onRemoveAccount={(id) => {
|
||||||
const newAccounts = selectedAccounts.filter((a) => a !== id);
|
const newAccounts = selectedAccounts.filter((a) => a !== id);
|
||||||
setSelectedAccounts(
|
setSelectedAccounts(
|
||||||
newAccounts.length > 0 ? newAccounts : ["all"],
|
newAccounts.length > 0 ? newAccounts : ["all"]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onClearAccounts={() => setSelectedAccounts(["all"])}
|
onClearAccounts={() => setSelectedAccounts(["all"])}
|
||||||
@@ -861,7 +940,7 @@ export default function StatisticsPage() {
|
|||||||
onRemoveCategory={(id) => {
|
onRemoveCategory={(id) => {
|
||||||
const newCategories = selectedCategories.filter((c) => c !== id);
|
const newCategories = selectedCategories.filter((c) => c !== id);
|
||||||
setSelectedCategories(
|
setSelectedCategories(
|
||||||
newCategories.length > 0 ? newCategories : ["all"],
|
newCategories.length > 0 ? newCategories : ["all"]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onClearCategories={() => setSelectedCategories(["all"])}
|
onClearCategories={() => setSelectedCategories(["all"])}
|
||||||
@@ -948,27 +1027,34 @@ export default function StatisticsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-3 flex gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-sm font-medium">
|
||||||
Date de début
|
Date de début
|
||||||
</label>
|
</label>
|
||||||
|
<div className="scale-90 origin-top-left">
|
||||||
<CalendarComponent
|
<CalendarComponent
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={customStartDate}
|
selected={customStartDate}
|
||||||
onSelect={(date) => {
|
onSelect={(date) => {
|
||||||
setCustomStartDate(date);
|
setCustomStartDate(date);
|
||||||
if (date && customEndDate && date > customEndDate) {
|
if (
|
||||||
|
date &&
|
||||||
|
customEndDate &&
|
||||||
|
date > customEndDate
|
||||||
|
) {
|
||||||
setCustomEndDate(undefined);
|
setCustomEndDate(undefined);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
locale={fr}
|
locale={fr}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-sm font-medium">
|
||||||
Date de fin
|
Date de fin
|
||||||
</label>
|
</label>
|
||||||
|
<div className="scale-90 origin-top-left">
|
||||||
<CalendarComponent
|
<CalendarComponent
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={customEndDate}
|
selected={customEndDate}
|
||||||
@@ -992,8 +1078,10 @@ export default function StatisticsPage() {
|
|||||||
locale={fr}
|
locale={fr}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{customStartDate && customEndDate && (
|
{customStartDate && customEndDate && (
|
||||||
<div className="flex gap-2 pt-2 border-t">
|
<div className="flex gap-2 pt-2 border-t px-3 pb-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1014,7 +1102,6 @@ export default function StatisticsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
@@ -1043,17 +1130,17 @@ export default function StatisticsPage() {
|
|||||||
onRemoveAccount={(id) => {
|
onRemoveAccount={(id) => {
|
||||||
const newAccounts = selectedAccounts.filter((a) => a !== id);
|
const newAccounts = selectedAccounts.filter((a) => a !== id);
|
||||||
setSelectedAccounts(
|
setSelectedAccounts(
|
||||||
newAccounts.length > 0 ? newAccounts : ["all"],
|
newAccounts.length > 0 ? newAccounts : ["all"]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onClearAccounts={() => setSelectedAccounts(["all"])}
|
onClearAccounts={() => setSelectedAccounts(["all"])}
|
||||||
selectedCategories={selectedCategories}
|
selectedCategories={selectedCategories}
|
||||||
onRemoveCategory={(id) => {
|
onRemoveCategory={(id) => {
|
||||||
const newCategories = selectedCategories.filter(
|
const newCategories = selectedCategories.filter(
|
||||||
(c) => c !== id,
|
(c) => c !== id
|
||||||
);
|
);
|
||||||
setSelectedCategories(
|
setSelectedCategories(
|
||||||
newCategories.length > 0 ? newCategories : ["all"],
|
newCategories.length > 0 ? newCategories : ["all"]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onClearCategories={() => setSelectedCategories(["all"])}
|
onClearCategories={() => setSelectedCategories(["all"])}
|
||||||
@@ -1155,9 +1242,10 @@ export default function StatisticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 md:mt-6">
|
<div className="mt-4 md:mt-6">
|
||||||
<TopExpensesList
|
<TopExpensesList
|
||||||
expenses={stats.topExpenses}
|
expensesByCategory={stats.topExpensesByCategory}
|
||||||
categories={data.categories}
|
categories={data.categories}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
|
allTransactions={stats.transactions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1203,7 +1291,7 @@ function ActiveFilters({
|
|||||||
|
|
||||||
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
|
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
|
||||||
const selectedCats = categories.filter((c) =>
|
const selectedCats = categories.filter((c) =>
|
||||||
selectedCategories.includes(c.id),
|
selectedCategories.includes(c.id)
|
||||||
);
|
);
|
||||||
const isUncategorized = selectedCategories.includes("uncategorized");
|
const isUncategorized = selectedCategories.includes("uncategorized");
|
||||||
|
|
||||||
|
|||||||
@@ -1,531 +1,211 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { PageLayout, PageHeader } from "@/components/layout";
|
import { PageLayout, PageHeader } from "@/components/layout";
|
||||||
import { RefreshCw } from "lucide-react";
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Receipt,
|
||||||
|
Euro,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
CheckCircle2,
|
||||||
|
Tag,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
TransactionFilters,
|
TransactionFilters,
|
||||||
TransactionBulkActions,
|
TransactionBulkActions,
|
||||||
TransactionTable,
|
TransactionTable,
|
||||||
|
TransactionPagination,
|
||||||
|
formatCurrency,
|
||||||
|
formatDate,
|
||||||
} from "@/components/transactions";
|
} from "@/components/transactions";
|
||||||
import { RuleCreateDialog } from "@/components/rules";
|
import { RuleCreateDialog } from "@/components/rules";
|
||||||
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
||||||
import {
|
import { MonthlyChart } from "@/components/statistics";
|
||||||
useBankingMetadata,
|
|
||||||
useTransactions,
|
|
||||||
getTransactionsQueryKey,
|
|
||||||
} from "@/lib/hooks";
|
|
||||||
import { updateCategory } from "@/lib/store-db";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Upload } from "lucide-react";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import type { Transaction } from "@/lib/types";
|
|
||||||
import type { TransactionsPaginatedParams } from "@/services/banking.service";
|
|
||||||
import {
|
import {
|
||||||
normalizeDescription,
|
Collapsible,
|
||||||
suggestKeyword,
|
CollapsibleContent,
|
||||||
} from "@/components/rules/constants";
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
type SortField = "date" | "amount" | "description";
|
import { Upload } from "lucide-react";
|
||||||
type SortOrder = "asc" | "desc";
|
import { useTransactionsPage } from "@/hooks/use-transactions-page";
|
||||||
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
import { useTransactionMutations } from "@/hooks/use-transaction-mutations";
|
||||||
|
import { useTransactionRules } from "@/hooks/use-transaction-rules";
|
||||||
const PAGE_SIZE = 100;
|
import { useTransactionsChartData } from "@/hooks/use-transactions-chart-data";
|
||||||
|
import { useTransactionsForAccountFilter } from "@/hooks/use-transactions-for-account-filter";
|
||||||
|
import { useTransactionsForCategoryFilter } from "@/hooks/use-transactions-for-category-filter";
|
||||||
|
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||||
|
|
||||||
export default function TransactionsPage() {
|
export default function TransactionsPage() {
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
|
|
||||||
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
|
|
||||||
// Debounce search query
|
// Main page state and logic
|
||||||
useEffect(() => {
|
const {
|
||||||
const timer = setTimeout(() => {
|
metadata,
|
||||||
setDebouncedSearchQuery(searchQuery);
|
isLoadingMetadata,
|
||||||
setPage(0); // Reset to first page when search changes
|
searchQuery,
|
||||||
}, 300);
|
setSearchQuery,
|
||||||
return () => clearTimeout(timer);
|
selectedAccounts,
|
||||||
}, [searchQuery]);
|
onAccountsChange,
|
||||||
|
selectedCategories,
|
||||||
useEffect(() => {
|
onCategoriesChange,
|
||||||
const accountId = searchParams.get("accountId");
|
showReconciled,
|
||||||
if (accountId) {
|
onReconciledChange,
|
||||||
setSelectedAccounts([accountId]);
|
period,
|
||||||
setPage(0);
|
onPeriodChange,
|
||||||
}
|
customStartDate,
|
||||||
}, [searchParams]);
|
customEndDate,
|
||||||
|
onCustomStartDateChange,
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([
|
onCustomEndDateChange,
|
||||||
"all",
|
isCustomDatePickerOpen,
|
||||||
]);
|
onCustomDatePickerOpenChange,
|
||||||
const [showReconciled, setShowReconciled] = useState<string>("all");
|
showDuplicates,
|
||||||
const [period, setPeriod] = useState<Period>("all");
|
onShowDuplicatesChange,
|
||||||
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
page,
|
||||||
undefined
|
pageSize,
|
||||||
);
|
onPageChange,
|
||||||
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
|
|
||||||
const [sortField, setSortField] = useState<SortField>("date");
|
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
|
||||||
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
|
|
||||||
new Set()
|
|
||||||
);
|
|
||||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
|
||||||
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [updatingTransactionIds, setUpdatingTransactionIds] = useState<
|
|
||||||
Set<string>
|
|
||||||
>(new Set());
|
|
||||||
|
|
||||||
// Get start date based on period
|
|
||||||
const startDate = useMemo(() => {
|
|
||||||
const now = new Date();
|
|
||||||
switch (period) {
|
|
||||||
case "1month":
|
|
||||||
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
|
||||||
case "3months":
|
|
||||||
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
|
|
||||||
case "6months":
|
|
||||||
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
|
||||||
case "12months":
|
|
||||||
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
|
|
||||||
case "custom":
|
|
||||||
return customStartDate || new Date(0);
|
|
||||||
default:
|
|
||||||
return new Date(0);
|
|
||||||
}
|
|
||||||
}, [period, customStartDate]);
|
|
||||||
|
|
||||||
// Get end date (only for custom period)
|
|
||||||
const endDate = useMemo(() => {
|
|
||||||
if (period === "custom" && customEndDate) {
|
|
||||||
return customEndDate;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [period, customEndDate]);
|
|
||||||
|
|
||||||
// Build transaction query params
|
|
||||||
const transactionParams = useMemo(() => {
|
|
||||||
const params: TransactionsPaginatedParams = {
|
|
||||||
limit: PAGE_SIZE,
|
|
||||||
offset: page * PAGE_SIZE,
|
|
||||||
sortField,
|
sortField,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
};
|
onSortChange,
|
||||||
|
selectedTransactions,
|
||||||
|
onToggleSelectAll,
|
||||||
|
onToggleSelectTransaction,
|
||||||
|
clearSelection,
|
||||||
|
transactionsData,
|
||||||
|
isLoadingTransactions,
|
||||||
|
invalidateTransactions,
|
||||||
|
duplicateIds,
|
||||||
|
transactionParams,
|
||||||
|
} = useTransactionsPage();
|
||||||
|
|
||||||
if (startDate && period !== "all") {
|
// Transaction mutations
|
||||||
params.startDate = startDate.toISOString().split("T")[0];
|
const {
|
||||||
}
|
toggleReconciled,
|
||||||
if (endDate) {
|
markReconciled,
|
||||||
params.endDate = endDate.toISOString().split("T")[0];
|
setCategory,
|
||||||
}
|
deleteTransaction,
|
||||||
if (!selectedAccounts.includes("all")) {
|
bulkReconcile: handleBulkReconcile,
|
||||||
params.accountIds = selectedAccounts;
|
bulkSetCategory: handleBulkSetCategory,
|
||||||
}
|
updatingTransactionIds,
|
||||||
if (!selectedCategories.includes("all")) {
|
} = useTransactionMutations({
|
||||||
if (selectedCategories.includes("uncategorized")) {
|
transactionParams,
|
||||||
params.includeUncategorized = true;
|
transactionsData,
|
||||||
} else {
|
});
|
||||||
params.categoryIds = selectedCategories;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (debouncedSearchQuery) {
|
|
||||||
params.search = debouncedSearchQuery;
|
|
||||||
}
|
|
||||||
if (showReconciled !== "all") {
|
|
||||||
params.isReconciled = showReconciled === "reconciled";
|
|
||||||
}
|
|
||||||
|
|
||||||
return params;
|
// Transaction rules
|
||||||
}, [
|
const {
|
||||||
page,
|
ruleDialogOpen,
|
||||||
startDate,
|
setRuleDialogOpen,
|
||||||
endDate,
|
ruleGroup,
|
||||||
|
handleCreateRule,
|
||||||
|
handleSaveRule,
|
||||||
|
} = useTransactionRules({
|
||||||
|
transactionsData,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chart data
|
||||||
|
const {
|
||||||
|
monthlyData,
|
||||||
|
isLoading: isLoadingChart,
|
||||||
|
totalAmount: chartTotalAmount,
|
||||||
|
totalCount: chartTotalCount,
|
||||||
|
transactions: chartTransactions,
|
||||||
|
} = useTransactionsChartData({
|
||||||
selectedAccounts,
|
selectedAccounts,
|
||||||
selectedCategories,
|
selectedCategories,
|
||||||
debouncedSearchQuery,
|
|
||||||
showReconciled,
|
|
||||||
sortField,
|
|
||||||
sortOrder,
|
|
||||||
period,
|
period,
|
||||||
]);
|
customStartDate,
|
||||||
|
customEndDate,
|
||||||
// Fetch transactions with pagination
|
showReconciled,
|
||||||
const {
|
searchQuery,
|
||||||
data: transactionsData,
|
|
||||||
isLoading: isLoadingTransactions,
|
|
||||||
invalidate: invalidateTransactions,
|
|
||||||
} = useTransactions(transactionParams, !!metadata);
|
|
||||||
|
|
||||||
// For filter comboboxes, we'll use empty arrays for now
|
|
||||||
// They can be enhanced later with separate queries if needed
|
|
||||||
const transactionsForAccountFilter: Transaction[] = [];
|
|
||||||
const transactionsForCategoryFilter: Transaction[] = [];
|
|
||||||
|
|
||||||
const handleCreateRule = useCallback((transaction: Transaction) => {
|
|
||||||
setRuleTransaction(transaction);
|
|
||||||
setRuleDialogOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Create a virtual group for the rule dialog based on selected transaction
|
|
||||||
// Note: This requires fetching similar transactions - simplified for now
|
|
||||||
const ruleGroup = useMemo(() => {
|
|
||||||
if (!ruleTransaction || !transactionsData) return null;
|
|
||||||
|
|
||||||
// Use transactions from current page to find similar ones
|
|
||||||
const normalizedDesc = normalizeDescription(ruleTransaction.description);
|
|
||||||
const similarTransactions = transactionsData.transactions.filter(
|
|
||||||
(t) => normalizeDescription(t.description) === normalizedDesc
|
|
||||||
);
|
|
||||||
|
|
||||||
if (similarTransactions.length === 0) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: normalizedDesc,
|
|
||||||
displayName: ruleTransaction.description,
|
|
||||||
transactions: similarTransactions,
|
|
||||||
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
|
|
||||||
suggestedKeyword: suggestKeyword(
|
|
||||||
similarTransactions.map((t) => t.description)
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}, [ruleTransaction, transactionsData]);
|
|
||||||
|
|
||||||
const handleSaveRule = useCallback(
|
|
||||||
async (ruleData: {
|
|
||||||
keyword: string;
|
|
||||||
categoryId: string;
|
|
||||||
applyToExisting: boolean;
|
|
||||||
transactionIds: string[];
|
|
||||||
}) => {
|
|
||||||
if (!metadata) return;
|
|
||||||
|
|
||||||
// 1. Add keyword to category
|
|
||||||
const category = metadata.categories.find(
|
|
||||||
(c: { id: string }) => c.id === ruleData.categoryId
|
|
||||||
);
|
|
||||||
if (!category) {
|
|
||||||
throw new Error("Category not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if keyword already exists
|
|
||||||
const keywordExists = category.keywords.some(
|
|
||||||
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!keywordExists) {
|
|
||||||
await updateCategory({
|
|
||||||
...category,
|
|
||||||
keywords: [...category.keywords, ruleData.keyword],
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Apply to existing transactions if requested
|
// Transactions for account filter (filtered by categories, period, search, reconciled - NOT by accounts)
|
||||||
if (ruleData.applyToExisting) {
|
const { transactions: transactionsForAccountFilter } =
|
||||||
await Promise.all(
|
useTransactionsForAccountFilter({
|
||||||
ruleData.transactionIds.map((id) =>
|
selectedCategories,
|
||||||
fetch("/api/banking/transactions", {
|
period,
|
||||||
method: "PUT",
|
customStartDate,
|
||||||
headers: { "Content-Type": "application/json" },
|
customEndDate,
|
||||||
body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
|
showReconciled,
|
||||||
})
|
searchQuery,
|
||||||
)
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalidate queries
|
// Transactions for category filter (filtered by accounts, period, search, reconciled - NOT by categories)
|
||||||
queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
const { transactions: transactionsForCategoryFilter } =
|
||||||
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
useTransactionsForCategoryFilter({
|
||||||
setRuleDialogOpen(false);
|
selectedAccounts,
|
||||||
},
|
period,
|
||||||
[metadata, queryClient]
|
customStartDate,
|
||||||
);
|
customEndDate,
|
||||||
|
showReconciled,
|
||||||
|
searchQuery,
|
||||||
|
});
|
||||||
|
|
||||||
const invalidateAll = useCallback(() => {
|
const invalidateAll = useCallback(() => {
|
||||||
invalidateTransactions();
|
invalidateTransactions();
|
||||||
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||||
}, [invalidateTransactions, queryClient]);
|
}, [invalidateTransactions, queryClient]);
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const handleBulkReconcileWithClear = useCallback(
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
(reconciled: boolean) => {
|
||||||
style: "currency",
|
handleBulkReconcile(reconciled, selectedTransactions);
|
||||||
currency: "EUR",
|
clearSelection();
|
||||||
}).format(amount);
|
},
|
||||||
};
|
[handleBulkReconcile, selectedTransactions, clearSelection],
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleDateString("fr-FR", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleReconciled = async (transactionId: string) => {
|
|
||||||
if (!transactionsData) return;
|
|
||||||
|
|
||||||
const transaction = transactionsData.transactions.find(
|
|
||||||
(t) => t.id === transactionId
|
|
||||||
);
|
|
||||||
if (!transaction) return;
|
|
||||||
|
|
||||||
const updatedTransaction = {
|
|
||||||
...transaction,
|
|
||||||
isReconciled: !transaction.isReconciled,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch("/api/banking/transactions", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(updatedTransaction),
|
|
||||||
});
|
|
||||||
invalidateTransactions();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update transaction:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const markReconciled = async (transactionId: string) => {
|
|
||||||
if (!transactionsData) return;
|
|
||||||
|
|
||||||
const transaction = transactionsData.transactions.find(
|
|
||||||
(t) => t.id === transactionId
|
|
||||||
);
|
|
||||||
if (!transaction || transaction.isReconciled) return;
|
|
||||||
|
|
||||||
const updatedTransaction = {
|
|
||||||
...transaction,
|
|
||||||
isReconciled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch("/api/banking/transactions", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(updatedTransaction),
|
|
||||||
});
|
|
||||||
invalidateTransactions();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update transaction:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setCategory = async (
|
|
||||||
transactionId: string,
|
|
||||||
categoryId: string | null
|
|
||||||
) => {
|
|
||||||
if (!transactionsData) return;
|
|
||||||
|
|
||||||
const transaction = transactionsData.transactions.find(
|
|
||||||
(t) => t.id === transactionId
|
|
||||||
);
|
|
||||||
if (!transaction) return;
|
|
||||||
|
|
||||||
setUpdatingTransactionIds((prev) => new Set(prev).add(transactionId));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/banking/transactions", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ ...transaction, categoryId }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mise à jour directe du cache après succès
|
|
||||||
const queryKey = getTransactionsQueryKey(transactionParams);
|
|
||||||
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
|
|
||||||
if (!oldData) return oldData;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...oldData,
|
|
||||||
transactions: oldData.transactions.map((t) =>
|
|
||||||
t.id === transactionId ? { ...t, categoryId } : t
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update transaction:", error);
|
|
||||||
invalidateTransactions();
|
|
||||||
} finally {
|
|
||||||
setUpdatingTransactionIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(transactionId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const bulkReconcile = async (reconciled: boolean) => {
|
|
||||||
if (!transactionsData) return;
|
|
||||||
|
|
||||||
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
|
|
||||||
selectedTransactions.has(t.id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setSelectedTransactions(new Set());
|
const handleBulkSetCategoryWithClear = useCallback(
|
||||||
|
(categoryId: string | null) => {
|
||||||
try {
|
handleBulkSetCategory(categoryId, selectedTransactions);
|
||||||
await Promise.all(
|
clearSelection();
|
||||||
transactionsToUpdate.map((t) =>
|
},
|
||||||
fetch("/api/banking/transactions", {
|
[handleBulkSetCategory, selectedTransactions, clearSelection],
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ ...t, isReconciled: reconciled }),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
invalidateTransactions();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update transactions:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const bulkSetCategory = async (categoryId: string | null) => {
|
|
||||||
if (!transactionsData) return;
|
|
||||||
|
|
||||||
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
|
|
||||||
selectedTransactions.has(t.id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const transactionIds = transactionsToUpdate.map((t) => t.id);
|
// Stabilize transactions reference to prevent unnecessary re-renders
|
||||||
setSelectedTransactions(new Set());
|
const filteredTransactions = useMemo(
|
||||||
setUpdatingTransactionIds((prev) => {
|
() => transactionsData?.transactions || [],
|
||||||
const next = new Set(prev);
|
[transactionsData?.transactions],
|
||||||
transactionIds.forEach((id) => next.add(id));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all(
|
|
||||||
transactionsToUpdate.map((t) =>
|
|
||||||
fetch("/api/banking/transactions", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ ...t, categoryId }),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mise à jour directe du cache après succès
|
|
||||||
const queryKey = getTransactionsQueryKey(transactionParams);
|
|
||||||
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
|
|
||||||
if (!oldData) return oldData;
|
|
||||||
return {
|
|
||||||
...oldData,
|
|
||||||
transactions: oldData.transactions.map((t) =>
|
|
||||||
transactionIds.includes(t.id) ? { ...t, categoryId } : t
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update transactions:", error);
|
|
||||||
invalidateTransactions();
|
|
||||||
} finally {
|
|
||||||
setUpdatingTransactionIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
transactionIds.forEach((id) => next.delete(id));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSelectAll = () => {
|
|
||||||
if (!transactionsData) return;
|
|
||||||
if (selectedTransactions.size === transactionsData.transactions.length) {
|
|
||||||
setSelectedTransactions(new Set());
|
|
||||||
} else {
|
|
||||||
setSelectedTransactions(
|
|
||||||
new Set(transactionsData.transactions.map((t) => t.id))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSelectTransaction = (id: string) => {
|
|
||||||
const newSelected = new Set(selectedTransactions);
|
|
||||||
if (newSelected.has(id)) {
|
|
||||||
newSelected.delete(id);
|
|
||||||
} else {
|
|
||||||
newSelected.add(id);
|
|
||||||
}
|
|
||||||
setSelectedTransactions(newSelected);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortChange = (field: SortField) => {
|
|
||||||
if (sortField === field) {
|
|
||||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder(field === "date" ? "desc" : "asc");
|
|
||||||
}
|
|
||||||
setPage(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteTransaction = async (transactionId: string) => {
|
|
||||||
// Remove from selected if selected
|
|
||||||
const newSelected = new Set(selectedTransactions);
|
|
||||||
newSelected.delete(transactionId);
|
|
||||||
setSelectedTransactions(newSelected);
|
|
||||||
|
|
||||||
// Sauvegarder les données actuelles pour pouvoir les restaurer en cas d'erreur
|
|
||||||
const queryKey = getTransactionsQueryKey(transactionParams);
|
|
||||||
const previousData =
|
|
||||||
queryClient.getQueryData<typeof transactionsData>(queryKey);
|
|
||||||
|
|
||||||
// Mise à jour optimiste du cache
|
|
||||||
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
|
|
||||||
if (!oldData) return oldData;
|
|
||||||
return {
|
|
||||||
...oldData,
|
|
||||||
transactions: oldData.transactions.filter(
|
|
||||||
(t) => t.id !== transactionId
|
|
||||||
),
|
|
||||||
total: oldData.total - 1,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/banking/transactions?id=${transactionId}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(
|
|
||||||
errorData.error || `Failed to delete transaction: ${response.status}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ne pas invalider immédiatement - la mise à jour optimiste est déjà correcte
|
|
||||||
// On invalide seulement les autres queries qui pourraient être affectées (métadonnées, stats)
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete transaction:", error);
|
|
||||||
// Restaurer les données précédentes en cas d'erreur
|
|
||||||
if (previousData) {
|
|
||||||
queryClient.setQueryData(queryKey, previousData);
|
|
||||||
}
|
|
||||||
// Invalider pour récupérer les données correctes du serveur
|
|
||||||
invalidateTransactions();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredTransactions = transactionsData?.transactions || [];
|
|
||||||
const totalTransactions = transactionsData?.total || 0;
|
const totalTransactions = transactionsData?.total || 0;
|
||||||
const hasMore = transactionsData?.hasMore || false;
|
const hasMore = transactionsData?.hasMore || false;
|
||||||
|
const uncategorizedCount = transactionsData?.uncategorizedCount || 0;
|
||||||
|
const uncategorizedPercent =
|
||||||
|
totalTransactions > 0
|
||||||
|
? Math.round((uncategorizedCount / totalTransactions) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Use total from chart data (all filtered transactions) or fallback to paginated data
|
||||||
|
const totalAmount = chartTotalAmount ?? 0;
|
||||||
|
const displayTotalCount = chartTotalCount ?? totalTransactions;
|
||||||
|
|
||||||
|
// Calculate percentages from chart transactions (all filtered transactions)
|
||||||
|
const reconciledPercent = useMemo(() => {
|
||||||
|
if (chartTransactions.length === 0) return "0.00";
|
||||||
|
const reconciledCount = chartTransactions.filter(
|
||||||
|
(t) => t.isReconciled,
|
||||||
|
).length;
|
||||||
|
return ((reconciledCount / chartTransactions.length) * 100).toFixed(2);
|
||||||
|
}, [chartTransactions]);
|
||||||
|
|
||||||
|
const categorizedPercent = useMemo(() => {
|
||||||
|
if (chartTransactions.length === 0) return "0.00";
|
||||||
|
const categorizedCount = chartTransactions.filter(
|
||||||
|
(t) => t.categoryId !== null,
|
||||||
|
).length;
|
||||||
|
return ((categorizedCount / chartTransactions.length) * 100).toFixed(2);
|
||||||
|
}, [chartTransactions]);
|
||||||
|
|
||||||
|
// Persist statistics collapsed state in localStorage
|
||||||
|
const [isStatsExpanded, setIsStatsExpanded] = useLocalStorage(
|
||||||
|
"transactions-stats-expanded",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
// Early return for loading state - prevents sidebar flash
|
// Early return for loading state - prevents sidebar flash
|
||||||
if (isLoadingMetadata || !metadata) {
|
if (isLoadingMetadata || !metadata) {
|
||||||
@@ -542,7 +222,11 @@ export default function TransactionsPage() {
|
|||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Transactions"
|
title="Transactions"
|
||||||
description={`${totalTransactions} transaction${totalTransactions > 1 ? "s" : ""}`}
|
description={
|
||||||
|
totalTransactions > 0
|
||||||
|
? `${totalTransactions} transaction${totalTransactions > 1 ? "s" : ""} • ${uncategorizedPercent}% non catégorisées`
|
||||||
|
: `${totalTransactions} transaction${totalTransactions > 1 ? "s" : ""}`
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
<OFXImportDialog onImportComplete={invalidateAll}>
|
<OFXImportDialog onImportComplete={invalidateAll}>
|
||||||
<Button className="md:h-10 md:px-5">
|
<Button className="md:h-10 md:px-5">
|
||||||
@@ -557,42 +241,21 @@ export default function TransactionsPage() {
|
|||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
selectedAccounts={selectedAccounts}
|
selectedAccounts={selectedAccounts}
|
||||||
onAccountsChange={(accounts) => {
|
onAccountsChange={onAccountsChange}
|
||||||
setSelectedAccounts(accounts);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
selectedCategories={selectedCategories}
|
selectedCategories={selectedCategories}
|
||||||
onCategoriesChange={(categories) => {
|
onCategoriesChange={onCategoriesChange}
|
||||||
setPage(0);
|
|
||||||
setSelectedCategories(categories);
|
|
||||||
}}
|
|
||||||
showReconciled={showReconciled}
|
showReconciled={showReconciled}
|
||||||
onReconciledChange={(value) => {
|
onReconciledChange={onReconciledChange}
|
||||||
setShowReconciled(value);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
period={period}
|
period={period}
|
||||||
onPeriodChange={(p) => {
|
onPeriodChange={onPeriodChange}
|
||||||
setPeriod(p);
|
|
||||||
setPage(0);
|
|
||||||
if (p !== "custom") {
|
|
||||||
setIsCustomDatePickerOpen(false);
|
|
||||||
} else {
|
|
||||||
setIsCustomDatePickerOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
customStartDate={customStartDate}
|
customStartDate={customStartDate}
|
||||||
customEndDate={customEndDate}
|
customEndDate={customEndDate}
|
||||||
onCustomStartDateChange={(date) => {
|
onCustomStartDateChange={onCustomStartDateChange}
|
||||||
setCustomStartDate(date);
|
onCustomEndDateChange={onCustomEndDateChange}
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
onCustomEndDateChange={(date) => {
|
|
||||||
setCustomEndDate(date);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
isCustomDatePickerOpen={isCustomDatePickerOpen}
|
isCustomDatePickerOpen={isCustomDatePickerOpen}
|
||||||
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen}
|
onCustomDatePickerOpenChange={onCustomDatePickerOpenChange}
|
||||||
|
showDuplicates={showDuplicates}
|
||||||
|
onShowDuplicatesChange={onShowDuplicatesChange}
|
||||||
accounts={metadata.accounts}
|
accounts={metadata.accounts}
|
||||||
folders={metadata.folders}
|
folders={metadata.folders}
|
||||||
categories={metadata.categories}
|
categories={metadata.categories}
|
||||||
@@ -600,11 +263,148 @@ export default function TransactionsPage() {
|
|||||||
transactionsForCategoryFilter={transactionsForCategoryFilter}
|
transactionsForCategoryFilter={transactionsForCategoryFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{(!isLoadingChart || !isLoadingTransactions) && (
|
||||||
|
<Card className="mb-6 card-hover">
|
||||||
|
<Collapsible open={isStatsExpanded} onOpenChange={setIsStatsExpanded}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 py-3 px-6">
|
||||||
|
<CardTitle className="text-base font-semibold">
|
||||||
|
Statistiques
|
||||||
|
{!isStatsExpanded && (
|
||||||
|
<span className="ml-3 text-sm font-normal text-muted-foreground">
|
||||||
|
{displayTotalCount} opération
|
||||||
|
{displayTotalCount > 1 ? "s" : ""} •{" "}
|
||||||
|
{formatCurrency(totalAmount)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8">
|
||||||
|
{isStatsExpanded ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="w-4 h-4 mr-1" />
|
||||||
|
Réduire
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="w-4 h-4 mr-1" />
|
||||||
|
Afficher
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</CardHeader>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
{/* Summary cards */}
|
||||||
|
{!isLoadingTransactions && (
|
||||||
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 mb-6">
|
||||||
|
<Card className="card-hover">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Nombre de transactions
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold mt-1">
|
||||||
|
{displayTotalCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Receipt
|
||||||
|
className="w-8 h-8"
|
||||||
|
style={{ color: "var(--gray)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="card-hover">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-2xl font-bold mt-1 ${
|
||||||
|
totalAmount >= 0
|
||||||
|
? "text-emerald-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatCurrency(totalAmount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Euro
|
||||||
|
className={`w-8 h-8 ${
|
||||||
|
totalAmount >= 0
|
||||||
|
? "text-emerald-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="card-hover">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Pointé
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold mt-1 text-primary">
|
||||||
|
{reconciledPercent}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CheckCircle2 className="w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="card-hover">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Catégorisé
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-2xl font-bold mt-1"
|
||||||
|
style={{ color: "var(--blue)" }}
|
||||||
|
>
|
||||||
|
{categorizedPercent}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Tag
|
||||||
|
className="w-8 h-8"
|
||||||
|
style={{ color: "var(--blue)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
{!isLoadingChart && monthlyData.length > 0 && (
|
||||||
|
<MonthlyChart
|
||||||
|
data={monthlyData}
|
||||||
|
formatCurrency={formatCurrency}
|
||||||
|
collapsible={false}
|
||||||
|
showDots={
|
||||||
|
period !== "all" &&
|
||||||
|
(period === "12months" || monthlyData.length <= 12)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<TransactionBulkActions
|
<TransactionBulkActions
|
||||||
selectedCount={selectedTransactions.size}
|
selectedCount={selectedTransactions.size}
|
||||||
categories={metadata.categories}
|
categories={metadata.categories}
|
||||||
onReconcile={bulkReconcile}
|
onReconcile={handleBulkReconcileWithClear}
|
||||||
onSetCategory={bulkSetCategory}
|
onSetCategory={handleBulkSetCategoryWithClear}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoadingTransactions ? (
|
{isLoadingTransactions ? (
|
||||||
@@ -620,9 +420,9 @@ export default function TransactionsPage() {
|
|||||||
selectedTransactions={selectedTransactions}
|
selectedTransactions={selectedTransactions}
|
||||||
sortField={sortField}
|
sortField={sortField}
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
onSortChange={handleSortChange}
|
onSortChange={onSortChange}
|
||||||
onToggleSelectAll={toggleSelectAll}
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
onToggleSelectTransaction={toggleSelectTransaction}
|
onToggleSelectTransaction={onToggleSelectTransaction}
|
||||||
onToggleReconciled={toggleReconciled}
|
onToggleReconciled={toggleReconciled}
|
||||||
onMarkReconciled={markReconciled}
|
onMarkReconciled={markReconciled}
|
||||||
onSetCategory={setCategory}
|
onSetCategory={setCategory}
|
||||||
@@ -631,36 +431,17 @@ export default function TransactionsPage() {
|
|||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
updatingTransactionIds={updatingTransactionIds}
|
updatingTransactionIds={updatingTransactionIds}
|
||||||
|
duplicateIds={duplicateIds}
|
||||||
|
highlightDuplicates={showDuplicates}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Pagination controls */}
|
<TransactionPagination
|
||||||
{totalTransactions > PAGE_SIZE && (
|
page={page}
|
||||||
<div className="flex items-center justify-between mt-4">
|
pageSize={pageSize}
|
||||||
<div className="text-sm text-muted-foreground">
|
total={totalTransactions}
|
||||||
Affichage de {page * PAGE_SIZE + 1} à{" "}
|
hasMore={hasMore}
|
||||||
{Math.min((page + 1) * PAGE_SIZE, totalTransactions)} sur{" "}
|
onPageChange={onPageChange}
|
||||||
{totalTransactions}
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
disabled={page === 0}
|
|
||||||
>
|
|
||||||
Précédent
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
disabled={!hasMore}
|
|
||||||
>
|
|
||||||
Suivant
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,29 +2,35 @@
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2, GitMerge } from "lucide-react";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface AccountBulkActionsProps {
|
interface AccountBulkActionsProps {
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
|
selectedAccountIds: string[];
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onMerge?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountBulkActions({
|
export function AccountBulkActions({
|
||||||
selectedCount,
|
selectedCount,
|
||||||
|
selectedAccountIds: _selectedAccountIds,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onMerge,
|
||||||
}: AccountBulkActionsProps) {
|
}: AccountBulkActionsProps) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
if (selectedCount === 0) return null;
|
if (selectedCount === 0) return null;
|
||||||
|
|
||||||
|
const canMerge = selectedCount === 2 && onMerge;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-destructive/5 border-destructive/20 sticky top-0 z-10 mb-4">
|
<Card className="bg-destructive/5 border-destructive/20 sticky top-0 z-10 mb-4">
|
||||||
<CardContent className={cn("py-3", isMobile && "px-3")}>
|
<CardContent className={cn("py-3", isMobile && "px-3")}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 sm:gap-4",
|
"flex items-center gap-2 sm:gap-4 flex-wrap",
|
||||||
isMobile && "flex-col sm:flex-row",
|
isMobile && "flex-col sm:flex-row",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -32,6 +38,19 @@ export function AccountBulkActions({
|
|||||||
{selectedCount} compte{selectedCount > 1 ? "s" : ""} sélectionné
|
{selectedCount} compte{selectedCount > 1 ? "s" : ""} sélectionné
|
||||||
{selectedCount > 1 ? "s" : ""}
|
{selectedCount > 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
|
{canMerge && (
|
||||||
|
<Button
|
||||||
|
size={isMobile ? "sm" : "sm"}
|
||||||
|
variant="default"
|
||||||
|
onClick={onMerge}
|
||||||
|
className={cn(isMobile && "w-full sm:w-auto")}
|
||||||
|
>
|
||||||
|
<GitMerge
|
||||||
|
className={cn(isMobile ? "w-3.5 h-3.5" : "w-4 h-4", "mr-1")}
|
||||||
|
/>
|
||||||
|
Fusionner
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size={isMobile ? "sm" : "sm"}
|
size={isMobile ? "sm" : "sm"}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface AccountCardProps {
|
|||||||
account: Account;
|
account: Account;
|
||||||
folder?: Folder;
|
folder?: Folder;
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
|
calculatedBalance?: number;
|
||||||
onEdit: (account: Account) => void;
|
onEdit: (account: Account) => void;
|
||||||
onDelete: (accountId: string) => void;
|
onDelete: (accountId: string) => void;
|
||||||
formatCurrency: (amount: number) => string;
|
formatCurrency: (amount: number) => string;
|
||||||
@@ -42,6 +43,7 @@ export function AccountCard({
|
|||||||
account,
|
account,
|
||||||
folder,
|
folder,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
|
calculatedBalance,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
@@ -53,6 +55,9 @@ export function AccountCard({
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const Icon = accountTypeIcons[account.type];
|
const Icon = accountTypeIcons[account.type];
|
||||||
const realBalance = getAccountBalance(account);
|
const realBalance = getAccountBalance(account);
|
||||||
|
const hasBalanceDifference =
|
||||||
|
calculatedBalance !== undefined &&
|
||||||
|
Math.abs(account.balance - calculatedBalance) > 0.01;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
@@ -82,7 +87,6 @@ export function AccountCard({
|
|||||||
"relative group",
|
"relative group",
|
||||||
isSelected && "ring-2 ring-primary shadow-lg shadow-primary/10",
|
isSelected && "ring-2 ring-primary shadow-lg shadow-primary/10",
|
||||||
isDragging && "bg-muted/80 opacity-60",
|
isDragging && "bg-muted/80 opacity-60",
|
||||||
"hover:scale-[1.02] transition-transform duration-200",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader className={cn("pb-0", isMobile && "px-2 pt-2")}>
|
<CardHeader className={cn("pb-0", isMobile && "px-2 pt-2")}>
|
||||||
@@ -173,6 +177,7 @@ export function AccountCard({
|
|||||||
className={cn(isMobile ? "px-2 pb-2 pt-1" : "pt-1", compact && "pt-0")}
|
className={cn(isMobile ? "px-2 pb-2 pt-1" : "pt-1", compact && "pt-0")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-1.5">
|
<div className="flex items-center justify-between gap-1.5">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-bold truncate",
|
"font-bold truncate",
|
||||||
@@ -184,11 +189,25 @@ export function AccountCard({
|
|||||||
? "text-base"
|
? "text-base"
|
||||||
: "text-xl",
|
: "text-xl",
|
||||||
!compact && !isMobile && "mb-1.5",
|
!compact && !isMobile && "mb-1.5",
|
||||||
realBalance >= 0 ? "text-emerald-600" : "text-red-600",
|
realBalance >= 0 ? "text-success" : "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(realBalance)}
|
{formatCurrency(realBalance)}
|
||||||
</div>
|
</div>
|
||||||
|
{!compact && calculatedBalance !== undefined && (
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Calculé: {formatCurrency(calculatedBalance)}
|
||||||
|
</span>
|
||||||
|
{hasBalanceDifference && (
|
||||||
|
<span className="text-xs text-destructive font-semibold">
|
||||||
|
(diff: {formatCurrency(account.balance - calculatedBalance)}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{compact && (
|
{compact && (
|
||||||
<Link
|
<Link
|
||||||
href={`/transactions?accountId=${account.id}`}
|
href={`/transactions?accountId=${account.id}`}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface AccountFormData {
|
|||||||
folderId: string;
|
folderId: string;
|
||||||
externalUrl: string;
|
externalUrl: string;
|
||||||
initialBalance: number;
|
initialBalance: number;
|
||||||
|
totalBalance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AccountEditDialogProps {
|
interface AccountEditDialogProps {
|
||||||
@@ -101,21 +102,43 @@ export function AccountEditDialog({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Solde initial</Label>
|
<Label>Solde total</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={formData.initialBalance}
|
value={formData.totalBalance}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onFormDataChange({
|
onFormDataChange({
|
||||||
...formData,
|
...formData,
|
||||||
initialBalance: parseFloat(e.target.value) || 0,
|
totalBalance: parseFloat(e.target.value) || 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Solde de départ pour équilibrer le compte
|
Solde total du compte (balance + solde initial)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Solde initial</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.initialBalance}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newInitialBalance = parseFloat(e.target.value) || 0;
|
||||||
|
onFormDataChange({
|
||||||
|
...formData,
|
||||||
|
initialBalance: newInitialBalance,
|
||||||
|
// Ajuster le solde total pour maintenir la cohérence
|
||||||
|
totalBalance: formData.totalBalance,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Solde de départ pour équilibrer le compte. Le balance sera calculé
|
||||||
|
automatiquement (solde total - solde initial).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
168
components/accounts/account-merge-select-dialog.tsx
Normal file
168
components/accounts/account-merge-select-dialog.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { AlertTriangle, Info } from "lucide-react";
|
||||||
|
import type { Account } from "@/lib/types";
|
||||||
|
import { getAccountBalance } from "@/lib/account-utils";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
|
||||||
|
interface AccountMergeSelectDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
accounts: Account[];
|
||||||
|
selectedAccountIds: string[];
|
||||||
|
onMerge: (sourceAccountId: string, targetAccountId: string) => Promise<void>;
|
||||||
|
formatCurrency: (amount: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountMergeSelectDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
accounts,
|
||||||
|
selectedAccountIds,
|
||||||
|
onMerge,
|
||||||
|
formatCurrency,
|
||||||
|
}: AccountMergeSelectDialogProps) {
|
||||||
|
const [targetAccountId, setTargetAccountId] = useState<string>("");
|
||||||
|
const [isMerging, setIsMerging] = useState(false);
|
||||||
|
|
||||||
|
const selectedAccounts = accounts.filter((a) =>
|
||||||
|
selectedAccountIds.includes(a.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sourceAccountId =
|
||||||
|
selectedAccounts.length === 2 && targetAccountId
|
||||||
|
? selectedAccounts.find((a) => a.id !== targetAccountId)?.id || ""
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const sourceAccount = accounts.find((a) => a.id === sourceAccountId);
|
||||||
|
const targetAccount = accounts.find((a) => a.id === targetAccountId);
|
||||||
|
|
||||||
|
// Initialiser avec le premier compte si pas encore sélectionné
|
||||||
|
if (open && selectedAccounts.length === 2 && !targetAccountId) {
|
||||||
|
setTargetAccountId(selectedAccounts[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMerge = async () => {
|
||||||
|
if (
|
||||||
|
!sourceAccountId ||
|
||||||
|
!targetAccountId ||
|
||||||
|
sourceAccountId === targetAccountId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsMerging(true);
|
||||||
|
try {
|
||||||
|
await onMerge(sourceAccountId, targetAccountId);
|
||||||
|
setTargetAccountId("");
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error merging accounts:", error);
|
||||||
|
alert("Erreur lors de la fusion des comptes");
|
||||||
|
} finally {
|
||||||
|
setIsMerging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canMerge =
|
||||||
|
selectedAccounts.length === 2 &&
|
||||||
|
sourceAccountId &&
|
||||||
|
targetAccountId &&
|
||||||
|
sourceAccountId !== targetAccountId &&
|
||||||
|
sourceAccount &&
|
||||||
|
targetAccount;
|
||||||
|
|
||||||
|
if (selectedAccounts.length !== 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Fusionner des comptes</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choisissez quel compte conserver. Toutes les transactions de l'autre
|
||||||
|
compte seront déplacées vers celui-ci.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Le compte sélectionné sera conservé et utilisé pour les prochains
|
||||||
|
imports. Choisissez celui qui a le bon bankId.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Compte à conserver (destination)</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={targetAccountId}
|
||||||
|
onValueChange={setTargetAccountId}
|
||||||
|
>
|
||||||
|
{selectedAccounts.map((account) => (
|
||||||
|
<div
|
||||||
|
key={account.id}
|
||||||
|
className="flex items-start space-x-3 rounded-lg border p-3 hover:bg-accent"
|
||||||
|
>
|
||||||
|
<RadioGroupItem
|
||||||
|
value={account.id}
|
||||||
|
id={account.id}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={account.id}
|
||||||
|
className="flex-1 cursor-pointer space-y-1"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{account.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||||
|
<div>Numéro: {account.accountNumber}</div>
|
||||||
|
<div>Bank ID: {account.bankId}</div>
|
||||||
|
<div>
|
||||||
|
Solde: {formatCurrency(getAccountBalance(account))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canMerge && (
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>Attention :</strong> Toutes les transactions du compte "
|
||||||
|
{sourceAccount.name}" seront déplacées vers "
|
||||||
|
{targetAccount.name}". Le compte "{sourceAccount.name}" sera
|
||||||
|
supprimé après la fusion. Cette action est irréversible.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleMerge} disabled={!canMerge || isMerging}>
|
||||||
|
{isMerging ? "Fusion en cours..." : "Fusionner"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { AccountCard } from "./account-card";
|
export { AccountCard } from "./account-card";
|
||||||
export { AccountEditDialog } from "./account-edit-dialog";
|
export { AccountEditDialog } from "./account-edit-dialog";
|
||||||
|
export { AccountMergeSelectDialog } from "./account-merge-select-dialog";
|
||||||
export { AccountBulkActions } from "./account-bulk-actions";
|
export { AccountBulkActions } from "./account-bulk-actions";
|
||||||
export { accountTypeIcons, accountTypeLabels } from "./constants";
|
export { accountTypeIcons, accountTypeLabels } from "./constants";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
@@ -25,7 +26,7 @@ export function CategoryCard({
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group">
|
<div className="flex items-center justify-between py-1.5 px-2 rounded group">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
className="w-4 h-4 md:w-5 md:h-5 rounded-full flex items-center justify-center shrink-0"
|
className="w-4 h-4 md:w-5 md:h-5 rounded-full flex items-center justify-center shrink-0"
|
||||||
@@ -37,7 +38,13 @@ export function CategoryCard({
|
|||||||
size={isMobile ? 10 : 12}
|
size={isMobile ? 10 : 12}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs md:text-sm truncate">{category.name}</span>
|
<Link
|
||||||
|
href={`/transactions?categoryIds=${category.id}`}
|
||||||
|
className="text-xs md:text-sm truncate hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</Link>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<span className="text-xs md:text-sm text-muted-foreground shrink-0">
|
<span className="text-xs md:text-sm text-muted-foreground shrink-0">
|
||||||
{stats.count} opération{stats.count > 1 ? "s" : ""} •{" "}
|
{stats.count} opération{stats.count > 1 ? "s" : ""} •{" "}
|
||||||
@@ -59,7 +66,7 @@ export function CategoryCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 md:opacity-100 transition-opacity">
|
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 md:opacity-100">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -53,11 +55,11 @@ export function ParentCategoryRow({
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg bg-card">
|
<Card className="card-hover">
|
||||||
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
|
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
|
||||||
<div className="flex items-center justify-between px-2 md:px-3 py-1.5 md:py-2">
|
<div className="flex items-center justify-between px-2 md:px-3 py-1.5 md:py-2">
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button className="flex items-center gap-1.5 md:gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
|
<button className="flex items-center gap-1.5 md:gap-2 hover:opacity-80 flex-1 min-w-0">
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="w-3 h-3 md:w-4 md:h-4 text-muted-foreground shrink-0" />
|
<ChevronDown className="w-3 h-3 md:w-4 md:h-4 text-muted-foreground shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
@@ -73,9 +75,13 @@ export function ParentCategoryRow({
|
|||||||
size={isMobile ? 10 : 14}
|
size={isMobile ? 10 : 14}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-xs md:text-sm truncate">
|
<Link
|
||||||
|
href={`/transactions?categoryIds=${parent.id}`}
|
||||||
|
className="font-medium text-xs md:text-sm truncate hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
{parent.name}
|
{parent.name}
|
||||||
</span>
|
</Link>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<span className="text-xs md:text-sm text-muted-foreground shrink-0">
|
<span className="text-xs md:text-sm text-muted-foreground shrink-0">
|
||||||
{children.length} • {stats.count} opération
|
{children.length} • {stats.count} opération
|
||||||
@@ -119,7 +125,7 @@ export function ParentCategoryRow({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onDelete(parent.id)}
|
onClick={() => onDelete(parent.id)}
|
||||||
className="text-red-600"
|
className="text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
Supprimer
|
Supprimer
|
||||||
@@ -150,6 +156,6 @@ export function ParentCategoryRow({
|
|||||||
)}
|
)}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs font-semibold tabular-nums ml-auto",
|
"text-xs font-semibold tabular-nums ml-auto",
|
||||||
folderTotal >= 0 ? "text-emerald-600" : "text-red-600",
|
folderTotal >= 0 ? "text-success" : "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(folderTotal)}
|
{formatCurrency(folderTotal)}
|
||||||
@@ -113,11 +113,11 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
key={account.id}
|
||||||
className="space-y-2.5 p-3 rounded-xl bg-muted/30 hover:bg-muted/50 border border-border/50 hover:border-primary/20 transition-all duration-300 group"
|
className="space-y-2.5 p-3 rounded-xl bg-muted/30 border border-border/50 group"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center">
|
||||||
<Building2 className="w-5 h-5 text-primary" />
|
<Building2 className="w-5 h-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -130,9 +130,7 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-bold tabular-nums text-base",
|
"font-bold tabular-nums text-base",
|
||||||
realBalance >= 0
|
realBalance >= 0 ? "text-success" : "text-destructive",
|
||||||
? "text-emerald-600 dark:text-emerald-400"
|
|
||||||
: "text-red-600 dark:text-red-400",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(realBalance)}
|
{formatCurrency(realBalance)}
|
||||||
@@ -204,7 +202,7 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs font-semibold tabular-nums ml-auto",
|
"text-xs font-semibold tabular-nums ml-auto",
|
||||||
orphanTotal >= 0 ? "text-emerald-600" : "text-red-600",
|
orphanTotal >= 0 ? "text-success" : "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(orphanTotal)}
|
{formatCurrency(orphanTotal)}
|
||||||
@@ -225,11 +223,11 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
key={account.id}
|
||||||
className="space-y-2.5 p-3 rounded-xl bg-muted/30 hover:bg-muted/50 border border-border/50 hover:border-primary/20 transition-all duration-300 group"
|
className="space-y-2.5 p-3 rounded-xl bg-muted/30 border border-border/50 group"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center">
|
||||||
<Building2 className="w-5 h-5 text-primary" />
|
<Building2 className="w-5 h-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -245,8 +243,8 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"font-bold tabular-nums text-base",
|
"font-bold tabular-nums text-base",
|
||||||
realBalance >= 0
|
realBalance >= 0
|
||||||
? "text-emerald-600 dark:text-emerald-400"
|
? "text-success"
|
||||||
: "text-red-600 dark:text-red-400",
|
: "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(realBalance)}
|
{formatCurrency(realBalance)}
|
||||||
|
|||||||
@@ -36,10 +36,43 @@ export function CategoryBreakdown({ data }: CategoryBreakdownProps) {
|
|||||||
value: total,
|
value: total,
|
||||||
color: category?.color || "#94a3b8",
|
color: category?.color || "#94a3b8",
|
||||||
icon: category?.icon || "HelpCircle",
|
icon: category?.icon || "HelpCircle",
|
||||||
|
categoryId: categoryId === "uncategorized" ? null : categoryId,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.value - a.value)
|
.sort((a, b) => b.value - a.value);
|
||||||
.slice(0, 6);
|
|
||||||
|
// Category breakdown grouped by parent
|
||||||
|
const categoryTotalsByParent = new Map<string, number>();
|
||||||
|
monthExpenses.forEach((t) => {
|
||||||
|
const category = data.categories.find((c) => c.id === t.categoryId);
|
||||||
|
// Use parent category ID if exists, otherwise use the category itself
|
||||||
|
let groupId: string;
|
||||||
|
if (!category) {
|
||||||
|
groupId = "uncategorized";
|
||||||
|
} else if (category.parentId) {
|
||||||
|
groupId = category.parentId;
|
||||||
|
} else {
|
||||||
|
// Category is a parent itself
|
||||||
|
groupId = category.id;
|
||||||
|
}
|
||||||
|
const current = categoryTotalsByParent.get(groupId) || 0;
|
||||||
|
categoryTotalsByParent.set(groupId, current + Math.abs(t.amount));
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartDataByParent: CategoryChartData[] = Array.from(
|
||||||
|
categoryTotalsByParent.entries(),
|
||||||
|
)
|
||||||
|
.map(([groupId, total]) => {
|
||||||
|
const category = data.categories.find((c) => c.id === groupId);
|
||||||
|
return {
|
||||||
|
name: category?.name || "Non catégorisé",
|
||||||
|
value: total,
|
||||||
|
color: category?.color || "#94a3b8",
|
||||||
|
icon: category?.icon || "HelpCircle",
|
||||||
|
categoryId: groupId === "uncategorized" ? null : groupId,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
@@ -51,6 +84,8 @@ export function CategoryBreakdown({ data }: CategoryBreakdownProps) {
|
|||||||
return (
|
return (
|
||||||
<CategoryPieChart
|
<CategoryPieChart
|
||||||
data={chartData}
|
data={chartData}
|
||||||
|
dataByParent={chartDataByParent}
|
||||||
|
categories={data.categories}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
title="Dépenses par catégorie"
|
title="Dépenses par catégorie"
|
||||||
height={250}
|
height={250}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { TrendingUp, TrendingDown, Wallet, CreditCard } from "lucide-react";
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Wallet,
|
||||||
|
CreditCard,
|
||||||
|
Tag,
|
||||||
|
} from "lucide-react";
|
||||||
import type { BankingData } from "@/lib/types";
|
import type { BankingData } from "@/lib/types";
|
||||||
import { getAccountBalance } from "@/lib/account-utils";
|
import { getAccountBalance } from "@/lib/account-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -35,7 +41,13 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
const reconciled = data.transactions.filter((t) => t.isReconciled).length;
|
const reconciled = data.transactions.filter((t) => t.isReconciled).length;
|
||||||
const total = data.transactions.length;
|
const total = data.transactions.length;
|
||||||
const reconciledPercent =
|
const reconciledPercent =
|
||||||
total > 0 ? Math.round((reconciled / total) * 100) : 0;
|
total > 0 ? ((reconciled / total) * 100).toFixed(2) : "0.00";
|
||||||
|
|
||||||
|
const categorized = data.transactions.filter(
|
||||||
|
(t) => t.categoryId !== null,
|
||||||
|
).length;
|
||||||
|
const categorizedPercent =
|
||||||
|
total > 0 ? ((categorized / total) * 100).toFixed(2) : "0.00";
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
@@ -45,49 +57,53 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 sm:gap-6 grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
<Card className="stat-card-gradient-1 card-hover group relative overflow-hidden">
|
<Card className="stat-card-gradient-1 card-hover group relative overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/8 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
{/* Icône en arrière-plan */}
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest">
|
<Wallet
|
||||||
|
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-primary"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
||||||
|
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
||||||
Solde Total
|
Solde Total
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="rounded-2xl bg-gradient-to-br from-primary/30 via-primary/20 to-primary/10 p-3 shrink-0 group-hover:scale-110 group-hover:rotate-3 transition-all duration-300 shadow-lg shadow-primary/20">
|
|
||||||
<Wallet className="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-6 pb-6 sm:px-7 sm:pb-7 lg:px-5 lg:pb-5 pt-0 relative z-10">
|
<CardContent className="px-5 pb-5 sm:px-6 sm:pb-6 pt-0 relative z-10">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl sm:text-3xl md:text-3xl lg:text-xl xl:text-xl font-black tracking-tight mb-4 leading-none break-words",
|
"text-2xl sm:text-3xl md:text-3xl lg:text-2xl xl:text-3xl font-black tracking-tight mb-3 leading-tight break-words",
|
||||||
totalBalance >= 0
|
totalBalance >= 0 ? "text-success" : "text-destructive",
|
||||||
? "text-emerald-600 dark:text-emerald-400"
|
|
||||||
: "text-red-600 dark:text-red-400",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(totalBalance)}
|
{formatCurrency(totalBalance)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs sm:text-sm font-semibold text-muted-foreground/60">
|
<p className="text-xs sm:text-sm font-medium text-muted-foreground/70">
|
||||||
{data.accounts.length} compte{data.accounts.length > 1 ? "s" : ""}
|
{data.accounts.length} compte{data.accounts.length > 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="stat-card-gradient-2 card-hover group relative overflow-hidden">
|
<Card className="stat-card-gradient-2 card-hover group relative overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-success/8 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
{/* Icône en arrière-plan */}
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest">
|
<TrendingUp
|
||||||
|
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-success"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
||||||
|
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
||||||
Revenus du mois
|
Revenus du mois
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="rounded-2xl bg-gradient-to-br from-success/30 via-success/20 to-success/10 p-3 shrink-0 group-hover:scale-110 group-hover:rotate-3 transition-all duration-300 shadow-lg shadow-success/20">
|
|
||||||
<TrendingUp className="h-5 w-5 text-success" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-6 pb-6 sm:px-7 sm:pb-7 lg:px-5 lg:pb-5 pt-0 relative z-10">
|
<CardContent className="px-5 pb-5 sm:px-6 sm:pb-6 pt-0 relative z-10">
|
||||||
<div className="text-2xl sm:text-3xl md:text-3xl lg:text-xl xl:text-xl font-black tracking-tight text-success mb-4 leading-none break-words">
|
<div className="text-2xl sm:text-3xl md:text-3xl lg:text-2xl xl:text-3xl font-black tracking-tight text-success mb-3 leading-tight break-words">
|
||||||
{formatCurrency(income)}
|
{formatCurrency(income)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs sm:text-sm font-semibold text-muted-foreground/60">
|
<p className="text-xs sm:text-sm font-medium text-muted-foreground/70">
|
||||||
{monthTransactions.filter((t) => t.amount > 0).length} opération
|
{monthTransactions.filter((t) => t.amount > 0).length} opération
|
||||||
{monthTransactions.filter((t) => t.amount > 0).length > 1
|
{monthTransactions.filter((t) => t.amount > 0).length > 1
|
||||||
? "s"
|
? "s"
|
||||||
@@ -97,20 +113,23 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="stat-card-gradient-3 card-hover group relative overflow-hidden">
|
<Card className="stat-card-gradient-3 card-hover group relative overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-destructive/8 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
{/* Icône en arrière-plan */}
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest">
|
<TrendingDown
|
||||||
|
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-destructive"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
||||||
|
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
||||||
Dépenses du mois
|
Dépenses du mois
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="rounded-2xl bg-gradient-to-br from-destructive/30 via-destructive/20 to-destructive/10 p-3 shrink-0 group-hover:scale-110 group-hover:rotate-3 transition-all duration-300 shadow-lg shadow-destructive/20">
|
|
||||||
<TrendingDown className="h-5 w-5 text-destructive" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-6 pb-6 sm:px-7 sm:pb-7 lg:px-5 lg:pb-5 pt-0 relative z-10">
|
<CardContent className="px-5 pb-5 sm:px-6 sm:pb-6 pt-0 relative z-10">
|
||||||
<div className="text-2xl sm:text-3xl md:text-3xl lg:text-xl xl:text-xl font-black tracking-tight text-destructive mb-4 leading-none break-words">
|
<div className="text-2xl sm:text-3xl md:text-3xl lg:text-2xl xl:text-3xl font-black tracking-tight text-destructive mb-3 leading-tight break-words">
|
||||||
{formatCurrency(expenses)}
|
{formatCurrency(expenses)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs sm:text-sm font-semibold text-muted-foreground/60">
|
<p className="text-xs sm:text-sm font-medium text-muted-foreground/70">
|
||||||
{monthTransactions.filter((t) => t.amount < 0).length} opération
|
{monthTransactions.filter((t) => t.amount < 0).length} opération
|
||||||
{monthTransactions.filter((t) => t.amount < 0).length > 1
|
{monthTransactions.filter((t) => t.amount < 0).length > 1
|
||||||
? "s"
|
? "s"
|
||||||
@@ -120,24 +139,50 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="stat-card-gradient-4 card-hover group relative overflow-hidden">
|
<Card className="stat-card-gradient-4 card-hover group relative overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-chart-4/8 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
{/* Icône en arrière-plan */}
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest">
|
<CreditCard
|
||||||
|
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-chart-4"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
||||||
|
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
||||||
Pointage
|
Pointage
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="rounded-2xl bg-gradient-to-br from-chart-4/30 via-chart-4/20 to-chart-4/10 p-3 shrink-0 group-hover:scale-110 group-hover:rotate-3 transition-all duration-300 shadow-lg shadow-chart-4/20">
|
|
||||||
<CreditCard className="h-5 w-5 text-chart-4" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-6 pb-6 sm:px-7 sm:pb-7 lg:px-5 lg:pb-5 pt-0 relative z-10">
|
<CardContent className="px-5 pb-5 sm:px-6 sm:pb-6 pt-0 relative z-10">
|
||||||
<div className="text-2xl sm:text-3xl md:text-3xl lg:text-xl xl:text-xl font-black tracking-tight mb-4 leading-none break-words">
|
<div className="text-2xl sm:text-3xl md:text-3xl lg:text-2xl xl:text-3xl font-black tracking-tight mb-3 leading-tight break-words">
|
||||||
{reconciledPercent}%
|
{reconciledPercent}%
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs sm:text-sm font-semibold text-muted-foreground/60">
|
<p className="text-xs sm:text-sm font-medium text-muted-foreground/70">
|
||||||
{reconciled} / {total} opérations pointées
|
{reconciled} / {total} opérations pointées
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="stat-card-gradient-5 card-hover group relative overflow-hidden">
|
||||||
|
{/* Icône en arrière-plan */}
|
||||||
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
|
<Tag
|
||||||
|
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-chart-5"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
||||||
|
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
||||||
|
Catégorisation
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-5 pb-5 sm:px-6 sm:pb-6 pt-0 relative z-10">
|
||||||
|
<div className="text-2xl sm:text-3xl md:text-3xl lg:text-2xl xl:text-3xl font-black tracking-tight mb-3 leading-tight break-words">
|
||||||
|
{categorizedPercent}%
|
||||||
|
</div>
|
||||||
|
<p className="text-xs sm:text-sm font-medium text-muted-foreground/70">
|
||||||
|
{categorized} / {total} opérations catégorisées
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,13 +61,13 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="card-hover">
|
<Card className="card-hover relative overflow-hidden">
|
||||||
<CardHeader className="pb-5">
|
<CardHeader className="pb-5 relative z-10">
|
||||||
<CardTitle className="text-lg md:text-xl font-black tracking-tight">
|
<CardTitle className="text-lg md:text-xl font-black tracking-tight">
|
||||||
Transactions récentes
|
Transactions récentes
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-5 md:px-6">
|
<CardContent className="px-5 md:px-6 relative z-10">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{recentTransactions.map((transaction) => {
|
{recentTransactions.map((transaction) => {
|
||||||
const category = getCategory(transaction.categoryId);
|
const category = getCategory(transaction.categoryId);
|
||||||
@@ -76,7 +76,7 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={transaction.id}
|
key={transaction.id}
|
||||||
className="group rounded-2xl bg-gradient-to-r from-muted/50 via-muted/30 to-muted/20 hover:from-muted/70 hover:via-muted/50 hover:to-muted/40 border-2 border-border/40 hover:border-primary/30 transition-all duration-300 overflow-hidden hover:shadow-lg hover:shadow-primary/10 hover:scale-[1.02] backdrop-blur-sm"
|
className="group rounded-2xl bg-gradient-to-r from-muted/50 via-muted/30 to-muted/20 border-2 border-border/40 overflow-hidden backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4 md:gap-5 p-4 md:p-5">
|
<div className="flex items-start gap-4 md:gap-5 p-4 md:p-5">
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
@@ -88,8 +88,8 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"font-black tabular-nums text-sm md:text-base shrink-0 md:hidden",
|
"font-black tabular-nums text-sm md:text-base shrink-0 md:hidden",
|
||||||
transaction.amount >= 0
|
transaction.amount >= 0
|
||||||
? "text-emerald-600 dark:text-emerald-400"
|
? "text-success"
|
||||||
: "text-red-600 dark:text-red-400",
|
: "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{transaction.amount >= 0 ? "+" : ""}
|
{transaction.amount >= 0 ? "+" : ""}
|
||||||
@@ -130,8 +130,8 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"font-black tabular-nums text-base md:text-lg shrink-0 hidden md:block leading-tight",
|
"font-black tabular-nums text-base md:text-lg shrink-0 hidden md:block leading-tight",
|
||||||
transaction.amount >= 0
|
transaction.amount >= 0
|
||||||
? "text-emerald-600 dark:text-emerald-400"
|
? "text-success"
|
||||||
: "text-red-600 dark:text-red-400",
|
: "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{transaction.amount >= 0 ? "+" : ""}
|
{transaction.amount >= 0 ? "+" : ""}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
@@ -19,8 +18,9 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/", label: "Tableau de bord", icon: LayoutDashboard },
|
{ href: "/", label: "Tableau de bord", icon: LayoutDashboard },
|
||||||
@@ -77,29 +77,27 @@ function SidebarContent({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<nav className={cn(
|
<nav className={cn("flex-1 pt-2", collapsed ? "p-2" : "p-4")}>
|
||||||
"flex-1 space-y-2",
|
|
||||||
collapsed ? "p-2" : "p-4"
|
|
||||||
)}>
|
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = pathname === item.href;
|
const isActive = pathname === item.href;
|
||||||
return (
|
return (
|
||||||
<Link key={item.href} href={item.href} onClick={handleLinkClick}>
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
className="block mb-2 first:mt-0"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant={isActive ? "secondary" : "ghost"}
|
variant={isActive ? "secondary" : "ghost"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start gap-4 h-12 rounded-2xl transition-all duration-300",
|
"w-full justify-start gap-3 h-12 rounded-2xl px-3",
|
||||||
"hover:bg-muted/70 hover:scale-[1.02] hover:shadow-md",
|
|
||||||
isActive &&
|
isActive &&
|
||||||
"bg-gradient-to-r from-primary/15 via-primary/10 to-primary/5 border-2 border-primary/30 shadow-lg shadow-primary/10 backdrop-blur-sm",
|
"bg-gradient-to-r from-primary/15 via-primary/10 to-primary/5 border-2 border-primary/30 shadow-lg shadow-primary/10 backdrop-blur-sm",
|
||||||
collapsed && "justify-center px-2 w-12 mx-auto",
|
collapsed && "justify-center px-2 w-12 mx-auto",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon
|
||||||
className={cn(
|
className={cn("w-5 h-5 shrink-0", isActive && "text-primary")}
|
||||||
"w-5 h-5 shrink-0 transition-all duration-300",
|
|
||||||
isActive && "text-primary scale-110",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<span
|
<span
|
||||||
@@ -117,15 +115,17 @@ function SidebarContent({
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className={cn(
|
<div
|
||||||
"border-t border-border/30 space-y-2",
|
className={cn(
|
||||||
collapsed ? "p-2" : "p-4"
|
"border-t border-border/30 pt-2",
|
||||||
)}>
|
collapsed ? "p-2" : "p-4",
|
||||||
<Link href="/settings" onClick={handleLinkClick}>
|
)}
|
||||||
|
>
|
||||||
|
<Link href="/settings" onClick={handleLinkClick} className="block mb-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start gap-4 h-12 rounded-2xl transition-all duration-300 hover:bg-muted/70 hover:scale-[1.02] hover:shadow-md",
|
"w-full justify-start gap-3 h-12 rounded-2xl px-3",
|
||||||
collapsed && "justify-center px-2 w-12 mx-auto",
|
collapsed && "justify-center px-2 w-12 mx-auto",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -139,8 +139,8 @@ function SidebarContent({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start gap-4 h-12 rounded-2xl transition-all duration-300",
|
"w-full justify-start gap-3 h-12 rounded-2xl px-3 mb-2",
|
||||||
"text-destructive hover:text-destructive hover:bg-destructive/15 hover:scale-[1.02] hover:shadow-md",
|
"text-destructive",
|
||||||
collapsed && "justify-center px-2 w-12 mx-auto",
|
collapsed && "justify-center px-2 w-12 mx-auto",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -160,13 +160,18 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ open, onOpenChange }: SidebarProps) {
|
export function Sidebar({ open, onOpenChange }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useLocalStorage("sidebar-collapsed", false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent side="left" className="w-64 p-0">
|
<SheetContent
|
||||||
|
side="left"
|
||||||
|
className="w-64 p-0 text-sidebar-foreground border-sidebar-border"
|
||||||
|
style={{ backgroundColor: 'var(--sidebar-opaque)' }}
|
||||||
|
>
|
||||||
|
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
showHeader
|
showHeader
|
||||||
@@ -181,15 +186,17 @@ export function Sidebar({ open, onOpenChange }: SidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden md:flex flex-col h-screen glass border-r border-border transition-all duration-300",
|
"hidden md:flex flex-col h-screen bg-sidebar text-sidebar-foreground border-r border-sidebar-border",
|
||||||
"backdrop-blur-xl",
|
"backdrop-blur-xl",
|
||||||
collapsed ? "w-16" : "w-64",
|
collapsed ? "w-16" : "w-64",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<div
|
||||||
"flex items-center border-b border-border/30 transition-all duration-300",
|
className={cn(
|
||||||
collapsed ? "justify-center p-4" : "justify-between p-6"
|
"flex items-center border-b border-border/30",
|
||||||
)}>
|
collapsed ? "justify-center p-4" : "justify-between p-6",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary via-primary/90 to-primary/80 flex items-center justify-center shadow-xl shadow-primary/30 backdrop-blur-sm">
|
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary via-primary/90 to-primary/80 flex items-center justify-center shadow-xl shadow-primary/30 backdrop-blur-sm">
|
||||||
@@ -204,10 +211,7 @@ export function Sidebar({ open, onOpenChange }: SidebarProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
className={cn(
|
className={cn("rounded-xl", collapsed ? "" : "ml-auto")}
|
||||||
"hover:bg-muted/60 rounded-xl transition-all duration-300 hover:scale-110",
|
|
||||||
collapsed ? "" : "ml-auto"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<ChevronRight className="w-5 h-5" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function DraggableAccountItem({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm tabular-nums",
|
"text-sm tabular-nums",
|
||||||
realBalance >= 0 ? "text-emerald-600" : "text-red-600",
|
realBalance >= 0 ? "text-success" : "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(realBalance)}
|
{formatCurrency(realBalance)}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function DraggableFolderItem({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-semibold tabular-nums",
|
"text-sm font-semibold tabular-nums",
|
||||||
folderTotal >= 0 ? "text-emerald-600" : "text-red-600",
|
folderTotal >= 0 ? "text-success" : "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(folderTotal)}
|
{formatCurrency(folderTotal)}
|
||||||
@@ -145,7 +145,7 @@ export function DraggableFolderItem({
|
|||||||
{folder.id !== "folder-root" && (
|
{folder.id !== "folder-root" && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onDelete(folder.id)}
|
onClick={() => onDelete(folder.id)}
|
||||||
className="text-red-600"
|
className="text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
Supprimer
|
Supprimer
|
||||||
|
|||||||
@@ -505,9 +505,9 @@ export function OFXImportDialog({
|
|||||||
{importResults.map((result, i) => (
|
{importResults.map((result, i) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
{result.error ? (
|
{result.error ? (
|
||||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
|
<AlertCircle className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<CheckCircle2 className="w-4 h-4 text-emerald-500 flex-shrink-0" />
|
<CheckCircle2 className="w-4 h-4 text-success flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{result.fileName}</span>
|
<span className="truncate">{result.fileName}</span>
|
||||||
{!result.error && (
|
{!result.error && (
|
||||||
@@ -524,16 +524,16 @@ export function OFXImportDialog({
|
|||||||
|
|
||||||
{step === "success" && (
|
{step === "success" && (
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<CheckCircle2 className="w-16 h-16 mx-auto mb-4 text-emerald-600" />
|
<CheckCircle2 className="w-16 h-16 mx-auto mb-4 text-success" />
|
||||||
|
|
||||||
{importResults.length > 1 && (
|
{importResults.length > 1 && (
|
||||||
<div className="max-h-48 overflow-auto space-y-1 text-sm mb-4 border rounded-lg p-2">
|
<div className="max-h-48 overflow-auto space-y-1 text-sm mb-4 border rounded-lg p-2">
|
||||||
{importResults.map((result, i) => (
|
{importResults.map((result, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 py-1">
|
<div key={i} className="flex items-center gap-2 py-1">
|
||||||
{result.error ? (
|
{result.error ? (
|
||||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
|
<AlertCircle className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<CheckCircle2 className="w-4 h-4 text-emerald-500 flex-shrink-0" />
|
<CheckCircle2 className="w-4 h-4 text-success flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="truncate font-medium">
|
<p className="truncate font-medium">
|
||||||
@@ -544,7 +544,7 @@ export function OFXImportDialog({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{result.error ? (
|
{result.error ? (
|
||||||
<span className="text-xs text-red-500 flex-shrink-0">
|
<span className="text-xs text-destructive flex-shrink-0">
|
||||||
{result.error}
|
{result.error}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -559,7 +559,7 @@ export function OFXImportDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{errorCount > 0 && (
|
{errorCount > 0 && (
|
||||||
<p className="text-sm text-red-600 mb-4 text-center">
|
<p className="text-sm text-destructive mb-4 text-center">
|
||||||
{errorCount} fichier{errorCount > 1 ? "s" : ""} en erreur
|
{errorCount} fichier{errorCount > 1 ? "s" : ""} en erreur
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -572,7 +572,7 @@ export function OFXImportDialog({
|
|||||||
|
|
||||||
{step === "error" && (
|
{step === "error" && (
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-red-600" />
|
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-destructive" />
|
||||||
<Button onClick={() => setStep("upload")}>Réessayer</Button>
|
<Button onClick={() => setStep("upload")}>Réessayer</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ReactNode } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Menu } from "lucide-react";
|
import { Menu } from "lucide-react";
|
||||||
import { useSidebarContext } from "@/components/layout/sidebar-context";
|
import { useSidebarContext } from "@/components/layout/sidebar-context";
|
||||||
@@ -21,6 +21,30 @@ export function PageHeader({
|
|||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
const { setOpen } = useSidebarContext();
|
const { setOpen } = useSidebarContext();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const [textColor, setTextColor] = useState("var(--foreground)");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkDarkBackground = () => {
|
||||||
|
const pageBackground = document.querySelector(".page-background");
|
||||||
|
if (pageBackground?.classList.contains("bg-solid-dark")) {
|
||||||
|
setTextColor("#f5f5f5");
|
||||||
|
} else {
|
||||||
|
setTextColor("var(--foreground)");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkDarkBackground();
|
||||||
|
const observer = new MutationObserver(checkDarkBackground);
|
||||||
|
const pageBackground = document.querySelector(".page-background");
|
||||||
|
if (pageBackground) {
|
||||||
|
observer.observe(pageBackground, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mb-2">
|
<div className="flex flex-col gap-4 mb-2">
|
||||||
@@ -37,12 +61,16 @@ export function PageHeader({
|
|||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2 mb-2">
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
<h1 className="text-2xl md:text-4xl lg:text-5xl font-black text-foreground tracking-tight leading-tight flex-1 min-w-0">
|
<h1
|
||||||
|
className="text-2xl md:text-4xl lg:text-5xl font-black tracking-tight leading-tight flex-1 min-w-0"
|
||||||
|
style={{
|
||||||
|
color: textColor,
|
||||||
|
WebkitTextFillColor: textColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
{rightContent && (
|
{rightContent && <div className="shrink-0">{rightContent}</div>}
|
||||||
<div className="shrink-0">{rightContent}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<div className="text-base md:text-lg text-muted-foreground/70 font-semibold">
|
<div className="text-base md:text-lg text-muted-foreground/70 font-semibold">
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ export function PageLayout({ children }: PageLayoutProps) {
|
|||||||
<SidebarContext.Provider
|
<SidebarContext.Provider
|
||||||
value={{ open: sidebarOpen, setOpen: setSidebarOpen }}
|
value={{ open: sidebarOpen, setOpen: setSidebarOpen }}
|
||||||
>
|
>
|
||||||
<div className="flex h-screen bg-background overflow-hidden page-background">
|
<div className="flex h-screen bg-background overflow-hidden page-background bg-default">
|
||||||
<Sidebar open={sidebarOpen} onOpenChange={setSidebarOpen} />
|
<Sidebar open={sidebarOpen} onOpenChange={setSidebarOpen} />
|
||||||
<main className="flex-1 overflow-auto overflow-x-hidden page-content">
|
<main className="flex-1 overflow-auto overflow-x-hidden page-content">
|
||||||
<div className="p-6 md:p-8 lg:p-10 space-y-6 md:space-y-8 max-w-full">
|
<div className="p-3 md:p-8 lg:p-10 space-y-4 md:space-y-8 max-w-full">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
89
components/providers/background-provider.tsx
Normal file
89
components/providers/background-provider.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
interface BackgroundSettings {
|
||||||
|
type: string;
|
||||||
|
customImageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackgroundProvider() {
|
||||||
|
useEffect(() => {
|
||||||
|
const applyBackground = () => {
|
||||||
|
try {
|
||||||
|
const pageBackground = document.querySelector(
|
||||||
|
".page-background",
|
||||||
|
) as HTMLElement;
|
||||||
|
if (!pageBackground) return;
|
||||||
|
|
||||||
|
const stored = localStorage.getItem("background-settings");
|
||||||
|
const settings: BackgroundSettings = stored
|
||||||
|
? JSON.parse(stored)
|
||||||
|
: { type: "default" };
|
||||||
|
|
||||||
|
// Retirer toutes les classes de fond
|
||||||
|
pageBackground.classList.remove(
|
||||||
|
"bg-default",
|
||||||
|
"bg-gradient-blue",
|
||||||
|
"bg-gradient-purple",
|
||||||
|
"bg-gradient-green",
|
||||||
|
"bg-gradient-orange",
|
||||||
|
"bg-solid-light",
|
||||||
|
"bg-solid-dark",
|
||||||
|
"bg-custom-image",
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
if (settings.type === "custom-image" && settings.customImageUrl) {
|
||||||
|
pageBackground.classList.add("bg-custom-image");
|
||||||
|
root.style.setProperty(
|
||||||
|
"--custom-background-image",
|
||||||
|
`url(${settings.customImageUrl})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
pageBackground.classList.add(`bg-${settings.type || "default"}`);
|
||||||
|
root.style.removeProperty("--custom-background-image");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error applying background:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Appliquer immédiatement
|
||||||
|
applyBackground();
|
||||||
|
|
||||||
|
// Observer pour les changements de DOM (si le page-background est ajouté plus tard)
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
applyBackground();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Écouter les changements dans localStorage (autres onglets)
|
||||||
|
const handleStorageChange = (e: StorageEvent) => {
|
||||||
|
if (e.key === "background-settings") {
|
||||||
|
applyBackground();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Écouter les événements personnalisés (même onglet)
|
||||||
|
const handleBackgroundChanged = () => {
|
||||||
|
applyBackground();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("storage", handleStorageChange);
|
||||||
|
window.addEventListener("background-changed", handleBackgroundChanged);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
window.removeEventListener("storage", handleStorageChange);
|
||||||
|
window.removeEventListener("background-changed", handleBackgroundChanged);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -133,7 +133,7 @@ export function RuleCreateDialog({
|
|||||||
catégorisées.
|
catégorisées.
|
||||||
</p>
|
</p>
|
||||||
{existingCategory && (
|
{existingCategory && (
|
||||||
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
|
<div className="flex items-center gap-2 text-xs text-warning">
|
||||||
<AlertCircle className="h-3 w-3" />
|
<AlertCircle className="h-3 w-3" />
|
||||||
<span>
|
<span>
|
||||||
Ce mot-clé existe déjà dans "{existingCategory.name}
|
Ce mot-clé existe déjà dans "{existingCategory.name}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ChevronDown, ChevronRight, Plus, Tag } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { CategoryCombobox } from "@/components/ui/category-combobox";
|
import { CategoryCombobox } from "@/components/ui/category-combobox";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Transaction, Category } from "@/lib/types";
|
import type { Transaction, Category } from "@/lib/types";
|
||||||
@@ -54,10 +55,10 @@ export function RuleGroupCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-border rounded-lg bg-card overflow-hidden">
|
<Card className="card-hover overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className="flex flex-col md:flex-row md:items-center gap-2 md:gap-3 p-3 md:p-4 cursor-pointer hover:bg-accent/5 transition-colors"
|
className="flex flex-col md:flex-row md:items-center gap-2 md:gap-3 p-3 md:p-4 cursor-pointer hover:bg-accent/5"
|
||||||
onClick={onToggleExpand}
|
onClick={onToggleExpand}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 md:gap-3 flex-1 min-w-0">
|
<div className="flex items-center gap-2 md:gap-3 flex-1 min-w-0">
|
||||||
@@ -247,6 +248,6 @@ export function RuleGroupCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
326
components/settings/background-card.tsx
Normal file
326
components/settings/background-card.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Image, X } from "lucide-react";
|
||||||
|
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type BackgroundType =
|
||||||
|
| "default"
|
||||||
|
| "gradient-blue"
|
||||||
|
| "gradient-purple"
|
||||||
|
| "gradient-green"
|
||||||
|
| "gradient-orange"
|
||||||
|
| "solid-light"
|
||||||
|
| "solid-dark"
|
||||||
|
| "custom-image";
|
||||||
|
|
||||||
|
interface BackgroundSettings {
|
||||||
|
type: BackgroundType;
|
||||||
|
customImageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BACKGROUNDS: Array<{
|
||||||
|
value: BackgroundType;
|
||||||
|
label: string;
|
||||||
|
preview: string;
|
||||||
|
description?: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: "default",
|
||||||
|
label: "Neutre",
|
||||||
|
preview:
|
||||||
|
"linear-gradient(135deg, oklch(0.985 0 0) 0%, oklch(0.97 0.005 260) 50%, oklch(0.985 0 0) 100%)",
|
||||||
|
description: "Fond neutre et élégant",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gradient-blue",
|
||||||
|
label: "Océan",
|
||||||
|
preview:
|
||||||
|
"linear-gradient(135deg, oklch(0.95 0.03 230) 0%, oklch(0.88 0.08 225) 50%, oklch(0.78 0.12 220) 100%)",
|
||||||
|
description: "Dégradé bleu apaisant",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gradient-purple",
|
||||||
|
label: "Améthyste",
|
||||||
|
preview:
|
||||||
|
"linear-gradient(135deg, oklch(0.95 0.04 300) 0%, oklch(0.88 0.1 295) 50%, oklch(0.78 0.15 290) 100%)",
|
||||||
|
description: "Dégradé violet sophistiqué",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gradient-green",
|
||||||
|
label: "Forêt",
|
||||||
|
preview:
|
||||||
|
"linear-gradient(135deg, oklch(0.95 0.04 160) 0%, oklch(0.88 0.1 155) 50%, oklch(0.78 0.14 150) 100%)",
|
||||||
|
description: "Dégradé vert naturel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gradient-orange",
|
||||||
|
label: "Aurore",
|
||||||
|
preview:
|
||||||
|
"linear-gradient(135deg, oklch(0.97 0.03 80) 0%, oklch(0.92 0.08 60) 50%, oklch(0.85 0.14 45) 100%)",
|
||||||
|
description: "Dégradé orange chaleureux",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "solid-light",
|
||||||
|
label: "Lumineux",
|
||||||
|
preview:
|
||||||
|
"linear-gradient(135deg, oklch(1 0 0) 0%, oklch(0.98 0.005 260) 100%)",
|
||||||
|
description: "Fond blanc épuré",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "solid-dark",
|
||||||
|
label: "Minuit",
|
||||||
|
preview:
|
||||||
|
"linear-gradient(135deg, oklch(0.18 0.02 260) 0%, oklch(0.08 0.015 250) 100%)",
|
||||||
|
description: "Fond sombre immersif",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BackgroundCard() {
|
||||||
|
const [backgroundSettings, setBackgroundSettings] =
|
||||||
|
useLocalStorage<BackgroundSettings>("background-settings", {
|
||||||
|
type: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentSettings = useMemo<BackgroundSettings>(
|
||||||
|
() => backgroundSettings || { type: "default" },
|
||||||
|
[backgroundSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [customImageUrl, setCustomImageUrl] = useState(
|
||||||
|
currentSettings.customImageUrl || "",
|
||||||
|
);
|
||||||
|
const [showCustomInput, setShowCustomInput] = useState(
|
||||||
|
currentSettings.type === "custom-image",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Synchroniser customImageUrl avec les settings
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
currentSettings.type === "custom-image" &&
|
||||||
|
currentSettings.customImageUrl
|
||||||
|
) {
|
||||||
|
setCustomImageUrl(currentSettings.customImageUrl);
|
||||||
|
}
|
||||||
|
}, [currentSettings]);
|
||||||
|
|
||||||
|
const applyBackground = (settings: BackgroundSettings) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const pageBackground = document.querySelector(
|
||||||
|
".page-background",
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
|
if (!pageBackground) return;
|
||||||
|
|
||||||
|
// Retirer toutes les classes de fond
|
||||||
|
pageBackground.classList.remove(
|
||||||
|
"bg-default",
|
||||||
|
"bg-gradient-blue",
|
||||||
|
"bg-gradient-purple",
|
||||||
|
"bg-gradient-green",
|
||||||
|
"bg-gradient-orange",
|
||||||
|
"bg-solid-light",
|
||||||
|
"bg-solid-dark",
|
||||||
|
"bg-custom-image",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (settings.type === "custom-image" && settings.customImageUrl) {
|
||||||
|
pageBackground.classList.add("bg-custom-image");
|
||||||
|
root.style.setProperty(
|
||||||
|
"--custom-background-image",
|
||||||
|
`url(${settings.customImageUrl})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
pageBackground.classList.add(`bg-${settings.type || "default"}`);
|
||||||
|
root.style.removeProperty("--custom-background-image");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déclencher un événement personnalisé pour notifier les autres composants
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("background-changed", { detail: settings }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackgroundChange = (type: BackgroundType) => {
|
||||||
|
if (type === "custom-image") {
|
||||||
|
setShowCustomInput(true);
|
||||||
|
if (customImageUrl.trim()) {
|
||||||
|
const newSettings: BackgroundSettings = {
|
||||||
|
type,
|
||||||
|
customImageUrl: customImageUrl.trim(),
|
||||||
|
};
|
||||||
|
setBackgroundSettings(newSettings);
|
||||||
|
applyBackground(newSettings);
|
||||||
|
} else {
|
||||||
|
const newSettings: BackgroundSettings = { type };
|
||||||
|
setBackgroundSettings(newSettings);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setShowCustomInput(false);
|
||||||
|
const newSettings: BackgroundSettings = { type };
|
||||||
|
setBackgroundSettings(newSettings);
|
||||||
|
applyBackground(newSettings);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomImageSubmit = () => {
|
||||||
|
if (customImageUrl.trim()) {
|
||||||
|
const newSettings: BackgroundSettings = {
|
||||||
|
type: "custom-image",
|
||||||
|
customImageUrl: customImageUrl.trim(),
|
||||||
|
};
|
||||||
|
setBackgroundSettings(newSettings);
|
||||||
|
applyBackground(newSettings);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCustomImage = () => {
|
||||||
|
setCustomImageUrl("");
|
||||||
|
setShowCustomInput(false);
|
||||||
|
const newSettings: BackgroundSettings = { type: "default" };
|
||||||
|
setBackgroundSettings(newSettings);
|
||||||
|
applyBackground(newSettings);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Appliquer le fond au chargement et quand il change
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
applyBackground(currentSettings);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentSettings.type, currentSettings.customImageUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="card-hover">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Image className="w-5 h-5" />
|
||||||
|
Fond du site
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Personnalisez l'apparence du fond de l'application
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<RadioGroup
|
||||||
|
value={currentSettings.type}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleBackgroundChange(value as BackgroundType)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
{DEFAULT_BACKGROUNDS.map((bg) => (
|
||||||
|
<label
|
||||||
|
key={bg.value}
|
||||||
|
htmlFor={`bg-${bg.value}`}
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
|
||||||
|
currentSettings.type === bg.value
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioGroupItem
|
||||||
|
value={bg.value}
|
||||||
|
id={`bg-${bg.value}`}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-full h-16 rounded-md mb-2 border border-border/50"
|
||||||
|
style={{ background: bg.preview }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium text-center">
|
||||||
|
{bg.label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<label
|
||||||
|
htmlFor="bg-custom-image"
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
|
||||||
|
currentSettings.type === "custom-image"
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioGroupItem
|
||||||
|
value="custom-image"
|
||||||
|
id="bg-custom-image"
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div className="w-full h-16 rounded-md mb-2 border border-border/50 bg-muted flex items-center justify-center">
|
||||||
|
<Image className="w-6 h-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-center">
|
||||||
|
Image personnalisée
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{showCustomInput && (
|
||||||
|
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="custom-image-url">URL de l'image</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="custom-image-url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
value={customImageUrl}
|
||||||
|
onChange={(e) => setCustomImageUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleCustomImageSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{customImageUrl && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRemoveCustomImage}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleCustomImageSubmit}
|
||||||
|
disabled={!customImageUrl.trim()}
|
||||||
|
>
|
||||||
|
Appliquer l'image
|
||||||
|
</Button>
|
||||||
|
{currentSettings.type === "custom-image" &&
|
||||||
|
currentSettings.customImageUrl && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-xs text-muted-foreground mb-2">
|
||||||
|
Aperçu :
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-full h-32 rounded-md border border-border bg-cover bg-center bg-no-repeat"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${currentSettings.customImageUrl})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,9 +59,9 @@ export function DangerZoneCard({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Card className="border-red-200">
|
<Card className="border-destructive/30 card-hover">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-red-600">
|
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||||
<Trash2 className="w-5 h-5" />
|
<Trash2 className="w-5 h-5" />
|
||||||
Zone dangereuse
|
Zone dangereuse
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ export { DangerZoneCard } from "./danger-zone-card";
|
|||||||
export { OFXInfoCard } from "./ofx-info-card";
|
export { OFXInfoCard } from "./ofx-info-card";
|
||||||
export { BackupCard } from "./backup-card";
|
export { BackupCard } from "./backup-card";
|
||||||
export { PasswordCard } from "./password-card";
|
export { PasswordCard } from "./password-card";
|
||||||
|
export { ReconcileDateRangeCard } from "./reconcile-date-range-card";
|
||||||
|
export { BackgroundCard } from "./background-card";
|
||||||
|
export { ThemeCard } from "./theme-card";
|
||||||
|
|||||||
263
components/settings/reconcile-date-range-card.tsx
Normal file
263
components/settings/reconcile-date-range-card.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
|
||||||
|
import { CheckCircle2, Calendar } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { invalidateAllTransactionQueries } from "@/lib/cache-utils";
|
||||||
|
|
||||||
|
export function ReconcileDateRangeCard() {
|
||||||
|
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||||
|
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||||
|
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
|
||||||
|
const [isReconciling, setIsReconciling] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleReconcile = async () => {
|
||||||
|
if (!endDate) return;
|
||||||
|
|
||||||
|
setIsReconciling(true);
|
||||||
|
try {
|
||||||
|
const endDateStr = format(endDate, "yyyy-MM-dd");
|
||||||
|
const body: { endDate: string; startDate?: string; reconciled: boolean } =
|
||||||
|
{
|
||||||
|
endDate: endDateStr,
|
||||||
|
reconciled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
body.startDate = format(startDate, "yyyy-MM-dd");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"/api/banking/transactions/reconcile-date-range",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || "Erreur lors du pointage");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Invalider toutes les requêtes de transactions pour rafraîchir les données
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
|
||||||
|
alert(
|
||||||
|
`${result.updatedCount} opération${result.updatedCount > 1 ? "s" : ""} pointée${result.updatedCount > 1 ? "s" : ""}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Réinitialiser les dates
|
||||||
|
setStartDate(undefined);
|
||||||
|
setEndDate(undefined);
|
||||||
|
setIsDatePickerOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Erreur lors du pointage des opérations",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsReconciling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canReconcile = endDate && (!startDate || startDate <= endDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
|
Pointer les opérations par date
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Marquer toutes les opérations pointées dans une plage de dates
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Popover open={isDatePickerOpen} onOpenChange={setIsDatePickerOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left font-normal"
|
||||||
|
>
|
||||||
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
|
{endDate ? (
|
||||||
|
<>
|
||||||
|
{startDate ? (
|
||||||
|
<>
|
||||||
|
{format(startDate, "PPP", { locale: fr })} -{" "}
|
||||||
|
{format(endDate, "PPP", { locale: fr })}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Jusqu'au {format(endDate, "PPP", { locale: fr })}</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>Sélectionner une date de fin</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<div className="p-3 flex gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Date de début{" "}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
(optionnel)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="scale-90 origin-top-left">
|
||||||
|
<CalendarComponent
|
||||||
|
mode="single"
|
||||||
|
selected={startDate}
|
||||||
|
onSelect={(date) => {
|
||||||
|
setStartDate(date);
|
||||||
|
if (date && endDate && date > endDate) {
|
||||||
|
setEndDate(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
locale={fr}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{startDate && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full text-xs"
|
||||||
|
onClick={() => setStartDate(undefined)}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Date de fin</label>
|
||||||
|
<div className="scale-90 origin-top-left">
|
||||||
|
<CalendarComponent
|
||||||
|
mode="single"
|
||||||
|
selected={endDate}
|
||||||
|
onSelect={(date) => {
|
||||||
|
if (date && startDate && date < startDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEndDate(date);
|
||||||
|
if (date && startDate) {
|
||||||
|
setIsDatePickerOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={(date) => {
|
||||||
|
if (startDate) {
|
||||||
|
return date < startDate;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
locale={fr}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{endDate && (
|
||||||
|
<div className="px-3 pb-3 text-sm text-muted-foreground">
|
||||||
|
{startDate ? (
|
||||||
|
<>
|
||||||
|
{format(startDate, "PPP", { locale: fr })} -{" "}
|
||||||
|
{format(endDate, "PPP", { locale: fr })}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Jusqu'au {format(endDate, "PPP", { locale: fr })}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={!canReconcile || isReconciling}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||||
|
Pointer les opérations
|
||||||
|
{isReconciling && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
En cours...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Pointer toutes les opérations ?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{endDate && (
|
||||||
|
<>
|
||||||
|
Cette action va marquer toutes les opérations non pointées{" "}
|
||||||
|
{startDate ? (
|
||||||
|
<>
|
||||||
|
entre {format(startDate, "PPP", { locale: fr })} et{" "}
|
||||||
|
{format(endDate, "PPP", { locale: fr })}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>jusqu'au {format(endDate, "PPP", { locale: fr })}</>
|
||||||
|
)}{" "}
|
||||||
|
comme pointées. Seules les opérations non encore pointées
|
||||||
|
seront modifiées.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isReconciling}>
|
||||||
|
Annuler
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleReconcile}
|
||||||
|
disabled={isReconciling}
|
||||||
|
className="bg-primary hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{isReconciling ? "Pointage..." : "Pointer"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
components/settings/theme-card.tsx
Normal file
93
components/settings/theme-card.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const THEMES = [
|
||||||
|
{
|
||||||
|
value: "light",
|
||||||
|
label: "Clair",
|
||||||
|
icon: Sun,
|
||||||
|
description: "Thème clair",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dark",
|
||||||
|
label: "Sombre",
|
||||||
|
icon: Moon,
|
||||||
|
description: "Thème sombre",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ThemeCard() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// Éviter le flash de contenu non stylé
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTheme = theme || "light";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="card-hover">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Moon className="w-5 h-5" />
|
||||||
|
Thème
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Choisissez le thème d'affichage de l'application
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RadioGroup
|
||||||
|
value={currentTheme}
|
||||||
|
onValueChange={(value) => setTheme(value)}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{THEMES.map((themeOption) => {
|
||||||
|
const Icon = themeOption.icon;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={themeOption.value}
|
||||||
|
htmlFor={`theme-${themeOption.value}`}
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
|
||||||
|
currentTheme === themeOption.value
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioGroupItem
|
||||||
|
value={themeOption.value}
|
||||||
|
id={`theme-${themeOption.value}`}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<Icon className="w-6 h-6 mb-2" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{themeOption.label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ export function BalanceLineChart({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="card-hover">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle>Évolution du solde</CardTitle>
|
<CardTitle>Évolution du solde</CardTitle>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
@@ -103,11 +103,38 @@ export function BalanceLineChart({
|
|||||||
tick={{ fill: "var(--muted-foreground)" }}
|
tick={{ fill: "var(--muted-foreground)" }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number) => formatCurrency(value)}
|
content={({ active, payload }) => {
|
||||||
contentStyle={{
|
if (!active || !payload?.length) return null;
|
||||||
backgroundColor: "var(--card)",
|
return (
|
||||||
|
<div
|
||||||
|
className="px-3 py-2 rounded-lg shadow-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--popover)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "8px",
|
opacity: 1,
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{payload.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={`tooltip-${index}`}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
style={{ color: entry.color }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{entry.name}:
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{formatCurrency(entry.value as number)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{mode === "aggregated" ? (
|
{mode === "aggregated" ? (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
import {
|
import {
|
||||||
@@ -29,8 +30,58 @@ export function CategoryBarChart({
|
|||||||
}: CategoryBarChartProps) {
|
}: CategoryBarChartProps) {
|
||||||
const displayData = data.slice(0, maxItems).reverse(); // Reverse pour avoir le plus grand en haut
|
const displayData = data.slice(0, maxItems).reverse(); // Reverse pour avoir le plus grand en haut
|
||||||
|
|
||||||
|
// Custom tick component for clickable labels
|
||||||
|
const CustomYAxisTick = ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
payload: { value: string };
|
||||||
|
}) => {
|
||||||
|
const categoryName = payload.value;
|
||||||
|
const item = displayData.find((d) => d.name === categoryName);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<text
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
fill="var(--muted-foreground)"
|
||||||
|
fontSize={12}
|
||||||
|
textAnchor="end"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
>
|
||||||
|
{categoryName}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const href =
|
||||||
|
item.categoryId === null || item.categoryId === undefined
|
||||||
|
? "/transactions?includeUncategorized=true"
|
||||||
|
: `/transactions?categoryIds=${item.categoryId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href}>
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
fill="var(--muted-foreground)"
|
||||||
|
fontSize={12}
|
||||||
|
className="hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
|
textAnchor="end"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
>
|
||||||
|
{categoryName}
|
||||||
|
</text>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="card-hover">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -60,11 +111,7 @@ export function CategoryBarChart({
|
|||||||
dataKey="name"
|
dataKey="name"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
width={90}
|
width={90}
|
||||||
tick={{ fill: "var(--muted-foreground)" }}
|
tick={CustomYAxisTick}
|
||||||
tickFormatter={(value) => {
|
|
||||||
const item = displayData.find((d) => d.name === value);
|
|
||||||
return item ? value : "";
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={({ active, payload }) => {
|
content={({ active, payload }) => {
|
||||||
@@ -115,3 +162,5 @@ export function CategoryBarChart({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find the Card component in this file
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
@@ -13,7 +14,8 @@ export interface CategoryChartData {
|
|||||||
value: number;
|
value: number;
|
||||||
color: string;
|
color: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
[key: string]: string | number;
|
categoryId?: string | null; // null for "Non catégorisé"
|
||||||
|
[key: string]: string | number | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CategoryPieChartProps {
|
interface CategoryPieChartProps {
|
||||||
@@ -49,7 +51,7 @@ export function CategoryPieChart({
|
|||||||
const currentData = isExpanded ? baseData : baseData.slice(0, maxItems);
|
const currentData = isExpanded ? baseData : baseData.slice(0, maxItems);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="card-hover">
|
||||||
<CardHeader className="flex flex-col md:flex-row md:items-center md:justify-between space-y-2 md:space-y-0 pb-2">
|
<CardHeader className="flex flex-col md:flex-row md:items-center md:justify-between space-y-2 md:space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm md:text-base">{title}</CardTitle>
|
<CardTitle className="text-sm md:text-base">{title}</CardTitle>
|
||||||
<div className="flex flex-col md:flex-row gap-2 w-full md:w-auto">
|
<div className="flex flex-col md:flex-row gap-2 w-full md:w-auto">
|
||||||
@@ -162,10 +164,17 @@ export function CategoryPieChart({
|
|||||||
Légende
|
Légende
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden pr-2 space-y-2">
|
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden pr-2 space-y-2">
|
||||||
{currentData.map((item, index) => (
|
{currentData.map((item, index) => {
|
||||||
<div
|
const href =
|
||||||
|
item.categoryId === null || item.categoryId === undefined
|
||||||
|
? "/transactions?includeUncategorized=true"
|
||||||
|
: `/transactions?categoryIds=${item.categoryId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
key={`legend-${index}`}
|
key={`legend-${index}`}
|
||||||
className="flex items-center gap-2 text-sm"
|
href={href}
|
||||||
|
className="flex items-center gap-2 text-sm hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
@@ -178,8 +187,9 @@ export function CategoryPieChart({
|
|||||||
<span className="text-foreground font-semibold tabular-nums whitespace-nowrap">
|
<span className="text-foreground font-semibold tabular-nums whitespace-nowrap">
|
||||||
{formatCurrency(item.value)}
|
{formatCurrency(item.value)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function CategoryTrendChart({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="card-hover">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle>Évolution des dépenses par catégorie</CardTitle>
|
<CardTitle>Évolution des dépenses par catégorie</CardTitle>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -166,11 +166,67 @@ export function CategoryTrendChart({
|
|||||||
tick={{ fill: "var(--muted-foreground)" }}
|
tick={{ fill: "var(--muted-foreground)" }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number) => formatCurrency(value)}
|
content={({ active, payload, label }) => {
|
||||||
contentStyle={{
|
if (!active || !payload?.length) return null;
|
||||||
backgroundColor: "var(--card)",
|
|
||||||
|
// Filtrer seulement les catégories qui ont une valeur
|
||||||
|
const entriesWithValue = payload.filter(
|
||||||
|
(entry) => entry.value !== undefined && entry.value !== null && Number(entry.value) !== 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entriesWithValue.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="px-2.5 py-2 rounded-lg shadow-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--background)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "8px",
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-foreground mb-1.5 pb-1 border-b border-border/50">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{entriesWithValue.map((entry, index) => {
|
||||||
|
const categoryId = entry.dataKey as string;
|
||||||
|
const categoryInfo = getCategoryInfo(categoryId);
|
||||||
|
const categoryName = getCategoryName(categoryId);
|
||||||
|
const value = entry.value as number;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`tooltip-${categoryId}-${index}`}
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{categoryInfo ? (
|
||||||
|
<CategoryIcon
|
||||||
|
icon={categoryInfo.icon}
|
||||||
|
color={categoryInfo.color}
|
||||||
|
size={12}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: "#94a3b8" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-foreground flex-1 min-w-0 truncate">
|
||||||
|
{categoryName}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-foreground tabular-nums whitespace-nowrap">
|
||||||
|
{formatCurrency(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
wrapperStyle={{
|
||||||
|
opacity: 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Legend
|
<Legend
|
||||||
@@ -179,7 +235,8 @@ export function CategoryTrendChart({
|
|||||||
const allCategoryIds = Array.from(categoryTotals.keys());
|
const allCategoryIds = Array.from(categoryTotals.keys());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 mt-2">
|
<div className="max-h-[60px] overflow-y-auto overflow-x-hidden pr-2 mt-2">
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
|
||||||
{allCategoryIds.map((categoryId) => {
|
{allCategoryIds.map((categoryId) => {
|
||||||
const categoryInfo = getCategoryInfo(categoryId);
|
const categoryInfo = getCategoryInfo(categoryId);
|
||||||
const categoryName = getCategoryName(categoryId);
|
const categoryName = getCategoryName(categoryId);
|
||||||
@@ -234,6 +291,7 @@ export function CategoryTrendChart({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -259,6 +317,7 @@ export function CategoryTrendChart({
|
|||||||
strokeWidth={isSelected ? 2 : 1}
|
strokeWidth={isSelected ? 2 : 1}
|
||||||
strokeOpacity={isSelected ? 1 : 0.3}
|
strokeOpacity={isSelected ? 1 : 0.3}
|
||||||
dot={false}
|
dot={false}
|
||||||
|
connectNulls={true}
|
||||||
hide={!isSelected && selectedCategories.length > 0}
|
hide={!isSelected && selectedCategories.length > 0}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function IncomeExpenseTrendChart({
|
|||||||
formatCurrency,
|
formatCurrency,
|
||||||
}: IncomeExpenseTrendChartProps) {
|
}: IncomeExpenseTrendChartProps) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="card-hover">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Tendances revenus et dépenses</CardTitle>
|
<CardTitle>Tendances revenus et dépenses</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
BarChart,
|
LineChart,
|
||||||
Bar,
|
Line,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
@@ -11,6 +14,11 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Legend,
|
Legend,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
|
||||||
interface MonthlyChartData {
|
interface MonthlyChartData {
|
||||||
month: string;
|
month: string;
|
||||||
@@ -22,30 +30,67 @@ interface MonthlyChartData {
|
|||||||
interface MonthlyChartProps {
|
interface MonthlyChartProps {
|
||||||
data: MonthlyChartData[];
|
data: MonthlyChartData[];
|
||||||
formatCurrency: (amount: number) => string;
|
formatCurrency: (amount: number) => string;
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
showDots?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonthlyChart({ data, formatCurrency }: MonthlyChartProps) {
|
export function MonthlyChart({
|
||||||
return (
|
data,
|
||||||
<Card>
|
formatCurrency,
|
||||||
<CardHeader>
|
collapsible = false,
|
||||||
<CardTitle>Revenus vs Dépenses par mois</CardTitle>
|
defaultExpanded = true,
|
||||||
</CardHeader>
|
showDots = true,
|
||||||
<CardContent>
|
}: MonthlyChartProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
// Calculer l'intervalle dynamiquement selon le nombre de données
|
||||||
|
const getXAxisInterval = () => {
|
||||||
|
if (data.length <= 6) return 0; // Afficher tous les labels pour 6 mois ou moins
|
||||||
|
if (data.length <= 12) return 1; // Afficher un label sur deux pour 7-12 mois
|
||||||
|
return Math.floor(data.length / 12); // Pour plus de 12 mois, afficher environ 12 labels
|
||||||
|
};
|
||||||
|
|
||||||
|
// Formater les labels de manière plus compacte
|
||||||
|
const formatMonthLabel = (month: string) => {
|
||||||
|
// Format: "janv. 24" -> "janv 24" (enlever le point)
|
||||||
|
return month.replace(".", "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartContent = (
|
||||||
|
<>
|
||||||
{data.length > 0 ? (
|
{data.length > 0 ? (
|
||||||
<div className="h-[300px]">
|
<div className="h-[400px] sm:h-[300px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={data}>
|
<LineChart
|
||||||
|
data={data}
|
||||||
|
margin={{
|
||||||
|
left: 0,
|
||||||
|
right: 10,
|
||||||
|
top: 10,
|
||||||
|
bottom: data.length > 6 ? 80 : 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
<XAxis dataKey="month" className="text-xs" />
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
className="text-xs"
|
||||||
|
angle={data.length > 6 ? -45 : 0}
|
||||||
|
textAnchor={data.length > 6 ? "end" : "middle"}
|
||||||
|
height={data.length > 6 ? 80 : 60}
|
||||||
|
interval={getXAxisInterval()}
|
||||||
|
tickFormatter={formatMonthLabel}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
width={80}
|
width={60}
|
||||||
tickFormatter={(v) => {
|
tickFormatter={(v) => {
|
||||||
// Format compact pour les grandes valeurs
|
// Format compact pour les grandes valeurs
|
||||||
if (Math.abs(v) >= 1000) {
|
if (Math.abs(v) >= 1000) {
|
||||||
return `${(v / 1000).toFixed(1)}k€`;
|
return `${(v / 1000).toFixed(1)}k€`;
|
||||||
}
|
}
|
||||||
return `${Math.round(v)}€`;
|
return `${v.toFixed(0)}€`;
|
||||||
}}
|
}}
|
||||||
tick={{ fill: "var(--muted-foreground)" }}
|
tick={{ fill: "var(--muted-foreground)" }}
|
||||||
/>
|
/>
|
||||||
@@ -59,17 +104,73 @@ export function MonthlyChart({ data, formatCurrency }: MonthlyChartProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
<Bar dataKey="revenus" fill="#22c55e" radius={[4, 4, 0, 0]} />
|
<Line
|
||||||
<Bar dataKey="depenses" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
type="monotone"
|
||||||
</BarChart>
|
dataKey="revenus"
|
||||||
|
name="Revenus"
|
||||||
|
stroke="#22c55e"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={showDots ? { fill: "#22c55e", r: 4 } : false}
|
||||||
|
activeDot={showDots ? { r: 6 } : false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="depenses"
|
||||||
|
name="Dépenses"
|
||||||
|
stroke="#ef4444"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={showDots ? { fill: "#ef4444", r: 4 } : false}
|
||||||
|
activeDot={showDots ? { r: 6 } : false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
<div className="h-[400px] sm:h-[300px] flex items-center justify-center text-muted-foreground">
|
||||||
Pas de données pour cette période
|
Pas de données pour cette période
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!collapsible) {
|
||||||
|
return (
|
||||||
|
<Card className="card-hover">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Revenus vs Dépenses par mois</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>{chartContent}</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="card-hover">
|
||||||
|
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 py-3 px-6">
|
||||||
|
<CardTitle className="text-base font-semibold">
|
||||||
|
Revenus vs Dépenses par mois
|
||||||
|
</CardTitle>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8">
|
||||||
|
{isExpanded ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="w-4 h-4 mr-1" />
|
||||||
|
Réduire
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="w-4 h-4 mr-1" />
|
||||||
|
Afficher
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</CardHeader>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<CardContent className="pt-0">{chartContent}</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,18 +31,18 @@ export function SavingsTrendChart({
|
|||||||
const isPositive = latestSavings >= 0;
|
const isPositive = latestSavings >= 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="card-hover">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle>Évolution des économies</CardTitle>
|
<CardTitle>Évolution des économies</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isPositive ? (
|
{isPositive ? (
|
||||||
<TrendingUp className="w-4 h-4 text-emerald-600" />
|
<TrendingUp className="w-4 h-4 text-success" />
|
||||||
) : (
|
) : (
|
||||||
<TrendingDown className="w-4 h-4 text-red-600" />
|
<TrendingDown className="w-4 h-4 text-destructive" />
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={`text-sm font-semibold ${
|
className={`text-sm font-semibold ${
|
||||||
isPositive ? "text-emerald-600" : "text-red-600"
|
isPositive ? "text-success" : "text-destructive"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{formatCurrency(latestSavings)}
|
{formatCurrency(latestSavings)}
|
||||||
|
|||||||
@@ -21,59 +21,86 @@ export function StatsSummaryCards({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 md:gap-4 grid-cols-2 md:grid-cols-4">
|
<div className="grid gap-3 md:gap-4 grid-cols-2 md:grid-cols-4">
|
||||||
<Card>
|
<Card className="stat-card-textured relative overflow-hidden">
|
||||||
<CardHeader className="pb-2">
|
{/* Icône en arrière-plan */}
|
||||||
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<TrendingUp className="w-3 h-3 md:w-4 md:h-4 text-emerald-600" />
|
<TrendingUp
|
||||||
|
className="h-16 w-16 md:h-20 md:w-20 text-success"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CardHeader className="pb-3 px-5 pt-5 relative z-10">
|
||||||
|
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||||
Total Revenus
|
Total Revenus
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="px-5 pb-5 pt-0 relative z-10">
|
||||||
<div className="text-lg md:text-2xl font-bold text-emerald-600">
|
<div className="text-xl md:text-2xl font-black text-success tracking-tight">
|
||||||
{formatCurrency(totalIncome)}
|
{formatCurrency(totalIncome)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card className="stat-card-textured relative overflow-hidden">
|
||||||
<CardHeader className="pb-2">
|
{/* Icône en arrière-plan */}
|
||||||
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<TrendingDown className="w-3 h-3 md:w-4 md:h-4 text-red-600" />
|
<TrendingDown
|
||||||
|
className="h-16 w-16 md:h-20 md:w-20 text-destructive"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CardHeader className="pb-3 px-5 pt-5 relative z-10">
|
||||||
|
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||||
Total Dépenses
|
Total Dépenses
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="px-5 pb-5 pt-0 relative z-10">
|
||||||
<div className="text-lg md:text-2xl font-bold text-red-600">
|
<div className="text-xl md:text-2xl font-black text-destructive tracking-tight">
|
||||||
{formatCurrency(totalExpenses)}
|
{formatCurrency(totalExpenses)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card className="stat-card-textured relative overflow-hidden">
|
||||||
<CardHeader className="pb-2">
|
{/* Icône en arrière-plan */}
|
||||||
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground flex items-center gap-1.5 md:gap-2">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<ArrowRight className="w-3 h-3 md:w-4 md:h-4" />
|
<ArrowRight
|
||||||
|
className="h-16 w-16 md:h-20 md:w-20 text-muted-foreground/40"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CardHeader className="pb-3 px-5 pt-5 relative z-10">
|
||||||
|
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||||
Moyenne mensuelle
|
Moyenne mensuelle
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="px-5 pb-5 pt-0 relative z-10">
|
||||||
<div className="text-lg md:text-2xl font-bold">
|
<div className="text-xl md:text-2xl font-black tracking-tight">
|
||||||
{formatCurrency(avgMonthlyExpenses)}
|
{formatCurrency(avgMonthlyExpenses)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card className="stat-card-textured relative overflow-hidden">
|
||||||
<CardHeader className="pb-2">
|
{/* Icône en arrière-plan */}
|
||||||
<CardTitle className="text-xs md:text-sm font-medium text-muted-foreground">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-16 w-16 md:h-20 md:w-20 rounded-full border-2",
|
||||||
|
savings >= 0 ? "border-success" : "border-destructive",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CardHeader className="pb-3 px-5 pt-5 relative z-10">
|
||||||
|
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||||
Économies
|
Économies
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="px-5 pb-5 pt-0 relative z-10">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg md:text-2xl font-bold",
|
"text-xl md:text-2xl font-black tracking-tight",
|
||||||
savings >= 0 ? "text-emerald-600" : "text-red-600",
|
savings >= 0 ? "text-success" : "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(savings)}
|
{formatCurrency(savings)}
|
||||||
|
|||||||
@@ -1,42 +1,324 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import { List, ListOrdered } from "lucide-react";
|
||||||
import type { Transaction, Category } from "@/lib/types";
|
import type { Transaction, Category } from "@/lib/types";
|
||||||
|
|
||||||
interface TopExpensesListProps {
|
interface TopExpensesListProps {
|
||||||
|
expensesByCategory: Array<{
|
||||||
|
categoryId: string | null;
|
||||||
expenses: Transaction[];
|
expenses: Transaction[];
|
||||||
|
}>;
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
formatCurrency: (amount: number) => string;
|
formatCurrency: (amount: number) => string;
|
||||||
|
allTransactions?: Transaction[]; // Toutes les transactions filtrées pour calculer toutes les dépenses
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopExpensesList({
|
export function TopExpensesList({
|
||||||
expenses,
|
expensesByCategory,
|
||||||
categories,
|
categories,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
|
allTransactions = [],
|
||||||
}: TopExpensesListProps) {
|
}: TopExpensesListProps) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const [showAllExpenses, setShowAllExpenses] = useState(false);
|
||||||
|
|
||||||
|
// Filtrer les catégories qui ont des dépenses
|
||||||
|
const categoriesWithExpenses = expensesByCategory.filter(
|
||||||
|
(group) => group.expenses.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasExpenses = categoriesWithExpenses.length > 0;
|
||||||
|
|
||||||
|
// Déterminer la valeur par défaut du premier onglet
|
||||||
|
const defaultTabValue =
|
||||||
|
categoriesWithExpenses.length > 0
|
||||||
|
? categoriesWithExpenses[0].categoryId || "uncategorized"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(() => defaultTabValue);
|
||||||
|
|
||||||
|
// Mettre à jour activeTab quand defaultTabValue change ou si activeTab est invalide
|
||||||
|
useEffect(() => {
|
||||||
|
if (!defaultTabValue) return;
|
||||||
|
|
||||||
|
// Vérifier si activeTab correspond à une catégorie valide
|
||||||
|
const isValidTab = categoriesWithExpenses.some(
|
||||||
|
(group) => (group.categoryId || "uncategorized") === activeTab,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si activeTab est vide ou invalide, utiliser defaultTabValue
|
||||||
|
if (!activeTab || !isValidTab) {
|
||||||
|
setActiveTab(defaultTabValue);
|
||||||
|
}
|
||||||
|
}, [defaultTabValue, categoriesWithExpenses, activeTab]);
|
||||||
|
|
||||||
|
// Calculer les données du graphique pour la catégorie active
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!hasExpenses) return [];
|
||||||
|
|
||||||
|
// Utiliser activeTab ou defaultTabValue comme fallback
|
||||||
|
const currentTab = activeTab || defaultTabValue;
|
||||||
|
if (!currentTab) return [];
|
||||||
|
|
||||||
|
const activeCategoryGroup = categoriesWithExpenses.find(
|
||||||
|
(group) => (group.categoryId || "uncategorized") === currentTab,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!activeCategoryGroup) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si showAllExpenses est activé et qu'on a toutes les transactions, utiliser toutes les dépenses de la catégorie
|
||||||
|
let expenses: Transaction[];
|
||||||
|
if (showAllExpenses && allTransactions.length > 0) {
|
||||||
|
// Filtrer toutes les transactions pour obtenir toutes les dépenses de cette catégorie parente
|
||||||
|
const categoryId = activeCategoryGroup.categoryId;
|
||||||
|
expenses = allTransactions
|
||||||
|
.filter((t) => t.amount < 0) // Seulement les dépenses
|
||||||
|
.filter((t) => {
|
||||||
|
if (categoryId === null) {
|
||||||
|
return !t.categoryId;
|
||||||
|
}
|
||||||
|
// Vérifier si la transaction appartient à cette catégorie parente ou ses sous-catégories
|
||||||
|
const category = categories.find((c) => c.id === t.categoryId);
|
||||||
|
if (!category) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const transactionGroupId = category.parentId || category.id;
|
||||||
|
return transactionGroupId === categoryId;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Utiliser seulement les top 10
|
||||||
|
expenses = activeCategoryGroup.expenses;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expenses.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grouper les dépenses par période
|
||||||
|
// Si moins de 30 jours, groupe par jour
|
||||||
|
// Si moins de 6 mois, groupe par semaine
|
||||||
|
// Sinon groupe par mois
|
||||||
|
const sortedExpenses = [...expenses].sort(
|
||||||
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sortedExpenses.length === 0) return [];
|
||||||
|
|
||||||
|
const firstDate = new Date(sortedExpenses[0].date);
|
||||||
|
const lastDate = new Date(
|
||||||
|
sortedExpenses[sortedExpenses.length - 1].date,
|
||||||
|
);
|
||||||
|
const daysDiff =
|
||||||
|
(lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
let groupBy: "day" | "week" | "month";
|
||||||
|
if (daysDiff <= 30) {
|
||||||
|
groupBy = "day";
|
||||||
|
} else if (daysDiff <= 180) {
|
||||||
|
groupBy = "week";
|
||||||
|
} else {
|
||||||
|
groupBy = "month";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction helper pour obtenir la clé de période d'une date
|
||||||
|
const getPeriodKey = (date: Date): { key: string; dateKey: string } => {
|
||||||
|
if (groupBy === "day") {
|
||||||
|
return {
|
||||||
|
key: date.toLocaleDateString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
}),
|
||||||
|
dateKey: date.toISOString().substring(0, 10), // YYYY-MM-DD
|
||||||
|
};
|
||||||
|
} else if (groupBy === "week") {
|
||||||
|
// Semaine commençant le lundi
|
||||||
|
const weekStart = new Date(date);
|
||||||
|
const day = weekStart.getDay();
|
||||||
|
const diff = weekStart.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
|
weekStart.setDate(diff);
|
||||||
|
return {
|
||||||
|
key: `Sem. ${weekStart.toLocaleDateString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
})}`,
|
||||||
|
dateKey: weekStart.toISOString().substring(0, 10), // YYYY-MM-DD
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
key: date.toLocaleDateString("fr-FR", {
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
}),
|
||||||
|
dateKey: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`, // YYYY-MM
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Grouper les dépenses par période
|
||||||
|
const groupedData = new Map<
|
||||||
|
string,
|
||||||
|
{ total: number; dateKey: string }
|
||||||
|
>();
|
||||||
|
|
||||||
|
sortedExpenses.forEach((expense) => {
|
||||||
|
const date = new Date(expense.date);
|
||||||
|
const { key, dateKey } = getPeriodKey(date);
|
||||||
|
|
||||||
|
const current = groupedData.get(key);
|
||||||
|
if (current) {
|
||||||
|
current.total += Math.abs(expense.amount);
|
||||||
|
} else {
|
||||||
|
groupedData.set(key, {
|
||||||
|
total: Math.abs(expense.amount),
|
||||||
|
dateKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Générer toutes les périodes entre la première et la dernière date
|
||||||
|
const allPeriodsMap = new Map<
|
||||||
|
string,
|
||||||
|
{ period: string; montant: number; dateKey: string }
|
||||||
|
>();
|
||||||
|
const currentDate = new Date(firstDate);
|
||||||
|
const endDate = new Date(lastDate);
|
||||||
|
|
||||||
|
// Normaliser les dates selon le type de groupement
|
||||||
|
if (groupBy === "day") {
|
||||||
|
currentDate.setHours(0, 0, 0, 0);
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
} else if (groupBy === "week") {
|
||||||
|
// Début de la semaine de la première date
|
||||||
|
const day = currentDate.getDay();
|
||||||
|
const diff = currentDate.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
|
currentDate.setDate(diff);
|
||||||
|
currentDate.setHours(0, 0, 0, 0);
|
||||||
|
// Début de la semaine de la dernière date (pour la boucle)
|
||||||
|
const lastDay = endDate.getDay();
|
||||||
|
const lastDiff = endDate.getDate() - lastDay + (lastDay === 0 ? -6 : 1);
|
||||||
|
endDate.setDate(lastDiff);
|
||||||
|
endDate.setHours(0, 0, 0, 0);
|
||||||
|
} else {
|
||||||
|
// Mois : premier jour du mois
|
||||||
|
currentDate.setDate(1);
|
||||||
|
currentDate.setHours(0, 0, 0, 0);
|
||||||
|
endDate.setDate(1);
|
||||||
|
endDate.setHours(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (currentDate <= endDate) {
|
||||||
|
const { key, dateKey } = getPeriodKey(currentDate);
|
||||||
|
const existingData = groupedData.get(key);
|
||||||
|
|
||||||
|
// Utiliser dateKey comme clé unique pour éviter les doublons
|
||||||
|
if (!allPeriodsMap.has(dateKey)) {
|
||||||
|
allPeriodsMap.set(dateKey, {
|
||||||
|
period: key,
|
||||||
|
montant: existingData ? Math.round(existingData.total) : 0,
|
||||||
|
dateKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passer à la période suivante
|
||||||
|
if (groupBy === "day") {
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
} else if (groupBy === "week") {
|
||||||
|
currentDate.setDate(currentDate.getDate() + 7);
|
||||||
|
} else {
|
||||||
|
// Mois suivant
|
||||||
|
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(allPeriodsMap.values()) .sort((a, b) =>
|
||||||
|
a.dateKey.localeCompare(b.dateKey),
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
hasExpenses,
|
||||||
|
categoriesWithExpenses,
|
||||||
|
activeTab,
|
||||||
|
defaultTabValue,
|
||||||
|
showAllExpenses,
|
||||||
|
allTransactions,
|
||||||
|
categories,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="card-hover">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm md:text-base">Top 5 dépenses</CardTitle>
|
<CardTitle className="text-sm md:text-base">
|
||||||
|
Top 10 dépenses par top 5 catégories parentes
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{expenses.length > 0 ? (
|
{hasExpenses ? (
|
||||||
<div className="space-y-3 md:space-y-4">
|
<Tabs
|
||||||
{expenses.map((expense, index) => {
|
defaultValue={defaultTabValue}
|
||||||
const category = categories.find(
|
value={activeTab || defaultTabValue}
|
||||||
(c) => c.id === expense.categoryId,
|
onValueChange={setActiveTab}
|
||||||
);
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="w-full flex-wrap h-auto p-1 mb-4">
|
||||||
|
{categoriesWithExpenses.map(({ categoryId, expenses }) => {
|
||||||
|
const category = categoryId
|
||||||
|
? categories.find((c) => c.id === categoryId)
|
||||||
|
: null;
|
||||||
|
const tabValue = categoryId || "uncategorized";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tabValue}
|
||||||
|
value={tabValue}
|
||||||
|
className="flex items-center gap-1.5 md:gap-2 text-xs md:text-sm"
|
||||||
|
>
|
||||||
|
{category && (
|
||||||
|
<CategoryIcon
|
||||||
|
icon={category.icon}
|
||||||
|
color={category.color}
|
||||||
|
size={isMobile ? 12 : 14}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="truncate max-w-[100px] md:max-w-none">
|
||||||
|
{category?.name || "Non catégorisé"}
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TabsList>
|
||||||
|
{categoriesWithExpenses.map(({ categoryId, expenses }) => {
|
||||||
|
const category = categoryId
|
||||||
|
? categories.find((c) => c.id === categoryId)
|
||||||
|
: null;
|
||||||
|
const tabValue = categoryId || "uncategorized";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContent key={tabValue} value={tabValue}>
|
||||||
|
<div className="space-y-2 md:space-y-3">
|
||||||
|
{expenses.map((expense, index) => (
|
||||||
<div
|
<div
|
||||||
key={expense.id}
|
key={expense.id}
|
||||||
className="flex items-start gap-2 md:gap-3"
|
className="flex items-start gap-2 md:gap-3"
|
||||||
>
|
>
|
||||||
<div className="w-6 h-6 md:w-8 md:h-8 rounded-full bg-muted flex items-center justify-center text-xs md:text-sm font-semibold shrink-0">
|
<div className="w-5 h-5 md:w-6 md:h-6 rounded-full bg-muted flex items-center justify-center text-[10px] md:text-xs font-semibold shrink-0">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -44,40 +326,143 @@ export function TopExpensesList({
|
|||||||
<p className="font-medium text-xs md:text-sm truncate flex-1">
|
<p className="font-medium text-xs md:text-sm truncate flex-1">
|
||||||
{expense.description}
|
{expense.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="text-red-600 font-semibold tabular-nums text-xs md:text-sm shrink-0">
|
<div className="text-destructive font-semibold tabular-nums text-xs md:text-sm shrink-0">
|
||||||
{formatCurrency(expense.amount)}
|
{formatCurrency(expense.amount)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
|
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
|
||||||
<span className="text-[10px] md:text-xs text-muted-foreground">
|
<span className="text-[10px] md:text-xs text-muted-foreground">
|
||||||
{new Date(expense.date).toLocaleDateString("fr-FR")}
|
{new Date(expense.date).toLocaleDateString(
|
||||||
|
"fr-FR",
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{category && (
|
{expense.categoryId &&
|
||||||
|
(() => {
|
||||||
|
const expenseCategory = categories.find(
|
||||||
|
(c) => c.id === expense.categoryId,
|
||||||
|
);
|
||||||
|
// Afficher seulement si c'est une sous-catégorie (a un parentId)
|
||||||
|
if (expenseCategory?.parentId) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/transactions?categoryIds=${expenseCategory.id}`}
|
||||||
|
className="inline-block"
|
||||||
|
>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-[10px] md:text-xs px-1.5 md:px-2 py-0.5 inline-flex items-center gap-1 shrink-0"
|
className="text-[10px] md:text-xs px-1.5 md:px-2 py-0.5 inline-flex items-center gap-1 shrink-0 hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${category.color}20`,
|
backgroundColor: `${expenseCategory.color}20`,
|
||||||
color: category.color,
|
color: expenseCategory.color,
|
||||||
borderColor: `${category.color}30`,
|
borderColor: `${expenseCategory.color}30`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
icon={category.icon}
|
icon={expenseCategory.icon}
|
||||||
color={category.color}
|
color={expenseCategory.color}
|
||||||
size={isMobile ? 8 : 10}
|
size={isMobile ? 8 : 10}
|
||||||
/>
|
/>
|
||||||
<span className="truncate max-w-[120px] md:max-w-none">
|
<span className="truncate max-w-[100px] md:max-w-none">
|
||||||
{category.name}
|
{expenseCategory.name}
|
||||||
</span>
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Graphique d'évolution temporelle */}
|
||||||
|
{chartData.length > 0 && (() => {
|
||||||
|
const currentTab = activeTab || defaultTabValue;
|
||||||
|
const activeCategoryGroup = categoriesWithExpenses.find(
|
||||||
|
(group) => (group.categoryId || "uncategorized") === currentTab,
|
||||||
|
);
|
||||||
|
const activeCategory = activeCategoryGroup?.categoryId
|
||||||
|
? categories.find((c) => c.id === activeCategoryGroup.categoryId)
|
||||||
|
: null;
|
||||||
|
const barColor = activeCategory?.color || "var(--destructive)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 pt-6 border-t border-border">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-semibold">
|
||||||
|
Évolution des dépenses dans le temps
|
||||||
|
</h3>
|
||||||
|
{allTransactions.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAllExpenses(!showAllExpenses)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{showAllExpenses ? (
|
||||||
|
<>
|
||||||
|
<ListOrdered className="w-3 h-3 mr-1.5" />
|
||||||
|
Top 10 seulement
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<List className="w-3 h-3 mr-1.5" />
|
||||||
|
Voir toutes les dépenses
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="h-[250px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
className="stroke-muted"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="period"
|
||||||
|
className="text-xs"
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={60}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
className="text-xs"
|
||||||
|
width={80}
|
||||||
|
tickFormatter={(v) => {
|
||||||
|
if (Math.abs(v) >= 1000) {
|
||||||
|
return `${(v / 1000).toFixed(1)}k€`;
|
||||||
|
}
|
||||||
|
return `${Math.round(v)}€`;
|
||||||
|
}}
|
||||||
|
tick={{ fill: "var(--muted-foreground)" }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => formatCurrency(value)}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="montant"
|
||||||
|
fill={barColor}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})()}
|
||||||
</div>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-xs md:text-sm">
|
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-xs md:text-sm">
|
||||||
Pas de dépenses pour cette période
|
Pas de dépenses pour cette période
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function YearOverYearChart({
|
|||||||
previousYearLabel = "Année précédente",
|
previousYearLabel = "Année précédente",
|
||||||
}: YearOverYearChartProps) {
|
}: YearOverYearChartProps) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="card-hover">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Comparaison année sur année</CardTitle>
|
<CardTitle>Comparaison année sur année</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export { TransactionFilters } from "./transaction-filters";
|
export { TransactionFilters } from "./transaction-filters";
|
||||||
export { TransactionBulkActions } from "./transaction-bulk-actions";
|
export { TransactionBulkActions } from "./transaction-bulk-actions";
|
||||||
export { TransactionTable } from "./transaction-table";
|
export { TransactionTable } from "./transaction-table";
|
||||||
|
export { TransactionPagination } from "./transaction-pagination";
|
||||||
|
export { formatCurrency, formatDate } from "./transaction-utils";
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { Search, X, Filter, Wallet, Calendar } from "lucide-react";
|
import { Search, X, Filter, Wallet, Calendar, Copy } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import type { Account, Category, Folder, Transaction } from "@/lib/types";
|
import type { Account, Category, Folder, Transaction } from "@/lib/types";
|
||||||
|
|
||||||
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
||||||
@@ -53,6 +55,8 @@ interface TransactionFiltersProps {
|
|||||||
onCustomEndDateChange: (date: Date | undefined) => void;
|
onCustomEndDateChange: (date: Date | undefined) => void;
|
||||||
isCustomDatePickerOpen: boolean;
|
isCustomDatePickerOpen: boolean;
|
||||||
onCustomDatePickerOpenChange: (open: boolean) => void;
|
onCustomDatePickerOpenChange: (open: boolean) => void;
|
||||||
|
showDuplicates: boolean;
|
||||||
|
onShowDuplicatesChange: (show: boolean) => void;
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
@@ -77,6 +81,8 @@ export function TransactionFilters({
|
|||||||
onCustomEndDateChange,
|
onCustomEndDateChange,
|
||||||
isCustomDatePickerOpen,
|
isCustomDatePickerOpen,
|
||||||
onCustomDatePickerOpenChange,
|
onCustomDatePickerOpenChange,
|
||||||
|
showDuplicates,
|
||||||
|
onShowDuplicatesChange,
|
||||||
accounts,
|
accounts,
|
||||||
folders,
|
folders,
|
||||||
categories,
|
categories,
|
||||||
@@ -153,6 +159,23 @@ export function TransactionFilters({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-border bg-card">
|
||||||
|
<Checkbox
|
||||||
|
id="show-duplicates"
|
||||||
|
checked={showDuplicates}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onShowDuplicatesChange(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="show-duplicates"
|
||||||
|
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 text-muted-foreground" />
|
||||||
|
Afficher les doublons
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{period === "custom" && (
|
{period === "custom" && (
|
||||||
<Popover
|
<Popover
|
||||||
open={isCustomDatePickerOpen}
|
open={isCustomDatePickerOpen}
|
||||||
@@ -179,9 +202,10 @@ export function TransactionFilters({
|
|||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-3 flex gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Date de début</label>
|
<label className="text-sm font-medium">Date de début</label>
|
||||||
|
<div className="scale-90 origin-top-left">
|
||||||
<CalendarComponent
|
<CalendarComponent
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={customStartDate}
|
selected={customStartDate}
|
||||||
@@ -194,8 +218,10 @@ export function TransactionFilters({
|
|||||||
locale={fr}
|
locale={fr}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Date de fin</label>
|
<label className="text-sm font-medium">Date de fin</label>
|
||||||
|
<div className="scale-90 origin-top-left">
|
||||||
<CalendarComponent
|
<CalendarComponent
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={customEndDate}
|
selected={customEndDate}
|
||||||
@@ -215,8 +241,10 @@ export function TransactionFilters({
|
|||||||
locale={fr}
|
locale={fr}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{customStartDate && customEndDate && (
|
{customStartDate && customEndDate && (
|
||||||
<div className="flex gap-2 pt-2 border-t">
|
<div className="flex gap-2 pt-2 border-t px-3 pb-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -237,7 +265,6 @@ export function TransactionFilters({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
@@ -282,7 +309,8 @@ export function TransactionFilters({
|
|||||||
(!selectedAccounts.includes("all") ? selectedAccounts.length : 0) +
|
(!selectedAccounts.includes("all") ? selectedAccounts.length : 0) +
|
||||||
(!selectedCategories.includes("all") ? selectedCategories.length : 0) +
|
(!selectedCategories.includes("all") ? selectedCategories.length : 0) +
|
||||||
(showReconciled !== "all" ? 1 : 0) +
|
(showReconciled !== "all" ? 1 : 0) +
|
||||||
(period !== "all" ? 1 : 0);
|
(period !== "all" ? 1 : 0) +
|
||||||
|
(showDuplicates ? 1 : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
50
components/transactions/transaction-pagination.tsx
Normal file
50
components/transactions/transaction-pagination.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface TransactionPaginationProps {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransactionPagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
hasMore,
|
||||||
|
onPageChange,
|
||||||
|
}: TransactionPaginationProps) {
|
||||||
|
if (total <= pageSize) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Affichage de {page * pageSize + 1} à{" "}
|
||||||
|
{Math.min((page + 1) * pageSize, total)} sur {total}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(Math.max(0, page - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
>
|
||||||
|
Précédent
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={!hasMore}
|
||||||
|
>
|
||||||
|
Suivant
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Wand2,
|
Wand2,
|
||||||
Trash2,
|
Trash2,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -52,6 +53,8 @@ interface TransactionTableProps {
|
|||||||
formatCurrency: (amount: number) => string;
|
formatCurrency: (amount: number) => string;
|
||||||
formatDate: (dateStr: string) => string;
|
formatDate: (dateStr: string) => string;
|
||||||
updatingTransactionIds?: Set<string>;
|
updatingTransactionIds?: Set<string>;
|
||||||
|
duplicateIds?: Set<string>;
|
||||||
|
highlightDuplicates?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROW_HEIGHT = 72; // Hauteur approximative d'une ligne
|
const ROW_HEIGHT = 72; // Hauteur approximative d'une ligne
|
||||||
@@ -145,12 +148,14 @@ export function TransactionTable({
|
|||||||
formatCurrency,
|
formatCurrency,
|
||||||
formatDate,
|
formatDate,
|
||||||
updatingTransactionIds = new Set(),
|
updatingTransactionIds = new Set(),
|
||||||
|
duplicateIds = new Set(),
|
||||||
|
highlightDuplicates = false,
|
||||||
}: TransactionTableProps) {
|
}: TransactionTableProps) {
|
||||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const MOBILE_ROW_HEIGHT = 120;
|
const MOBILE_ROW_HEIGHT = 140;
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: transactions.length,
|
count: transactions.length,
|
||||||
@@ -164,7 +169,7 @@ export function TransactionTable({
|
|||||||
setFocusedIndex(index);
|
setFocusedIndex(index);
|
||||||
onMarkReconciled(transactionId);
|
onMarkReconciled(transactionId);
|
||||||
},
|
},
|
||||||
[onMarkReconciled]
|
[onMarkReconciled],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
@@ -193,7 +198,7 @@ export function TransactionTable({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[focusedIndex, transactions, onMarkReconciled, virtualizer]
|
[focusedIndex, transactions, onMarkReconciled, virtualizer],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -210,7 +215,7 @@ export function TransactionTable({
|
|||||||
(accountId: string) => {
|
(accountId: string) => {
|
||||||
return accounts.find((a) => a.id === accountId);
|
return accounts.find((a) => a.id === accountId);
|
||||||
},
|
},
|
||||||
[accounts]
|
[accounts],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getCategory = useCallback(
|
const getCategory = useCallback(
|
||||||
@@ -218,22 +223,22 @@ export function TransactionTable({
|
|||||||
if (!categoryId) return null;
|
if (!categoryId) return null;
|
||||||
return categories.find((c) => c.id === categoryId);
|
return categories.find((c) => c.id === categoryId);
|
||||||
},
|
},
|
||||||
[categories]
|
[categories],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden w-full card-hover">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0 w-full">
|
||||||
{transactions.length === 0 ? (
|
{transactions.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
<p className="text-muted-foreground">Aucune transaction trouvée</p>
|
<p className="text-muted-foreground">Aucune transaction trouvée</p>
|
||||||
</div>
|
</div>
|
||||||
) : isMobile ? (
|
) : isMobile ? (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border w-full">
|
||||||
<div
|
<div
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
className="overflow-auto"
|
className="overflow-auto w-full"
|
||||||
style={{ height: "calc(100vh - 300px)", minHeight: "400px" }}
|
style={{ height: "calc(100vh - 200px)", minHeight: "600px" }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -247,6 +252,8 @@ export function TransactionTable({
|
|||||||
const account = getAccount(transaction.accountId);
|
const account = getAccount(transaction.accountId);
|
||||||
const _category = getCategory(transaction.categoryId);
|
const _category = getCategory(transaction.categoryId);
|
||||||
const isFocused = focusedIndex === virtualRow.index;
|
const isFocused = focusedIndex === virtualRow.index;
|
||||||
|
const isDuplicate =
|
||||||
|
highlightDuplicates && duplicateIds.has(transaction.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -259,6 +266,10 @@ export function TransactionTable({
|
|||||||
left: 0,
|
left: 0,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
...(isDuplicate && {
|
||||||
|
backgroundColor: "rgb(254 252 232)", // yellow-50
|
||||||
|
borderLeft: "4px solid rgb(234 179 8)", // yellow-500
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Désactiver le pointage au clic sur mobile
|
// Désactiver le pointage au clic sur mobile
|
||||||
@@ -267,26 +278,42 @@ export function TransactionTable({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border",
|
"p-4 md:p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border",
|
||||||
transaction.isReconciled && "bg-emerald-500/5",
|
transaction.isReconciled && "bg-emerald-500/5",
|
||||||
isFocused && "bg-primary/10 ring-1 ring-primary/30"
|
isFocused && "bg-primary/10 ring-1 ring-primary/30",
|
||||||
|
isDuplicate && "shadow-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
<div className="flex items-start gap-0 md:gap-3 flex-1 min-w-0">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedTransactions.has(transaction.id)}
|
checked={selectedTransactions.has(transaction.id)}
|
||||||
onCheckedChange={() => {
|
onCheckedChange={() => {
|
||||||
onToggleSelectTransaction(transaction.id);
|
onToggleSelectTransaction(transaction.id);
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="mt-0.5 hidden md:flex"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium text-xs md:text-sm truncate">
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-sm md:text-sm truncate leading-tight">
|
||||||
{transaction.description}
|
{transaction.description}
|
||||||
</p>
|
</p>
|
||||||
|
{isDuplicate && (
|
||||||
|
<Tooltip delayDuration={200}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<AlertTriangle className="h-4 w-4 md:h-4 md:w-4 text-[var(--warning)] shrink-0" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Transaction en double (même somme et date)
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{transaction.memo && (
|
{transaction.memo && (
|
||||||
<p className="text-[10px] md:text-xs text-muted-foreground truncate mt-1">
|
<p className="text-xs md:text-xs text-muted-foreground truncate mt-1.5">
|
||||||
{transaction.memo}
|
{transaction.memo}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -294,10 +321,10 @@ export function TransactionTable({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-semibold tabular-nums text-sm md:text-base shrink-0",
|
"font-semibold tabular-nums text-base md:text-base shrink-0",
|
||||||
transaction.amount >= 0
|
transaction.amount >= 0
|
||||||
? "text-emerald-600"
|
? "text-success"
|
||||||
: "text-red-600"
|
: "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{transaction.amount >= 0 ? "+" : ""}
|
{transaction.amount >= 0 ? "+" : ""}
|
||||||
@@ -305,18 +332,18 @@ export function TransactionTable({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 md:gap-3 flex-wrap">
|
<div className="flex items-center gap-2.5 md:gap-3 flex-wrap">
|
||||||
<span className="text-[10px] md:text-xs text-muted-foreground">
|
<span className="text-xs md:text-xs text-muted-foreground">
|
||||||
{formatDate(transaction.date)}
|
{formatDate(transaction.date)}
|
||||||
</span>
|
</span>
|
||||||
{account && (
|
{account && (
|
||||||
<span className="text-[10px] md:text-xs text-muted-foreground">
|
<span className="text-xs md:text-xs text-muted-foreground">
|
||||||
• {account.name}
|
• {account.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="flex-1 relative"
|
className="flex-1 relative min-w-[120px]"
|
||||||
>
|
>
|
||||||
{updatingTransactionIds.has(transaction.id) && (
|
{updatingTransactionIds.has(transaction.id) && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10 rounded">
|
<div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10 rounded">
|
||||||
@@ -332,7 +359,7 @@ export function TransactionTable({
|
|||||||
showBadge
|
showBadge
|
||||||
align="start"
|
align="start"
|
||||||
disabled={updatingTransactionIds.has(
|
disabled={updatingTransactionIds.has(
|
||||||
transaction.id
|
transaction.id,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -344,9 +371,9 @@ export function TransactionTable({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 shrink-0"
|
className="h-9 w-9 shrink-0"
|
||||||
>
|
>
|
||||||
<MoreVertical className="w-4 h-4" />
|
<MoreVertical className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@@ -365,13 +392,13 @@ export function TransactionTable({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (
|
if (
|
||||||
confirm(
|
confirm(
|
||||||
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`
|
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
onDelete(transaction.id);
|
onDelete(transaction.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="text-red-600 focus:text-red-600"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
Supprimer
|
Supprimer
|
||||||
@@ -420,7 +447,7 @@ export function TransactionTable({
|
|||||||
<div className="p-3 text-sm font-medium text-muted-foreground">
|
<div className="p-3 text-sm font-medium text-muted-foreground">
|
||||||
Compte
|
Compte
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 text-sm font-medium text-muted-foreground">
|
<div className="p-3 text-sm font-medium text-muted-foreground text-center">
|
||||||
Catégorie
|
Catégorie
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 text-right">
|
<div className="p-3 text-right">
|
||||||
@@ -438,11 +465,11 @@ export function TransactionTable({
|
|||||||
<div className="p-3"></div>
|
<div className="p-3"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Body virtualisé */}
|
{/* Body virtualisé ou non selon le mode */}
|
||||||
<div
|
<div
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
className="overflow-auto"
|
className="overflow-auto"
|
||||||
style={{ height: "calc(100vh - 400px)", minHeight: "400px" }}
|
style={{ height: "calc(100vh - 200px)", minHeight: "700px" }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -455,6 +482,8 @@ export function TransactionTable({
|
|||||||
const transaction = transactions[virtualRow.index];
|
const transaction = transactions[virtualRow.index];
|
||||||
const account = getAccount(transaction.accountId);
|
const account = getAccount(transaction.accountId);
|
||||||
const isFocused = focusedIndex === virtualRow.index;
|
const isFocused = focusedIndex === virtualRow.index;
|
||||||
|
const isDuplicate =
|
||||||
|
highlightDuplicates && duplicateIds.has(transaction.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -467,6 +496,10 @@ export function TransactionTable({
|
|||||||
left: 0,
|
left: 0,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
...(isDuplicate && {
|
||||||
|
backgroundColor: "rgb(254 252 232)", // yellow-50
|
||||||
|
borderLeft: "4px solid rgb(234 179 8)", // yellow-500
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleRowClick(virtualRow.index, transaction.id)
|
handleRowClick(virtualRow.index, transaction.id)
|
||||||
@@ -474,10 +507,11 @@ export function TransactionTable({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer",
|
"grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer",
|
||||||
transaction.isReconciled && "bg-emerald-500/5",
|
transaction.isReconciled && "bg-emerald-500/5",
|
||||||
isFocused && "bg-primary/10 ring-1 ring-primary/30"
|
isFocused && "bg-primary/10 ring-1 ring-primary/30",
|
||||||
|
isDuplicate && "shadow-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="p-3">
|
<div className="p-3" onClick={(e) => e.stopPropagation()}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedTransactions.has(transaction.id)}
|
checked={selectedTransactions.has(transaction.id)}
|
||||||
onCheckedChange={() =>
|
onCheckedChange={() =>
|
||||||
@@ -492,9 +526,23 @@ export function TransactionTable({
|
|||||||
className="p-3 min-w-0 overflow-hidden"
|
className="p-3 min-w-0 overflow-hidden"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<p className="font-medium text-sm truncate">
|
<p className="font-medium text-sm truncate">
|
||||||
{transaction.description}
|
{transaction.description}
|
||||||
</p>
|
</p>
|
||||||
|
{isDuplicate && (
|
||||||
|
<Tooltip delayDuration={200}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-[var(--warning)] shrink-0" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Transaction en double (même somme et date)
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{transaction.memo && (
|
{transaction.memo && (
|
||||||
<DescriptionWithTooltip
|
<DescriptionWithTooltip
|
||||||
description={transaction.memo}
|
description={transaction.memo}
|
||||||
@@ -505,7 +553,7 @@ export function TransactionTable({
|
|||||||
{account?.name || "-"}
|
{account?.name || "-"}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="p-3 relative"
|
className="p-3 relative flex items-center justify-center"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{updatingTransactionIds.has(transaction.id) && (
|
{updatingTransactionIds.has(transaction.id) && (
|
||||||
@@ -528,8 +576,8 @@ export function TransactionTable({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-3 text-right font-semibold tabular-nums",
|
"p-3 text-right font-semibold tabular-nums",
|
||||||
transaction.amount >= 0
|
transaction.amount >= 0
|
||||||
? "text-emerald-600"
|
? "text-success"
|
||||||
: "text-red-600"
|
: "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{transaction.amount >= 0 ? "+" : ""}
|
{transaction.amount >= 0 ? "+" : ""}
|
||||||
@@ -544,7 +592,7 @@ export function TransactionTable({
|
|||||||
className="p-1 hover:bg-muted rounded"
|
className="p-1 hover:bg-muted rounded"
|
||||||
>
|
>
|
||||||
{transaction.isReconciled ? (
|
{transaction.isReconciled ? (
|
||||||
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
|
<CheckCircle2 className="w-5 h-5 text-success" />
|
||||||
) : (
|
) : (
|
||||||
<Circle className="w-5 h-5 text-muted-foreground" />
|
<Circle className="w-5 h-5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
@@ -596,13 +644,13 @@ export function TransactionTable({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (
|
if (
|
||||||
confirm(
|
confirm(
|
||||||
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`
|
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
onDelete(transaction.id);
|
onDelete(transaction.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="text-red-600 focus:text-red-600"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
Supprimer
|
Supprimer
|
||||||
|
|||||||
18
components/transactions/transaction-utils.ts
Normal file
18
components/transactions/transaction-utils.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for transaction formatting
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const formatCurrency = (amount: number): string => {
|
||||||
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDate = (dateStr: string): string => {
|
||||||
|
return new Date(dateStr).toLocaleDateString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -182,9 +182,7 @@ export function AccountFilterCombobox({
|
|||||||
{isFolderPartiallySelected(folder.id) && (
|
{isFolderPartiallySelected(folder.id) && (
|
||||||
<div className="h-3 w-3 rounded-sm bg-primary/50 mr-1" />
|
<div className="h-3 w-3 rounded-sm bg-primary/50 mr-1" />
|
||||||
)}
|
)}
|
||||||
{isFolderSelected(folder.id) && (
|
{isFolderSelected(folder.id) && <Check className="h-4 w-4" />}
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|
||||||
@@ -306,9 +304,7 @@ export function AccountFilterCombobox({
|
|||||||
)
|
)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isAll && (
|
{isAll && <Check className="ml-auto h-4 w-4" />}
|
||||||
<Check className="ml-auto h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ function AlertDialogContent({
|
|||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
data-slot="alert-dialog-content"
|
data-slot="alert-dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 overflow-y-auto sm:max-w-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/30 aria-invalid:border-destructive overflow-hidden",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -14,7 +14,7 @@ const badgeVariants = cva(
|
|||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/30",
|
||||||
outline:
|
outline:
|
||||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function BreadcrumbLink({
|
|||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="breadcrumb-link"
|
data-slot="breadcrumb-link"
|
||||||
className={cn("hover:text-foreground transition-colors", className)}
|
className={cn("hover:text-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,21 +5,20 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold transition-all duration-300 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/30 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-gradient-to-r from-primary via-primary/95 to-primary/90 text-primary-foreground hover:from-primary/95 hover:via-primary hover:to-primary/95 shadow-xl shadow-primary/30 hover:shadow-2xl hover:shadow-primary/40 hover:scale-[1.03] active:scale-[0.97] backdrop-blur-sm border border-primary/20",
|
"bg-gradient-to-r from-primary via-primary/95 to-primary/90 text-primary-foreground shadow-xl shadow-primary/30 backdrop-blur-sm border border-primary/20",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-gradient-to-r from-destructive via-destructive/95 to-destructive/90 text-white hover:from-destructive/95 hover:via-destructive hover:to-destructive/95 shadow-xl shadow-destructive/30 hover:shadow-2xl hover:shadow-destructive/40 hover:scale-[1.03] focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 active:scale-[0.97] backdrop-blur-sm border border-destructive/20",
|
"bg-gradient-to-r from-destructive via-destructive/95 to-destructive/90 text-destructive-foreground shadow-xl shadow-destructive/30 focus-visible:ring-destructive/30 backdrop-blur-sm border border-destructive/20",
|
||||||
outline:
|
outline:
|
||||||
"border-2 bg-background/95 backdrop-blur-md shadow-md hover:bg-accent hover:text-accent-foreground hover:border-primary/50 hover:shadow-lg hover:scale-[1.03] dark:bg-input/40 dark:border-input dark:hover:bg-input/60 active:scale-[0.97]",
|
"border-2 bg-background/95 backdrop-blur-md shadow-md border-border",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-gradient-to-r from-secondary via-secondary/95 to-secondary/90 text-secondary-foreground hover:from-secondary/95 hover:via-secondary hover:to-secondary/95 hover:shadow-lg hover:scale-[1.03] active:scale-[0.97] backdrop-blur-sm",
|
"bg-gradient-to-r from-secondary via-secondary/95 to-secondary/90 text-secondary-foreground backdrop-blur-sm",
|
||||||
ghost:
|
ghost: "backdrop-blur-sm",
|
||||||
"hover:bg-accent/70 hover:text-accent-foreground dark:hover:bg-accent/50 hover:scale-[1.03] active:scale-[0.97] backdrop-blur-sm",
|
link: "text-primary underline-offset-4 font-semibold",
|
||||||
link: "text-primary underline-offset-4 hover:underline font-semibold",
|
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-5 py-2.5 has-[>svg]:px-4",
|
default: "h-10 px-5 py-2.5 has-[>svg]:px-4",
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ function CalendarDayButton({
|
|||||||
data-range-end={modifiers.range_end}
|
data-range-end={modifiers.range_end}
|
||||||
data-range-middle={modifiers.range_middle}
|
data-range-middle={modifiers.range_middle}
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||||
defaultClassNames.day,
|
defaultClassNames.day,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"fintech-card",
|
"fintech-card",
|
||||||
"bg-card text-card-foreground flex flex-col",
|
"bg-card text-card-foreground flex flex-col",
|
||||||
"transition-all duration-300 ease-out",
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -87,21 +87,55 @@ export function CategoryFilterCombobox({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a parent category
|
||||||
|
const isParentCategory = parentCategories.some((p) => p.id === newValue);
|
||||||
|
const childCategories = isParentCategory
|
||||||
|
? childrenByParent[newValue] || []
|
||||||
|
: [];
|
||||||
|
|
||||||
// Category selection - toggle
|
// Category selection - toggle
|
||||||
let newSelection: string[];
|
let newSelection: string[];
|
||||||
|
|
||||||
if (isAll || isUncategorized) {
|
if (isAll || isUncategorized) {
|
||||||
// Start fresh with just this category
|
// Start fresh with this category and its children (if parent)
|
||||||
|
if (isParentCategory && childCategories.length > 0) {
|
||||||
|
newSelection = [newValue, ...childCategories.map((child) => child.id)];
|
||||||
|
} else {
|
||||||
newSelection = [newValue];
|
newSelection = [newValue];
|
||||||
|
}
|
||||||
} else if (value.includes(newValue)) {
|
} else if (value.includes(newValue)) {
|
||||||
// Remove category
|
// Remove category and its children (if parent)
|
||||||
|
if (isParentCategory && childCategories.length > 0) {
|
||||||
|
const childIds = childCategories.map((child) => child.id);
|
||||||
|
newSelection = value.filter(
|
||||||
|
(v) => v !== newValue && !childIds.includes(v),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
newSelection = value.filter((v) => v !== newValue);
|
newSelection = value.filter((v) => v !== newValue);
|
||||||
|
}
|
||||||
if (newSelection.length === 0) {
|
if (newSelection.length === 0) {
|
||||||
newSelection = ["all"];
|
newSelection = ["all"];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add category
|
// Add category and its children (if parent)
|
||||||
|
if (isParentCategory && childCategories.length > 0) {
|
||||||
|
const childIds = childCategories.map((child) => child.id);
|
||||||
|
newSelection = [
|
||||||
|
...value.filter((v) => !childIds.includes(v)), // Remove any existing children
|
||||||
|
newValue,
|
||||||
|
...childIds,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Check if this child's parent is already selected
|
||||||
|
const category = categories.find((c) => c.id === newValue);
|
||||||
|
if (category?.parentId && value.includes(category.parentId)) {
|
||||||
|
// Parent is selected, so we're adding a child - keep parent
|
||||||
newSelection = [...value, newValue];
|
newSelection = [...value, newValue];
|
||||||
|
} else {
|
||||||
|
// Regular add
|
||||||
|
newSelection = [...value, newValue];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange(newSelection);
|
onChange(newSelection);
|
||||||
@@ -193,7 +227,11 @@ export function CategoryFilterCombobox({
|
|||||||
align="start"
|
align="start"
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<Command value={isAll ? "all" : isUncategorized ? "uncategorized" : value.join(",")}>
|
<Command
|
||||||
|
value={
|
||||||
|
isAll ? "all" : isUncategorized ? "uncategorized" : value.join(",")
|
||||||
|
}
|
||||||
|
>
|
||||||
<CommandInput placeholder="Rechercher..." />
|
<CommandInput placeholder="Rechercher..." />
|
||||||
<CommandList className="max-h-[300px]">
|
<CommandList className="max-h-[300px]">
|
||||||
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
|
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
|
||||||
@@ -212,9 +250,7 @@ export function CategoryFilterCombobox({
|
|||||||
({filteredTransactions.length})
|
({filteredTransactions.length})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isAll && (
|
{isAll && <Check className="ml-auto h-4 w-4 shrink-0" />}
|
||||||
<Check className="ml-auto h-4 w-4 shrink-0" />
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value="uncategorized"
|
value="uncategorized"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function Checkbox({
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
"peer border-input bg-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/30 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ function ContextMenuItem({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/15 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 overflow-y-auto sm:max-w-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -69,7 +69,7 @@ function DialogContent({
|
|||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
data-slot="dialog-close"
|
data-slot="dialog-close"
|
||||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ function DropdownMenuItem({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/15 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ function FieldLabel({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||||
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
"has-data-[state=checked]:bg-primary/8 has-data-[state=checked]:border-primary",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ export function IconPicker({ value, onChange, color }: IconPickerProps) {
|
|||||||
key={icon}
|
key={icon}
|
||||||
onClick={() => handleSelect(icon)}
|
onClick={() => handleSelect(icon)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center p-2 rounded-md hover:bg-accent transition-colors",
|
"flex items-center justify-center p-2 rounded-md hover:bg-accent",
|
||||||
value === icon && "bg-accent ring-2 ring-primary",
|
value === icon && "bg-accent ring-2 ring-primary",
|
||||||
)}
|
)}
|
||||||
title={icon}
|
title={icon}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="input-group"
|
data-slot="input-group"
|
||||||
role="group"
|
role="group"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
"group/input-group border-input bg-input relative flex w-full items-center rounded-md border shadow-xs outline-none",
|
||||||
"h-9 has-[>textarea]:h-auto",
|
"h-9 has-[>textarea]:h-auto",
|
||||||
|
|
||||||
// Variants based on alignment.
|
// Variants based on alignment.
|
||||||
@@ -26,7 +26,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||||
|
|
||||||
// Error state.
|
// Error state.
|
||||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
"has-[[data-slot][aria-invalid=true]]:ring-destructive/30 has-[[data-slot][aria-invalid=true]]:border-destructive",
|
||||||
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -135,7 +135,7 @@ function InputGroupInput({
|
|||||||
<Input
|
<Input
|
||||||
data-slot="input-group-control"
|
data-slot="input-group-control"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -151,7 +151,7 @@ function InputGroupTextarea({
|
|||||||
<Textarea
|
<Textarea
|
||||||
data-slot="input-group-control"
|
data-slot="input-group-control"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function InputOTPSlot({
|
|||||||
data-slot="input-otp-slot"
|
data-slot="input-otp-slot"
|
||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/30 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive bg-input border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
|
||||||
"dark:bg-input/40 border-input h-9 w-full min-w-0 rounded-lg border bg-background/50 backdrop-blur-sm px-3 py-1 text-base",
|
"bg-input border-border h-9 w-full min-w-0 rounded-lg border backdrop-blur-sm px-3 py-1 text-base",
|
||||||
"shadow-sm transition-all duration-200 outline-none",
|
"shadow-sm outline-none",
|
||||||
"file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
"file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-primary/50 focus-visible:ring-primary/20 focus-visible:ring-[3px] focus-visible:shadow-md focus-visible:shadow-primary/10",
|
"focus-visible:border-primary/50 focus-visible:ring-primary/20 focus-visible:ring-[3px] focus-visible:shadow-md focus-visible:shadow-primary/10",
|
||||||
"hover:border-primary/30 hover:shadow-sm",
|
"hover:border-primary/30 hover:shadow-sm",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/30 aria-invalid:border-destructive",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
|
"bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
|
||||||
"[&_svg:not([class*='size-'])]:size-3",
|
"[&_svg:not([class*='size-'])]:size-3",
|
||||||
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
"[[data-slot=tooltip-content]_&]:bg-background/15 [[data-slot=tooltip-content]_&]:text-background",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ function MenubarItem({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/15 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function RadioGroupItem({
|
|||||||
<RadioGroupPrimitive.Item
|
<RadioGroupPrimitive.Item
|
||||||
data-slot="radio-group-item"
|
data-slot="radio-group-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/30 aria-invalid:border-destructive bg-input aspect-square size-4 shrink-0 rounded-full border shadow-xs outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function SelectTrigger({
|
|||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/30 aria-invalid:border-destructive bg-input hover:bg-input/80 flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -58,21 +58,21 @@ function SheetContent({
|
|||||||
<SheetPrimitive.Content
|
<SheetPrimitive.Content
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 overflow-y-auto",
|
||||||
side === "right" &&
|
side === "right" &&
|
||||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
side === "left" &&
|
side === "left" &&
|
||||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
side === "top" &&
|
side === "top" &&
|
||||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 max-h-[calc(100vh-2rem)] border-b",
|
||||||
side === "bottom" &&
|
side === "bottom" &&
|
||||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 max-h-[calc(100vh-2rem)] border-t",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
<XIcon className="size-4" />
|
<XIcon className="size-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { useTheme } from "next-themes";
|
|||||||
import { Toaster as Sonner, ToasterProps } from "sonner";
|
import { Toaster as Sonner, ToasterProps } from "sonner";
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme();
|
const { theme = "light" } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={theme as ToasterProps["theme"]}
|
theme={theme === "system" ? "dark" : (theme as ToasterProps["theme"])}
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function Switch({
|
|||||||
<SwitchPrimitive.Root
|
<SwitchPrimitive.Root
|
||||||
data-slot="switch"
|
data-slot="switch"
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -21,7 +21,7 @@ function Switch({
|
|||||||
<SwitchPrimitive.Thumb
|
<SwitchPrimitive.Thumb
|
||||||
data-slot="switch-thumb"
|
data-slot="switch-thumb"
|
||||||
className={
|
className={
|
||||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
"bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitive.Root>
|
</SwitchPrimitive.Root>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|||||||
<tr
|
<tr
|
||||||
data-slot="table-row"
|
data-slot="table-row"
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function TabsTrigger({
|
|||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"data-[state=active]:bg-background data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring data-[state=active]:border-input text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input placeholder:text-muted-foreground",
|
"border-input placeholder:text-muted-foreground",
|
||||||
"dark:bg-input/40 flex field-sizing-content min-h-16 w-full rounded-lg border bg-background/50 backdrop-blur-sm px-3 py-2 text-base",
|
"bg-input flex field-sizing-content min-h-16 w-full rounded-lg border backdrop-blur-sm px-3 py-2 text-base",
|
||||||
"shadow-sm transition-all duration-200 outline-none",
|
"shadow-sm outline-none",
|
||||||
"focus-visible:border-primary/50 focus-visible:ring-primary/20 focus-visible:ring-[3px] focus-visible:shadow-md focus-visible:shadow-primary/10",
|
"focus-visible:border-primary/50 focus-visible:ring-primary/20 focus-visible:ring-[3px] focus-visible:shadow-md focus-visible:shadow-primary/10",
|
||||||
"hover:border-primary/30 hover:shadow-sm",
|
"hover:border-primary/30 hover:shadow-sm",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/30 aria-invalid:border-destructive",
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const toggleVariants = cva(
|
const toggleVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none aria-invalid:ring-destructive/30 aria-invalid:border-destructive whitespace-nowrap",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
banking-app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dummy-secret-for-build-only}
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dummy-secret-for-build-only}
|
||||||
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:4000}
|
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:4000}
|
||||||
- DATABASE_URL=${DATABASE_URL:-file:./prisma/dev.db}
|
- DATABASE_URL=file:/app/prisma/dev.db
|
||||||
ports:
|
ports:
|
||||||
- "4000:3000"
|
- "4000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./prisma:/app/prisma
|
- ${DATA_VOLUME_PATH:-./prisma}:/app/prisma
|
||||||
environment:
|
environment:
|
||||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||||
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:4000}
|
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:4000}
|
||||||
- DATABASE_URL=${DATABASE_URL:-file:./prisma/dev.db}
|
- DATABASE_URL=file:/app/prisma/dev.db
|
||||||
env_file:
|
labels:
|
||||||
- .env
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
|||||||
44
hooks/use-local-storage.ts
Normal file
44
hooks/use-local-storage.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook pour gérer la persistance d'une valeur dans le localStorage
|
||||||
|
*/
|
||||||
|
export function useLocalStorage<T>(
|
||||||
|
key: string,
|
||||||
|
initialValue: T,
|
||||||
|
): [T, (value: T | ((val: T) => T)) => void] {
|
||||||
|
// État pour stocker la valeur
|
||||||
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const item = window.localStorage.getItem(key);
|
||||||
|
return item ? JSON.parse(item) : initialValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading localStorage key "${key}":`, error);
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fonction pour mettre à jour la valeur
|
||||||
|
const setValue = (value: T | ((val: T) => T)) => {
|
||||||
|
try {
|
||||||
|
// Permet d'utiliser une fonction comme setState
|
||||||
|
const valueToStore =
|
||||||
|
value instanceof Function ? value(storedValue) : value;
|
||||||
|
|
||||||
|
setStoredValue(valueToStore);
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error setting localStorage key "${key}":`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return [storedValue, setValue];
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ const MOBILE_BREAKPOINT = 768;
|
|||||||
|
|
||||||
export function useIsMobile() {
|
export function useIsMobile() {
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||||
undefined
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
366
hooks/use-transaction-mutations.ts
Normal file
366
hooks/use-transaction-mutations.ts
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Transaction } from "@/lib/types";
|
||||||
|
import { getTransactionsQueryKey } from "@/lib/hooks";
|
||||||
|
import type { TransactionsPaginatedParams } from "@/services/banking.service";
|
||||||
|
import { invalidateAllTransactionQueries } from "@/lib/cache-utils";
|
||||||
|
|
||||||
|
interface UseTransactionMutationsProps {
|
||||||
|
transactionParams: TransactionsPaginatedParams;
|
||||||
|
transactionsData: { transactions: Transaction[]; total: number } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransactionMutations({
|
||||||
|
transactionParams,
|
||||||
|
transactionsData,
|
||||||
|
}: UseTransactionMutationsProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [updatingTransactionIds, setUpdatingTransactionIds] = useState<
|
||||||
|
Set<string>
|
||||||
|
>(new Set());
|
||||||
|
|
||||||
|
const toggleReconciled = useCallback(
|
||||||
|
async (transactionId: string) => {
|
||||||
|
if (!transactionsData) return;
|
||||||
|
|
||||||
|
const transaction = transactionsData.transactions.find(
|
||||||
|
(t) => t.id === transactionId,
|
||||||
|
);
|
||||||
|
if (!transaction) return;
|
||||||
|
|
||||||
|
const newReconciledState = !transaction.isReconciled;
|
||||||
|
const updatedTransaction = {
|
||||||
|
...transaction,
|
||||||
|
isReconciled: newReconciledState,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimistic cache update
|
||||||
|
const queryKey = getTransactionsQueryKey(transactionParams);
|
||||||
|
const previousData =
|
||||||
|
queryClient.getQueryData<typeof transactionsData>(queryKey);
|
||||||
|
|
||||||
|
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
|
||||||
|
if (!oldData) return oldData;
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
transactions: oldData.transactions.map((t) =>
|
||||||
|
t.id === transactionId
|
||||||
|
? { ...t, isReconciled: newReconciledState }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/banking/transactions", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(updatedTransaction),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOUJOURS revalider après succès pour garantir la cohérence
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update transaction:", error);
|
||||||
|
// Rollback on error
|
||||||
|
if (previousData) {
|
||||||
|
queryClient.setQueryData(queryKey, previousData);
|
||||||
|
}
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[transactionsData, transactionParams, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const markReconciled = useCallback(
|
||||||
|
async (transactionId: string) => {
|
||||||
|
if (!transactionsData) return;
|
||||||
|
|
||||||
|
const transaction = transactionsData.transactions.find(
|
||||||
|
(t) => t.id === transactionId,
|
||||||
|
);
|
||||||
|
if (!transaction || transaction.isReconciled) return;
|
||||||
|
|
||||||
|
const updatedTransaction = {
|
||||||
|
...transaction,
|
||||||
|
isReconciled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimistic cache update
|
||||||
|
const queryKey = getTransactionsQueryKey(transactionParams);
|
||||||
|
const previousData =
|
||||||
|
queryClient.getQueryData<typeof transactionsData>(queryKey);
|
||||||
|
|
||||||
|
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
|
||||||
|
if (!oldData) return oldData;
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
transactions: oldData.transactions.map((t) =>
|
||||||
|
t.id === transactionId ? { ...t, isReconciled: true } : t,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/banking/transactions", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(updatedTransaction),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOUJOURS revalider après succès pour garantir la cohérence
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update transaction:", error);
|
||||||
|
// Rollback on error
|
||||||
|
if (previousData) {
|
||||||
|
queryClient.setQueryData(queryKey, previousData);
|
||||||
|
}
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[transactionsData, transactionParams, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setCategory = useCallback(
|
||||||
|
async (transactionId: string, categoryId: string | null) => {
|
||||||
|
if (!transactionsData) return;
|
||||||
|
|
||||||
|
const transaction = transactionsData.transactions.find(
|
||||||
|
(t) => t.id === transactionId,
|
||||||
|
);
|
||||||
|
if (!transaction) return;
|
||||||
|
|
||||||
|
setUpdatingTransactionIds((prev) => new Set(prev).add(transactionId));
|
||||||
|
|
||||||
|
// Optimistic cache update
|
||||||
|
const queryKey = getTransactionsQueryKey(transactionParams);
|
||||||
|
const previousData =
|
||||||
|
queryClient.getQueryData<typeof transactionsData>(queryKey);
|
||||||
|
|
||||||
|
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
|
||||||
|
if (!oldData) return oldData;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
transactions: oldData.transactions.map((t) =>
|
||||||
|
t.id === transactionId ? { ...t, categoryId } : t,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/banking/transactions", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...transaction, categoryId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOUJOURS revalider après succès pour garantir la cohérence
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update transaction:", error);
|
||||||
|
// Rollback on error
|
||||||
|
if (previousData) {
|
||||||
|
queryClient.setQueryData(queryKey, previousData);
|
||||||
|
}
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
} finally {
|
||||||
|
setUpdatingTransactionIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(transactionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[transactionsData, transactionParams, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteTransaction = useCallback(
|
||||||
|
async (transactionId: string) => {
|
||||||
|
if (!transactionsData) return;
|
||||||
|
|
||||||
|
// Save current data for rollback
|
||||||
|
const queryKey = getTransactionsQueryKey(transactionParams);
|
||||||
|
const previousData =
|
||||||
|
queryClient.getQueryData<typeof transactionsData>(queryKey);
|
||||||
|
|
||||||
|
// Optimistic cache update
|
||||||
|
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
|
||||||
|
if (!oldData) return oldData;
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
transactions: oldData.transactions.filter(
|
||||||
|
(t) => t.id !== transactionId,
|
||||||
|
),
|
||||||
|
total: oldData.total - 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/banking/transactions?id=${transactionId}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
errorData.error ||
|
||||||
|
`Failed to delete transaction: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOUJOURS revalider après succès pour garantir la cohérence
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete transaction:", error);
|
||||||
|
// Rollback on error
|
||||||
|
if (previousData) {
|
||||||
|
queryClient.setQueryData(queryKey, previousData);
|
||||||
|
}
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[transactionsData, transactionParams, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bulkReconcile = useCallback(
|
||||||
|
async (reconciled: boolean, selectedTransactionIds: Set<string>) => {
|
||||||
|
if (!transactionsData) return;
|
||||||
|
|
||||||
|
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
|
||||||
|
selectedTransactionIds.has(t.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const transactionIds = transactionsToUpdate.map((t) => t.id);
|
||||||
|
|
||||||
|
// Optimistic cache update
|
||||||
|
const queryKey = getTransactionsQueryKey(transactionParams);
|
||||||
|
const previousData =
|
||||||
|
queryClient.getQueryData<typeof transactionsData>(queryKey);
|
||||||
|
|
||||||
|
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
|
||||||
|
if (!oldData) return oldData;
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
transactions: oldData.transactions.map((t) =>
|
||||||
|
transactionIds.includes(t.id)
|
||||||
|
? { ...t, isReconciled: reconciled }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
transactionsToUpdate.map((t) =>
|
||||||
|
fetch("/api/banking/transactions", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...t, isReconciled: reconciled }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// TOUJOURS revalider après succès pour garantir la cohérence
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update transactions:", error);
|
||||||
|
// Rollback on error
|
||||||
|
if (previousData) {
|
||||||
|
queryClient.setQueryData(queryKey, previousData);
|
||||||
|
}
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[transactionsData, transactionParams, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bulkSetCategory = useCallback(
|
||||||
|
async (categoryId: string | null, selectedTransactionIds: Set<string>) => {
|
||||||
|
if (!transactionsData) return;
|
||||||
|
|
||||||
|
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
|
||||||
|
selectedTransactionIds.has(t.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const transactionIds = transactionsToUpdate.map((t) => t.id);
|
||||||
|
setUpdatingTransactionIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
transactionIds.forEach((id) => next.add(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optimistic cache update
|
||||||
|
const queryKey = getTransactionsQueryKey(transactionParams);
|
||||||
|
const previousData =
|
||||||
|
queryClient.getQueryData<typeof transactionsData>(queryKey);
|
||||||
|
|
||||||
|
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
|
||||||
|
if (!oldData) return oldData;
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
transactions: oldData.transactions.map((t) =>
|
||||||
|
transactionIds.includes(t.id) ? { ...t, categoryId } : t,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
transactionsToUpdate.map((t) =>
|
||||||
|
fetch("/api/banking/transactions", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...t, categoryId }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// TOUJOURS revalider après succès pour garantir la cohérence
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update transactions:", error);
|
||||||
|
// Rollback on error
|
||||||
|
if (previousData) {
|
||||||
|
queryClient.setQueryData(queryKey, previousData);
|
||||||
|
}
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
} finally {
|
||||||
|
setUpdatingTransactionIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
transactionIds.forEach((id) => next.delete(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[transactionsData, transactionParams, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
toggleReconciled,
|
||||||
|
markReconciled,
|
||||||
|
setCategory,
|
||||||
|
deleteTransaction,
|
||||||
|
bulkReconcile,
|
||||||
|
bulkSetCategory,
|
||||||
|
updatingTransactionIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
116
hooks/use-transaction-rules.ts
Normal file
116
hooks/use-transaction-rules.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Transaction, Category } from "@/lib/types";
|
||||||
|
import { updateCategory } from "@/lib/store-db";
|
||||||
|
import {
|
||||||
|
normalizeDescription,
|
||||||
|
suggestKeyword,
|
||||||
|
} from "@/components/rules/constants";
|
||||||
|
import {
|
||||||
|
invalidateAllTransactionQueries,
|
||||||
|
invalidateAllCategoryQueries,
|
||||||
|
} from "@/lib/cache-utils";
|
||||||
|
|
||||||
|
interface UseTransactionRulesProps {
|
||||||
|
transactionsData: { transactions: Transaction[] } | undefined;
|
||||||
|
metadata: {
|
||||||
|
categories: Category[];
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransactionRules({
|
||||||
|
transactionsData,
|
||||||
|
metadata,
|
||||||
|
}: UseTransactionRulesProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||||
|
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateRule = useCallback((transaction: Transaction) => {
|
||||||
|
setRuleTransaction(transaction);
|
||||||
|
setRuleDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ruleGroup = useMemo(() => {
|
||||||
|
if (!ruleTransaction || !transactionsData) return null;
|
||||||
|
|
||||||
|
const normalizedDesc = normalizeDescription(ruleTransaction.description);
|
||||||
|
const similarTransactions = transactionsData.transactions.filter(
|
||||||
|
(t) => normalizeDescription(t.description) === normalizedDesc,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (similarTransactions.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: normalizedDesc,
|
||||||
|
displayName: ruleTransaction.description,
|
||||||
|
transactions: similarTransactions,
|
||||||
|
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
|
||||||
|
suggestedKeyword: suggestKeyword(
|
||||||
|
similarTransactions.map((t) => t.description),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}, [ruleTransaction, transactionsData]);
|
||||||
|
|
||||||
|
const handleSaveRule = useCallback(
|
||||||
|
async (ruleData: {
|
||||||
|
keyword: string;
|
||||||
|
categoryId: string;
|
||||||
|
applyToExisting: boolean;
|
||||||
|
transactionIds: string[];
|
||||||
|
}) => {
|
||||||
|
if (!metadata) return;
|
||||||
|
|
||||||
|
// Add keyword to category
|
||||||
|
const category = metadata.categories.find(
|
||||||
|
(c) => c.id === ruleData.categoryId,
|
||||||
|
);
|
||||||
|
if (!category) {
|
||||||
|
throw new Error("Category not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if keyword already exists
|
||||||
|
const keywordExists = category.keywords.some(
|
||||||
|
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!keywordExists) {
|
||||||
|
await updateCategory({
|
||||||
|
...category,
|
||||||
|
keywords: [...category.keywords, ruleData.keyword],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply to existing transactions if requested
|
||||||
|
if (ruleData.applyToExisting) {
|
||||||
|
await Promise.all(
|
||||||
|
ruleData.transactionIds.map((id) =>
|
||||||
|
fetch("/api/banking/transactions", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalider toutes les queries liées
|
||||||
|
invalidateAllTransactionQueries(queryClient);
|
||||||
|
invalidateAllCategoryQueries(queryClient);
|
||||||
|
setRuleDialogOpen(false);
|
||||||
|
},
|
||||||
|
[metadata, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ruleDialogOpen,
|
||||||
|
setRuleDialogOpen,
|
||||||
|
ruleGroup,
|
||||||
|
handleCreateRule,
|
||||||
|
handleSaveRule,
|
||||||
|
};
|
||||||
|
}
|
||||||
328
hooks/use-transactions-balance-chart.ts
Normal file
328
hooks/use-transactions-balance-chart.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTransactions } from "@/lib/hooks";
|
||||||
|
import { useBankingMetadata } from "@/lib/hooks";
|
||||||
|
import type { TransactionsPaginatedParams } from "@/services/banking.service";
|
||||||
|
import type { Account } from "@/lib/types";
|
||||||
|
|
||||||
|
interface BalanceChartDataPoint {
|
||||||
|
date: string;
|
||||||
|
[key: string]: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTransactionsBalanceChartParams {
|
||||||
|
selectedAccounts: string[];
|
||||||
|
selectedCategories: string[];
|
||||||
|
period: "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
||||||
|
customStartDate?: Date;
|
||||||
|
customEndDate?: Date;
|
||||||
|
showReconciled: string;
|
||||||
|
searchQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransactionsBalanceChart({
|
||||||
|
selectedAccounts,
|
||||||
|
selectedCategories,
|
||||||
|
period,
|
||||||
|
customStartDate,
|
||||||
|
customEndDate,
|
||||||
|
showReconciled,
|
||||||
|
searchQuery,
|
||||||
|
}: UseTransactionsBalanceChartParams) {
|
||||||
|
const { data: metadata } = useBankingMetadata();
|
||||||
|
|
||||||
|
// Calculate start date based on period
|
||||||
|
const startDate = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
switch (period) {
|
||||||
|
case "1month":
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
case "3months":
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
|
||||||
|
case "6months":
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
||||||
|
case "12months":
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
|
||||||
|
case "custom":
|
||||||
|
return customStartDate || new Date(0);
|
||||||
|
default:
|
||||||
|
return new Date(0);
|
||||||
|
}
|
||||||
|
}, [period, customStartDate]);
|
||||||
|
|
||||||
|
// Calculate end date (only for custom period)
|
||||||
|
const endDate = useMemo(() => {
|
||||||
|
if (period === "custom" && customEndDate) {
|
||||||
|
return customEndDate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [period, customEndDate]);
|
||||||
|
|
||||||
|
// Build params for fetching all transactions (no pagination)
|
||||||
|
const chartParams = useMemo<TransactionsPaginatedParams>(() => {
|
||||||
|
const params: TransactionsPaginatedParams = {
|
||||||
|
limit: 10000, // Large limit to get all transactions
|
||||||
|
offset: 0,
|
||||||
|
sortField: "date",
|
||||||
|
sortOrder: "asc", // Ascending for balance calculation
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate && period !== "all") {
|
||||||
|
params.startDate = startDate.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
params.endDate = endDate.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
if (!selectedAccounts.includes("all")) {
|
||||||
|
params.accountIds = selectedAccounts;
|
||||||
|
}
|
||||||
|
if (!selectedCategories.includes("all")) {
|
||||||
|
if (selectedCategories.includes("uncategorized")) {
|
||||||
|
params.includeUncategorized = true;
|
||||||
|
} else {
|
||||||
|
params.categoryIds = selectedCategories;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (searchQuery) {
|
||||||
|
params.search = searchQuery;
|
||||||
|
}
|
||||||
|
if (showReconciled !== "all") {
|
||||||
|
params.isReconciled = showReconciled === "reconciled";
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}, [
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
selectedAccounts,
|
||||||
|
selectedCategories,
|
||||||
|
searchQuery,
|
||||||
|
showReconciled,
|
||||||
|
period,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build params for fetching transactions before startDate (for initial balance)
|
||||||
|
const beforeStartDateParams = useMemo<TransactionsPaginatedParams>(() => {
|
||||||
|
if (period === "all" || !startDate) {
|
||||||
|
return { limit: 0, offset: 0 }; // Don't fetch if no start date
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: TransactionsPaginatedParams = {
|
||||||
|
limit: 10000,
|
||||||
|
offset: 0,
|
||||||
|
sortField: "date",
|
||||||
|
sortOrder: "asc",
|
||||||
|
endDate: new Date(startDate.getTime() - 1).toISOString().split("T")[0], // Day before startDate
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selectedAccounts.includes("all")) {
|
||||||
|
params.accountIds = selectedAccounts;
|
||||||
|
}
|
||||||
|
if (!selectedCategories.includes("all")) {
|
||||||
|
if (selectedCategories.includes("uncategorized")) {
|
||||||
|
params.includeUncategorized = true;
|
||||||
|
} else {
|
||||||
|
params.categoryIds = selectedCategories;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (searchQuery) {
|
||||||
|
params.search = searchQuery;
|
||||||
|
}
|
||||||
|
if (showReconciled !== "all") {
|
||||||
|
params.isReconciled = showReconciled === "reconciled";
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}, [
|
||||||
|
startDate,
|
||||||
|
selectedAccounts,
|
||||||
|
selectedCategories,
|
||||||
|
searchQuery,
|
||||||
|
showReconciled,
|
||||||
|
period,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fetch transactions before startDate for initial balance calculation
|
||||||
|
const { data: beforeStartDateData } = useTransactions(
|
||||||
|
beforeStartDateParams,
|
||||||
|
!!metadata && period !== "all" && !!startDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch all filtered transactions for chart
|
||||||
|
const { data: transactionsData, isLoading } = useTransactions(
|
||||||
|
chartParams,
|
||||||
|
!!metadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate balance chart data
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!transactionsData || !metadata) {
|
||||||
|
return {
|
||||||
|
aggregatedData: [],
|
||||||
|
perAccountData: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactions = transactionsData.transactions;
|
||||||
|
const accounts = metadata.accounts;
|
||||||
|
|
||||||
|
// Sort transactions by date
|
||||||
|
const sortedTransactions = [...transactions].sort(
|
||||||
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate starting balance: initialBalance + transactions before startDate
|
||||||
|
let runningBalance = 0;
|
||||||
|
const accountsToUse = selectedAccounts.includes("all")
|
||||||
|
? accounts
|
||||||
|
: accounts.filter((acc: Account) => selectedAccounts.includes(acc.id));
|
||||||
|
|
||||||
|
// Start with initial balances
|
||||||
|
runningBalance = accountsToUse.reduce(
|
||||||
|
(sum: number, acc: Account) => sum + (acc.initialBalance || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add transactions before startDate if we have them
|
||||||
|
if (beforeStartDateData?.transactions) {
|
||||||
|
const beforeStartTransactions = beforeStartDateData.transactions.filter(
|
||||||
|
(t) => {
|
||||||
|
const transactionDate = new Date(t.date);
|
||||||
|
return transactionDate < startDate;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
beforeStartTransactions.forEach((t) => {
|
||||||
|
runningBalance += t.amount;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregatedBalanceByDate = new Map<string, number>();
|
||||||
|
|
||||||
|
// Calculate balance evolution
|
||||||
|
sortedTransactions.forEach((t) => {
|
||||||
|
runningBalance += t.amount;
|
||||||
|
aggregatedBalanceByDate.set(t.date, runningBalance);
|
||||||
|
});
|
||||||
|
|
||||||
|
const aggregatedBalanceData: BalanceChartDataPoint[] = Array.from(
|
||||||
|
aggregatedBalanceByDate.entries(),
|
||||||
|
).map(([date, balance]) => ({
|
||||||
|
date: new Date(date).toLocaleDateString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
}),
|
||||||
|
solde: Math.round(balance),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Per account balance calculation
|
||||||
|
const accountBalances = new Map<string, Map<string, number>>();
|
||||||
|
accounts.forEach((account: Account) => {
|
||||||
|
accountBalances.set(account.id, new Map());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate running balance per account
|
||||||
|
const accountRunningBalances = new Map<string, number>();
|
||||||
|
accounts.forEach((account: Account) => {
|
||||||
|
accountRunningBalances.set(account.id, account.initialBalance || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add transactions before startDate per account
|
||||||
|
if (beforeStartDateData?.transactions) {
|
||||||
|
const beforeStartTransactions = beforeStartDateData.transactions.filter(
|
||||||
|
(t) => {
|
||||||
|
const transactionDate = new Date(t.date);
|
||||||
|
return transactionDate < startDate;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
beforeStartTransactions.forEach((t) => {
|
||||||
|
const currentBalance = accountRunningBalances.get(t.accountId) || 0;
|
||||||
|
accountRunningBalances.set(t.accountId, currentBalance + t.amount);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter transactions by account if needed
|
||||||
|
const transactionsForAccounts = selectedAccounts.includes("all")
|
||||||
|
? sortedTransactions
|
||||||
|
: sortedTransactions.filter((t) =>
|
||||||
|
selectedAccounts.includes(t.accountId),
|
||||||
|
);
|
||||||
|
|
||||||
|
transactionsForAccounts.forEach((t) => {
|
||||||
|
const currentBalance = accountRunningBalances.get(t.accountId) || 0;
|
||||||
|
const newBalance = currentBalance + t.amount;
|
||||||
|
accountRunningBalances.set(t.accountId, newBalance);
|
||||||
|
|
||||||
|
const accountDates = accountBalances.get(t.accountId);
|
||||||
|
if (accountDates) {
|
||||||
|
accountDates.set(t.date, newBalance);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge all dates and create data points
|
||||||
|
const allDates = new Set<string>();
|
||||||
|
accountBalances.forEach((dates) => {
|
||||||
|
dates.forEach((_, date) => allDates.add(date));
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedDates = Array.from(allDates).sort();
|
||||||
|
const lastBalances = new Map<string, number>();
|
||||||
|
accounts.forEach((account: Account) => {
|
||||||
|
// Start with initial balance + transactions before startDate
|
||||||
|
let accountStartingBalance = account.initialBalance || 0;
|
||||||
|
if (beforeStartDateData?.transactions) {
|
||||||
|
const beforeStartTransactions = beforeStartDateData.transactions.filter(
|
||||||
|
(t) => {
|
||||||
|
const transactionDate = new Date(t.date);
|
||||||
|
return transactionDate < startDate && t.accountId === account.id;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
beforeStartTransactions.forEach((t) => {
|
||||||
|
accountStartingBalance += t.amount;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lastBalances.set(account.id, accountStartingBalance);
|
||||||
|
});
|
||||||
|
|
||||||
|
const perAccountBalanceData: BalanceChartDataPoint[] = sortedDates.map(
|
||||||
|
(date) => {
|
||||||
|
const point: BalanceChartDataPoint = {
|
||||||
|
date: new Date(date).toLocaleDateString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
accounts.forEach((account: Account) => {
|
||||||
|
const accountDates = accountBalances.get(account.id);
|
||||||
|
if (accountDates?.has(date)) {
|
||||||
|
lastBalances.set(account.id, accountDates.get(date)!);
|
||||||
|
}
|
||||||
|
point[account.id] = Math.round(lastBalances.get(account.id) || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return point;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
aggregatedData: aggregatedBalanceData,
|
||||||
|
perAccountData: perAccountBalanceData,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
transactionsData,
|
||||||
|
beforeStartDateData,
|
||||||
|
metadata,
|
||||||
|
selectedAccounts,
|
||||||
|
startDate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
aggregatedData: chartData.aggregatedData,
|
||||||
|
perAccountData: chartData.perAccountData,
|
||||||
|
accounts: metadata?.accounts || [],
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
224
hooks/use-transactions-chart-data.ts
Normal file
224
hooks/use-transactions-chart-data.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTransactions } from "@/lib/hooks";
|
||||||
|
import { useBankingMetadata } from "@/lib/hooks";
|
||||||
|
import type { TransactionsPaginatedParams } from "@/services/banking.service";
|
||||||
|
import type { Category } from "@/lib/types";
|
||||||
|
|
||||||
|
interface MonthlyChartData {
|
||||||
|
month: string;
|
||||||
|
revenus: number;
|
||||||
|
depenses: number;
|
||||||
|
solde: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryChartData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
categoryId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTransactionsChartDataParams {
|
||||||
|
selectedAccounts: string[];
|
||||||
|
selectedCategories: string[];
|
||||||
|
period: "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
||||||
|
customStartDate?: Date;
|
||||||
|
customEndDate?: Date;
|
||||||
|
showReconciled: string;
|
||||||
|
searchQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransactionsChartData({
|
||||||
|
selectedAccounts,
|
||||||
|
selectedCategories,
|
||||||
|
period,
|
||||||
|
customStartDate,
|
||||||
|
customEndDate,
|
||||||
|
showReconciled,
|
||||||
|
searchQuery,
|
||||||
|
}: UseTransactionsChartDataParams) {
|
||||||
|
const { data: metadata } = useBankingMetadata();
|
||||||
|
|
||||||
|
// Calculate start date based on period
|
||||||
|
const startDate = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
switch (period) {
|
||||||
|
case "1month":
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
case "3months":
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
|
||||||
|
case "6months":
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
||||||
|
case "12months":
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
|
||||||
|
case "custom":
|
||||||
|
return customStartDate || new Date(0);
|
||||||
|
default:
|
||||||
|
return new Date(0);
|
||||||
|
}
|
||||||
|
}, [period, customStartDate]);
|
||||||
|
|
||||||
|
// Calculate end date (only for custom period)
|
||||||
|
const endDate = useMemo(() => {
|
||||||
|
if (period === "custom" && customEndDate) {
|
||||||
|
return customEndDate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [period, customEndDate]);
|
||||||
|
|
||||||
|
// Build params for fetching all transactions (no pagination)
|
||||||
|
const chartParams = useMemo<TransactionsPaginatedParams>(() => {
|
||||||
|
const params: TransactionsPaginatedParams = {
|
||||||
|
limit: 10000, // Large limit to get all transactions
|
||||||
|
offset: 0,
|
||||||
|
sortField: "date",
|
||||||
|
sortOrder: "asc",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate && period !== "all") {
|
||||||
|
params.startDate = startDate.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
params.endDate = endDate.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
if (!selectedAccounts.includes("all")) {
|
||||||
|
params.accountIds = selectedAccounts;
|
||||||
|
}
|
||||||
|
if (!selectedCategories.includes("all")) {
|
||||||
|
if (selectedCategories.includes("uncategorized")) {
|
||||||
|
params.includeUncategorized = true;
|
||||||
|
} else {
|
||||||
|
params.categoryIds = selectedCategories;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (searchQuery) {
|
||||||
|
params.search = searchQuery;
|
||||||
|
}
|
||||||
|
if (showReconciled !== "all") {
|
||||||
|
params.isReconciled = showReconciled === "reconciled";
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}, [
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
selectedAccounts,
|
||||||
|
selectedCategories,
|
||||||
|
searchQuery,
|
||||||
|
showReconciled,
|
||||||
|
period,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fetch all filtered transactions for chart
|
||||||
|
const { data: transactionsData, isLoading } = useTransactions(
|
||||||
|
chartParams,
|
||||||
|
!!metadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate monthly chart data
|
||||||
|
const monthlyData = useMemo(() => {
|
||||||
|
if (!transactionsData || !metadata) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactions = transactionsData.transactions;
|
||||||
|
|
||||||
|
// Monthly breakdown
|
||||||
|
const monthlyMap = new Map<string, { income: number; expenses: number }>();
|
||||||
|
transactions.forEach((t) => {
|
||||||
|
const monthKey = t.date.substring(0, 7);
|
||||||
|
const current = monthlyMap.get(monthKey) || { income: 0, expenses: 0 };
|
||||||
|
if (t.amount >= 0) {
|
||||||
|
current.income += t.amount;
|
||||||
|
} else {
|
||||||
|
current.expenses += Math.abs(t.amount);
|
||||||
|
}
|
||||||
|
monthlyMap.set(monthKey, current);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format months with year: use short format for better readability
|
||||||
|
const sortedMonths = Array.from(monthlyMap.entries()).sort((a, b) =>
|
||||||
|
a[0].localeCompare(b[0]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const monthlyChartData: MonthlyChartData[] = sortedMonths.map(
|
||||||
|
([monthKey, values]) => {
|
||||||
|
const date = new Date(monthKey + "-01");
|
||||||
|
|
||||||
|
// Format: "janv. 24" instead of "janv. 2024" for compactness
|
||||||
|
const monthLabel = date.toLocaleDateString("fr-FR", {
|
||||||
|
month: "short",
|
||||||
|
});
|
||||||
|
const yearShort = date.getFullYear().toString().slice(-2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
month: `${monthLabel} ${yearShort}`,
|
||||||
|
revenus: values.income,
|
||||||
|
depenses: values.expenses,
|
||||||
|
solde: values.income - values.expenses,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return monthlyChartData;
|
||||||
|
}, [transactionsData, metadata]);
|
||||||
|
|
||||||
|
// Calculate category chart data (expenses only)
|
||||||
|
const categoryData = useMemo(() => {
|
||||||
|
if (!transactionsData || !metadata) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactions = transactionsData.transactions;
|
||||||
|
const categoryTotals = new Map<string, number>();
|
||||||
|
|
||||||
|
transactions
|
||||||
|
.filter((t) => t.amount < 0)
|
||||||
|
.forEach((t) => {
|
||||||
|
const catId = t.categoryId || "uncategorized";
|
||||||
|
const current = categoryTotals.get(catId) || 0;
|
||||||
|
categoryTotals.set(catId, current + Math.abs(t.amount));
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryChartData: CategoryChartData[] = Array.from(
|
||||||
|
categoryTotals.entries(),
|
||||||
|
)
|
||||||
|
.map(([categoryId, total]) => {
|
||||||
|
const category = metadata.categories.find(
|
||||||
|
(c: Category) => c.id === categoryId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name: category?.name || "Non catégorisé",
|
||||||
|
value: Math.round(total),
|
||||||
|
color: category?.color || "#94a3b8",
|
||||||
|
icon: category?.icon || "HelpCircle",
|
||||||
|
categoryId: categoryId === "uncategorized" ? null : categoryId,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
return categoryChartData;
|
||||||
|
}, [transactionsData, metadata]);
|
||||||
|
|
||||||
|
// Calculate total amount and count from all filtered transactions
|
||||||
|
const totalAmount = useMemo(() => {
|
||||||
|
if (!transactionsData) return 0;
|
||||||
|
return transactionsData.transactions.reduce((sum, t) => sum + t.amount, 0);
|
||||||
|
}, [transactionsData]);
|
||||||
|
|
||||||
|
const totalCount = useMemo(() => {
|
||||||
|
return transactionsData?.total || 0;
|
||||||
|
}, [transactionsData?.total]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
monthlyData,
|
||||||
|
categoryData,
|
||||||
|
isLoading,
|
||||||
|
totalAmount,
|
||||||
|
totalCount,
|
||||||
|
transactions: transactionsData?.transactions || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
108
hooks/use-transactions-for-account-filter.ts
Normal file
108
hooks/use-transactions-for-account-filter.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTransactions } from "@/lib/hooks";
|
||||||
|
import { useBankingMetadata } from "@/lib/hooks";
|
||||||
|
import type { TransactionsPaginatedParams } from "@/services/banking.service";
|
||||||
|
|
||||||
|
interface UseTransactionsForAccountFilterParams {
|
||||||
|
selectedCategories: string[];
|
||||||
|
period: "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
||||||
|
customStartDate?: Date;
|
||||||
|
customEndDate?: Date;
|
||||||
|
showReconciled: string;
|
||||||
|
searchQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch transactions filtered by categories, period, search, reconciled
|
||||||
|
* but NOT by accounts. Used for displaying account totals in account filter.
|
||||||
|
*/
|
||||||
|
export function useTransactionsForAccountFilter({
|
||||||
|
selectedCategories,
|
||||||
|
period,
|
||||||
|
customStartDate,
|
||||||
|
customEndDate,
|
||||||
|
showReconciled,
|
||||||
|
searchQuery,
|
||||||
|
}: UseTransactionsForAccountFilterParams) {
|
||||||
|
const { data: metadata } = useBankingMetadata();
|
||||||
|
|
||||||
|
// Calculate start date based on period
|
||||||
|
const startDate = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
switch (period) {
|
||||||
|
case "1month":
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
case "3months":
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
|
||||||
|
case "6months":
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
||||||
|
case "12months":
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
|
||||||
|
case "custom":
|
||||||
|
return customStartDate || new Date(0);
|
||||||
|
default:
|
||||||
|
return new Date(0);
|
||||||
|
}
|
||||||
|
}, [period, customStartDate]);
|
||||||
|
|
||||||
|
// Calculate end date (only for custom period)
|
||||||
|
const endDate = useMemo(() => {
|
||||||
|
if (period === "custom" && customEndDate) {
|
||||||
|
return customEndDate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [period, customEndDate]);
|
||||||
|
|
||||||
|
// Build params for fetching all transactions (no pagination, no account filter)
|
||||||
|
const filterParams = useMemo<TransactionsPaginatedParams>(() => {
|
||||||
|
const params: TransactionsPaginatedParams = {
|
||||||
|
limit: 10000, // Large limit to get all transactions
|
||||||
|
offset: 0,
|
||||||
|
sortField: "date",
|
||||||
|
sortOrder: "asc",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startDate && period !== "all") {
|
||||||
|
params.startDate = startDate.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
params.endDate = endDate.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
// NOTE: We intentionally don't filter by accounts here
|
||||||
|
if (!selectedCategories.includes("all")) {
|
||||||
|
if (selectedCategories.includes("uncategorized")) {
|
||||||
|
params.includeUncategorized = true;
|
||||||
|
} else {
|
||||||
|
params.categoryIds = selectedCategories;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (searchQuery) {
|
||||||
|
params.search = searchQuery;
|
||||||
|
}
|
||||||
|
if (showReconciled !== "all") {
|
||||||
|
params.isReconciled = showReconciled === "reconciled";
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}, [
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
selectedCategories,
|
||||||
|
searchQuery,
|
||||||
|
showReconciled,
|
||||||
|
period,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fetch all filtered transactions (without account filter)
|
||||||
|
const { data: transactionsData, isLoading } = useTransactions(
|
||||||
|
filterParams,
|
||||||
|
!!metadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
transactions: transactionsData?.transactions || [],
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user