refactor: streamline transaction page logic by consolidating state management and enhancing pagination, improving overall performance and maintainability

This commit is contained in:
Julien Froidefond
2025-12-08 12:52:59 +01:00
parent ba4d112cb8
commit 53bae084c4
7 changed files with 926 additions and 560 deletions

View File

@@ -1,537 +1,124 @@
"use client"; "use client";
import { useState, useMemo, useEffect, useCallback } from "react"; import { useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { PageLayout, PageHeader } from "@/components/layout"; import { PageLayout, PageHeader } from "@/components/layout";
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import { import {
TransactionFilters, TransactionFilters,
TransactionBulkActions, TransactionBulkActions,
TransactionTable, TransactionTable,
TransactionPagination,
formatCurrency,
formatDate,
} from "@/components/transactions"; } from "@/components/transactions";
import { RuleCreateDialog } from "@/components/rules"; import { RuleCreateDialog } from "@/components/rules";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog"; import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import {
useBankingMetadata,
useTransactions,
getTransactionsQueryKey,
useDuplicateIds,
} from "@/lib/hooks";
import { updateCategory } from "@/lib/store-db";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Upload } from "lucide-react"; import { Upload } from "lucide-react";
import type { Transaction } from "@/lib/types"; import { useTransactionsPage } from "@/hooks/use-transactions-page";
import type { TransactionsPaginatedParams } from "@/services/banking.service"; import { useTransactionMutations } from "@/hooks/use-transaction-mutations";
import { import { useTransactionRules } from "@/hooks/use-transaction-rules";
normalizeDescription,
suggestKeyword,
} from "@/components/rules/constants";
type SortField = "date" | "amount" | "description";
type SortOrder = "asc" | "desc";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
const PAGE_SIZE = 100;
export default function TransactionsPage() { export default function TransactionsPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
const [page, setPage] = useState(0);
// Debounce search query // Main page state and logic
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
setPage(0); // Reset to first page when search changes
}, 300);
return () => clearTimeout(timer);
}, [searchQuery]);
useEffect(() => {
const accountId = searchParams.get("accountId");
if (accountId) {
setSelectedAccounts([accountId]);
setPage(0);
}
}, [searchParams]);
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 [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
const [sortField, setSortField] = useState<SortField>("date");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
new Set()
);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(
null
);
const [updatingTransactionIds, setUpdatingTransactionIds] = useState<
Set<string>
>(new Set());
const [showDuplicates, setShowDuplicates] = useState(false);
// Get start date based on period
const startDate = useMemo(() => {
const now = new Date();
switch (period) {
case "1month":
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
case "3months":
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
case "6months":
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
case "12months":
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
case "custom":
return customStartDate || new Date(0);
default:
return new Date(0);
}
}, [period, customStartDate]);
// Get end date (only for custom period)
const endDate = useMemo(() => {
if (period === "custom" && customEndDate) {
return customEndDate;
}
return undefined;
}, [period, customEndDate]);
// Build transaction query params
const transactionParams = useMemo(() => {
const params: TransactionsPaginatedParams = {
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
sortField,
sortOrder,
};
if (startDate && period !== "all") {
params.startDate = startDate.toISOString().split("T")[0];
}
if (endDate) {
params.endDate = endDate.toISOString().split("T")[0];
}
if (!selectedAccounts.includes("all")) {
params.accountIds = selectedAccounts;
}
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
params.includeUncategorized = true;
} else {
params.categoryIds = selectedCategories;
}
}
if (debouncedSearchQuery) {
params.search = debouncedSearchQuery;
}
if (showReconciled !== "all") {
params.isReconciled = showReconciled === "reconciled";
}
return params;
}, [
page,
startDate,
endDate,
selectedAccounts,
selectedCategories,
debouncedSearchQuery,
showReconciled,
sortField,
sortOrder,
period,
]);
// Fetch transactions with pagination
const { const {
data: transactionsData, metadata,
isLoading: isLoadingTransactions, isLoadingMetadata,
invalidate: invalidateTransactions, searchQuery,
} = useTransactions(transactionParams, !!metadata); setSearchQuery,
selectedAccounts,
onAccountsChange,
selectedCategories,
onCategoriesChange,
showReconciled,
onReconciledChange,
period,
onPeriodChange,
customStartDate,
customEndDate,
onCustomStartDateChange,
onCustomEndDateChange,
isCustomDatePickerOpen,
onCustomDatePickerOpenChange,
showDuplicates,
onShowDuplicatesChange,
page,
pageSize,
onPageChange,
sortField,
sortOrder,
onSortChange,
selectedTransactions,
onToggleSelectAll,
onToggleSelectTransaction,
clearSelection,
transactionsData,
isLoadingTransactions,
invalidateTransactions,
duplicateIds,
transactionParams,
} = useTransactionsPage();
// Fetch duplicate IDs // Transaction mutations
const { data: duplicateIds = new Set<string>() } = useDuplicateIds(); const {
toggleReconciled,
// For filter comboboxes, we'll use empty arrays for now markReconciled,
// They can be enhanced later with separate queries if needed setCategory,
const transactionsForAccountFilter: Transaction[] = []; deleteTransaction,
const transactionsForCategoryFilter: Transaction[] = []; bulkReconcile: handleBulkReconcile,
bulkSetCategory: handleBulkSetCategory,
const handleCreateRule = useCallback((transaction: Transaction) => { updatingTransactionIds,
setRuleTransaction(transaction); } = useTransactionMutations({
setRuleDialogOpen(true); transactionParams,
}, []); transactionsData,
invalidateTransactions,
// Create a virtual group for the rule dialog based on selected transaction
// Note: This requires fetching similar transactions - simplified for now
const ruleGroup = useMemo(() => {
if (!ruleTransaction || !transactionsData) return null;
// Use transactions from current page to find similar ones
const normalizedDesc = normalizeDescription(ruleTransaction.description);
const similarTransactions = transactionsData.transactions.filter(
(t) => normalizeDescription(t.description) === normalizedDesc
);
if (similarTransactions.length === 0) return null;
return {
key: normalizedDesc,
displayName: ruleTransaction.description,
transactions: similarTransactions,
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
suggestedKeyword: suggestKeyword(
similarTransactions.map((t) => t.description)
),
};
}, [ruleTransaction, transactionsData]);
const handleSaveRule = useCallback(
async (ruleData: {
keyword: string;
categoryId: string;
applyToExisting: boolean;
transactionIds: string[];
}) => {
if (!metadata) return;
// 1. Add keyword to category
const category = metadata.categories.find(
(c: { id: string }) => c.id === ruleData.categoryId
);
if (!category) {
throw new Error("Category not found");
}
// Check if keyword already exists
const keywordExists = category.keywords.some(
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase()
);
if (!keywordExists) {
await updateCategory({
...category,
keywords: [...category.keywords, ruleData.keyword],
}); });
}
// 2. Apply to existing transactions if requested // Transaction rules
if (ruleData.applyToExisting) { const {
await Promise.all( ruleDialogOpen,
ruleData.transactionIds.map((id) => setRuleDialogOpen,
fetch("/api/banking/transactions", { ruleGroup,
method: "PUT", handleCreateRule,
headers: { "Content-Type": "application/json" }, handleSaveRule,
body: JSON.stringify({ id, categoryId: ruleData.categoryId }), } = useTransactionRules({
}) transactionsData,
) metadata,
); });
}
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["transactions"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
setRuleDialogOpen(false);
},
[metadata, queryClient]
);
const invalidateAll = useCallback(() => { const invalidateAll = useCallback(() => {
invalidateTransactions(); invalidateTransactions();
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
}, [invalidateTransactions, queryClient]); }, [invalidateTransactions, queryClient]);
const formatCurrency = (amount: number) => { const handleBulkReconcileWithClear = useCallback(
return new Intl.NumberFormat("fr-FR", { (reconciled: boolean) => {
style: "currency", handleBulkReconcile(reconciled, selectedTransactions);
currency: "EUR", clearSelection();
}).format(amount); },
}; [handleBulkReconcile, selectedTransactions, clearSelection]
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const toggleReconciled = async (transactionId: string) => {
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId
);
if (!transaction) return;
const updatedTransaction = {
...transaction,
isReconciled: !transaction.isReconciled,
};
try {
await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTransaction),
});
invalidateTransactions();
} catch (error) {
console.error("Failed to update transaction:", error);
}
};
const markReconciled = async (transactionId: string) => {
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId
);
if (!transaction || transaction.isReconciled) return;
const updatedTransaction = {
...transaction,
isReconciled: true,
};
try {
await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTransaction),
});
invalidateTransactions();
} catch (error) {
console.error("Failed to update transaction:", error);
}
};
const setCategory = async (
transactionId: string,
categoryId: string | null
) => {
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId
);
if (!transaction) return;
setUpdatingTransactionIds((prev) => new Set(prev).add(transactionId));
try {
const response = await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...transaction, categoryId }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Mise à jour directe du cache après succès
const queryKey = getTransactionsQueryKey(transactionParams);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
t.id === transactionId ? { ...t, categoryId } : t
),
};
});
} catch (error) {
console.error("Failed to update transaction:", error);
invalidateTransactions();
} finally {
setUpdatingTransactionIds((prev) => {
const next = new Set(prev);
next.delete(transactionId);
return next;
});
}
};
const bulkReconcile = async (reconciled: boolean) => {
if (!transactionsData) return;
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
selectedTransactions.has(t.id)
); );
setSelectedTransactions(new Set()); const handleBulkSetCategoryWithClear = useCallback(
(categoryId: string | null) => {
try { handleBulkSetCategory(categoryId, selectedTransactions);
await Promise.all( clearSelection();
transactionsToUpdate.map((t) => },
fetch("/api/banking/transactions", { [handleBulkSetCategory, selectedTransactions, clearSelection]
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, isReconciled: reconciled }),
})
)
); );
invalidateTransactions();
} catch (error) {
console.error("Failed to update transactions:", error);
}
};
const bulkSetCategory = async (categoryId: string | null) => {
if (!transactionsData) return;
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
selectedTransactions.has(t.id)
);
const transactionIds = transactionsToUpdate.map((t) => t.id);
setSelectedTransactions(new Set());
setUpdatingTransactionIds((prev) => {
const next = new Set(prev);
transactionIds.forEach((id) => next.add(id));
return next;
});
try {
await Promise.all(
transactionsToUpdate.map((t) =>
fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, categoryId }),
})
)
);
// Mise à jour directe du cache après succès
const queryKey = getTransactionsQueryKey(transactionParams);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
transactionIds.includes(t.id) ? { ...t, categoryId } : t
),
};
});
} catch (error) {
console.error("Failed to update transactions:", error);
invalidateTransactions();
} finally {
setUpdatingTransactionIds((prev) => {
const next = new Set(prev);
transactionIds.forEach((id) => next.delete(id));
return next;
});
}
};
const toggleSelectAll = () => {
if (!transactionsData) return;
if (selectedTransactions.size === transactionsData.transactions.length) {
setSelectedTransactions(new Set());
} else {
setSelectedTransactions(
new Set(transactionsData.transactions.map((t) => t.id))
);
}
};
const toggleSelectTransaction = (id: string) => {
const newSelected = new Set(selectedTransactions);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedTransactions(newSelected);
};
const handleSortChange = (field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortOrder(field === "date" ? "desc" : "asc");
}
setPage(0);
};
const deleteTransaction = async (transactionId: string) => {
// Remove from selected if selected
const newSelected = new Set(selectedTransactions);
newSelected.delete(transactionId);
setSelectedTransactions(newSelected);
// Sauvegarder les données actuelles pour pouvoir les restaurer en cas d'erreur
const queryKey = getTransactionsQueryKey(transactionParams);
const previousData =
queryClient.getQueryData<typeof transactionsData>(queryKey);
// Mise à jour optimiste du cache
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.filter(
(t) => t.id !== transactionId
),
total: oldData.total - 1,
};
});
try {
const response = await fetch(
`/api/banking/transactions?id=${transactionId}`,
{
method: "DELETE",
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.error || `Failed to delete transaction: ${response.status}`
);
}
// Ne pas invalider immédiatement - la mise à jour optimiste est déjà correcte
// On invalide seulement les autres queries qui pourraient être affectées (métadonnées, stats)
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) {
console.error("Failed to delete transaction:", error);
// Restaurer les données précédentes en cas d'erreur
if (previousData) {
queryClient.setQueryData(queryKey, previousData);
}
// Invalider pour récupérer les données correctes du serveur
invalidateTransactions();
}
};
const filteredTransactions = transactionsData?.transactions || []; const filteredTransactions = transactionsData?.transactions || [];
const totalTransactions = transactionsData?.total || 0; const totalTransactions = transactionsData?.total || 0;
const hasMore = transactionsData?.hasMore || false; const hasMore = transactionsData?.hasMore || false;
// For filter comboboxes, we'll use empty arrays for now
// They can be enhanced later with separate queries if needed
const transactionsForAccountFilter: never[] = [];
const transactionsForCategoryFilter: never[] = [];
// Early return for loading state - prevents sidebar flash // Early return for loading state - prevents sidebar flash
if (isLoadingMetadata || !metadata) { if (isLoadingMetadata || !metadata) {
return ( return (
@@ -562,44 +149,21 @@ export default function TransactionsPage() {
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
selectedAccounts={selectedAccounts} selectedAccounts={selectedAccounts}
onAccountsChange={(accounts) => { onAccountsChange={onAccountsChange}
setSelectedAccounts(accounts);
setPage(0);
}}
selectedCategories={selectedCategories} selectedCategories={selectedCategories}
onCategoriesChange={(categories) => { onCategoriesChange={onCategoriesChange}
setPage(0);
setSelectedCategories(categories);
}}
showReconciled={showReconciled} showReconciled={showReconciled}
onReconciledChange={(value) => { onReconciledChange={onReconciledChange}
setShowReconciled(value);
setPage(0);
}}
period={period} period={period}
onPeriodChange={(p) => { onPeriodChange={onPeriodChange}
setPeriod(p);
setPage(0);
if (p !== "custom") {
setIsCustomDatePickerOpen(false);
} else {
setIsCustomDatePickerOpen(true);
}
}}
customStartDate={customStartDate} customStartDate={customStartDate}
customEndDate={customEndDate} customEndDate={customEndDate}
onCustomStartDateChange={(date) => { onCustomStartDateChange={onCustomStartDateChange}
setCustomStartDate(date); onCustomEndDateChange={onCustomEndDateChange}
setPage(0);
}}
onCustomEndDateChange={(date) => {
setCustomEndDate(date);
setPage(0);
}}
isCustomDatePickerOpen={isCustomDatePickerOpen} isCustomDatePickerOpen={isCustomDatePickerOpen}
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen} onCustomDatePickerOpenChange={onCustomDatePickerOpenChange}
showDuplicates={showDuplicates} showDuplicates={showDuplicates}
onShowDuplicatesChange={setShowDuplicates} onShowDuplicatesChange={onShowDuplicatesChange}
accounts={metadata.accounts} accounts={metadata.accounts}
folders={metadata.folders} folders={metadata.folders}
categories={metadata.categories} categories={metadata.categories}
@@ -610,8 +174,8 @@ export default function TransactionsPage() {
<TransactionBulkActions <TransactionBulkActions
selectedCount={selectedTransactions.size} selectedCount={selectedTransactions.size}
categories={metadata.categories} categories={metadata.categories}
onReconcile={bulkReconcile} onReconcile={handleBulkReconcileWithClear}
onSetCategory={bulkSetCategory} onSetCategory={handleBulkSetCategoryWithClear}
/> />
{isLoadingTransactions ? ( {isLoadingTransactions ? (
@@ -627,9 +191,9 @@ export default function TransactionsPage() {
selectedTransactions={selectedTransactions} selectedTransactions={selectedTransactions}
sortField={sortField} sortField={sortField}
sortOrder={sortOrder} sortOrder={sortOrder}
onSortChange={handleSortChange} onSortChange={onSortChange}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={onToggleSelectAll}
onToggleSelectTransaction={toggleSelectTransaction} onToggleSelectTransaction={onToggleSelectTransaction}
onToggleReconciled={toggleReconciled} onToggleReconciled={toggleReconciled}
onMarkReconciled={markReconciled} onMarkReconciled={markReconciled}
onSetCategory={setCategory} onSetCategory={setCategory}
@@ -642,34 +206,13 @@ export default function TransactionsPage() {
highlightDuplicates={showDuplicates} highlightDuplicates={showDuplicates}
/> />
{/* Pagination controls */} <TransactionPagination
{totalTransactions > PAGE_SIZE && ( page={page}
<div className="flex items-center justify-between mt-4"> pageSize={pageSize}
<div className="text-sm text-muted-foreground"> total={totalTransactions}
Affichage de {page * PAGE_SIZE + 1} à{" "} hasMore={hasMore}
{Math.min((page + 1) * PAGE_SIZE, totalTransactions)} sur{" "} onPageChange={onPageChange}
{totalTransactions} />
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={!hasMore}
>
Suivant
</Button>
</div>
</div>
)}
</> </>
)} )}

View File

@@ -1,3 +1,5 @@
export { TransactionFilters } from "./transaction-filters"; export { TransactionFilters } from "./transaction-filters";
export { TransactionBulkActions } from "./transaction-bulk-actions"; export { TransactionBulkActions } from "./transaction-bulk-actions";
export { TransactionTable } from "./transaction-table"; export { TransactionTable } from "./transaction-table";
export { TransactionPagination } from "./transaction-pagination";
export { formatCurrency, formatDate } from "./transaction-utils";

View File

@@ -0,0 +1,51 @@
"use client";
import { Button } from "@/components/ui/button";
interface TransactionPaginationProps {
page: number;
pageSize: number;
total: number;
hasMore: boolean;
onPageChange: (page: number) => void;
}
export function TransactionPagination({
page,
pageSize,
total,
hasMore,
onPageChange,
}: TransactionPaginationProps) {
if (total <= pageSize) {
return null;
}
return (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
Affichage de {page * pageSize + 1} à{" "}
{Math.min((page + 1) * pageSize, total)} sur {total}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(Math.max(0, page - 1))}
disabled={page === 0}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={!hasMore}
>
Suivant
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
/**
* Utility functions for transaction formatting
*/
export const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(amount);
};
export const formatDate = (dateStr: string): string => {
return new Date(dateStr).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
});
};

View File

@@ -0,0 +1,353 @@
"use client";
import { useState, useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { Transaction } from "@/lib/types";
import { getTransactionsQueryKey } from "@/lib/hooks";
import type { TransactionsPaginatedParams } from "@/services/banking.service";
interface UseTransactionMutationsProps {
transactionParams: TransactionsPaginatedParams;
transactionsData: { transactions: Transaction[]; total: number } | undefined;
invalidateTransactions: () => void;
}
export function useTransactionMutations({
transactionParams,
transactionsData,
invalidateTransactions,
}: UseTransactionMutationsProps) {
const queryClient = useQueryClient();
const [updatingTransactionIds, setUpdatingTransactionIds] = useState<
Set<string>
>(new Set());
const toggleReconciled = useCallback(
async (transactionId: string) => {
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId
);
if (!transaction) return;
const newReconciledState = !transaction.isReconciled;
const updatedTransaction = {
...transaction,
isReconciled: newReconciledState,
};
// Optimistic cache update
const queryKey = getTransactionsQueryKey(transactionParams);
const previousData =
queryClient.getQueryData<typeof transactionsData>(queryKey);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
t.id === transactionId
? { ...t, isReconciled: newReconciledState }
: t
),
};
});
try {
const response = await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTransaction),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
} catch (error) {
console.error("Failed to update transaction:", error);
// Rollback on error
if (previousData) {
queryClient.setQueryData(queryKey, previousData);
}
invalidateTransactions();
}
},
[
transactionsData,
transactionParams,
queryClient,
invalidateTransactions,
]
);
const markReconciled = useCallback(
async (transactionId: string) => {
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId
);
if (!transaction || transaction.isReconciled) return;
const updatedTransaction = {
...transaction,
isReconciled: true,
};
// Optimistic cache update
const queryKey = getTransactionsQueryKey(transactionParams);
const previousData =
queryClient.getQueryData<typeof transactionsData>(queryKey);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
t.id === transactionId ? { ...t, isReconciled: true } : t
),
};
});
try {
const response = await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTransaction),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
} catch (error) {
console.error("Failed to update transaction:", error);
// Rollback on error
if (previousData) {
queryClient.setQueryData(queryKey, previousData);
}
invalidateTransactions();
}
},
[
transactionsData,
transactionParams,
queryClient,
invalidateTransactions,
]
);
const setCategory = useCallback(
async (transactionId: string, categoryId: string | null) => {
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId
);
if (!transaction) return;
setUpdatingTransactionIds((prev) => new Set(prev).add(transactionId));
try {
const response = await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...transaction, categoryId }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Optimistic cache update
const queryKey = getTransactionsQueryKey(transactionParams);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
t.id === transactionId ? { ...t, categoryId } : t
),
};
});
} catch (error) {
console.error("Failed to update transaction:", error);
invalidateTransactions();
} finally {
setUpdatingTransactionIds((prev) => {
const next = new Set(prev);
next.delete(transactionId);
return next;
});
}
},
[transactionsData, transactionParams, queryClient, invalidateTransactions]
);
const deleteTransaction = useCallback(
async (transactionId: string) => {
if (!transactionsData) return;
// Save current data for rollback
const queryKey = getTransactionsQueryKey(transactionParams);
const previousData =
queryClient.getQueryData<typeof transactionsData>(queryKey);
// Optimistic cache update
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.filter(
(t) => t.id !== transactionId
),
total: oldData.total - 1,
};
});
try {
const response = await fetch(
`/api/banking/transactions?id=${transactionId}`,
{
method: "DELETE",
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.error || `Failed to delete transaction: ${response.status}`
);
}
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) {
console.error("Failed to delete transaction:", error);
// Rollback on error
if (previousData) {
queryClient.setQueryData(queryKey, previousData);
}
invalidateTransactions();
}
},
[transactionsData, transactionParams, queryClient, invalidateTransactions]
);
const bulkReconcile = useCallback(
async (reconciled: boolean, selectedTransactionIds: Set<string>) => {
if (!transactionsData) return;
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
selectedTransactionIds.has(t.id)
);
const transactionIds = transactionsToUpdate.map((t) => t.id);
// Optimistic cache update
const queryKey = getTransactionsQueryKey(transactionParams);
const previousData =
queryClient.getQueryData<typeof transactionsData>(queryKey);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
transactionIds.includes(t.id)
? { ...t, isReconciled: reconciled }
: t
),
};
});
try {
await Promise.all(
transactionsToUpdate.map((t) =>
fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, isReconciled: reconciled }),
})
)
);
} catch (error) {
console.error("Failed to update transactions:", error);
// Rollback on error
if (previousData) {
queryClient.setQueryData(queryKey, previousData);
}
invalidateTransactions();
}
},
[
transactionsData,
transactionParams,
queryClient,
invalidateTransactions,
]
);
const bulkSetCategory = useCallback(
async (categoryId: string | null, selectedTransactionIds: Set<string>) => {
if (!transactionsData) return;
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
selectedTransactionIds.has(t.id)
);
const transactionIds = transactionsToUpdate.map((t) => t.id);
setUpdatingTransactionIds((prev) => {
const next = new Set(prev);
transactionIds.forEach((id) => next.add(id));
return next;
});
try {
await Promise.all(
transactionsToUpdate.map((t) =>
fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, categoryId }),
})
)
);
// Optimistic cache update
const queryKey = getTransactionsQueryKey(transactionParams);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
transactionIds.includes(t.id) ? { ...t, categoryId } : t
),
};
});
} catch (error) {
console.error("Failed to update transactions:", error);
invalidateTransactions();
} finally {
setUpdatingTransactionIds((prev) => {
const next = new Set(prev);
transactionIds.forEach((id) => next.delete(id));
return next;
});
}
},
[transactionsData, transactionParams, queryClient, invalidateTransactions]
);
return {
toggleReconciled,
markReconciled,
setCategory,
deleteTransaction,
bulkReconcile,
bulkSetCategory,
updatingTransactionIds,
};
}

