The link of the "categorize by hand" step carries `guided`, which the
Transactions page reads through `readOnboardingNavState` and turns into
start filters: `useTransactions(initialFilters?)` puts them over the
defaults through `initTransactionsState`, at mount only, so the card
consuming the navigation state changes nothing. The guided entry lists
the uncategorized transactions whatever their date (the default filter
starts on January 1st, and in January the sample statements fall in the
previous year). When its first page comes back empty, the reducer drops
"uncategorized only" once: a new filters object, so the existing filter
effect reads again, and the loading placeholder meanwhile. The first
page and any filter the user sets disarm the fallback, so it never loops
and never drops a filter behind the user's back.
`transactionsCardModel(filters, checklist)` says which sentence matches
the list, names the filter by its own label, points at the first row's
category, and says "Done." once the step is checked, which the card
needs to offer the Budget step or the end of the path. The label loses
its capital through an i18next `lowercase` formatter used by the
sentence itself, `{{filter, lowercase}}`.
TransactionTable wraps every row's category in the same element and
puts the ring on the first one only, so the ring coming or going never
remounts the combobox; CategoryCombobox is unchanged. CARD_PAGES gets
/transactions, and the page renders the card first and highlights its
target only while the card shows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
461 lines
14 KiB
TypeScript
461 lines
14 KiB
TypeScript
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<TransactionFilters> = {
|
|
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<TransactionFilters> = {}
|
|
): 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<TransactionsState, "totalInDb" | "isLoading">): 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<TransactionFilters>) {
|
|
const [state, dispatch] = useReducer(transactionsReducer, initialFilters, initTransactionsState);
|
|
const fetchIdRef = useRef(0);
|
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | 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<SplitChild[]> => {
|
|
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,
|
|
};
|
|
}
|