chore: clean up code by removing trailing whitespace and ensuring consistent formatting across various files = prettier
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -4,4 +4,3 @@ import { authOptions } from "@/lib/auth";
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,5 +27,3 @@ export async function POST() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user