View File

@@ -0,0 +1,113 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { Transaction, Category } from "@/lib/types";
import { updateCategory } from "@/lib/store-db";
import {
normalizeDescription,
suggestKeyword,
} from "@/components/rules/constants";
interface UseTransactionRulesProps {
transactionsData: { transactions: Transaction[] } | undefined;
metadata: {
categories: Category[];
} | null;
}
export function useTransactionRules({
transactionsData,
metadata,
}: UseTransactionRulesProps) {
const queryClient = useQueryClient();
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(
null
);
const handleCreateRule = useCallback((transaction: Transaction) => {
setRuleTransaction(transaction);
setRuleDialogOpen(true);
}, []);
const ruleGroup = useMemo(() => {
if (!ruleTransaction || !transactionsData) return null;
const normalizedDesc = normalizeDescription(ruleTransaction.description);
const similarTransactions = transactionsData.transactions.filter(
(t) => normalizeDescription(t.description) === normalizedDesc
);
if (similarTransactions.length === 0) return null;
return {
key: normalizedDesc,
displayName: ruleTransaction.description,
transactions: similarTransactions,
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
suggestedKeyword: suggestKeyword(
similarTransactions.map((t) => t.description)
),
};
}, [ruleTransaction, transactionsData]);
const handleSaveRule = useCallback(
async (ruleData: {
keyword: string;
categoryId: string;
applyToExisting: boolean;
transactionIds: string[];
}) => {
if (!metadata) return;
// Add keyword to category
const category = metadata.categories.find(
(c) => c.id === ruleData.categoryId
);
if (!category) {
throw new Error("Category not found");
}
// Check if keyword already exists
const keywordExists = category.keywords.some(
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase()
);
if (!keywordExists) {
await updateCategory({
...category,
keywords: [...category.keywords, ruleData.keyword],
});
}
// Apply to existing transactions if requested
if (ruleData.applyToExisting) {
await Promise.all(
ruleData.transactionIds.map((id) =>
fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
})
)
);
}
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ["transactions"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
setRuleDialogOpen(false);
},
[metadata, queryClient]
);
return {
ruleDialogOpen,
setRuleDialogOpen,
ruleGroup,
handleCreateRule,
handleSaveRule,
};
}

