Simpl-Resultat/src/hooks/useTransactions.ts
Le-King-Fu f9c6fabc13 feat: add period quick-select filter to transactions page
Add period buttons (This month, 3 months, 6 months, This year, All)
above the transaction filters. "This year" is selected by default so
the page no longer shows all transactions since the beginning of time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:32:08 +00:00

321 lines
9.2 KiB
TypeScript

import { useReducer, useCallback, useEffect, useRef } from "react";
import type {
TransactionRow,
TransactionFilters,
TransactionSort,
TransactionPageResult,
Category,
ImportSource,
} from "../shared/types";
import {
getTransactionPage,
updateTransactionCategory,
updateTransactionNotes,
getAllCategories,
getAllImportSources,
autoCategorizeTransactions,
} from "../services/transactionService";
import { createKeyword } from "../services/categoryService";
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;
}
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: "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 initialFilters: TransactionFilters = {
search: "",
categoryId: null,
sourceId: null,
dateFrom: `${new Date().getFullYear()}-01-01`,
dateTo: null,
uncategorizedOnly: false,
};
const initialState: TransactionsState = {
rows: [],
totalCount: 0,
totalAmount: 0,
incomeTotal: 0,
expenseTotal: 0,
filters: initialFilters,
sort: { column: "date", direction: "desc" },
page: 1,
pageSize: 50,
categories: [],
sources: [],
isLoading: false,
isAutoCategorizing: false,
error: null,
};
function reducer(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":
return {
...state,
rows: action.payload.rows,
totalCount: action.payload.totalCount,
totalAmount: action.payload.totalAmount,
incomeTotal: action.payload.incomeTotal,
expenseTotal: action.payload.expenseTotal,
isLoading: false,
};
case "SET_FILTER":
return {
...state,
filters: { ...state.filters, [action.payload.key]: action.payload.value },
page: 1,
};
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 "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;
}
}
export function useTransactions() {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedFiltersRef = useRef(state.filters);
// Load categories and sources once on mount
useEffect(() => {
(async () => {
try {
const [cats, srcs] = await Promise.all([
getAllCategories(),
getAllImportSources(),
]);
dispatch({ type: "SET_CATEGORIES", payload: cats });
dispatch({ type: "SET_SOURCES", payload: srcs });
} 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),
});
}
},
[]
);
return {
state,
setFilter,
setSort,
setPage,
updateCategory,
saveNotes,
autoCategorize,
addKeywordToCategory,
};
}