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
.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

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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,7 +73,9 @@ export function ParentCategoryRow({
size={isMobile ? 10 : 14}
/>
</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 && (
<span className="text-xs md:text-sm text-muted-foreground shrink-0">
{children.length} {stats.count} opération
@@ -102,7 +104,11 @@ export function ParentCategoryRow({
</Button>
<DropdownMenu>
<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" />
</Button>
</DropdownMenuTrigger>
@@ -147,4 +153,3 @@ export function ParentCategoryRow({
</div>
);
}

View File

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

View File

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

View File

@@ -116,7 +116,9 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<CreditCard className="h-3 w-3 md:h-4 md:w-4 text-muted-foreground" />
</CardHeader>
<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">
{reconciled} / {total} opérations pointées
</p>

View File

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

View File

@@ -36,7 +36,11 @@ interface SidebarContentProps {
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 router = useRouter();
@@ -134,7 +138,10 @@ export function Sidebar({ open, onOpenChange }: SidebarProps) {
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-64 p-0">
<div className="flex flex-col h-full">
<SidebarContent showHeader onNavigate={() => onOpenChange?.(false)} />
<SidebarContent
showHeader
onNavigate={() => onOpenChange?.(false)}
/>
</div>
</SheetContent>
</Sheet>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,9 +37,13 @@ export function PageHeader({
</Button>
)}
<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 && (
<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>
@@ -56,4 +60,3 @@ export function PageHeader({
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,7 +73,7 @@ export function StatsSummaryCards({
<div
className={cn(
"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)}
@@ -83,4 +83,3 @@ export function StatsSummaryCards({
</div>
);
}

View File

@@ -29,10 +29,13 @@ export function TopExpensesList({
<div className="space-y-3 md:space-y-4">
{expenses.map((expense, index) => {
const category = categories.find(
(c) => c.id === expense.categoryId
(c) => c.id === expense.categoryId,
);
return (
<div key={expense.id} className="flex items-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">
{index + 1}
</div>
@@ -84,4 +87,3 @@ export function TopExpensesList({
</Card>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,16 +3,22 @@ import CredentialsProvider from "next-auth/providers/credentials";
import { authService } from "@/services/auth.service";
// Get secret with fallback for development
const secret = process.env.NEXTAUTH_SECRET || "dev-secret-key-change-in-production";
const secret =
process.env.NEXTAUTH_SECRET || "dev-secret-key-change-in-production";
// Debug: log secret status (remove in production)
if (process.env.NODE_ENV === "development") {
console.log("🔐 NextAuth secret:", process.env.NEXTAUTH_SECRET ? "✅ Loaded from .env.local" : "⚠️ Using fallback");
console.log(
"🔐 NextAuth secret:",
process.env.NEXTAUTH_SECRET
? "✅ Loaded from .env.local"
: "⚠️ Using fallback",
);
}
if (!process.env.NEXTAUTH_SECRET && process.env.NODE_ENV === "production") {
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;
}
const isValid = await authService.verifyPassword(credentials.password);
const isValid = await authService.verifyPassword(
credentials.password,
);
if (!isValid) {
return null;
}
@@ -69,4 +77,3 @@ export const authOptions: NextAuthOptions = {
},
secret,
};

View File

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

View File

@@ -6,7 +6,7 @@ const nextConfig = {
images: {
unoptimized: true,
},
output: 'standalone',
output: "standalone",
};
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",
"lastBackup": "2025-11-30T09:50:17.696Z",
"nextBackup": "2025-11-30T10:00:00.000Z"
}
}

View File

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

View File

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

View File

@@ -19,7 +19,9 @@ async function main() {
console.log("Creating automatic backup...");
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);
} catch (error) {
console.error("Error running automatic backup:", error);
@@ -28,4 +30,3 @@ async function main() {
}
main();

View File

@@ -20,7 +20,11 @@ async function ensurePasswordFile(): Promise<void> {
createdAt: 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 {
// Verify old password
const isValid = await this.verifyPassword(oldPassword);
@@ -56,17 +63,20 @@ export const authService = {
// Hash new password
const newHash = await bcrypt.hash(newPassword, 10);
const data = await loadPasswordData();
// Update password
data.hash = newHash;
data.updatedAt = new Date().toISOString();
await savePasswordData(data);
return { success: true };
} catch (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;
}
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() {
if (!existsSync(BACKUP_DIR)) {
@@ -48,20 +52,20 @@ function getDatabasePath(): string {
}
// Remove "file:" prefix if present
let cleanUrl = dbUrl.replace(/^file:/, "");
// Handle absolute paths
if (path.isAbsolute(cleanUrl)) {
return cleanUrl;
}
// Handle relative paths - normalize "./" prefix
if (cleanUrl.startsWith("./")) {
cleanUrl = cleanUrl.substring(2);
}
// Resolve relative to process.cwd()
const resolvedPath = path.resolve(process.cwd(), cleanUrl);
// If file doesn't exist, try common locations
if (!existsSync(resolvedPath)) {
// Try in prisma/ directory
@@ -69,7 +73,7 @@ function getDatabasePath(): string {
if (existsSync(prismaPath)) {
return prismaPath;
}
// Try just the filename in prisma/
const filename = path.basename(cleanUrl);
const prismaFilenamePath = path.resolve(process.cwd(), "prisma", filename);
@@ -77,7 +81,7 @@ function getDatabasePath(): string {
return prismaFilenamePath;
}
}
return resolvedPath;
}
@@ -120,7 +124,7 @@ async function calculateDataHash(): Promise<string> {
// Create a deterministic string representation of all data
const dataString = JSON.stringify({
accounts: accounts.map(a => ({
accounts: accounts.map((a) => ({
id: a.id,
name: a.name,
bankId: a.bankId,
@@ -132,7 +136,7 @@ async function calculateDataHash(): Promise<string> {
lastImport: a.lastImport,
externalUrl: a.externalUrl,
})),
transactions: transactions.map(t => ({
transactions: transactions.map((t) => ({
id: t.id,
accountId: t.accountId,
date: t.date,
@@ -145,14 +149,14 @@ async function calculateDataHash(): Promise<string> {
memo: t.memo,
checkNum: t.checkNum,
})),
folders: folders.map(f => ({
folders: folders.map((f) => ({
id: f.id,
name: f.name,
parentId: f.parentId,
color: f.color,
icon: f.icon,
})),
categories: categories.map(c => ({
categories: categories.map((c) => ({
id: c.id,
name: c.name,
color: c.color,
@@ -166,7 +170,14 @@ async function calculateDataHash(): Promise<string> {
}
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();
const dbPath = getDatabasePath();
@@ -195,7 +206,9 @@ export const backupService = {
// Update settings to reflect that backup is still current
const settings = await loadSettings();
settings.lastBackup = new Date().toISOString();
settings.nextBackup = getNextBackupDate(settings.frequency).toISOString();
settings.nextBackup = getNextBackupDate(
settings.frequency,
).toISOString();
await saveSettings(settings);
// Return existing backup without creating a new file
@@ -263,7 +276,10 @@ export const backupService = {
await fs.unlink(backup.filePath);
}
} catch (error) {
console.error(`Error deleting backup file ${backup.filePath}:`, error);
console.error(
`Error deleting backup file ${backup.filePath}:`,
error,
);
}
// Delete metadata
@@ -339,7 +355,9 @@ export const backupService = {
return loadSettings();
},
async updateSettings(settings: Partial<BackupSettings>): Promise<BackupSettings> {
async updateSettings(
settings: Partial<BackupSettings>,
): Promise<BackupSettings> {
const current = await loadSettings();
const updated = { ...current, ...settings };
@@ -367,4 +385,3 @@ export const backupService = {
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> {
// Get unique account IDs
const accountIds = [...new Set(transactions.map((t) => t.accountId))];
// Check for existing transactions by fitId
const existingByFitId = await prisma.transaction.findMany({
where: {
@@ -43,11 +43,11 @@ export const transactionService = {
const existingFitIdSet = new Set(
existingByFitId.map((t) => `${t.accountId}-${t.fitId}`),
);
// Create set for duplicates by amount + date + description
const existingCriteriaSet = new Set(
allExistingTransactions.map((t) =>
`${t.accountId}-${t.date}-${t.amount}-${t.description}`
allExistingTransactions.map(
(t) => `${t.accountId}-${t.date}-${t.amount}-${t.description}`,
),
);
@@ -55,8 +55,10 @@ export const transactionService = {
const newTransactions = transactions.filter((t) => {
const fitIdKey = `${t.accountId}-${t.fitId}`;
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) {
@@ -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
const allTransactions = await prisma.transaction.findMany({
orderBy: [
@@ -155,7 +160,7 @@ export const transactionService = {
for (const [accountId, transactions] of transactionsByAccount.entries()) {
for (const transaction of transactions) {
const key = `${accountId}-${transaction.date}-${transaction.amount}-${transaction.description}`;
if (seenKeys.has(key)) {
// This is a duplicate, mark for deletion
duplicatesToDelete.push(transaction.id);
@@ -181,5 +186,3 @@ export const transactionService = {
};
},
};

View File

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

View File

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