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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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