import { useReducer, useCallback, useEffect, useRef } from "react"; import type { TransactionRow, TransactionFilters, TransactionSort, TransactionPageResult, Category, ImportSource, SplitChild, } from "../shared/types"; import { getTransactionPage, updateTransactionCategory, updateTransactionNotes, getAllCategories, getAllImportSources, getTransactionCount, autoCategorizeTransactions, getSplitChildren, saveSplitAdjustment, deleteSplitAdjustment, } from "../services/transactionService"; import { createKeyword } from "../services/categoryService"; export interface TransactionsState { rows: TransactionRow[]; totalCount: number; totalAmount: number; incomeTotal: number; expenseTotal: number; filters: TransactionFilters; sort: TransactionSort; page: number; pageSize: number; categories: Category[]; sources: ImportSource[]; isLoading: boolean; isAutoCategorizing: boolean; error: string | null; /** * Number of transactions in the whole profile, independent of the filters * (#353). `null` until the count is loaded. The page needs it because the * default filter is "since January 1st", so an empty `rows` cannot tell a * profile without any transaction from a year without any. */ totalInDb: number | null; /** * The start filters asked for the uncategorized transactions only (the * guided entry, #399) and their first page has not come back yet. If it * comes back empty, `SET_PAGE_RESULT` drops that filter, once. Off after * that first page, after any filter change, and for an ordinary visit. */ guidedFallback: boolean; } export type TransactionsAction = | { type: "SET_LOADING"; payload: boolean } | { type: "SET_ERROR"; payload: string | null } | { type: "SET_PAGE_RESULT"; payload: TransactionPageResult } | { type: "SET_FILTER"; payload: { key: keyof TransactionFilters; value: unknown } } | { type: "SET_SORT"; payload: TransactionSort } | { type: "SET_PAGE"; payload: number } | { type: "SET_CATEGORIES"; payload: Category[] } | { type: "SET_SOURCES"; payload: ImportSource[] } | { type: "SET_TOTAL_IN_DB"; payload: number } | { type: "UPDATE_ROW_CATEGORY"; payload: { txId: number; categoryId: number | null; categoryName: string | null; categoryColor: string | null } } | { type: "UPDATE_ROW_NOTES"; payload: { txId: number; notes: string } } | { type: "SET_AUTO_CATEGORIZING"; payload: boolean }; const defaultFilters: TransactionFilters = { search: "", categoryId: null, sourceId: null, dateFrom: `${new Date().getFullYear()}-01-01`, dateTo: null, uncategorizedOnly: false, }; /** * The guided entry of the "categorize by hand" step (#399): the uncategorized * transactions, whatever their date. No date bound, because the default * filter starts on January 1st and, in January, the sample statements fall in * the previous year. */ export const GUIDED_ENTRY_FILTERS: Partial = { dateFrom: null, uncategorizedOnly: true, }; export const initialTransactionsState: TransactionsState = { rows: [], totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0, filters: defaultFilters, sort: { column: "date", direction: "desc" }, page: 1, pageSize: 50, categories: [], sources: [], isLoading: false, isAutoCategorizing: false, error: null, totalInDb: null, guidedFallback: false, }; /** * The hook's initial state (#399): the default filters with `initialFilters` * over them. Start filters that ask for the uncategorized transactions only * arm the fallback of their first page (`guidedFallback`). Without * `initialFilters`, an ordinary visit: the default state. */ export function initTransactionsState( initialFilters: Partial = {} ): TransactionsState { return { ...initialTransactionsState, filters: { ...initialTransactionsState.filters, ...initialFilters }, guidedFallback: initialFilters.uncategorizedOnly === true, }; } export function transactionsReducer( state: TransactionsState, action: TransactionsAction ): TransactionsState { switch (action.type) { case "SET_LOADING": return { ...state, isLoading: action.payload }; case "SET_ERROR": return { ...state, error: action.payload, isLoading: false }; case "SET_PAGE_RESULT": { const next: TransactionsState = { ...state, rows: action.payload.rows, totalCount: action.payload.totalCount, totalAmount: action.payload.totalAmount, incomeTotal: action.payload.incomeTotal, expenseTotal: action.payload.expenseTotal, isLoading: false, guidedFallback: false, }; // The guided entry's first page is empty: no transaction is left // uncategorized (#399). Drop that filter, once, so the page lists every // transaction instead of an empty table. The new filters make the hook // read again (the filters' 300 ms debounce); until then, the loading // placeholder takes the place of the empty table. if (state.guidedFallback && action.payload.totalCount === 0) { return { ...next, filters: { ...state.filters, uncategorizedOnly: false }, page: 1, isLoading: true, }; } return next; } case "SET_FILTER": return { ...state, filters: { ...state.filters, [action.payload.key]: action.payload.value }, page: 1, // The user's own filters: never dropped behind their back. guidedFallback: false, }; case "SET_SORT": return { ...state, sort: action.payload }; case "SET_PAGE": return { ...state, page: action.payload }; case "SET_CATEGORIES": return { ...state, categories: action.payload }; case "SET_SOURCES": return { ...state, sources: action.payload }; case "SET_TOTAL_IN_DB": return { ...state, totalInDb: action.payload }; case "UPDATE_ROW_CATEGORY": return { ...state, rows: state.rows.map((r) => r.id === action.payload.txId ? { ...r, category_id: action.payload.categoryId, category_name: action.payload.categoryName, category_color: action.payload.categoryColor, is_manually_categorized: true, } : r ), }; case "UPDATE_ROW_NOTES": return { ...state, rows: state.rows.map((r) => r.id === action.payload.txId ? { ...r, notes: action.payload.notes } : r ), }; case "SET_AUTO_CATEGORIZING": return { ...state, isAutoCategorizing: action.payload }; default: return state; } } /** * Whether the Transactions page shows the "import first" empty state (#353) * instead of the filter bar, table and pagination. Pure, so the 0 → N * transition is unit-testable without a renderer. `null` (count not loaded) * and a loading page keep the regular layout. */ export function showsEmptyState(state: Pick): boolean { return state.totalInDb === 0 && !state.isLoading; } /** * The Transactions page's state. `initialFilters` go over the default filters * (the guided entry passes `GUIDED_ENTRY_FILTERS`, #399); like any initial * state, they are read once, at mount, and a later value changes nothing. */ export function useTransactions(initialFilters?: Partial) { const [state, dispatch] = useReducer(transactionsReducer, initialFilters, initTransactionsState); const fetchIdRef = useRef(0); const searchTimerRef = useRef | null>(null); const debouncedFiltersRef = useRef(state.filters); // Load categories, sources and the profile-wide transaction count once on mount useEffect(() => { (async () => { try { const [cats, srcs, total] = await Promise.all([ getAllCategories(), getAllImportSources(), getTransactionCount(), ]); dispatch({ type: "SET_CATEGORIES", payload: cats }); dispatch({ type: "SET_SOURCES", payload: srcs }); dispatch({ type: "SET_TOTAL_IN_DB", payload: total }); } catch (e) { dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e), }); } })(); }, []); // Fetch transactions when filters/sort/page change const fetchData = useCallback(async ( filters: TransactionFilters, sort: TransactionSort, page: number, pageSize: number ) => { const fetchId = ++fetchIdRef.current; dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_ERROR", payload: null }); try { const result = await getTransactionPage(filters, sort, page, pageSize); // Ignore stale responses if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_PAGE_RESULT", payload: result }); } catch (e) { if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e), }); } }, []); // Auto-fetch when sort, page, or non-search filters change useEffect(() => { fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize); }, [state.sort, state.page, state.pageSize, fetchData]); // Debounced search — trigger fetch after 300ms useEffect(() => { if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); } searchTimerRef.current = setTimeout(() => { debouncedFiltersRef.current = state.filters; fetchData(state.filters, state.sort, state.page, state.pageSize); }, 300); return () => { if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); } }; }, [state.filters]); // eslint-disable-line react-hooks/exhaustive-deps const setFilter = useCallback( (key: keyof TransactionFilters, value: unknown) => { dispatch({ type: "SET_FILTER", payload: { key, value } }); }, [] ); const setSort = useCallback( (column: TransactionSort["column"]) => { dispatch({ type: "SET_SORT", payload: { column, direction: state.sort.column === column && state.sort.direction === "desc" ? "asc" : "desc", }, }); }, [state.sort] ); const setPage = useCallback((page: number) => { dispatch({ type: "SET_PAGE", payload: page }); }, []); const updateCategory = useCallback( async (txId: number, categoryId: number | null) => { const cat = state.categories.find((c) => c.id === categoryId) ?? null; dispatch({ type: "UPDATE_ROW_CATEGORY", payload: { txId, categoryId, categoryName: cat?.name ?? null, categoryColor: cat?.color ?? null, }, }); try { await updateTransactionCategory(txId, categoryId, true); } catch (e) { dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e), }); // Refetch to restore correct state fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize); } }, [state.categories, state.sort, state.page, state.pageSize, fetchData] ); const saveNotes = useCallback( async (txId: number, notes: string) => { dispatch({ type: "UPDATE_ROW_NOTES", payload: { txId, notes } }); try { await updateTransactionNotes(txId, notes); } catch (e) { dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e), }); fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize); } }, [state.sort, state.page, state.pageSize, fetchData] ); const autoCategorize = useCallback(async () => { dispatch({ type: "SET_AUTO_CATEGORIZING", payload: true }); try { const count = await autoCategorizeTransactions(); if (count > 0) { fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize); } return count; } catch (e) { dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e), }); return 0; } finally { dispatch({ type: "SET_AUTO_CATEGORIZING", payload: false }); } }, [state.sort, state.page, state.pageSize, fetchData]); const addKeywordToCategory = useCallback( async (categoryId: number, keyword: string) => { try { await createKeyword(categoryId, keyword.trim(), 0); } catch (e) { dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e), }); } }, [] ); const loadSplitChildren = useCallback( async (parentId: number): Promise => { try { return await getSplitChildren(parentId); } catch (e) { dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e), }); return []; } }, [] ); const saveSplit = useCallback( async ( parentId: number, entries: Array<{ category_id: number; amount: number; description: string }> ) => { try { await saveSplitAdjustment(parentId, entries); fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize); } catch (e) { dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e), }); } }, [state.sort, state.page, state.pageSize, fetchData] ); const deleteSplit = useCallback( async (parentId: number) => { try { await deleteSplitAdjustment(parentId); fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize); } catch (e) { dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e), }); } }, [state.sort, state.page, state.pageSize, fetchData] ); return { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit, }; }