refactor: streamline transaction page logic by consolidating state management and enhancing pagination, improving overall performance and maintainability
This commit is contained in:
@@ -1,537 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { PageLayout, PageHeader } from "@/components/layout";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import {
|
||||
TransactionFilters,
|
||||
TransactionBulkActions,
|
||||
TransactionTable,
|
||||
TransactionPagination,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
} from "@/components/transactions";
|
||||
import { RuleCreateDialog } from "@/components/rules";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Upload } from "lucide-react";
|
||||
import type { Transaction } from "@/lib/types";
|
||||
import type { TransactionsPaginatedParams } from "@/services/banking.service";
|
||||
import {
|
||||
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;
|
||||
import { useTransactionsPage } from "@/hooks/use-transactions-page";
|
||||
import { useTransactionMutations } from "@/hooks/use-transaction-mutations";
|
||||
import { useTransactionRules } from "@/hooks/use-transaction-rules";
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
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
|
||||
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
|
||||
// Main page state and logic
|
||||
const {
|
||||
data: transactionsData,
|
||||
isLoading: isLoadingTransactions,
|
||||
invalidate: invalidateTransactions,
|
||||
} = useTransactions(transactionParams, !!metadata);
|
||||
metadata,
|
||||
isLoadingMetadata,
|
||||
searchQuery,
|
||||
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
|
||||
const { data: duplicateIds = new Set<string>() } = useDuplicateIds();
|
||||
|
||||
// For filter comboboxes, we'll use empty arrays for now
|
||||
// They can be enhanced later with separate queries if needed
|
||||
const transactionsForAccountFilter: Transaction[] = [];
|
||||
const transactionsForCategoryFilter: Transaction[] = [];
|
||||
|
||||
const handleCreateRule = useCallback((transaction: Transaction) => {
|
||||
setRuleTransaction(transaction);
|
||||
setRuleDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// 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],
|
||||
// Transaction mutations
|
||||
const {
|
||||
toggleReconciled,
|
||||
markReconciled,
|
||||
setCategory,
|
||||
deleteTransaction,
|
||||
bulkReconcile: handleBulkReconcile,
|
||||
bulkSetCategory: handleBulkSetCategory,
|
||||
updatingTransactionIds,
|
||||
} = useTransactionMutations({
|
||||
transactionParams,
|
||||
transactionsData,
|
||||
invalidateTransactions,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 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]
|
||||
);
|
||||
// Transaction rules
|
||||
const {
|
||||
ruleDialogOpen,
|
||||
setRuleDialogOpen,
|
||||
ruleGroup,
|
||||
handleCreateRule,
|
||||
handleSaveRule,
|
||||
} = useTransactionRules({
|
||||
transactionsData,
|
||||
metadata,
|
||||
});
|
||||
|
||||
const invalidateAll = useCallback(() => {
|
||||
invalidateTransactions();
|
||||
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||
}, [invalidateTransactions, queryClient]);
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
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)
|
||||
const handleBulkReconcileWithClear = useCallback(
|
||||
(reconciled: boolean) => {
|
||||
handleBulkReconcile(reconciled, selectedTransactions);
|
||||
clearSelection();
|
||||
},
|
||||
[handleBulkReconcile, selectedTransactions, clearSelection]
|
||||
);
|
||||
|
||||
setSelectedTransactions(new Set());
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
transactionsToUpdate.map((t) =>
|
||||
fetch("/api/banking/transactions", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...t, isReconciled: reconciled }),
|
||||
})
|
||||
)
|
||||
const handleBulkSetCategoryWithClear = useCallback(
|
||||
(categoryId: string | null) => {
|
||||
handleBulkSetCategory(categoryId, selectedTransactions);
|
||||
clearSelection();
|
||||
},
|
||||
[handleBulkSetCategory, selectedTransactions, clearSelection]
|
||||
);
|
||||
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 totalTransactions = transactionsData?.total || 0;
|
||||
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
|
||||
if (isLoadingMetadata || !metadata) {
|
||||
return (
|
||||
@@ -562,44 +149,21 @@ export default function TransactionsPage() {
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
selectedAccounts={selectedAccounts}
|
||||
onAccountsChange={(accounts) => {
|
||||
setSelectedAccounts(accounts);
|
||||
setPage(0);
|
||||
}}
|
||||
onAccountsChange={onAccountsChange}
|
||||
selectedCategories={selectedCategories}
|
||||
onCategoriesChange={(categories) => {
|
||||
setPage(0);
|
||||
setSelectedCategories(categories);
|
||||
}}
|
||||
onCategoriesChange={onCategoriesChange}
|
||||
showReconciled={showReconciled}
|
||||
onReconciledChange={(value) => {
|
||||
setShowReconciled(value);
|
||||
setPage(0);
|
||||
}}
|
||||
onReconciledChange={onReconciledChange}
|
||||
period={period}
|
||||
onPeriodChange={(p) => {
|
||||
setPeriod(p);
|
||||
setPage(0);
|
||||
if (p !== "custom") {
|
||||
setIsCustomDatePickerOpen(false);
|
||||
} else {
|
||||
setIsCustomDatePickerOpen(true);
|
||||
}
|
||||
}}
|
||||
onPeriodChange={onPeriodChange}
|
||||
customStartDate={customStartDate}
|
||||
customEndDate={customEndDate}
|
||||
onCustomStartDateChange={(date) => {
|
||||
setCustomStartDate(date);
|
||||
setPage(0);
|
||||
}}
|
||||
onCustomEndDateChange={(date) => {
|
||||
setCustomEndDate(date);
|
||||
setPage(0);
|
||||
}}
|
||||
onCustomStartDateChange={onCustomStartDateChange}
|
||||
onCustomEndDateChange={onCustomEndDateChange}
|
||||
isCustomDatePickerOpen={isCustomDatePickerOpen}
|
||||
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen}
|
||||
onCustomDatePickerOpenChange={onCustomDatePickerOpenChange}
|
||||
showDuplicates={showDuplicates}
|
||||
onShowDuplicatesChange={setShowDuplicates}
|
||||
onShowDuplicatesChange={onShowDuplicatesChange}
|
||||
accounts={metadata.accounts}
|
||||
folders={metadata.folders}
|
||||
categories={metadata.categories}
|
||||
@@ -610,8 +174,8 @@ export default function TransactionsPage() {
|
||||
<TransactionBulkActions
|
||||
selectedCount={selectedTransactions.size}
|
||||
categories={metadata.categories}
|
||||
onReconcile={bulkReconcile}
|
||||
onSetCategory={bulkSetCategory}
|
||||
onReconcile={handleBulkReconcileWithClear}
|
||||
onSetCategory={handleBulkSetCategoryWithClear}
|
||||
/>
|
||||
|
||||
{isLoadingTransactions ? (
|
||||
@@ -627,9 +191,9 @@ export default function TransactionsPage() {
|
||||
selectedTransactions={selectedTransactions}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onToggleSelectTransaction={toggleSelectTransaction}
|
||||
onSortChange={onSortChange}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
onToggleSelectTransaction={onToggleSelectTransaction}
|
||||
onToggleReconciled={toggleReconciled}
|
||||
onMarkReconciled={markReconciled}
|
||||
onSetCategory={setCategory}
|
||||
@@ -642,34 +206,13 @@ export default function TransactionsPage() {
|
||||
highlightDuplicates={showDuplicates}
|
||||
/>
|
||||
|
||||
{/* Pagination controls */}
|
||||
{totalTransactions > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Affichage de {page * PAGE_SIZE + 1} à{" "}
|
||||
{Math.min((page + 1) * PAGE_SIZE, totalTransactions)} sur{" "}
|
||||
{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>
|
||||
)}
|
||||
<TransactionPagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={totalTransactions}
|
||||
hasMore={hasMore}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { TransactionFilters } from "./transaction-filters";
|
||||
export { TransactionBulkActions } from "./transaction-bulk-actions";
|
||||
export { TransactionTable } from "./transaction-table";
|
||||
export { TransactionPagination } from "./transaction-pagination";
|
||||
export { formatCurrency, formatDate } from "./transaction-utils";
|
||||
|
||||
51
components/transactions/transaction-pagination.tsx
Normal file
51
components/transactions/transaction-pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
18
components/transactions/transaction-utils.ts
Normal file
18
components/transactions/transaction-utils.ts
Normal 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",
|
||||
});
|
||||
};
|
||||
353
hooks/use-transaction-mutations.ts
Normal file
353
hooks/use-transaction-mutations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
113
hooks/use-transaction-rules.ts
Normal file
113
hooks/use-transaction-rules.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
286
hooks/use-transactions-page.ts
Normal file
286
hooks/use-transactions-page.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user