chore: clean up code by removing trailing whitespace and ensuring consistent formatting across various files = prettier

This commit is contained in:
Julien Froidefond
2025-12-01 08:37:30 +01:00
parent 757b1b84ab
commit e715779de7
98 changed files with 5453 additions and 3126 deletions

View File

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

View File

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

View File

@@ -18,11 +18,15 @@ import {
AccountEditDialog, AccountEditDialog,
AccountBulkActions, AccountBulkActions,
} from "@/components/accounts"; } from "@/components/accounts";
import { import { FolderEditDialog } from "@/components/folders";
FolderEditDialog,
} from "@/components/folders";
import { useBankingData } from "@/lib/hooks"; import { useBankingData } from "@/lib/hooks";
import { updateAccount, deleteAccount, addFolder, updateFolder, deleteFolder } from "@/lib/store-db"; import {
updateAccount,
deleteAccount,
addFolder,
updateFolder,
deleteFolder,
} from "@/lib/store-db";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Building2, Folder, Plus, List, LayoutGrid } from "lucide-react"; import { Building2, Folder, Plus, List, LayoutGrid } from "lucide-react";
@@ -46,7 +50,7 @@ function FolderDropZone({
<div <div
ref={setNodeRef} ref={setNodeRef}
className={cn( className={cn(
isOver && "ring-2 ring-primary ring-offset-2 rounded-lg p-2" isOver && "ring-2 ring-primary ring-offset-2 rounded-lg p-2",
)} )}
> >
{children} {children}
@@ -85,7 +89,7 @@ export default function AccountsPage() {
activationConstraint: { activationConstraint: {
distance: 8, distance: 8,
}, },
}) }),
); );
if (isLoading || !data) { if (isLoading || !data) {
@@ -202,7 +206,9 @@ export default function AccountsPage() {
const handleSaveFolder = async () => { const handleSaveFolder = async () => {
const parentId = const parentId =
folderFormData.parentId === "folder-root" ? null : folderFormData.parentId; folderFormData.parentId === "folder-root"
? null
: folderFormData.parentId;
try { try {
if (editingFolder) { if (editingFolder) {
@@ -231,7 +237,7 @@ export default function AccountsPage() {
const handleDeleteFolder = async (folderId: string) => { const handleDeleteFolder = async (folderId: string) => {
if ( if (
!confirm( !confirm(
"Supprimer ce dossier ? Les comptes seront déplacés à la racine." "Supprimer ce dossier ? Les comptes seront déplacés à la racine.",
) )
) )
return; return;
@@ -270,7 +276,9 @@ export default function AccountsPage() {
} else if (overId.startsWith("account-")) { } else if (overId.startsWith("account-")) {
// Déplacer vers le dossier du compte cible // Déplacer vers le dossier du compte cible
const targetAccountId = overId.replace("account-", ""); const targetAccountId = overId.replace("account-", "");
const targetAccount = data.accounts.find((a) => a.id === targetAccountId); const targetAccount = data.accounts.find(
(a) => a.id === targetAccountId,
);
if (targetAccount) { if (targetAccount) {
targetFolderId = targetAccount.folderId; targetFolderId = targetAccount.folderId;
} }
@@ -289,7 +297,7 @@ export default function AccountsPage() {
folderId: targetFolderId, folderId: targetFolderId,
}; };
const updatedAccounts = data.accounts.map((a) => const updatedAccounts = data.accounts.map((a) =>
a.id === accountId ? updatedAccount : a a.id === accountId ? updatedAccount : a,
); );
update({ update({
...data, ...data,
@@ -311,7 +319,6 @@ export default function AccountsPage() {
} }
}; };
const getTransactionCount = (accountId: string) => { const getTransactionCount = (accountId: string) => {
return data.transactions.filter((t) => t.accountId === accountId).length; return data.transactions.filter((t) => t.accountId === accountId).length;
}; };
@@ -370,7 +377,7 @@ export default function AccountsPage() {
<p <p
className={cn( className={cn(
"text-2xl font-bold", "text-2xl font-bold",
totalBalance >= 0 ? "text-emerald-600" : "text-red-600" totalBalance >= 0 ? "text-emerald-600" : "text-red-600",
)} )}
> >
{formatCurrency(totalBalance)} {formatCurrency(totalBalance)}
@@ -438,21 +445,21 @@ export default function AccountsPage() {
(f) => f.id === account.folderId, (f) => f.id === account.folderId,
); );
return ( return (
<AccountCard <AccountCard
key={account.id} key={account.id}
account={account} account={account}
folder={folder} folder={folder}
transactionCount={getTransactionCount(account.id)} transactionCount={getTransactionCount(account.id)}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
formatCurrency={formatCurrency} formatCurrency={formatCurrency}
isSelected={selectedAccounts.has(account.id)} isSelected={selectedAccounts.has(account.id)}
onSelect={toggleSelectAccount} onSelect={toggleSelectAccount}
draggableId={`account-${account.id}`} draggableId={`account-${account.id}`}
compact={isCompactView} compact={isCompactView}
/> />
); );
})} })}
</div> </div>
</FolderDropZone> </FolderDropZone>
@@ -559,7 +566,7 @@ export default function AccountsPage() {
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
{data.accounts.find( {data.accounts.find(
(a) => a.id === activeId.replace("account-", "") (a) => a.id === activeId.replace("account-", ""),
)?.name || ""} )?.name || ""}
</CardContent> </CardContent>
</Card> </Card>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ export async function GET() {
console.error("Error fetching backups:", error); console.error("Error fetching backups:", error);
return NextResponse.json( return NextResponse.json(
{ success: false, error: "Failed to fetch backups" }, { success: false, error: "Failed to fetch backups" },
{ status: 500 } { status: 500 },
); );
} }
} }
@@ -25,15 +25,18 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const force = body.force === true; // Only allow force for manual backups const force = body.force === true; // Only allow force for manual backups
const backup = await backupService.createBackup(force); const backup = await backupService.createBackup(force);
return NextResponse.json({ success: true, data: backup }); return NextResponse.json({ success: true, data: backup });
} catch (error) { } catch (error) {
console.error("Error creating backup:", error); console.error("Error creating backup:", error);
return NextResponse.json( return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "Failed to create backup" }, {
{ status: 500 } success: false,
error:
error instanceof Error ? error.message : "Failed to create backup",
},
{ status: 500 },
); );
} }
} }