View File

@@ -0,0 +1,286 @@
"use client";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import {
useBankingMetadata,
useTransactions,
useDuplicateIds,
} from "@/lib/hooks";
import type { TransactionsPaginatedParams } from "@/services/banking.service";
type SortField = "date" | "amount" | "description";
type SortOrder = "asc" | "desc";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
const PAGE_SIZE = 100;
export function useTransactionsPage() {
const searchParams = useSearchParams();
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
// Search state
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
// Filter state
const [selectedAccounts, setSelectedAccounts] = 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 [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
const [showDuplicates, setShowDuplicates] = useState(false);
// Pagination state
const [page, setPage] = useState(0);
// Sort state
const [sortField, setSortField] = useState<SortField>("date");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
// Selection state
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
new Set()
);
// Debounce search query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
setPage(0);
}, 300);
return () => clearTimeout(timer);
}, [searchQuery]);
// Handle accountId from URL params
useEffect(() => {
const accountId = searchParams.get("accountId");
if (accountId) {
setSelectedAccounts([accountId]);
setPage(0);
}
}, [searchParams]);
// Calculate start date based on period
const startDate = useMemo(() => {
const now = new Date();
switch (period) {
case "1month":
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
case "3months":
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
case "6months":
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
case "12months":
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
case "custom":
return customStartDate || new Date(0);
default:
return new Date(0);
}
}, [period, customStartDate]);
// Calculate end date (only for custom period)
const endDate = useMemo(() => {
if (period === "custom" && customEndDate) {
return customEndDate;
}
return undefined;
}, [period, customEndDate]);
// Build transaction query params
const transactionParams = useMemo(() => {
const params: TransactionsPaginatedParams = {
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
sortField,
sortOrder,
};
if (startDate && period !== "all") {
params.startDate = startDate.toISOString().split("T")[0];
}
if (endDate) {
params.endDate = endDate.toISOString().split("T")[0];
}
if (!selectedAccounts.includes("all")) {
params.accountIds = selectedAccounts;
}
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
params.includeUncategorized = true;
} else {
params.categoryIds = selectedCategories;
}
}
if (debouncedSearchQuery) {
params.search = debouncedSearchQuery;
}
if (showReconciled !== "all") {
params.isReconciled = showReconciled === "reconciled";
}
return params;
}, [
page,
startDate,
endDate,
selectedAccounts,
selectedCategories,
debouncedSearchQuery,
showReconciled,
sortField,
sortOrder,
period,
]);
// Fetch transactions
const {
data: transactionsData,
isLoading: isLoadingTransactions,
invalidate: invalidateTransactions,
} = useTransactions(transactionParams, !!metadata);
// Fetch duplicate IDs
const { data: duplicateIds = new Set<string>() } = useDuplicateIds();
// Handlers
const handleAccountsChange = useCallback((accounts: string[]) => {
setSelectedAccounts(accounts);
setPage(0);
}, []);
const handleCategoriesChange = useCallback((categories: string[]) => {
setSelectedCategories(categories);
setPage(0);
}, []);
const handleReconciledChange = useCallback((value: string) => {
setShowReconciled(value);
setPage(0);
}, []);
const handlePeriodChange = useCallback(
(p: Period) => {
setPeriod(p);
setPage(0);
if (p !== "custom") {
setIsCustomDatePickerOpen(false);
} else {
setIsCustomDatePickerOpen(true);
}
},
[]
);
const handleCustomStartDateChange = useCallback((date: Date | undefined) => {
setCustomStartDate(date);
setPage(0);
}, []);
const handleCustomEndDateChange = useCallback((date: Date | undefined) => {
setCustomEndDate(date);
setPage(0);
}, []);
const handleSortChange = useCallback((field: SortField) => {
if (sortField === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortOrder(field === "date" ? "desc" : "asc");
}
setPage(0);
}, [sortField]);
const toggleSelectAll = useCallback(() => {
if (!transactionsData) return;
if (selectedTransactions.size === transactionsData.transactions.length) {
setSelectedTransactions(new Set());
} else {
setSelectedTransactions(
new Set(transactionsData.transactions.map((t) => t.id))
);
}
}, [transactionsData, selectedTransactions.size]);
const toggleSelectTransaction = useCallback((id: string) => {
setSelectedTransactions((prev) => {
const newSelected = new Set(prev);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
return newSelected;
});
}, []);
const handlePageChange = useCallback((newPage: number) => {
setPage(newPage);
}, []);
const clearSelection = useCallback(() => {
setSelectedTransactions(new Set());
}, []);
return {
// Metadata
metadata,
isLoadingMetadata,
// Search
searchQuery,
setSearchQuery,
// Filters
selectedAccounts,
onAccountsChange: handleAccountsChange,
selectedCategories,
onCategoriesChange: handleCategoriesChange,
showReconciled,
onReconciledChange: handleReconciledChange,
period,
onPeriodChange: handlePeriodChange,
customStartDate,
customEndDate,
onCustomStartDateChange: handleCustomStartDateChange,
onCustomEndDateChange: handleCustomEndDateChange,
isCustomDatePickerOpen,
onCustomDatePickerOpenChange: setIsCustomDatePickerOpen,
showDuplicates,
onShowDuplicatesChange: setShowDuplicates,
// Pagination
page,
pageSize: PAGE_SIZE,
onPageChange: handlePageChange,
// Sort
sortField,
sortOrder,
onSortChange: handleSortChange,
// Selection
selectedTransactions,
onToggleSelectAll: toggleSelectAll,
onToggleSelectTransaction: toggleSelectTransaction,
clearSelection,
// Data
transactionsData,
isLoadingTransactions,
invalidateTransactions,
duplicateIds,
transactionParams,
};
}