View File

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

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ export default function CategoriesPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null); const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [expandedParents, setExpandedParents] = useState<Set<string>>( const [expandedParents, setExpandedParents] = useState<Set<string>>(
new Set() new Set(),
); );
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
@@ -48,7 +48,9 @@ export default function CategoriesPage() {
parentId: null as string | null, parentId: null as string | null,
}); });
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [recatResults, setRecatResults] = useState<RecategorizationResult[]>([]); const [recatResults, setRecatResults] = useState<RecategorizationResult[]>(
[],
);
const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false); const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false);
const [isRecategorizing, setIsRecategorizing] = useState(false); const [isRecategorizing, setIsRecategorizing] = useState(false);
@@ -116,11 +118,11 @@ export default function CategoriesPage() {
} }
const categoryTransactions = data.transactions.filter((t) => const categoryTransactions = data.transactions.filter((t) =>
categoryIds.includes(t.categoryId || "") categoryIds.includes(t.categoryId || ""),
); );
const total = categoryTransactions.reduce( const total = categoryTransactions.reduce(
(sum, t) => sum + Math.abs(t.amount), (sum, t) => sum + Math.abs(t.amount),
0 0,
); );
const count = categoryTransactions.length; const count = categoryTransactions.length;
return { total, count }; return { total, count };
@@ -150,7 +152,13 @@ export default function CategoriesPage() {
const handleNewCategory = (parentId: string | null = null) => { const handleNewCategory = (parentId: string | null = null) => {
setEditingCategory(null); setEditingCategory(null);
setFormData({ name: "", color: "#22c55e", icon: "tag", keywords: [], parentId }); setFormData({
name: "",
color: "#22c55e",
icon: "tag",
keywords: [],
parentId,
});
setIsDialogOpen(true); setIsDialogOpen(true);
}; };
@@ -222,7 +230,7 @@ export default function CategoriesPage() {
for (const transaction of uncategorized) { for (const transaction of uncategorized) {
const categoryId = autoCategorize( const categoryId = autoCategorize(
transaction.description + " " + (transaction.memo || ""), transaction.description + " " + (transaction.memo || ""),
data.categories data.categories,
); );
if (categoryId) { if (categoryId) {
const category = data.categories.find((c) => c.id === categoryId); const category = data.categories.find((c) => c.id === categoryId);
@@ -245,7 +253,7 @@ export default function CategoriesPage() {
}; };
const uncategorizedCount = data.transactions.filter( const uncategorizedCount = data.transactions.filter(
(t) => !t.categoryId (t) => !t.categoryId,
).length; ).length;
// Filtrer les catégories selon la recherche // Filtrer les catégories selon la recherche
@@ -259,7 +267,7 @@ export default function CategoriesPage() {
return children.some( return children.some(
(c) => (c) =>
c.name.toLowerCase().includes(query) || c.name.toLowerCase().includes(query) ||
c.keywords.some((k) => k.toLowerCase().includes(query)) c.keywords.some((k) => k.toLowerCase().includes(query)),
); );
}); });
@@ -305,9 +313,9 @@ export default function CategoriesPage() {
(c) => (c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) || c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.keywords.some((k) => c.keywords.some((k) =>
k.toLowerCase().includes(searchQuery.toLowerCase()) k.toLowerCase().includes(searchQuery.toLowerCase()),
) || ) ||
parent.name.toLowerCase().includes(searchQuery.toLowerCase()) parent.name.toLowerCase().includes(searchQuery.toLowerCase()),
) )
: allChildren; : allChildren;
const stats = getCategoryStats(parent.id, true); const stats = getCategoryStats(parent.id, true);
@@ -393,7 +401,9 @@ export default function CategoriesPage() {
{result.transaction.description} {result.transaction.description}
</p> </p>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{new Date(result.transaction.date).toLocaleDateString("fr-FR")} {new Date(result.transaction.date).toLocaleDateString(
"fr-FR",
)}
{" • "} {" • "}
{new Intl.NumberFormat("fr-FR", { {new Intl.NumberFormat("fr-FR", {
style: "currency", style: "currency",
@@ -424,9 +434,7 @@ export default function CategoriesPage() {
)} )}
<div className="flex justify-end pt-4 border-t"> <div className="flex justify-end pt-4 border-t">
<Button onClick={() => setIsRecatDialogOpen(false)}> <Button onClick={() => setIsRecatDialogOpen(false)}>Fermer</Button>
Fermer
</Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

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

View File

@@ -21,17 +21,17 @@ export default function DashboardPage() {
// Filter data based on selected accounts // Filter data based on selected accounts
const filteredData = useMemo<BankingData | null>(() => { const filteredData = useMemo<BankingData | null>(() => {
if (!data) return null; if (!data) return null;
if (selectedAccounts.includes("all") || selectedAccounts.length === 0) { if (selectedAccounts.includes("all") || selectedAccounts.length === 0) {
return data; return data;
} }
const filteredAccounts = data.accounts.filter((a) => const filteredAccounts = data.accounts.filter((a) =>
selectedAccounts.includes(a.id) selectedAccounts.includes(a.id),
); );
const filteredAccountIds = new Set(filteredAccounts.map((a) => a.id)); const filteredAccountIds = new Set(filteredAccounts.map((a) => a.id));
const filteredTransactions = data.transactions.filter((t) => const filteredTransactions = data.transactions.filter((t) =>
filteredAccountIds.has(t.accountId) filteredAccountIds.has(t.accountId),
); );
return { return {

View File

@@ -11,7 +11,11 @@ import { useBankingData } from "@/lib/hooks";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Sparkles, RefreshCw } from "lucide-react"; import { Sparkles, RefreshCw } from "lucide-react";
import { updateCategory, autoCategorize, updateTransaction } from "@/lib/store-db"; import {
updateCategory,
autoCategorize,
updateTransaction,
} from "@/lib/store-db";
import { import {
normalizeDescription, normalizeDescription,
suggestKeyword, suggestKeyword,
@@ -33,7 +37,7 @@ export default function RulesPage() {
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);
@@ -64,7 +68,7 @@ export default function RulesPage() {
totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0), totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0),
suggestedKeyword: suggestKeyword(descriptions), suggestedKeyword: suggestKeyword(descriptions),
}; };
} },
); );
// Filter by search query // Filter by search query
@@ -75,7 +79,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),
); );
} }
@@ -146,14 +150,16 @@ export default function RulesPage() {
if (!data) return; if (!data) return;
// 1. Add keyword to category // 1. Add keyword to category
const category = data.categories.find((c) => c.id === ruleData.categoryId); const category = data.categories.find(
(c) => c.id === ruleData.categoryId,
);
if (!category) { if (!category) {
throw new Error("Category not found"); throw new Error("Category not found");
} }
// Check if keyword already exists // Check if keyword already exists
const keywordExists = category.keywords.some( const keywordExists = category.keywords.some(
(k) => k.toLowerCase() === ruleData.keyword.toLowerCase() (k) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
); );
if (!keywordExists) { if (!keywordExists) {
@@ -166,19 +172,19 @@ export default function RulesPage() {
// 2. Apply to existing transactions if requested // 2. Apply to existing transactions if requested
if (ruleData.applyToExisting) { if (ruleData.applyToExisting) {
const transactions = data.transactions.filter((t) => const transactions = data.transactions.filter((t) =>
ruleData.transactionIds.includes(t.id) ruleData.transactionIds.includes(t.id),
); );
await Promise.all( await Promise.all(
transactions.map((t) => transactions.map((t) =>
updateTransaction({ ...t, categoryId: ruleData.categoryId }) updateTransaction({ ...t, categoryId: ruleData.categoryId }),
) ),
); );
} }
refresh(); refresh();
}, },
[data, refresh] [data, refresh],
); );
const handleAutoCategorize = useCallback(async () => { const handleAutoCategorize = useCallback(async () => {
@@ -192,7 +198,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 || ""),
data.categories data.categories,
); );
if (categoryId) { if (categoryId) {
await updateTransaction({ ...transaction, categoryId }); await updateTransaction({ ...transaction, categoryId });
@@ -201,7 +207,9 @@ export default function RulesPage() {
} }
refresh(); refresh();
alert(`${categorizedCount} transaction(s) catégorisée(s) automatiquement`); alert(
`${categorizedCount} transaction(s) catégorisée(s) automatiquement`,
);
} catch (error) { } catch (error) {
console.error("Error auto-categorizing:", error); console.error("Error auto-categorizing:", error);
alert("Erreur lors de la catégorisation automatique"); alert("Erreur lors de la catégorisation automatique");
@@ -217,8 +225,8 @@ export default function RulesPage() {
try { try {
await Promise.all( await Promise.all(
group.transactions.map((t) => group.transactions.map((t) =>
updateTransaction({ ...t, categoryId }) updateTransaction({ ...t, categoryId }),
) ),
); );
refresh(); refresh();
} catch (error) { } catch (error) {
@@ -226,7 +234,7 @@ export default function RulesPage() {
alert("Erreur lors de la catégorisation"); alert("Erreur lors de la catégorisation");
} }
}, },
[data, refresh] [data, refresh],
); );
if (isLoading || !data) { if (isLoading || !data) {
@@ -241,7 +249,8 @@ export default function RulesPage() {
<span className="flex items-center gap-2 flex-wrap"> <span className="flex items-center gap-2 flex-wrap">
<span className="text-xs md:text-base"> <span className="text-xs md:text-base">
{transactionGroups.length} groupe {transactionGroups.length} groupe
{transactionGroups.length > 1 ? "s" : ""} de transactions similaires {transactionGroups.length > 1 ? "s" : ""} de transactions
similaires
</span> </span>
<Badge variant="secondary" className="text-[10px] md:text-xs"> <Badge variant="secondary" className="text-[10px] md:text-xs">
{uncategorizedCount} non catégorisées {uncategorizedCount} non catégorisées
@@ -321,4 +330,3 @@ export default function RulesPage() {
</PageLayout> </PageLayout>
); );
} }

View File

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

View File

@@ -29,7 +29,11 @@ import { Badge } from "@/components/ui/badge";
import { CategoryIcon } from "@/components/ui/category-icon"; import { CategoryIcon } from "@/components/ui/category-icon";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Filter, X, Wallet, CircleSlash, Calendar } from "lucide-react"; import { Filter, X, Wallet, CircleSlash, Calendar } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar as CalendarComponent } from "@/components/ui/calendar"; import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -43,10 +47,17 @@ export default function StatisticsPage() {
const { data, isLoading } = useBankingData(); const { data, isLoading } = useBankingData();
const [period, setPeriod] = useState<Period>("6months"); const [period, setPeriod] = useState<Period>("6months");
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]); const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]); const [selectedCategories, setSelectedCategories] = useState<string[]>([
const [excludeInternalTransfers, setExcludeInternalTransfers] = useState(true); "all",
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined); ]);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined); const [excludeInternalTransfers, setExcludeInternalTransfers] =
useState(true);
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
undefined,
);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
undefined,
);
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false); const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
// Get start date based on period // Get start date based on period
@@ -80,7 +91,7 @@ export default function StatisticsPage() {
const internalTransferCategory = useMemo(() => { const internalTransferCategory = useMemo(() => {
if (!data) return null; if (!data) return null;
return data.categories.find( return data.categories.find(
(c) => c.name.toLowerCase() === "virement interne" (c) => c.name.toLowerCase() === "virement interne",
); );
}, [data]); }, [data]);
@@ -88,73 +99,93 @@ export default function StatisticsPage() {
const transactionsForAccountFilter = useMemo(() => { const transactionsForAccountFilter = useMemo(() => {
if (!data) return []; if (!data) return [];
return data.transactions.filter((t) => { return data.transactions
const transactionDate = new Date(t.date); .filter((t) => {
if (endDate) { const transactionDate = new Date(t.date);
// Custom date range if (endDate) {
const endOfDay = new Date(endDate); // Custom date range
endOfDay.setHours(23, 59, 59, 999); const endOfDay = new Date(endDate);
if (transactionDate < startDate || transactionDate > endOfDay) { endOfDay.setHours(23, 59, 59, 999);
return false; if (transactionDate < startDate || transactionDate > endOfDay) {
} return false;
} else { }
// Standard period
if (transactionDate < startDate) {
return false;
}
}
return true;
}).filter((t) => {
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
return !t.categoryId;
} else { } else {
return t.categoryId && selectedCategories.includes(t.categoryId); // Standard period
if (transactionDate < startDate) {
return false;
}
} }
} return true;
return true; })
}).filter((t) => { .filter((t) => {
// Exclude "Virement interne" category if checkbox is checked if (!selectedCategories.includes("all")) {
if (excludeInternalTransfers && internalTransferCategory) { if (selectedCategories.includes("uncategorized")) {
return t.categoryId !== internalTransferCategory.id; return !t.categoryId;
} } else {
return true; return t.categoryId && selectedCategories.includes(t.categoryId);
}); }
}, [data, startDate, endDate, selectedCategories, excludeInternalTransfers, internalTransferCategory]); }
return true;
})
.filter((t) => {
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
return t.categoryId !== internalTransferCategory.id;
}
return true;
});
}, [
data,
startDate,
endDate,
selectedCategories,
excludeInternalTransfers,
internalTransferCategory,
]);
// Transactions filtered for category filter (by accounts, period - not categories) // Transactions filtered for category filter (by accounts, period - not categories)
const transactionsForCategoryFilter = useMemo(() => { const transactionsForCategoryFilter = useMemo(() => {
if (!data) return []; if (!data) return [];
return data.transactions.filter((t) => { return data.transactions
const transactionDate = new Date(t.date); .filter((t) => {
if (endDate) { const transactionDate = new Date(t.date);
// Custom date range if (endDate) {
const endOfDay = new Date(endDate); // Custom date range
endOfDay.setHours(23, 59, 59, 999); const endOfDay = new Date(endDate);
if (transactionDate < startDate || transactionDate > endOfDay) { endOfDay.setHours(23, 59, 59, 999);
return false; if (transactionDate < startDate || transactionDate > endOfDay) {
return false;
}
} else {
// Standard period
if (transactionDate < startDate) {
return false;
}
} }
} else { return true;
// Standard period })
if (transactionDate < startDate) { .filter((t) => {
return false; if (!selectedAccounts.includes("all")) {
return selectedAccounts.includes(t.accountId);
} }
} return true;
return true; })
}).filter((t) => { .filter((t) => {
if (!selectedAccounts.includes("all")) { // Exclude "Virement interne" category if checkbox is checked
return selectedAccounts.includes(t.accountId); if (excludeInternalTransfers && internalTransferCategory) {
} return t.categoryId !== internalTransferCategory.id;
return true; }
}).filter((t) => { return true;
// Exclude "Virement interne" category if checkbox is checked });
if (excludeInternalTransfers && internalTransferCategory) { }, [
return t.categoryId !== internalTransferCategory.id; data,
} startDate,
return true; endDate,
}); selectedAccounts,
}, [data, startDate, endDate, selectedAccounts, excludeInternalTransfers, internalTransferCategory]); excludeInternalTransfers,
internalTransferCategory,
]);
const stats = useMemo(() => { const stats = useMemo(() => {
if (!data) return null; if (!data) return null;
@@ -174,8 +205,8 @@ export default function StatisticsPage() {
// Filter by accounts // Filter by accounts
if (!selectedAccounts.includes("all")) { if (!selectedAccounts.includes("all")) {
transactions = transactions.filter( transactions = transactions.filter((t) =>
(t) => selectedAccounts.includes(t.accountId) selectedAccounts.includes(t.accountId),
); );
} }
@@ -185,7 +216,7 @@ export default function StatisticsPage() {
transactions = transactions.filter((t) => !t.categoryId); transactions = transactions.filter((t) => !t.categoryId);
} else { } else {
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.categoryId && selectedCategories.includes(t.categoryId) (t) => t.categoryId && selectedCategories.includes(t.categoryId),
); );
} }
} }
@@ -193,7 +224,7 @@ export default function StatisticsPage() {
// Exclude "Virement interne" category if checkbox is checked // Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) { if (excludeInternalTransfers && internalTransferCategory) {
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.categoryId !== internalTransferCategory.id (t) => t.categoryId !== internalTransferCategory.id,
); );
} }
@@ -264,7 +295,9 @@ export default function StatisticsPage() {
categoryTotalsByParent.set(groupId, current + Math.abs(t.amount)); categoryTotalsByParent.set(groupId, current + Math.abs(t.amount));
}); });
const categoryChartDataByParent = Array.from(categoryTotalsByParent.entries()) const categoryChartDataByParent = Array.from(
categoryTotalsByParent.entries(),
)
.map(([groupId, total]) => { .map(([groupId, total]) => {
const category = data.categories.find((c) => c.id === groupId); const category = data.categories.find((c) => c.id === groupId);
return { return {
@@ -278,7 +311,7 @@ export default function StatisticsPage() {
// Top expenses - deduplicate by ID and sort by amount (most negative first) // Top expenses - deduplicate by ID and sort by amount (most negative first)
const uniqueTransactions = Array.from( const uniqueTransactions = Array.from(
new Map(transactions.map((t) => [t.id, t])).values() new Map(transactions.map((t) => [t.id, t])).values(),
); );
const topExpenses = uniqueTransactions const topExpenses = uniqueTransactions
.filter((t) => t.amount < 0) .filter((t) => t.amount < 0)
@@ -304,7 +337,7 @@ export default function StatisticsPage() {
// Balance evolution - Aggregated (using filtered transactions) // Balance evolution - Aggregated (using filtered transactions)
const sortedFilteredTransactions = [...transactions].sort( const sortedFilteredTransactions = [...transactions].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
); );
// Calculate starting balance: initialBalance + transactions before startDate // Calculate starting balance: initialBalance + transactions before startDate
@@ -353,7 +386,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",
@@ -459,7 +492,7 @@ export default function StatisticsPage() {
.forEach((t) => { .forEach((t) => {
const monthKey = t.date.substring(0, 7); const monthKey = t.date.substring(0, 7);
const catId = t.categoryId || "uncategorized"; const catId = t.categoryId || "uncategorized";
if (!categoryTrendByMonth.has(monthKey)) { if (!categoryTrendByMonth.has(monthKey)) {
categoryTrendByMonth.set(monthKey, new Map()); categoryTrendByMonth.set(monthKey, new Map());
} }
@@ -501,7 +534,7 @@ export default function StatisticsPage() {
// Category is a parent itself // Category is a parent itself
groupId = category.id; groupId = category.id;
} }
if (!categoryTrendByMonthByParent.has(monthKey)) { if (!categoryTrendByMonthByParent.has(monthKey)) {
categoryTrendByMonthByParent.set(monthKey, new Map()); categoryTrendByMonthByParent.set(monthKey, new Map());
} }
@@ -581,7 +614,15 @@ export default function StatisticsPage() {
categoryTrendDataByParent, categoryTrendDataByParent,
yearOverYearData, yearOverYearData,
}; };
}, [data, startDate, endDate, selectedAccounts, selectedCategories, excludeInternalTransfers, internalTransferCategory]); }, [
data,
startDate,
endDate,
selectedAccounts,
selectedCategories,
excludeInternalTransfers,
internalTransferCategory,
]);
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
@@ -646,9 +687,15 @@ export default function StatisticsPage() {
</Select> </Select>
{period === "custom" && ( {period === "custom" && (
<Popover open={isCustomDatePickerOpen} onOpenChange={setIsCustomDatePickerOpen}> <Popover
open={isCustomDatePickerOpen}
onOpenChange={setIsCustomDatePickerOpen}
>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" className="w-full md:w-[280px] justify-start text-left font-normal"> <Button
variant="outline"
className="w-full md:w-[280px] justify-start text-left font-normal"
>
<Calendar className="mr-2 h-4 w-4" /> <Calendar className="mr-2 h-4 w-4" />
{customStartDate && customEndDate ? ( {customStartDate && customEndDate ? (
<> <>
@@ -658,14 +705,18 @@ export default function StatisticsPage() {
) : customStartDate ? ( ) : customStartDate ? (
format(customStartDate, "PPP", { locale: fr }) format(customStartDate, "PPP", { locale: fr })
) : ( ) : (
<span className="text-muted-foreground">Sélectionner les dates</span> <span className="text-muted-foreground">
Sélectionner les dates
</span>
)} )}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Date de début</label> <label className="text-sm font-medium">
Date de début
</label>
<CalendarComponent <CalendarComponent
mode="single" mode="single"
selected={customStartDate} selected={customStartDate}
@@ -684,7 +735,11 @@ export default function StatisticsPage() {
mode="single" mode="single"
selected={customEndDate} selected={customEndDate}
onSelect={(date) => { onSelect={(date) => {
if (date && customStartDate && date < customStartDate) { if (
date &&
customStartDate &&
date < customStartDate
) {
return; return;
} }
setCustomEndDate(date); setCustomEndDate(date);
@@ -731,7 +786,9 @@ export default function StatisticsPage() {
<Checkbox <Checkbox
id="exclude-internal-transfers" id="exclude-internal-transfers"
checked={excludeInternalTransfers} checked={excludeInternalTransfers}
onCheckedChange={(checked) => setExcludeInternalTransfers(checked === true)} onCheckedChange={(checked) =>
setExcludeInternalTransfers(checked === true)
}
/> />
<label <label
htmlFor="exclude-internal-transfers" htmlFor="exclude-internal-transfers"
@@ -747,13 +804,17 @@ export default function StatisticsPage() {
selectedAccounts={selectedAccounts} selectedAccounts={selectedAccounts}
onRemoveAccount={(id) => { onRemoveAccount={(id) => {
const newAccounts = selectedAccounts.filter((a) => a !== id); const newAccounts = selectedAccounts.filter((a) => a !== id);
setSelectedAccounts(newAccounts.length > 0 ? newAccounts : ["all"]); setSelectedAccounts(
newAccounts.length > 0 ? newAccounts : ["all"],
);
}} }}
onClearAccounts={() => setSelectedAccounts(["all"])} onClearAccounts={() => setSelectedAccounts(["all"])}
selectedCategories={selectedCategories} selectedCategories={selectedCategories}
onRemoveCategory={(id) => { onRemoveCategory={(id) => {
const newCategories = selectedCategories.filter((c) => c !== id); const newCategories = selectedCategories.filter((c) => c !== id);
setSelectedCategories(newCategories.length > 0 ? newCategories : ["all"]); setSelectedCategories(
newCategories.length > 0 ? newCategories : ["all"],
);
}} }}
onClearCategories={() => setSelectedCategories(["all"])} onClearCategories={() => setSelectedCategories(["all"])}
period={period} period={period}
@@ -772,7 +833,9 @@ export default function StatisticsPage() {
{/* Vue d'ensemble */} {/* Vue d'ensemble */}
<section className="mb-4 md:mb-8"> <section className="mb-4 md:mb-8">
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">Vue d'ensemble</h2> <h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">
Vue d'ensemble
</h2>
<StatsSummaryCards <StatsSummaryCards
totalIncome={stats.totalIncome} totalIncome={stats.totalIncome}
totalExpenses={stats.totalExpenses} totalExpenses={stats.totalExpenses}
@@ -797,7 +860,9 @@ export default function StatisticsPage() {
{/* Revenus et Dépenses */} {/* Revenus et Dépenses */}
<section className="mb-4 md:mb-8"> <section className="mb-4 md:mb-8">
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">Revenus et Dépenses</h2> <h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">
Revenus et Dépenses
</h2>
<div className="grid gap-4 md:gap-6 lg:grid-cols-2"> <div className="grid gap-4 md:gap-6 lg:grid-cols-2">
<MonthlyChart <MonthlyChart
data={stats.monthlyChartData} data={stats.monthlyChartData}
@@ -824,7 +889,9 @@ export default function StatisticsPage() {
{/* Analyse par Catégorie */} {/* Analyse par Catégorie */}
<section className="mb-4 md:mb-8"> <section className="mb-4 md:mb-8">
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">Analyse par Catégorie</h2> <h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">
Analyse par Catégorie
</h2>
<div className="grid gap-4 md:gap-6"> <div className="grid gap-4 md:gap-6">
<CategoryPieChart <CategoryPieChart
data={stats.categoryChartData} data={stats.categoryChartData}
@@ -853,7 +920,6 @@ export default function StatisticsPage() {
/> />
</div> </div>
</section> </section>
</PageLayout> </PageLayout>
); );
} }
@@ -895,7 +961,9 @@ function ActiveFilters({
if (!hasActiveFilters) return null; if (!hasActiveFilters) return null;
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id)); const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
const selectedCats = categories.filter((c) => selectedCategories.includes(c.id)); const selectedCats = categories.filter((c) =>
selectedCategories.includes(c.id),
);
const isUncategorized = selectedCategories.includes("uncategorized"); const isUncategorized = selectedCategories.includes("uncategorized");
const getPeriodLabel = (p: Period) => { const getPeriodLabel = (p: Period) => {
@@ -929,7 +997,11 @@ function ActiveFilters({
<Filter className="h-3 w-3 md:h-3.5 md:w-3.5 text-muted-foreground" /> <Filter className="h-3 w-3 md:h-3.5 md:w-3.5 text-muted-foreground" />
{selectedAccs.map((acc) => ( {selectedAccs.map((acc) => (
<Badge key={acc.id} variant="secondary" className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"> <Badge
key={acc.id}
variant="secondary"
className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"
>
<Wallet className="h-2.5 w-2.5 md:h-3 md:w-3" /> <Wallet className="h-2.5 w-2.5 md:h-3 md:w-3" />
{acc.name} {acc.name}
<button <button
@@ -942,10 +1014,16 @@ function ActiveFilters({
))} ))}
{isUncategorized && ( {isUncategorized && (
<Badge variant="secondary" className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"> <Badge
variant="secondary"
className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"
>
<CircleSlash className="h-2.5 w-2.5 md:h-3 md:w-3" /> <CircleSlash className="h-2.5 w-2.5 md:h-3 md:w-3" />
Non catégorisé Non catégorisé
<button onClick={onClearCategories} className="ml-0.5 md:ml-1 hover:text-foreground"> <button
onClick={onClearCategories}
className="ml-0.5 md:ml-1 hover:text-foreground"
>
<X className="h-2.5 w-2.5 md:h-3 md:w-3" /> <X className="h-2.5 w-2.5 md:h-3 md:w-3" />
</button> </button>
</Badge> </Badge>
@@ -961,7 +1039,11 @@ function ActiveFilters({
borderColor: `${cat.color}30`, borderColor: `${cat.color}30`,
}} }}
> >
<CategoryIcon icon={cat.icon} color={cat.color} size={isMobile ? 10 : 12} /> <CategoryIcon
icon={cat.icon}
color={cat.color}
size={isMobile ? 10 : 12}
/>
{cat.name} {cat.name}
<button <button
onClick={() => onRemoveCategory(cat.id)} onClick={() => onRemoveCategory(cat.id)}
@@ -973,10 +1055,16 @@ function ActiveFilters({
))} ))}
{hasPeriod && ( {hasPeriod && (
<Badge variant="secondary" className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"> <Badge
variant="secondary"
className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"
>
<Calendar className="h-2.5 w-2.5 md:h-3 md:w-3" /> <Calendar className="h-2.5 w-2.5 md:h-3 md:w-3" />
{getPeriodLabel(period)} {getPeriodLabel(period)}
<button onClick={onClearPeriod} className="ml-0.5 md:ml-1 hover:text-foreground"> <button
onClick={onClearPeriod}
className="ml-0.5 md:ml-1 hover:text-foreground"
>
<X className="h-2.5 w-2.5 md:h-3 md:w-3" /> <X className="h-2.5 w-2.5 md:h-3 md:w-3" />
</button> </button>
</Badge> </Badge>

View File

@@ -37,19 +37,27 @@ export default function TransactionsPage() {
} }
}, [searchParams]); }, [searchParams]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]); const [selectedCategories, setSelectedCategories] = useState<string[]>([
"all",
]);
const [showReconciled, setShowReconciled] = useState<string>("all"); const [showReconciled, setShowReconciled] = useState<string>("all");
const [period, setPeriod] = useState<Period>("all"); const [period, setPeriod] = useState<Period>("all");
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined); const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined); undefined,
);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
undefined,
);
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false); const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
const [sortField, setSortField] = useState<SortField>("date"); const [sortField, setSortField] = useState<SortField>("date");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc"); const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>( const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
new Set() new Set(),
); );
const [ruleDialogOpen, setRuleDialogOpen] = useState(false); const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(null); const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(
null,
);
// Get start date based on period // Get start date based on period
const startDate = useMemo(() => { const startDate = useMemo(() => {
@@ -104,7 +112,7 @@ export default function TransactionsPage() {
transactions = transactions.filter( transactions = transactions.filter(
(t) => (t) =>
t.description.toLowerCase().includes(query) || t.description.toLowerCase().includes(query) ||
t.memo?.toLowerCase().includes(query) t.memo?.toLowerCase().includes(query),
); );
} }
@@ -113,7 +121,7 @@ export default function TransactionsPage() {
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),
); );
} }
} }
@@ -121,12 +129,20 @@ export default function TransactionsPage() {
if (showReconciled !== "all") { if (showReconciled !== "all") {
const isReconciled = showReconciled === "reconciled"; const isReconciled = showReconciled === "reconciled";
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.isReconciled === isReconciled (t) => t.isReconciled === isReconciled,
); );
} }
return transactions; return transactions;
}, [data, searchQuery, selectedCategories, showReconciled, period, startDate, endDate]); }, [
data,
searchQuery,
selectedCategories,
showReconciled,
period,
startDate,
endDate,
]);
// Transactions filtered for category filter (by accounts, search, reconciled, period - not categories) // Transactions filtered for category filter (by accounts, search, reconciled, period - not categories)
const transactionsForCategoryFilter = useMemo(() => { const transactionsForCategoryFilter = useMemo(() => {
@@ -154,25 +170,33 @@ export default function TransactionsPage() {
transactions = transactions.filter( transactions = transactions.filter(
(t) => (t) =>
t.description.toLowerCase().includes(query) || t.description.toLowerCase().includes(query) ||
t.memo?.toLowerCase().includes(query) t.memo?.toLowerCase().includes(query),
); );
} }
if (!selectedAccounts.includes("all")) { if (!selectedAccounts.includes("all")) {
transactions = transactions.filter( transactions = transactions.filter((t) =>
(t) => selectedAccounts.includes(t.accountId) selectedAccounts.includes(t.accountId),
); );
} }
if (showReconciled !== "all") { if (showReconciled !== "all") {
const isReconciled = showReconciled === "reconciled"; const isReconciled = showReconciled === "reconciled";
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.isReconciled === isReconciled (t) => t.isReconciled === isReconciled,
); );
} }
return transactions; return transactions;
}, [data, searchQuery, selectedAccounts, showReconciled, period, startDate, endDate]); }, [
data,
searchQuery,
selectedAccounts,
showReconciled,
period,
startDate,
endDate,
]);
const filteredTransactions = useMemo(() => { const filteredTransactions = useMemo(() => {
if (!data) return []; if (!data) return [];
@@ -199,13 +223,13 @@ export default function TransactionsPage() {
transactions = transactions.filter( transactions = transactions.filter(
(t) => (t) =>
t.description.toLowerCase().includes(query) || t.description.toLowerCase().includes(query) ||
t.memo?.toLowerCase().includes(query) t.memo?.toLowerCase().includes(query),
); );
} }
if (!selectedAccounts.includes("all")) { if (!selectedAccounts.includes("all")) {
transactions = transactions.filter( transactions = transactions.filter((t) =>
(t) => selectedAccounts.includes(t.accountId) selectedAccounts.includes(t.accountId),
); );
} }
@@ -214,7 +238,7 @@ export default function TransactionsPage() {
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),
); );
} }
} }
@@ -222,7 +246,7 @@ export default function TransactionsPage() {
if (showReconciled !== "all") { if (showReconciled !== "all") {
const isReconciled = showReconciled === "reconciled"; const isReconciled = showReconciled === "reconciled";
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.isReconciled === isReconciled (t) => t.isReconciled === isReconciled,
); );
} }
@@ -268,7 +292,7 @@ export default function TransactionsPage() {
// Find similar transactions (same normalized description) // Find similar transactions (same normalized description)
const normalizedDesc = normalizeDescription(ruleTransaction.description); const normalizedDesc = normalizeDescription(ruleTransaction.description);
const similarTransactions = data.transactions.filter( const similarTransactions = data.transactions.filter(
(t) => normalizeDescription(t.description) === normalizedDesc (t) => normalizeDescription(t.description) === normalizedDesc,
); );
return { return {
@@ -276,7 +300,9 @@ export default function TransactionsPage() {
displayName: ruleTransaction.description, displayName: ruleTransaction.description,
transactions: similarTransactions, transactions: similarTransactions,
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0), totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
suggestedKeyword: suggestKeyword(similarTransactions.map((t) => t.description)), suggestedKeyword: suggestKeyword(
similarTransactions.map((t) => t.description),
),
}; };
}, [ruleTransaction, data]); }, [ruleTransaction, data]);
@@ -290,14 +316,16 @@ export default function TransactionsPage() {
if (!data) return; if (!data) return;
// 1. Add keyword to category // 1. Add keyword to category
const category = data.categories.find((c) => c.id === ruleData.categoryId); const category = data.categories.find(
(c) => c.id === ruleData.categoryId,
);
if (!category) { if (!category) {
throw new Error("Category not found"); throw new Error("Category not found");
} }
// Check if keyword already exists // Check if keyword already exists
const keywordExists = category.keywords.some( const keywordExists = category.keywords.some(
(k) => k.toLowerCase() === ruleData.keyword.toLowerCase() (k) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
); );
if (!keywordExists) { if (!keywordExists) {
@@ -310,20 +338,20 @@ export default function TransactionsPage() {
// 2. Apply to existing transactions if requested // 2. Apply to existing transactions if requested
if (ruleData.applyToExisting) { if (ruleData.applyToExisting) {
const transactions = data.transactions.filter((t) => const transactions = data.transactions.filter((t) =>
ruleData.transactionIds.includes(t.id) ruleData.transactionIds.includes(t.id),
); );
await Promise.all( await Promise.all(
transactions.map((t) => transactions.map((t) =>
updateTransaction({ ...t, categoryId: ruleData.categoryId }) updateTransaction({ ...t, categoryId: ruleData.categoryId }),
) ),
); );
} }
refresh(); refresh();
setRuleDialogOpen(false); setRuleDialogOpen(false);
}, },
[data, refresh] [data, refresh],
); );
if (isLoading || !data) { if (isLoading || !data) {
@@ -355,7 +383,7 @@ export default function TransactionsPage() {
}; };
const updatedTransactions = data.transactions.map((t) => const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t t.id === transactionId ? updatedTransaction : t,
); );
update({ ...data, transactions: updatedTransactions }); update({ ...data, transactions: updatedTransactions });
@@ -381,7 +409,7 @@ export default function TransactionsPage() {
}; };
const updatedTransactions = data.transactions.map((t) => const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t t.id === transactionId ? updatedTransaction : t,
); );
update({ ...data, transactions: updatedTransactions }); update({ ...data, transactions: updatedTransactions });
@@ -399,7 +427,7 @@ export default function TransactionsPage() {
const setCategory = async ( const setCategory = async (
transactionId: string, transactionId: string,
categoryId: string | null categoryId: string | null,
) => { ) => {
const transaction = data.transactions.find((t) => t.id === transactionId); const transaction = data.transactions.find((t) => t.id === transactionId);
if (!transaction) return; if (!transaction) return;
@@ -407,7 +435,7 @@ export default function TransactionsPage() {
const updatedTransaction = { ...transaction, categoryId }; const updatedTransaction = { ...transaction, categoryId };
const updatedTransactions = data.transactions.map((t) => const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t t.id === transactionId ? updatedTransaction : t,
); );
update({ ...data, transactions: updatedTransactions }); update({ ...data, transactions: updatedTransactions });
@@ -425,11 +453,11 @@ export default function TransactionsPage() {
const bulkReconcile = async (reconciled: boolean) => { const bulkReconcile = async (reconciled: boolean) => {
const transactionsToUpdate = data.transactions.filter((t) => const transactionsToUpdate = data.transactions.filter((t) =>
selectedTransactions.has(t.id) selectedTransactions.has(t.id),
); );
const updatedTransactions = data.transactions.map((t) => const updatedTransactions = data.transactions.map((t) =>
selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t,
); );
update({ ...data, transactions: updatedTransactions }); update({ ...data, transactions: updatedTransactions });
setSelectedTransactions(new Set()); setSelectedTransactions(new Set());
@@ -441,8 +469,8 @@ export default function TransactionsPage() {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, isReconciled: reconciled }), body: JSON.stringify({ ...t, isReconciled: reconciled }),
}) }),
) ),
); );
} catch (error) { } catch (error) {
console.error("Failed to update transactions:", error); console.error("Failed to update transactions:", error);
@@ -452,11 +480,11 @@ export default function TransactionsPage() {
const bulkSetCategory = async (categoryId: string | null) => { const bulkSetCategory = async (categoryId: string | null) => {
const transactionsToUpdate = data.transactions.filter((t) => const transactionsToUpdate = data.transactions.filter((t) =>
selectedTransactions.has(t.id) selectedTransactions.has(t.id),
); );
const updatedTransactions = data.transactions.map((t) => const updatedTransactions = data.transactions.map((t) =>
selectedTransactions.has(t.id) ? { ...t, categoryId } : t selectedTransactions.has(t.id) ? { ...t, categoryId } : t,
); );
update({ ...data, transactions: updatedTransactions }); update({ ...data, transactions: updatedTransactions });
setSelectedTransactions(new Set()); setSelectedTransactions(new Set());
@@ -468,8 +496,8 @@ export default function TransactionsPage() {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, categoryId }), body: JSON.stringify({ ...t, categoryId }),
}) }),
) ),
); );
} catch (error) { } catch (error) {
console.error("Failed to update transactions:", error); console.error("Failed to update transactions:", error);
@@ -507,10 +535,10 @@ export default function TransactionsPage() {
const deleteTransaction = async (transactionId: string) => { const deleteTransaction = async (transactionId: string) => {
// Optimistic update // Optimistic update
const updatedTransactions = data.transactions.filter( const updatedTransactions = data.transactions.filter(
(t) => t.id !== transactionId (t) => t.id !== transactionId,
); );
update({ ...data, transactions: updatedTransactions }); update({ ...data, transactions: updatedTransactions });
// Remove from selected if selected // Remove from selected if selected
const newSelected = new Set(selectedTransactions); const newSelected = new Set(selectedTransactions);
newSelected.delete(transactionId); newSelected.delete(transactionId);
@@ -521,7 +549,7 @@ export default function TransactionsPage() {
`/api/banking/transactions?id=${transactionId}`, `/api/banking/transactions?id=${transactionId}`,
{ {
method: "DELETE", method: "DELETE",
} },
); );
if (!response.ok) throw new Error("Failed to delete transaction"); if (!response.ok) throw new Error("Failed to delete transaction");
} catch (error) { } catch (error) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,7 +73,9 @@ export function ParentCategoryRow({
size={isMobile ? 10 : 14} size={isMobile ? 10 : 14}
/> />
</div> </div>
<span className="font-medium text-xs md:text-sm truncate">{parent.name}</span> <span className="font-medium text-xs md:text-sm truncate">
{parent.name}
</span>
{!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
@@ -102,7 +104,11 @@ export function ParentCategoryRow({
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 md:h-7 md:w-7"> <Button
variant="ghost"
size="icon"
className="h-6 w-6 md:h-7 md:w-7"
>
<MoreVertical className="w-3 h-3 md:w-4 md:h-4" /> <MoreVertical className="w-3 h-3 md:w-4 md:h-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -147,4 +153,3 @@ export function ParentCategoryRow({
</div> </div>
); );
} }

View File

@@ -23,7 +23,7 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
// Group accounts by folder // Group accounts by folder
const accountsByFolder = useMemo(() => { const accountsByFolder = useMemo(() => {
const grouped: Record<string, Account[]> = {}; const grouped: Record<string, Account[]> = {};
data.accounts.forEach((account) => { data.accounts.forEach((account) => {
const folderId = account.folderId || "no-folder"; const folderId = account.folderId || "no-folder";
if (!grouped[folderId]) { if (!grouped[folderId]) {
@@ -72,7 +72,12 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
{/* Folder header */} {/* Folder header */}
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<FolderIcon className="w-4 h-4 text-muted-foreground" /> <FolderIcon className="w-4 h-4 text-muted-foreground" />
<h3 className={cn("font-semibold text-sm", level > 0 && "text-muted-foreground")}> <h3
className={cn(
"font-semibold text-sm",
level > 0 && "text-muted-foreground",
)}
>
{folder.name} {folder.name}
</h3> </h3>
{folderAccounts.length > 0 && ( {folderAccounts.length > 0 && (
@@ -122,9 +127,7 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<span <span
className={cn( className={cn(
"font-semibold tabular-nums", "font-semibold tabular-nums",
realBalance >= 0 realBalance >= 0 ? "text-emerald-600" : "text-red-600",
? "text-emerald-600"
: "text-red-600",
)} )}
> >
{formatCurrency(realBalance)} {formatCurrency(realBalance)}
@@ -218,7 +221,9 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<Building2 className="w-4 h-4 text-primary" /> <Building2 className="w-4 h-4 text-primary" />
</div> </div>
<div> <div>
<p className="font-medium text-sm">{account.name}</p> <p className="font-medium text-sm">
{account.name}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{account.accountNumber} {account.accountNumber}
</p> </p>

View File

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

View File

@@ -116,7 +116,9 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<CreditCard className="h-3 w-3 md:h-4 md:w-4 text-muted-foreground" /> <CreditCard className="h-3 w-3 md:h-4 md:w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-xl md:text-2xl font-bold">{reconciledPercent}%</div> <div className="text-xl md:text-2xl font-bold">
{reconciledPercent}%
</div>
<p className="text-[10px] md:text-xs text-muted-foreground mt-1"> <p className="text-[10px] md:text-xs text-muted-foreground mt-1">
{reconciled} / {total} opérations pointées {reconciled} / {total} opérations pointées
</p> </p>

View File

@@ -60,7 +60,9 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm md:text-base">Transactions récentes</CardTitle> <CardTitle className="text-sm md:text-base">
Transactions récentes
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="px-3 md:px-6"> <CardContent className="px-3 md:px-6">
<div className="space-y-3"> <div className="space-y-3">

View File

@@ -36,7 +36,11 @@ interface SidebarContentProps {
onNavigate?: () => void; onNavigate?: () => void;
} }
function SidebarContent({ collapsed = false, onNavigate, showHeader = false }: SidebarContentProps & { showHeader?: boolean }) { function SidebarContent({
collapsed = false,
onNavigate,
showHeader = false,
}: SidebarContentProps & { showHeader?: boolean }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
@@ -134,7 +138,10 @@ export function Sidebar({ open, onOpenChange }: SidebarProps) {
<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">
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<SidebarContent showHeader onNavigate={() => onOpenChange?.(false)} /> <SidebarContent
showHeader
onNavigate={() => onOpenChange?.(false)}
/>
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,9 +37,13 @@ export function PageHeader({
</Button> </Button>
)} )}
<div> <div>
<h1 className="text-lg md:text-2xl font-bold text-foreground">{title}</h1> <h1 className="text-lg md:text-2xl font-bold text-foreground">
{title}
</h1>
{description && ( {description && (
<div className="text-xs md:text-base text-muted-foreground mt-1">{description}</div> <div className="text-xs md:text-base text-muted-foreground mt-1">
{description}
</div>
)} )}
</div> </div>
</div> </div>
@@ -56,4 +60,3 @@ export function PageHeader({
</div> </div>
); );
} }

View File

@@ -12,14 +12,17 @@ export function PageLayout({ children }: PageLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
return ( return (
<SidebarContext.Provider value={{ open: sidebarOpen, setOpen: setSidebarOpen }}> <SidebarContext.Provider
value={{ open: sidebarOpen, setOpen: setSidebarOpen }}
>
<div className="flex h-screen bg-background overflow-hidden"> <div className="flex h-screen bg-background overflow-hidden">
<Sidebar open={sidebarOpen} onOpenChange={setSidebarOpen} /> <Sidebar open={sidebarOpen} onOpenChange={setSidebarOpen} />
<main className="flex-1 overflow-auto overflow-x-hidden"> <main className="flex-1 overflow-auto overflow-x-hidden">
<div className="p-4 md:p-6 space-y-4 md:space-y-6 max-w-full">{children}</div> <div className="p-4 md:p-6 space-y-4 md:space-y-6 max-w-full">
{children}
</div>
</main> </main>
</div> </div>
</SidebarContext.Provider> </SidebarContext.Provider>
); );
} }

View File

@@ -15,4 +15,3 @@ export const SidebarContext = createContext<SidebarContextType>({
export function useSidebarContext() { export function useSidebarContext() {
return useContext(SidebarContext); return useContext(SidebarContext);
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,7 +38,9 @@ export function RuleGroupCard({
formatCurrency, formatCurrency,
formatDate, formatDate,
}: RuleGroupCardProps) { }: RuleGroupCardProps) {
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null); const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(
null,
);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const avgAmount = const avgAmount =
@@ -59,7 +61,11 @@ export function RuleGroupCard({
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">
<Button variant="ghost" size="icon" className="h-5 w-5 md:h-6 md:w-6 shrink-0"> <Button
variant="ghost"
size="icon"
className="h-5 w-5 md:h-6 md:w-6 shrink-0"
>
{isExpanded ? ( {isExpanded ? (
<ChevronDown className="h-3 w-3 md:h-4 md:w-4" /> <ChevronDown className="h-3 w-3 md:h-4 md:w-4" />
) : ( ) : (
@@ -72,7 +78,10 @@ export function RuleGroupCard({
<span className="font-medium text-xs md:text-base text-foreground truncate"> <span className="font-medium text-xs md:text-base text-foreground truncate">
{group.displayName} {group.displayName}
</span> </span>
<Badge variant="secondary" className="text-[10px] md:text-xs shrink-0"> <Badge
variant="secondary"
className="text-[10px] md:text-xs shrink-0"
>
{group.transactions.length} 💳 {group.transactions.length} 💳
</Badge> </Badge>
</div> </div>
@@ -91,7 +100,7 @@ export function RuleGroupCard({
<div <div
className={cn( className={cn(
"font-semibold tabular-nums text-sm", "font-semibold tabular-nums text-sm",
isDebit ? "text-destructive" : "text-success" isDebit ? "text-destructive" : "text-success",
)} )}
> >
{formatCurrency(group.totalAmount)} {formatCurrency(group.totalAmount)}
@@ -158,10 +167,7 @@ export function RuleGroupCard({
{isMobile ? ( {isMobile ? (
<div className="max-h-64 overflow-y-auto divide-y divide-border"> <div className="max-h-64 overflow-y-auto divide-y divide-border">
{group.transactions.map((transaction) => ( {group.transactions.map((transaction) => (
<div <div key={transaction.id} className="p-3 hover:bg-muted/50">
key={transaction.id}
className="p-3 hover:bg-muted/50"
>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-xs md:text-sm font-medium truncate"> <p className="text-xs md:text-sm font-medium truncate">
@@ -181,7 +187,7 @@ export function RuleGroupCard({
"text-xs md:text-sm font-semibold tabular-nums shrink-0", "text-xs md:text-sm font-semibold tabular-nums shrink-0",
transaction.amount < 0 transaction.amount < 0
? "text-destructive" ? "text-destructive"
: "text-success" : "text-success",
)} )}
> >
{formatCurrency(transaction.amount)} {formatCurrency(transaction.amount)}
@@ -228,7 +234,7 @@ export function RuleGroupCard({
"px-4 py-2 text-right tabular-nums whitespace-nowrap", "px-4 py-2 text-right tabular-nums whitespace-nowrap",
transaction.amount < 0 transaction.amount < 0
? "text-destructive" ? "text-destructive"
: "text-success" : "text-success",
)} )}
> >
{formatCurrency(transaction.amount)} {formatCurrency(transaction.amount)}
@@ -244,4 +250,3 @@ export function RuleGroupCard({
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,13 +4,7 @@ import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CategoryIcon } from "@/components/ui/category-icon"; import { CategoryIcon } from "@/components/ui/category-icon";
import { import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts";
PieChart,
Pie,
Cell,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { Layers, List, ChevronDown, ChevronUp } from "lucide-react"; import { Layers, List, ChevronDown, ChevronUp } from "lucide-react";
import type { Category } from "@/lib/types"; import type { Category } from "@/lib/types";
@@ -48,8 +42,8 @@ export function CategoryPieChart({
const [groupByParent, setGroupByParent] = useState(true); const [groupByParent, setGroupByParent] = useState(true);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const hasParentData = dataByParent && dataByParent.length > 0; const hasParentData = dataByParent && dataByParent.length > 0;
const baseData = (groupByParent && hasParentData) ? dataByParent : data; const baseData = groupByParent && hasParentData ? dataByParent : data;
// Limit to top 8 by default, show all if expanded // Limit to top 8 by default, show all if expanded
const maxItems = 8; const maxItems = 8;
const currentData = isExpanded ? baseData : baseData.slice(0, maxItems); const currentData = isExpanded ? baseData : baseData.slice(0, maxItems);
@@ -64,7 +58,11 @@ export function CategoryPieChart({
variant={groupByParent ? "default" : "ghost"} variant={groupByParent ? "default" : "ghost"}
size="sm" size="sm"
onClick={() => setGroupByParent(!groupByParent)} onClick={() => setGroupByParent(!groupByParent)}
title={groupByParent ? "Afficher toutes les catégories" : "Regrouper par catégories parentes"} title={
groupByParent
? "Afficher toutes les catégories"
: "Regrouper par catégories parentes"
}
className="w-full md:w-auto text-xs md:text-sm" className="w-full md:w-auto text-xs md:text-sm"
> >
{groupByParent ? ( {groupByParent ? (
@@ -197,4 +195,3 @@ export function CategoryPieChart({
</Card> </Card>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,7 +73,7 @@ export function StatsSummaryCards({
<div <div
className={cn( className={cn(
"text-lg md:text-2xl font-bold", "text-lg md:text-2xl font-bold",
savings >= 0 ? "text-emerald-600" : "text-red-600" savings >= 0 ? "text-emerald-600" : "text-red-600",
)} )}
> >
{formatCurrency(savings)} {formatCurrency(savings)}
@@ -83,4 +83,3 @@ export function StatsSummaryCards({
</div> </div>
); );
} }

View File

@@ -29,10 +29,13 @@ export function TopExpensesList({
<div className="space-y-3 md:space-y-4"> <div className="space-y-3 md:space-y-4">
{expenses.map((expense, index) => { {expenses.map((expense, index) => {
const category = categories.find( const category = categories.find(
(c) => c.id === expense.categoryId (c) => c.id === expense.categoryId,
); );
return ( return (
<div key={expense.id} className="flex items-start gap-2 md:gap-3"> <div
key={expense.id}
className="flex items-start gap-2 md:gap-3"
>
<div className="w-6 h-6 md:w-8 md:h-8 rounded-full bg-muted flex items-center justify-center text-xs md:text-sm font-semibold shrink-0"> <div className="w-6 h-6 md:w-8 md:h-8 rounded-full bg-muted flex items-center justify-center text-xs md:text-sm font-semibold shrink-0">
{index + 1} {index + 1}
</div> </div>
@@ -84,4 +87,3 @@ export function TopExpensesList({
</Card> </Card>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,11 @@ import {
import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox"; import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox";
import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox"; import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox";
import { CategoryIcon } from "@/components/ui/category-icon"; import { CategoryIcon } from "@/components/ui/category-icon";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar as CalendarComponent } from "@/components/ui/calendar"; import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Search, X, Filter, Wallet, Calendar } from "lucide-react"; import { Search, X, Filter, Wallet, Calendar } from "lucide-react";
@@ -139,9 +143,15 @@ export function TransactionFilters({
</Select> </Select>
{period === "custom" && ( {period === "custom" && (
<Popover open={isCustomDatePickerOpen} onOpenChange={onCustomDatePickerOpenChange}> <Popover
open={isCustomDatePickerOpen}
onOpenChange={onCustomDatePickerOpenChange}
>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" className="w-full md:w-[280px] justify-start text-left font-normal"> <Button
variant="outline"
className="w-full md:w-[280px] justify-start text-left font-normal"
>
<Calendar className="mr-2 h-4 w-4" /> <Calendar className="mr-2 h-4 w-4" />
{customStartDate && customEndDate ? ( {customStartDate && customEndDate ? (
<> <>
@@ -151,7 +161,9 @@ export function TransactionFilters({
) : customStartDate ? ( ) : customStartDate ? (
format(customStartDate, "PPP", { locale: fr }) format(customStartDate, "PPP", { locale: fr })
) : ( ) : (
<span className="text-muted-foreground">Sélectionner les dates</span> <span className="text-muted-foreground">
Sélectionner les dates
</span>
)} )}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -232,7 +244,9 @@ export function TransactionFilters({
selectedCategories={selectedCategories} selectedCategories={selectedCategories}
onRemoveCategory={(id) => { onRemoveCategory={(id) => {
const newCategories = selectedCategories.filter((c) => c !== id); const newCategories = selectedCategories.filter((c) => c !== id);
onCategoriesChange(newCategories.length > 0 ? newCategories : ["all"]); onCategoriesChange(
newCategories.length > 0 ? newCategories : ["all"],
);
}} }}
onClearCategories={() => onCategoriesChange(["all"])} onClearCategories={() => onCategoriesChange(["all"])}
showReconciled={showReconciled} showReconciled={showReconciled}
@@ -294,12 +308,15 @@ function ActiveFilters({
const hasReconciled = showReconciled !== "all"; const hasReconciled = showReconciled !== "all";
const hasPeriod = period !== "all"; const hasPeriod = period !== "all";
const hasActiveFilters = hasSearch || hasAccounts || hasCategories || hasReconciled || hasPeriod; const hasActiveFilters =
hasSearch || hasAccounts || hasCategories || hasReconciled || hasPeriod;
if (!hasActiveFilters) return null; if (!hasActiveFilters) return null;
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id)); const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
const selectedCats = categories.filter((c) => selectedCategories.includes(c.id)); const selectedCats = categories.filter((c) =>
selectedCategories.includes(c.id),
);
const isUncategorized = selectedCategories.includes("uncategorized"); const isUncategorized = selectedCategories.includes("uncategorized");
const clearAll = () => { const clearAll = () => {
@@ -313,18 +330,25 @@ function ActiveFilters({
return ( return (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border flex-wrap"> <div className="flex items-center gap-2 mt-3 pt-3 border-t border-border flex-wrap">
<Filter className="h-3.5 w-3.5 text-muted-foreground" /> <Filter className="h-3.5 w-3.5 text-muted-foreground" />
{hasSearch && ( {hasSearch && (
<Badge variant="secondary" className="gap-1 text-xs font-normal"> <Badge variant="secondary" className="gap-1 text-xs font-normal">
Recherche: &quot;{searchQuery}&quot; Recherche: &quot;{searchQuery}&quot;
<button onClick={onClearSearch} className="ml-1 hover:text-foreground"> <button
onClick={onClearSearch}
className="ml-1 hover:text-foreground"
>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
</Badge> </Badge>
)} )}
{selectedAccs.map((acc) => ( {selectedAccs.map((acc) => (
<Badge key={acc.id} variant="secondary" className="gap-1 text-xs font-normal"> <Badge
key={acc.id}
variant="secondary"
className="gap-1 text-xs font-normal"
>
<Wallet className="h-3 w-3" /> <Wallet className="h-3 w-3" />
{acc.name} {acc.name}
<button <button
@@ -339,7 +363,10 @@ function ActiveFilters({
{isUncategorized && ( {isUncategorized && (
<Badge variant="secondary" className="gap-1 text-xs font-normal"> <Badge variant="secondary" className="gap-1 text-xs font-normal">
Non catégorisé Non catégorisé
<button onClick={onClearCategories} className="ml-1 hover:text-foreground"> <button
onClick={onClearCategories}
className="ml-1 hover:text-foreground"
>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
</Badge> </Badge>
@@ -369,7 +396,10 @@ function ActiveFilters({
{hasReconciled && ( {hasReconciled && (
<Badge variant="secondary" className="gap-1 text-xs font-normal"> <Badge variant="secondary" className="gap-1 text-xs font-normal">
{showReconciled === "reconciled" ? "Pointées" : "Non pointées"} {showReconciled === "reconciled" ? "Pointées" : "Non pointées"}
<button onClick={onClearReconciled} className="ml-1 hover:text-foreground"> <button
onClick={onClearReconciled}
className="ml-1 hover:text-foreground"
>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
</Badge> </Badge>
@@ -381,15 +411,18 @@ function ActiveFilters({
{period === "custom" && customStartDate && customEndDate {period === "custom" && customStartDate && customEndDate
? `${format(customStartDate, "d MMM", { locale: fr })} - ${format(customEndDate, "d MMM yyyy", { locale: fr })}` ? `${format(customStartDate, "d MMM", { locale: fr })} - ${format(customEndDate, "d MMM yyyy", { locale: fr })}`
: period === "1month" : period === "1month"
? "1 mois" ? "1 mois"
: period === "3months" : period === "3months"
? "3 mois" ? "3 mois"
: period === "6months" : period === "6months"
? "6 mois" ? "6 mois"
: period === "12months" : period === "12months"
? "12 mois" ? "12 mois"
: "Période"} : "Période"}
<button onClick={onClearPeriod} className="ml-1 hover:text-foreground"> <button
onClick={onClearPeriod}
className="ml-1 hover:text-foreground"
>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
</Badge> </Badge>
@@ -404,4 +437,3 @@ function ActiveFilters({
</div> </div>
); );
} }

View File

@@ -62,7 +62,7 @@ function DescriptionWithTooltip({ description }: { description: string }) {
const checkTruncation = () => { const checkTruncation = () => {
const element = ref.current; const element = ref.current;
if (!element) return; if (!element) return;
// Check if text is truncated by comparing scrollWidth and clientWidth // Check if text is truncated by comparing scrollWidth and clientWidth
// Add a small threshold (1px) to account for rounding issues // Add a small threshold (1px) to account for rounding issues
const truncated = element.scrollWidth > element.clientWidth + 1; const truncated = element.scrollWidth > element.clientWidth + 1;
@@ -112,11 +112,9 @@ function DescriptionWithTooltip({ description }: { description: string }) {
return ( return (
<Tooltip delayDuration={200}> <Tooltip delayDuration={200}>
<TooltipTrigger asChild> <TooltipTrigger asChild>{content}</TooltipTrigger>
{content} <TooltipContent
</TooltipTrigger> side="top"
<TooltipContent
side="top"
align="start" align="start"
className="max-w-md break-words" className="max-w-md break-words"
sideOffset={5} sideOffset={5}
@@ -163,7 +161,7 @@ export function TransactionTable({
setFocusedIndex(index); setFocusedIndex(index);
onMarkReconciled(transactionId); onMarkReconciled(transactionId);
}, },
[onMarkReconciled] [onMarkReconciled],
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
@@ -192,7 +190,7 @@ export function TransactionTable({
} }
} }
}, },
[focusedIndex, transactions, onMarkReconciled, virtualizer] [focusedIndex, transactions, onMarkReconciled, virtualizer],
); );
useEffect(() => { useEffect(() => {
@@ -205,14 +203,20 @@ export function TransactionTable({
setFocusedIndex(null); setFocusedIndex(null);
}, [transactions.length]); }, [transactions.length]);
const getAccount = useCallback((accountId: string) => { const getAccount = useCallback(
return accounts.find((a) => a.id === accountId); (accountId: string) => {
}, [accounts]); return accounts.find((a) => a.id === accountId);
},
[accounts],
);
const getCategory = useCallback((categoryId: string | null) => { const getCategory = useCallback(
if (!categoryId) return null; (categoryId: string | null) => {
return categories.find((c) => c.id === categoryId); if (!categoryId) return null;
}, [categories]); return categories.find((c) => c.id === categoryId);
},
[categories],
);
return ( return (
<Card className="overflow-hidden"> <Card className="overflow-hidden">
@@ -262,7 +266,7 @@ export function TransactionTable({
className={cn( className={cn(
"p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border", "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",
)} )}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@@ -290,7 +294,7 @@ export function TransactionTable({
"font-semibold tabular-nums text-sm md:text-base shrink-0", "font-semibold tabular-nums text-sm md:text-base shrink-0",
transaction.amount >= 0 transaction.amount >= 0
? "text-emerald-600" ? "text-emerald-600"
: "text-red-600" : "text-red-600",
)} )}
> >
{transaction.amount >= 0 ? "+" : ""} {transaction.amount >= 0 ? "+" : ""}
@@ -307,7 +311,10 @@ export function TransactionTable({
{account.name} {account.name}
</span> </span>
)} )}
<div onClick={(e) => e.stopPropagation()} className="flex-1"> <div
onClick={(e) => e.stopPropagation()}
className="flex-1"
>
<CategoryCombobox <CategoryCombobox
categories={categories} categories={categories}
value={transaction.categoryId} value={transaction.categoryId}
@@ -319,7 +326,10 @@ export function TransactionTable({
/> />
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> <DropdownMenuTrigger
asChild
onClick={(e) => e.stopPropagation()}
>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -344,7 +354,7 @@ export function TransactionTable({
e.stopPropagation(); e.stopPropagation();
if ( if (
confirm( confirm(
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}` `Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
) )
) { ) {
onDelete(transaction.id); onDelete(transaction.id);
@@ -447,11 +457,13 @@ export function TransactionTable({
width: "100%", width: "100%",
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
}} }}
onClick={() => handleRowClick(virtualRow.index, transaction.id)} onClick={() =>
handleRowClick(virtualRow.index, transaction.id)
}
className={cn( className={cn(
"grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer", "grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer",
transaction.isReconciled && "bg-emerald-500/5", transaction.isReconciled && "bg-emerald-500/5",
isFocused && "bg-primary/10 ring-1 ring-primary/30" isFocused && "bg-primary/10 ring-1 ring-primary/30",
)} )}
> >
<div className="p-3"> <div className="p-3">
@@ -465,12 +477,17 @@ export function TransactionTable({
<div className="p-3 text-sm text-muted-foreground whitespace-nowrap"> <div className="p-3 text-sm text-muted-foreground whitespace-nowrap">
{formatDate(transaction.date)} {formatDate(transaction.date)}
</div> </div>
<div className="p-3 min-w-0 overflow-hidden" onClick={(e) => e.stopPropagation()}> <div
className="p-3 min-w-0 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<p className="font-medium text-sm truncate"> <p className="font-medium text-sm truncate">
{transaction.description} {transaction.description}
</p> </p>
{transaction.memo && ( {transaction.memo && (
<DescriptionWithTooltip description={transaction.memo} /> <DescriptionWithTooltip
description={transaction.memo}
/>
)} )}
</div> </div>
<div className="p-3 text-sm text-muted-foreground"> <div className="p-3 text-sm text-muted-foreground">
@@ -492,13 +509,16 @@ export function TransactionTable({
"p-3 text-right font-semibold tabular-nums", "p-3 text-right font-semibold tabular-nums",
transaction.amount >= 0 transaction.amount >= 0
? "text-emerald-600" ? "text-emerald-600"
: "text-red-600" : "text-red-600",
)} )}
> >
{transaction.amount >= 0 ? "+" : ""} {transaction.amount >= 0 ? "+" : ""}
{formatCurrency(transaction.amount)} {formatCurrency(transaction.amount)}
</div> </div>
<div className="p-3 text-center" onClick={(e) => e.stopPropagation()}> <div
className="p-3 text-center"
onClick={(e) => e.stopPropagation()}
>
<button <button
onClick={() => onToggleReconciled(transaction.id)} onClick={() => onToggleReconciled(transaction.id)}
className="p-1 hover:bg-muted rounded" className="p-1 hover:bg-muted rounded"
@@ -556,7 +576,7 @@ export function TransactionTable({
e.stopPropagation(); e.stopPropagation();
if ( if (
confirm( confirm(
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}` `Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
) )
) { ) {
onDelete(transaction.id); onDelete(transaction.id);
@@ -581,4 +601,3 @@ export function TransactionTable({
</Card> </Card>
); );
} }

View File

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

View File

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

View File

@@ -40,13 +40,13 @@ export function CategoryFilterCombobox({
// Calculate transaction counts per category based on filtered transactions // Calculate transaction counts per category based on filtered transactions
const categoryCounts = useMemo(() => { const categoryCounts = useMemo(() => {
if (!filteredTransactions) return {}; if (!filteredTransactions) return {};
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
filteredTransactions.forEach((t) => { filteredTransactions.forEach((t) => {
const catId = t.categoryId || "uncategorized"; const catId = t.categoryId || "uncategorized";
counts[catId] = (counts[catId] || 0) + 1; counts[catId] = (counts[catId] || 0) + 1;
}); });
return counts; return counts;
}, [filteredTransactions]); }, [filteredTransactions]);
@@ -89,7 +89,7 @@ export function CategoryFilterCombobox({
// 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 just this category
newSelection = [newValue]; newSelection = [newValue];
@@ -115,7 +115,8 @@ export function CategoryFilterCombobox({
if (isAll) return "Toutes catégories"; if (isAll) return "Toutes catégories";
if (isUncategorized) return "Non catégorisé"; if (isUncategorized) return "Non catégorisé";
if (selectedCategories.length === 1) return selectedCategories[0].name; if (selectedCategories.length === 1) return selectedCategories[0].name;
if (selectedCategories.length > 1) return `${selectedCategories.length} catégories`; if (selectedCategories.length > 1)
return `${selectedCategories.length} catégories`;
return "Catégorie"; return "Catégorie";
}; };
@@ -137,7 +138,9 @@ export function CategoryFilterCombobox({
size={16} size={16}
className="shrink-0" className="shrink-0"
/> />
<span className="truncate text-left">{selectedCategories[0].name}</span> <span className="truncate text-left">
{selectedCategories[0].name}
</span>
</> </>
) : selectedCategories.length > 1 ? ( ) : selectedCategories.length > 1 ? (
<> <>
@@ -150,7 +153,9 @@ export function CategoryFilterCombobox({
/> />
))} ))}
</div> </div>
<span className="truncate text-left">{selectedCategories.length} catégories</span> <span className="truncate text-left">
{selectedCategories.length} catégories
</span>
</> </>
) : isUncategorized ? ( ) : isUncategorized ? (
<> <>
@@ -160,7 +165,9 @@ export function CategoryFilterCombobox({
) : ( ) : (
<> <>
<Tags className="h-4 w-4 text-muted-foreground shrink-0" /> <Tags className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground truncate text-left">{getDisplayValue()}</span> <span className="text-muted-foreground truncate text-left">
{getDisplayValue()}
</span>
</> </>
)} )}
</div> </div>
@@ -191,9 +198,15 @@ export function CategoryFilterCombobox({
<CommandList className="max-h-[300px]"> <CommandList className="max-h-[300px]">
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty> <CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
<CommandGroup> <CommandGroup>
<CommandItem value="all" onSelect={() => handleSelect("all")} className="min-w-0"> <CommandItem
value="all"
onSelect={() => handleSelect("all")}
className="min-w-0"
>
<Tags className="h-4 w-4 text-muted-foreground shrink-0" /> <Tags className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate min-w-0 flex-1">Toutes catégories</span> <span className="truncate min-w-0 flex-1">
Toutes catégories
</span>
{filteredTransactions && ( {filteredTransactions && (
<span className="text-xs text-muted-foreground ml-1 shrink-0"> <span className="text-xs text-muted-foreground ml-1 shrink-0">
({filteredTransactions.length}) ({filteredTransactions.length})
@@ -202,7 +215,7 @@ export function CategoryFilterCombobox({
<Check <Check
className={cn( className={cn(
"ml-auto h-4 w-4 shrink-0", "ml-auto h-4 w-4 shrink-0",
isAll ? "opacity-100" : "opacity-0" isAll ? "opacity-100" : "opacity-0",
)} )}
/> />
</CommandItem> </CommandItem>
@@ -221,7 +234,7 @@ export function CategoryFilterCombobox({
<Check <Check
className={cn( className={cn(
"ml-auto h-4 w-4 shrink-0", "ml-auto h-4 w-4 shrink-0",
isUncategorized ? "opacity-100" : "opacity-0" isUncategorized ? "opacity-100" : "opacity-0",
)} )}
/> />
</CommandItem> </CommandItem>
@@ -240,7 +253,9 @@ export function CategoryFilterCombobox({
size={16} size={16}
className="shrink-0" className="shrink-0"
/> />
<span className="font-medium truncate min-w-0 flex-1">{parent.name}</span> <span className="font-medium truncate min-w-0 flex-1">
{parent.name}
</span>
{categoryCounts[parent.id] !== undefined && ( {categoryCounts[parent.id] !== undefined && (
<span className="text-xs text-muted-foreground ml-1 shrink-0"> <span className="text-xs text-muted-foreground ml-1 shrink-0">
({categoryCounts[parent.id]}) ({categoryCounts[parent.id]})
@@ -249,7 +264,7 @@ export function CategoryFilterCombobox({
<Check <Check
className={cn( className={cn(
"ml-auto h-4 w-4 shrink-0", "ml-auto h-4 w-4 shrink-0",
value.includes(parent.id) ? "opacity-100" : "opacity-0" value.includes(parent.id) ? "opacity-100" : "opacity-0",
)} )}
/> />
</CommandItem> </CommandItem>
@@ -266,7 +281,9 @@ export function CategoryFilterCombobox({
size={16} size={16}
className="shrink-0" className="shrink-0"
/> />
<span className="truncate min-w-0 flex-1">{child.name}</span> <span className="truncate min-w-0 flex-1">
{child.name}
</span>
{categoryCounts[child.id] !== undefined && ( {categoryCounts[child.id] !== undefined && (
<span className="text-xs text-muted-foreground ml-1 shrink-0"> <span className="text-xs text-muted-foreground ml-1 shrink-0">
({categoryCounts[child.id]}) ({categoryCounts[child.id]})
@@ -275,7 +292,9 @@ export function CategoryFilterCombobox({
<Check <Check
className={cn( className={cn(
"ml-auto h-4 w-4 shrink-0", "ml-auto h-4 w-4 shrink-0",
value.includes(child.id) ? "opacity-100" : "opacity-0" value.includes(child.id)
? "opacity-100"
: "opacity-0",
)} )}
/> />
</CommandItem> </CommandItem>

View File

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

View File

@@ -17,4 +17,3 @@ services:
- DATABASE_URL=${DATABASE_URL:-file:./prisma/dev.db} - DATABASE_URL=${DATABASE_URL:-file:./prisma/dev.db}
env_file: env_file:
- .env - .env

View File

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

View File

@@ -22,7 +22,7 @@ export function useIsMobile() {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
mql.addEventListener("change", checkMobile); mql.addEventListener("change", checkMobile);
return () => mql.removeEventListener("change", checkMobile); return () => mql.removeEventListener("change", checkMobile);
}, []); }, []);

View File

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

View File

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

View File

@@ -3,16 +3,22 @@ import CredentialsProvider from "next-auth/providers/credentials";
import { authService } from "@/services/auth.service"; import { authService } from "@/services/auth.service";
// Get secret with fallback for development // Get secret with fallback for development
const secret = process.env.NEXTAUTH_SECRET || "dev-secret-key-change-in-production"; const secret =
process.env.NEXTAUTH_SECRET || "dev-secret-key-change-in-production";
// Debug: log secret status (remove in production) // Debug: log secret status (remove in production)
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
console.log("🔐 NextAuth secret:", process.env.NEXTAUTH_SECRET ? "✅ Loaded from .env.local" : "⚠️ Using fallback"); console.log(
"🔐 NextAuth secret:",
process.env.NEXTAUTH_SECRET
? "✅ Loaded from .env.local"
: "⚠️ Using fallback",
);
} }
if (!process.env.NEXTAUTH_SECRET && process.env.NODE_ENV === "production") { if (!process.env.NEXTAUTH_SECRET && process.env.NODE_ENV === "production") {
throw new Error( throw new Error(
"NEXTAUTH_SECRET is required in production. Please set it in your environment variables." "NEXTAUTH_SECRET is required in production. Please set it in your environment variables.",
); );
} }
@@ -29,7 +35,9 @@ export const authOptions: NextAuthOptions = {
return null; return null;
} }
const isValid = await authService.verifyPassword(credentials.password); const isValid = await authService.verifyPassword(
credentials.password,
);
if (!isValid) { if (!isValid) {
return null; return null;
} }
@@ -69,4 +77,3 @@ export const authOptions: NextAuthOptions = {
}, },
secret, secret,
}; };

View File

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

View File

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

6708
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,4 +3,4 @@
"frequency": "hourly", "frequency": "hourly",
"lastBackup": "2025-11-30T09:50:17.696Z", "lastBackup": "2025-11-30T09:50:17.696Z",
"nextBackup": "2025-11-30T10:00:00.000Z" "nextBackup": "2025-11-30T10:00:00.000Z"
} }

View File

@@ -1,8 +1,8 @@
import * as fs from 'fs'; import * as fs from "fs";
import * as path from 'path'; import * as path from "path";
import { prisma } from '../lib/prisma'; import { prisma } from "../lib/prisma";
import { transactionService } from '../services/transaction.service'; import { transactionService } from "../services/transaction.service";
import { generateId } from '../lib/store-db'; import { generateId } from "../lib/store-db";
interface CSVTransaction { interface CSVTransaction {
date: string; date: string;
@@ -22,44 +22,44 @@ interface CSVTransaction {
function parseCSVLine(line: string): string[] { function parseCSVLine(line: string): string[] {
const result: string[] = []; const result: string[] = [];
let current = ''; let current = "";
let inQuotes = false; let inQuotes = false;
for (let i = 0; i < line.length; i++) { for (let i = 0; i < line.length; i++) {
const char = line[i]; const char = line[i];
if (char === '"') { if (char === '"') {
inQuotes = !inQuotes; inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) { } else if (char === "," && !inQuotes) {
result.push(current.trim()); result.push(current.trim());
current = ''; current = "";
} else { } else {
current += char; current += char;
} }
} }
result.push(current.trim()); result.push(current.trim());
return result; return result;
} }
function parseCSV(csvPath: string): CSVTransaction[] { function parseCSV(csvPath: string): CSVTransaction[] {
const content = fs.readFileSync(csvPath, 'utf-8'); const content = fs.readFileSync(csvPath, "utf-8");
const lines = content.split('\n'); const lines = content.split("\n");
// Skip header lines (first 8 lines) // Skip header lines (first 8 lines)
const dataLines = lines.slice(8); const dataLines = lines.slice(8);
const transactions: CSVTransaction[] = []; const transactions: CSVTransaction[] = [];
for (const line of dataLines) { for (const line of dataLines) {
if (!line.trim()) continue; if (!line.trim()) continue;
const fields = parseCSVLine(line); const fields = parseCSVLine(line);
if (fields.length < 13) continue; if (fields.length < 13) continue;
// Skip if date or amount is missing // Skip if date or amount is missing
if (!fields[0] || !fields[1] || !fields[2]) continue; if (!fields[0] || !fields[1] || !fields[2]) continue;
transactions.push({ transactions.push({
date: fields[0], date: fields[0],
amount: fields[1], amount: fields[1],
@@ -76,102 +76,114 @@ function parseCSV(csvPath: string): CSVTransaction[] {
compte: fields[12], compte: fields[12],
}); });
} }
return transactions; return transactions;
} }
function parseDate(dateStr: string): string { function parseDate(dateStr: string): string {
// Format: DD/MM/YYYY -> YYYY-MM-DD // Format: DD/MM/YYYY -> YYYY-MM-DD
const [day, month, year] = dateStr.split('/'); const [day, month, year] = dateStr.split("/");
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
} }
function parseAmount(amountStr: string): number { function parseAmount(amountStr: string): number {
if (!amountStr || amountStr.trim() === '' || amountStr === '""') { if (!amountStr || amountStr.trim() === "" || amountStr === '""') {
return 0; return 0;
} }
// Remove quotes, spaces (including non-breaking spaces), and replace comma with dot // Remove quotes, spaces (including non-breaking spaces), and replace comma with dot
const cleaned = amountStr.replace(/["\s\u00A0]/g, '').replace(',', '.'); const cleaned = amountStr.replace(/["\s\u00A0]/g, "").replace(",", ".");
const parsed = parseFloat(cleaned); const parsed = parseFloat(cleaned);
return isNaN(parsed) ? 0 : parsed; return isNaN(parsed) ? 0 : parsed;
} }
function generateFITID(transaction: CSVTransaction, index: number): string { function generateFITID(transaction: CSVTransaction, index: number): string {
const date = parseDate(transaction.date); const date = parseDate(transaction.date);
const dateStr = date.replace(/-/g, ''); const dateStr = date.replace(/-/g, "");
const amountStr = Math.abs(parseAmount(transaction.amount)).toFixed(2).replace('.', ''); const amountStr = Math.abs(parseAmount(transaction.amount))
const libelleHash = transaction.libelle.substring(0, 20).replace(/[^A-Z0-9]/gi, ''); .toFixed(2)
.replace(".", "");
const libelleHash = transaction.libelle
.substring(0, 20)
.replace(/[^A-Z0-9]/gi, "");
return `${dateStr}-${amountStr}-${libelleHash}-${index}`; return `${dateStr}-${amountStr}-${libelleHash}-${index}`;
} }
function removeAccountPrefix(accountName: string): string { function removeAccountPrefix(accountName: string): string {
// Remove prefixes: LivretA, LDDS, CCP, PEL (case insensitive) // Remove prefixes: LivretA, LDDS, CCP, PEL (case insensitive)
const prefixes = ['LivretA', 'Livret A', 'LDDS', 'CCP', 'PEL']; const prefixes = ["LivretA", "Livret A", "LDDS", "CCP", "PEL"];
let cleaned = accountName; let cleaned = accountName;
for (const prefix of prefixes) { for (const prefix of prefixes) {
// Remove prefix followed by optional spaces and dashes // Remove prefix followed by optional spaces and dashes
const regex = new RegExp(`^${prefix}\\s*-?\\s*`, 'i'); const regex = new RegExp(`^${prefix}\\s*-?\\s*`, "i");
cleaned = cleaned.replace(regex, ''); cleaned = cleaned.replace(regex, "");
} }
return cleaned.trim(); return cleaned.trim();
} }
function determineAccountType(accountName: string): "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER" { function determineAccountType(
accountName: string,
): "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER" {
const upper = accountName.toUpperCase(); const upper = accountName.toUpperCase();
if (upper.includes('LIVRET') || upper.includes('LDDS') || upper.includes('PEL')) { if (
return 'SAVINGS'; upper.includes("LIVRET") ||
upper.includes("LDDS") ||
upper.includes("PEL")
) {
return "SAVINGS";
} }
if (upper.includes('CCP') || upper.includes('COMPTE COURANT')) { if (upper.includes("CCP") || upper.includes("COMPTE COURANT")) {
return 'CHECKING'; return "CHECKING";
} }
return 'OTHER'; return "OTHER";
} }
async function main() { async function main() {
const csvPath = path.join(__dirname, '../temp/all account.csv'); const csvPath = path.join(__dirname, "../temp/all account.csv");
if (!fs.existsSync(csvPath)) { if (!fs.existsSync(csvPath)) {
console.error(`Fichier CSV introuvable: ${csvPath}`); console.error(`Fichier CSV introuvable: ${csvPath}`);
process.exit(1); process.exit(1);
} }
console.log('Lecture du fichier CSV...'); console.log("Lecture du fichier CSV...");
const csvTransactions = parseCSV(csvPath); const csvTransactions = parseCSV(csvPath);
console.log(`${csvTransactions.length} transactions trouvées`); console.log(`${csvTransactions.length} transactions trouvées`);
// Group by account // Group by account
const accountsMap = new Map<string, CSVTransaction[]>(); const accountsMap = new Map<string, CSVTransaction[]>();
for (const transaction of csvTransactions) { for (const transaction of csvTransactions) {
if (!transaction.compte) continue; if (!transaction.compte) continue;
const amount = parseAmount(transaction.amount); const amount = parseAmount(transaction.amount);
if (amount === 0) continue; // Skip zero-amount transactions if (amount === 0) continue; // Skip zero-amount transactions
if (!accountsMap.has(transaction.compte)) { if (!accountsMap.has(transaction.compte)) {
accountsMap.set(transaction.compte, []); accountsMap.set(transaction.compte, []);
} }
accountsMap.get(transaction.compte)!.push(transaction); accountsMap.get(transaction.compte)!.push(transaction);
} }
console.log(`${accountsMap.size} comptes trouvés\n`); console.log(`${accountsMap.size} comptes trouvés\n`);
let totalTransactionsCreated = 0; let totalTransactionsCreated = 0;
let totalAccountsCreated = 0; let totalAccountsCreated = 0;
let totalAccountsUpdated = 0; let totalAccountsUpdated = 0;
// Process each account // Process each account
for (const [accountName, transactions] of accountsMap.entries()) { for (const [accountName, transactions] of accountsMap.entries()) {
console.log(`Traitement du compte: ${accountName}`); console.log(`Traitement du compte: ${accountName}`);
console.log(` ${transactions.length} transactions`); console.log(` ${transactions.length} transactions`);
// Remove prefixes and extract account number from account name // Remove prefixes and extract account number from account name
const cleanedAccountName = removeAccountPrefix(accountName); const cleanedAccountName = removeAccountPrefix(accountName);
const accountNumber = cleanedAccountName.replace(/[^A-Z0-9]/gi, '').substring(0, 22); const accountNumber = cleanedAccountName
const bankId = transactions[0]?.codeBanque || 'FR'; .replace(/[^A-Z0-9]/gi, "")
.substring(0, 22);
const bankId = transactions[0]?.codeBanque || "FR";
console.log(` Numéro de compte extrait: ${accountNumber}`); console.log(` Numéro de compte extrait: ${accountNumber}`);
// Find account by account number (try multiple strategies) // Find account by account number (try multiple strategies)
let account = await prisma.account.findFirst({ let account = await prisma.account.findFirst({
where: { where: {
@@ -179,7 +191,7 @@ async function main() {
bankId: bankId, bankId: bankId,
}, },
}); });
// If not found with bankId, try without bankId constraint // If not found with bankId, try without bankId constraint
if (!account) { if (!account) {
account = await prisma.account.findFirst({ account = await prisma.account.findFirst({
@@ -188,7 +200,7 @@ async function main() {
}, },
}); });
} }
// If still not found, try to find by account number in existing account numbers // If still not found, try to find by account number in existing account numbers
// (some accounts might have been created with prefixes in accountNumber) // (some accounts might have been created with prefixes in accountNumber)
if (!account) { if (!account) {
@@ -199,18 +211,18 @@ async function main() {
}, },
}, },
}); });
// Try to find exact match in accountNumber (after cleaning) // Try to find exact match in accountNumber (after cleaning)
for (const acc of allAccounts) { for (const acc of allAccounts) {
const cleanedExisting = removeAccountPrefix(acc.accountNumber); const cleanedExisting = removeAccountPrefix(acc.accountNumber);
const existingNumber = cleanedExisting.replace(/[^A-Z0-9]/gi, ''); const existingNumber = cleanedExisting.replace(/[^A-Z0-9]/gi, "");
if (existingNumber === accountNumber) { if (existingNumber === accountNumber) {
account = acc; account = acc;
break; break;
} }
} }
} }
if (!account) { if (!account) {
console.log(` → Création du compte...`); console.log(` → Création du compte...`);
account = await prisma.account.create({ account = await prisma.account.create({
@@ -221,37 +233,39 @@ async function main() {
type: determineAccountType(accountName), type: determineAccountType(accountName),
folderId: null, folderId: null,
balance: 0, balance: 0,
currency: 'EUR', currency: "EUR",
lastImport: null, lastImport: null,
externalUrl: null, externalUrl: null,
}, },
}); });
totalAccountsCreated++; totalAccountsCreated++;
} else { } else {
console.log(` → Compte existant trouvé: ${account.name} (${account.accountNumber})`); console.log(
` → Compte existant trouvé: ${account.name} (${account.accountNumber})`,
);
totalAccountsUpdated++; totalAccountsUpdated++;
} }
// Sort transactions by date // Sort transactions by date
transactions.sort((a, b) => { transactions.sort((a, b) => {
const dateA = parseDate(a.date); const dateA = parseDate(a.date);
const dateB = parseDate(b.date); const dateB = parseDate(b.date);
return dateA.localeCompare(dateB); return dateA.localeCompare(dateB);
}); });
// Deduplicate transactions: same amount + same date + same libelle (description) // Deduplicate transactions: same amount + same date + same libelle (description)
const seenTransactions = new Map<string, CSVTransaction>(); const seenTransactions = new Map<string, CSVTransaction>();
const uniqueTransactions: CSVTransaction[] = []; const uniqueTransactions: CSVTransaction[] = [];
let duplicatesCount = 0; let duplicatesCount = 0;
for (const transaction of transactions) { for (const transaction of transactions) {
const amount = parseAmount(transaction.amount); const amount = parseAmount(transaction.amount);
const date = parseDate(transaction.date); const date = parseDate(transaction.date);
const description = transaction.libelle.trim(); const description = transaction.libelle.trim();
// Create a unique key: date-amount-description // Create a unique key: date-amount-description
const key = `${date}-${amount}-${description}`; const key = `${date}-${amount}-${description}`;
if (!seenTransactions.has(key)) { if (!seenTransactions.has(key)) {
seenTransactions.set(key, transaction); seenTransactions.set(key, transaction);
uniqueTransactions.push(transaction); uniqueTransactions.push(transaction);
@@ -259,19 +273,24 @@ async function main() {
duplicatesCount++; duplicatesCount++;
} }
} }
if (duplicatesCount > 0) { if (duplicatesCount > 0) {
console.log(`${duplicatesCount} doublons détectés et ignorés (même date, montant, libellé)`); console.log(
`${duplicatesCount} doublons détectés et ignorés (même date, montant, libellé)`,
);
} }
// Calculate balance from unique transactions // Calculate balance from unique transactions
const balance = uniqueTransactions.reduce((sum, t) => sum + parseAmount(t.amount), 0); const balance = uniqueTransactions.reduce(
(sum, t) => sum + parseAmount(t.amount),
0,
);
// Prepare transactions for insertion // Prepare transactions for insertion
const dbTransactions = uniqueTransactions.map((transaction, index) => { const dbTransactions = uniqueTransactions.map((transaction, index) => {
const amount = parseAmount(transaction.amount); const amount = parseAmount(transaction.amount);
const date = parseDate(transaction.date); const date = parseDate(transaction.date);
// Build memo from available fields // Build memo from available fields
let memo = transaction.libelle; let memo = transaction.libelle;
if (transaction.beneficiaire) { if (transaction.beneficiaire) {
@@ -283,14 +302,14 @@ async function main() {
if (transaction.commentaire) { if (transaction.commentaire) {
memo += ` (${transaction.commentaire})`; memo += ` (${transaction.commentaire})`;
} }
return { return {
id: generateId(), id: generateId(),
accountId: account.id, accountId: account.id,
date: date, date: date,
amount: amount, amount: amount,
description: transaction.libelle.substring(0, 255), description: transaction.libelle.substring(0, 255),
type: amount >= 0 ? 'CREDIT' as const : 'DEBIT' as const, type: amount >= 0 ? ("CREDIT" as const) : ("DEBIT" as const),
categoryId: null, // Will be auto-categorized later if needed categoryId: null, // Will be auto-categorized later if needed
isReconciled: false, isReconciled: false,
fitId: generateFITID(transaction, index), fitId: generateFITID(transaction, index),
@@ -298,12 +317,12 @@ async function main() {
checkNum: transaction.numeroCheque || undefined, checkNum: transaction.numeroCheque || undefined,
}; };
}); });
// Insert transactions (will skip duplicates based on fitId) // Insert transactions (will skip duplicates based on fitId)
const result = await transactionService.createMany(dbTransactions); const result = await transactionService.createMany(dbTransactions);
console.log(`${result.count} nouvelles transactions insérées`); console.log(`${result.count} nouvelles transactions insérées`);
totalTransactionsCreated += result.count; totalTransactionsCreated += result.count;
// Update account balance and lastImport // Update account balance and lastImport
await prisma.account.update({ await prisma.account.update({
where: { id: account.id }, where: { id: account.id },
@@ -312,21 +331,20 @@ async function main() {
lastImport: new Date().toISOString(), lastImport: new Date().toISOString(),
}, },
}); });
console.log(` ✓ Solde mis à jour: ${balance.toFixed(2)} EUR\n`); console.log(` ✓ Solde mis à jour: ${balance.toFixed(2)} EUR\n`);
} }
console.log('\n=== Résumé ==='); console.log("\n=== Résumé ===");
console.log(`Comptes créés: ${totalAccountsCreated}`); console.log(`Comptes créés: ${totalAccountsCreated}`);
console.log(`Comptes mis à jour: ${totalAccountsUpdated}`); console.log(`Comptes mis à jour: ${totalAccountsUpdated}`);
console.log(`Transactions insérées: ${totalTransactionsCreated}`); console.log(`Transactions insérées: ${totalTransactionsCreated}`);
console.log('\n✓ Import terminé!'); console.log("\n✓ Import terminé!");
await prisma.$disconnect(); await prisma.$disconnect();
} }
main().catch((error) => { main().catch((error) => {
console.error('Erreur:', error); console.error("Erreur:", error);
process.exit(1); process.exit(1);
}); });

View File

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

View File

@@ -19,7 +19,9 @@ async function main() {
console.log("Creating automatic backup..."); console.log("Creating automatic backup...");
const backup = await backupService.createBackup(); const backup = await backupService.createBackup();
console.log(`Backup created successfully: ${backup.filename} (${backup.size} bytes)`); console.log(
`Backup created successfully: ${backup.filename} (${backup.size} bytes)`,
);
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
console.error("Error running automatic backup:", error); console.error("Error running automatic backup:", error);
@@ -28,4 +30,3 @@ async function main() {
} }
main(); main();

View File

@@ -20,7 +20,11 @@ async function ensurePasswordFile(): Promise<void> {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
await fs.writeFile(PASSWORD_FILE, JSON.stringify(defaultData, null, 2), "utf-8"); await fs.writeFile(
PASSWORD_FILE,
JSON.stringify(defaultData, null, 2),
"utf-8",
);
} }
} }
@@ -45,7 +49,10 @@ export const authService = {
} }
}, },
async changePassword(oldPassword: string, newPassword: string): Promise<{ success: boolean; error?: string }> { async changePassword(
oldPassword: string,
newPassword: string,
): Promise<{ success: boolean; error?: string }> {
try { try {
// Verify old password // Verify old password
const isValid = await this.verifyPassword(oldPassword); const isValid = await this.verifyPassword(oldPassword);
@@ -56,17 +63,20 @@ export const authService = {
// Hash new password // Hash new password
const newHash = await bcrypt.hash(newPassword, 10); const newHash = await bcrypt.hash(newPassword, 10);
const data = await loadPasswordData(); const data = await loadPasswordData();
// Update password // Update password
data.hash = newHash; data.hash = newHash;
data.updatedAt = new Date().toISOString(); data.updatedAt = new Date().toISOString();
await savePasswordData(data); await savePasswordData(data);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("Error changing password:", error); console.error("Error changing password:", error);
return { success: false, error: "Erreur lors du changement de mot de passe" }; return {
success: false,
error: "Erreur lors du changement de mot de passe",
};
} }
}, },
@@ -79,4 +89,3 @@ export const authService = {
} }
}, },
}; };

View File

@@ -14,7 +14,11 @@ export interface BackupSettings {
nextBackup?: string; nextBackup?: string;
} }
const SETTINGS_FILE = path.join(process.cwd(), "prisma", "backup-settings.json"); const SETTINGS_FILE = path.join(
process.cwd(),
"prisma",
"backup-settings.json",
);
async function ensureBackupDir() { async function ensureBackupDir() {
if (!existsSync(BACKUP_DIR)) { if (!existsSync(BACKUP_DIR)) {
@@ -48,20 +52,20 @@ function getDatabasePath(): string {
} }
// Remove "file:" prefix if present // Remove "file:" prefix if present
let cleanUrl = dbUrl.replace(/^file:/, ""); let cleanUrl = dbUrl.replace(/^file:/, "");
// Handle absolute paths // Handle absolute paths
if (path.isAbsolute(cleanUrl)) { if (path.isAbsolute(cleanUrl)) {
return cleanUrl; return cleanUrl;
} }
// Handle relative paths - normalize "./" prefix // Handle relative paths - normalize "./" prefix
if (cleanUrl.startsWith("./")) { if (cleanUrl.startsWith("./")) {
cleanUrl = cleanUrl.substring(2); cleanUrl = cleanUrl.substring(2);
} }
// Resolve relative to process.cwd() // Resolve relative to process.cwd()
const resolvedPath = path.resolve(process.cwd(), cleanUrl); const resolvedPath = path.resolve(process.cwd(), cleanUrl);
// If file doesn't exist, try common locations // If file doesn't exist, try common locations
if (!existsSync(resolvedPath)) { if (!existsSync(resolvedPath)) {
// Try in prisma/ directory // Try in prisma/ directory
@@ -69,7 +73,7 @@ function getDatabasePath(): string {
if (existsSync(prismaPath)) { if (existsSync(prismaPath)) {
return prismaPath; return prismaPath;
} }
// Try just the filename in prisma/ // Try just the filename in prisma/
const filename = path.basename(cleanUrl); const filename = path.basename(cleanUrl);
const prismaFilenamePath = path.resolve(process.cwd(), "prisma", filename); const prismaFilenamePath = path.resolve(process.cwd(), "prisma", filename);
@@ -77,7 +81,7 @@ function getDatabasePath(): string {
return prismaFilenamePath; return prismaFilenamePath;
} }
} }
return resolvedPath; return resolvedPath;
} }
@@ -120,7 +124,7 @@ async function calculateDataHash(): Promise<string> {
// Create a deterministic string representation of all data // Create a deterministic string representation of all data
const dataString = JSON.stringify({ const dataString = JSON.stringify({
accounts: accounts.map(a => ({ accounts: accounts.map((a) => ({
id: a.id, id: a.id,
name: a.name, name: a.name,
bankId: a.bankId, bankId: a.bankId,
@@ -132,7 +136,7 @@ async function calculateDataHash(): Promise<string> {
lastImport: a.lastImport, lastImport: a.lastImport,
externalUrl: a.externalUrl, externalUrl: a.externalUrl,
})), })),
transactions: transactions.map(t => ({ transactions: transactions.map((t) => ({
id: t.id, id: t.id,
accountId: t.accountId, accountId: t.accountId,
date: t.date, date: t.date,
@@ -145,14 +149,14 @@ async function calculateDataHash(): Promise<string> {
memo: t.memo, memo: t.memo,
checkNum: t.checkNum, checkNum: t.checkNum,
})), })),
folders: folders.map(f => ({ folders: folders.map((f) => ({
id: f.id, id: f.id,
name: f.name, name: f.name,
parentId: f.parentId, parentId: f.parentId,
color: f.color, color: f.color,
icon: f.icon, icon: f.icon,
})), })),
categories: categories.map(c => ({ categories: categories.map((c) => ({
id: c.id, id: c.id,
name: c.name, name: c.name,
color: c.color, color: c.color,
@@ -166,7 +170,14 @@ async function calculateDataHash(): Promise<string> {
} }
export const backupService = { export const backupService = {
async createBackup(force: boolean = false): Promise<{ id: string; filename: string; size: number; skipped?: boolean }> { async createBackup(
force: boolean = false,
): Promise<{
id: string;
filename: string;
size: number;
skipped?: boolean;
}> {
await ensureBackupDir(); await ensureBackupDir();
const dbPath = getDatabasePath(); const dbPath = getDatabasePath();
@@ -195,7 +206,9 @@ export const backupService = {
// Update settings to reflect that backup is still current // Update settings to reflect that backup is still current
const settings = await loadSettings(); const settings = await loadSettings();
settings.lastBackup = new Date().toISOString(); settings.lastBackup = new Date().toISOString();
settings.nextBackup = getNextBackupDate(settings.frequency).toISOString(); settings.nextBackup = getNextBackupDate(
settings.frequency,
).toISOString();
await saveSettings(settings); await saveSettings(settings);
// Return existing backup without creating a new file // Return existing backup without creating a new file
@@ -263,7 +276,10 @@ export const backupService = {
await fs.unlink(backup.filePath); await fs.unlink(backup.filePath);
} }
} catch (error) { } catch (error) {
console.error(`Error deleting backup file ${backup.filePath}:`, error); console.error(
`Error deleting backup file ${backup.filePath}:`,
error,
);
} }
// Delete metadata // Delete metadata
@@ -339,7 +355,9 @@ export const backupService = {
return loadSettings(); return loadSettings();
}, },
async updateSettings(settings: Partial<BackupSettings>): Promise<BackupSettings> { async updateSettings(
settings: Partial<BackupSettings>,
): Promise<BackupSettings> {
const current = await loadSettings(); const current = await loadSettings();
const updated = { ...current, ...settings }; const updated = { ...current, ...settings };
@@ -367,4 +385,3 @@ export const backupService = {
return new Date() >= nextBackupDate; return new Date() >= nextBackupDate;
}, },
}; };

View File

@@ -60,5 +60,3 @@ export const categoryService = {
}); });
}, },
}; };

View File

@@ -75,5 +75,3 @@ export const folderService = {
}); });
}, },
}; };

View File

@@ -10,7 +10,7 @@ export const transactionService = {
async createMany(transactions: Transaction[]): Promise<CreateManyResult> { async createMany(transactions: Transaction[]): Promise<CreateManyResult> {
// Get unique account IDs // Get unique account IDs
const accountIds = [...new Set(transactions.map((t) => t.accountId))]; const accountIds = [...new Set(transactions.map((t) => t.accountId))];
// Check for existing transactions by fitId // Check for existing transactions by fitId
const existingByFitId = await prisma.transaction.findMany({ const existingByFitId = await prisma.transaction.findMany({
where: { where: {
@@ -43,11 +43,11 @@ export const transactionService = {
const existingFitIdSet = new Set( const existingFitIdSet = new Set(
existingByFitId.map((t) => `${t.accountId}-${t.fitId}`), existingByFitId.map((t) => `${t.accountId}-${t.fitId}`),
); );
// Create set for duplicates by amount + date + description // Create set for duplicates by amount + date + description
const existingCriteriaSet = new Set( const existingCriteriaSet = new Set(
allExistingTransactions.map((t) => allExistingTransactions.map(
`${t.accountId}-${t.date}-${t.amount}-${t.description}` (t) => `${t.accountId}-${t.date}-${t.amount}-${t.description}`,
), ),
); );
@@ -55,8 +55,10 @@ export const transactionService = {
const newTransactions = transactions.filter((t) => { const newTransactions = transactions.filter((t) => {
const fitIdKey = `${t.accountId}-${t.fitId}`; const fitIdKey = `${t.accountId}-${t.fitId}`;
const criteriaKey = `${t.accountId}-${t.date}-${t.amount}-${t.description}`; const criteriaKey = `${t.accountId}-${t.date}-${t.amount}-${t.description}`;
return !existingFitIdSet.has(fitIdKey) && !existingCriteriaSet.has(criteriaKey); return (
!existingFitIdSet.has(fitIdKey) && !existingCriteriaSet.has(criteriaKey)
);
}); });
if (newTransactions.length === 0) { if (newTransactions.length === 0) {
@@ -122,7 +124,10 @@ export const transactionService = {
}); });
}, },
async deduplicate(): Promise<{ deletedCount: number; duplicatesFound: number }> { async deduplicate(): Promise<{
deletedCount: number;
duplicatesFound: number;
}> {
// Get all transactions grouped by account // Get all transactions grouped by account
const allTransactions = await prisma.transaction.findMany({ const allTransactions = await prisma.transaction.findMany({
orderBy: [ orderBy: [
@@ -155,7 +160,7 @@ export const transactionService = {
for (const [accountId, transactions] of transactionsByAccount.entries()) { for (const [accountId, transactions] of transactionsByAccount.entries()) {
for (const transaction of transactions) { for (const transaction of transactions) {
const key = `${accountId}-${transaction.date}-${transaction.amount}-${transaction.description}`; const key = `${accountId}-${transaction.date}-${transaction.amount}-${transaction.description}`;
if (seenKeys.has(key)) { if (seenKeys.has(key)) {
// This is a duplicate, mark for deletion // This is a duplicate, mark for deletion
duplicatesToDelete.push(transaction.id); duplicatesToDelete.push(transaction.id);
@@ -181,5 +186,3 @@ export const transactionService = {
}; };
}, },
}; };

View File

@@ -20,4 +20,3 @@ declare module "next-auth/jwt" {
id: string; id: string;
} }
} }

View File

@@ -6,4 +6,3 @@
} }
] ]
} }