feat(onboarding): empty states on the Transactions and Budget pages (#353)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m51s
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m51s
A profile without any transaction now gets an "import first" card with a button to the import page on both pages, in place of the filter bar, the table and the pagination (Transactions) and of the filter panel and the grid (Budget). Title, page help and template actions stay. The signal is a profile-wide `totalInDb` count loaded once at mount via `getTransactionCount()`, not `rows.length`: the default Transactions filter is "since January 1st", so an empty page cannot tell an empty profile from a year without transactions. `null` (not loaded yet) keeps the regular layout, and so does a loading Transactions page. `transactionsReducer` / `initialTransactionsState` and `budgetReducer` / `initialBudgetState` are exported with a pure `showsEmptyState` selector per hook, so the reducer tests pin that the count never touches filters, sort or page and that 0 then N > 0 brings the table back. The existing "no transactions found" / "no categories found" rows are unchanged. Resolves #353 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
3f3531392e
commit
8eac47f69b
8 changed files with 315 additions and 65 deletions
|
|
@ -128,3 +128,51 @@ describe("BudgetTable section subtotals stay on RAW rows (collapse is purely vis
|
||||||
expect(rawExpense.annual).not.toBe(visibleExpense.annual);
|
expect(rawExpense.annual).not.toBe(visibleExpense.annual);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Issue #353 — the profile-wide transaction count (`totalInDb`) drives the
|
||||||
|
// "import first" empty state of the Budget page. The exported reducer is
|
||||||
|
// exercised directly (no renderHook harness in this repo).
|
||||||
|
|
||||||
|
import { budgetReducer, initialBudgetState, showsEmptyState } from "./useBudget";
|
||||||
|
|
||||||
|
describe("budgetReducer — SET_TOTAL_IN_DB (issue #353)", () => {
|
||||||
|
it("starts unknown: totalInDb is null, which keeps the grid", () => {
|
||||||
|
const state = initialBudgetState();
|
||||||
|
expect(state.totalInDb).toBeNull();
|
||||||
|
expect(showsEmptyState(state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the count without touching year, rows, templates or accounts", () => {
|
||||||
|
const before = budgetReducer(
|
||||||
|
budgetReducer(initialBudgetState(), { type: "SET_YEAR", payload: 2024 }),
|
||||||
|
{ type: "SET_DATA", payload: { rows: buildBudgetYearRows(CATEGORIES, [entry(2, 1000)], []), templates: [] } },
|
||||||
|
);
|
||||||
|
const after = budgetReducer(before, { type: "SET_TOTAL_IN_DB", payload: 0 });
|
||||||
|
|
||||||
|
expect(after.totalInDb).toBe(0);
|
||||||
|
expect(after.year).toBe(2024);
|
||||||
|
expect(after.rows).toBe(before.rows);
|
||||||
|
expect(after.templates).toBe(before.templates);
|
||||||
|
expect(after.accounts).toBe(before.accounts);
|
||||||
|
expect({ ...after, totalInDb: before.totalInDb }).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("0 shows the empty state, then N > 0 renders the grid again", () => {
|
||||||
|
const empty = budgetReducer(initialBudgetState(), { type: "SET_TOTAL_IN_DB", payload: 0 });
|
||||||
|
expect(showsEmptyState(empty)).toBe(true);
|
||||||
|
|
||||||
|
const filled = budgetReducer(empty, { type: "SET_TOTAL_IN_DB", payload: 12 });
|
||||||
|
expect(filled.totalInDb).toBe(12);
|
||||||
|
expect(showsEmptyState(filled)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the other actions preserve totalInDb", () => {
|
||||||
|
const known = budgetReducer(initialBudgetState(), { type: "SET_TOTAL_IN_DB", payload: 3 });
|
||||||
|
const after = budgetReducer(
|
||||||
|
budgetReducer(known, { type: "SET_YEAR", payload: 2025 }),
|
||||||
|
{ type: "SET_ERROR", payload: "boom" },
|
||||||
|
);
|
||||||
|
expect(after.totalInDb).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@ import {
|
||||||
applyTemplate as applyTemplateSvc,
|
applyTemplate as applyTemplateSvc,
|
||||||
deleteTemplate as deleteTemplateSvc,
|
deleteTemplate as deleteTemplateSvc,
|
||||||
} from "../services/budgetService";
|
} from "../services/budgetService";
|
||||||
import { getAllImportSources } from "../services/transactionService";
|
import { getAllImportSources, getTransactionCount } from "../services/transactionService";
|
||||||
import { useReportsPeriod } from "./useReportsPeriod";
|
import { useReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
interface BudgetState {
|
export interface BudgetState {
|
||||||
year: number;
|
year: number;
|
||||||
rows: BudgetYearRow[];
|
rows: BudgetYearRow[];
|
||||||
templates: BudgetTemplate[];
|
templates: BudgetTemplate[];
|
||||||
|
|
@ -22,17 +22,25 @@ interface BudgetState {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
/**
|
||||||
|
* Number of transactions in the whole profile (#353), `null` until loaded.
|
||||||
|
* The page shows the "import first" empty state when it is 0, instead of
|
||||||
|
* a budget grid with nothing to compare against.
|
||||||
|
*/
|
||||||
|
totalInDb: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type BudgetAction =
|
export type BudgetAction =
|
||||||
| { type: "SET_LOADING"; payload: boolean }
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
| { type: "SET_SAVING"; payload: boolean }
|
| { type: "SET_SAVING"; payload: boolean }
|
||||||
| { type: "SET_ERROR"; payload: string | null }
|
| { type: "SET_ERROR"; payload: string | null }
|
||||||
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
|
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
|
||||||
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
|
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
|
||||||
|
| { type: "SET_TOTAL_IN_DB"; payload: number }
|
||||||
| { type: "SET_YEAR"; payload: number };
|
| { type: "SET_YEAR"; payload: number };
|
||||||
|
|
||||||
function initialState(): BudgetState {
|
/** Lazy initial state: `year` is read at mount time, not at module load. */
|
||||||
|
export function initialBudgetState(): BudgetState {
|
||||||
return {
|
return {
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
rows: [],
|
rows: [],
|
||||||
|
|
@ -41,10 +49,11 @@ function initialState(): BudgetState {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
totalInDb: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function reducer(state: BudgetState, action: BudgetAction): BudgetState {
|
export function budgetReducer(state: BudgetState, action: BudgetAction): BudgetState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "SET_LOADING":
|
case "SET_LOADING":
|
||||||
return { ...state, isLoading: action.payload };
|
return { ...state, isLoading: action.payload };
|
||||||
|
|
@ -61,6 +70,8 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
|
||||||
};
|
};
|
||||||
case "SET_ACCOUNTS":
|
case "SET_ACCOUNTS":
|
||||||
return { ...state, accounts: action.payload };
|
return { ...state, accounts: action.payload };
|
||||||
|
case "SET_TOTAL_IN_DB":
|
||||||
|
return { ...state, totalInDb: action.payload };
|
||||||
case "SET_YEAR":
|
case "SET_YEAR":
|
||||||
return { ...state, year: action.payload };
|
return { ...state, year: action.payload };
|
||||||
default:
|
default:
|
||||||
|
|
@ -371,9 +382,18 @@ export function buildBudgetYearRows(
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the Budget page shows the "import first" empty state (#353) instead
|
||||||
|
* of the filter panel and the grid. Pure, so the 0 → N transition is
|
||||||
|
* unit-testable without a renderer. `null` (count not loaded) keeps the grid.
|
||||||
|
*/
|
||||||
|
export function showsEmptyState(state: Pick<BudgetState, "totalInDb">): boolean {
|
||||||
|
return state.totalInDb === 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function useBudget() {
|
export function useBudget() {
|
||||||
const { accountIds } = useReportsPeriod();
|
const { accountIds } = useReportsPeriod();
|
||||||
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
const [state, dispatch] = useReducer(budgetReducer, undefined, initialBudgetState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
const refreshData = useCallback(async (year: number, ids: number[]) => {
|
const refreshData = useCallback(async (year: number, ids: number[]) => {
|
||||||
|
|
@ -407,13 +427,18 @@ export function useBudget() {
|
||||||
refreshData(state.year, accountIds);
|
refreshData(state.year, accountIds);
|
||||||
}, [state.year, accountIds, refreshData]);
|
}, [state.year, accountIds, refreshData]);
|
||||||
|
|
||||||
// Load the import-source list once, for the FilterPanel's account checkboxes
|
// Load once on mount: the import-source list for the FilterPanel's account
|
||||||
// (same query as the Trends/Compare source filters — see useTrends.ts).
|
// checkboxes (same query as the Trends/Compare source filters — see
|
||||||
|
// useTrends.ts) and the profile-wide transaction count (#353).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const accounts = await getAllImportSources();
|
const [accounts, total] = await Promise.all([
|
||||||
|
getAllImportSources(),
|
||||||
|
getTransactionCount(),
|
||||||
|
]);
|
||||||
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
|
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
|
||||||
|
dispatch({ type: "SET_TOTAL_IN_DB", payload: total });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
94
src/hooks/useTransactions.test.ts
Normal file
94
src/hooks/useTransactions.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
// useTransactions — reducer unit tests (issue #353).
|
||||||
|
//
|
||||||
|
// The project has no jsdom / renderHook harness, so the EXPORTED reducer and
|
||||||
|
// its initial state are exercised directly (same approach as
|
||||||
|
// useSnapshotEditor.test.ts and useBudget.test.ts). What is pinned here: the
|
||||||
|
// profile-wide transaction count (`totalInDb`) is orthogonal to the paging
|
||||||
|
// state — setting it never touches `filters`, `sort` or `page` — and the
|
||||||
|
// 0 → N transition takes the page from the empty state back to the table.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
transactionsReducer,
|
||||||
|
initialTransactionsState,
|
||||||
|
showsEmptyState,
|
||||||
|
type TransactionsState,
|
||||||
|
} from "./useTransactions";
|
||||||
|
|
||||||
|
const populated = (): TransactionsState =>
|
||||||
|
transactionsReducer(
|
||||||
|
transactionsReducer(
|
||||||
|
transactionsReducer(initialTransactionsState, {
|
||||||
|
type: "SET_FILTER",
|
||||||
|
payload: { key: "search", value: "epicerie" },
|
||||||
|
}),
|
||||||
|
{ type: "SET_SORT", payload: { column: "amount", direction: "asc" } },
|
||||||
|
),
|
||||||
|
{ type: "SET_PAGE", payload: 3 },
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("transactionsReducer — SET_TOTAL_IN_DB (issue #353)", () => {
|
||||||
|
it("starts unknown: totalInDb is null, which keeps the regular layout", () => {
|
||||||
|
expect(initialTransactionsState.totalInDb).toBeNull();
|
||||||
|
expect(showsEmptyState(initialTransactionsState)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the count without touching filters, sort or page", () => {
|
||||||
|
const before = populated();
|
||||||
|
const after = transactionsReducer(before, { type: "SET_TOTAL_IN_DB", payload: 0 });
|
||||||
|
|
||||||
|
expect(after.totalInDb).toBe(0);
|
||||||
|
// Same references, not merely deep-equal: no hidden reset of the paging state.
|
||||||
|
expect(after.filters).toBe(before.filters);
|
||||||
|
expect(after.sort).toBe(before.sort);
|
||||||
|
expect(after.page).toBe(before.page);
|
||||||
|
expect(after.filters.search).toBe("epicerie");
|
||||||
|
expect(after.sort).toEqual({ column: "amount", direction: "asc" });
|
||||||
|
expect(after.page).toBe(3);
|
||||||
|
// And nothing else moved either.
|
||||||
|
expect({ ...after, totalInDb: before.totalInDb }).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("0 shows the empty state, then N > 0 renders the table again", () => {
|
||||||
|
const empty = transactionsReducer(initialTransactionsState, {
|
||||||
|
type: "SET_TOTAL_IN_DB",
|
||||||
|
payload: 0,
|
||||||
|
});
|
||||||
|
expect(showsEmptyState(empty)).toBe(true);
|
||||||
|
|
||||||
|
const filled = transactionsReducer(empty, { type: "SET_TOTAL_IN_DB", payload: 42 });
|
||||||
|
expect(filled.totalInDb).toBe(42);
|
||||||
|
expect(showsEmptyState(filled)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the regular layout while a page is loading, even at 0", () => {
|
||||||
|
// The loading placeholder stays in place of the table so the card does not
|
||||||
|
// flash during the first fetch of a profile whose count is already known.
|
||||||
|
const loadingEmpty = transactionsReducer(
|
||||||
|
transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 0 }),
|
||||||
|
{ type: "SET_LOADING", payload: true },
|
||||||
|
);
|
||||||
|
expect(showsEmptyState(loadingEmpty)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty page on a populated profile is NOT the empty state (default filter is 'since Jan 1st')", () => {
|
||||||
|
const state = transactionsReducer(
|
||||||
|
transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 7 }),
|
||||||
|
{
|
||||||
|
type: "SET_PAGE_RESULT",
|
||||||
|
payload: { rows: [], totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(state.rows).toHaveLength(0);
|
||||||
|
expect(showsEmptyState(state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the other actions preserve totalInDb", () => {
|
||||||
|
const known = transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 5 });
|
||||||
|
const after = transactionsReducer(
|
||||||
|
transactionsReducer(known, { type: "SET_FILTER", payload: { key: "uncategorizedOnly", value: true } }),
|
||||||
|
{ type: "SET_ERROR", payload: "boom" },
|
||||||
|
);
|
||||||
|
expect(after.totalInDb).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
updateTransactionNotes,
|
updateTransactionNotes,
|
||||||
getAllCategories,
|
getAllCategories,
|
||||||
getAllImportSources,
|
getAllImportSources,
|
||||||
|
getTransactionCount,
|
||||||
autoCategorizeTransactions,
|
autoCategorizeTransactions,
|
||||||
getSplitChildren,
|
getSplitChildren,
|
||||||
saveSplitAdjustment,
|
saveSplitAdjustment,
|
||||||
|
|
@ -21,7 +22,7 @@ import {
|
||||||
} from "../services/transactionService";
|
} from "../services/transactionService";
|
||||||
import { createKeyword } from "../services/categoryService";
|
import { createKeyword } from "../services/categoryService";
|
||||||
|
|
||||||
interface TransactionsState {
|
export interface TransactionsState {
|
||||||
rows: TransactionRow[];
|
rows: TransactionRow[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
|
|
@ -36,9 +37,16 @@ interface TransactionsState {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAutoCategorizing: boolean;
|
isAutoCategorizing: boolean;
|
||||||
error: string | null;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransactionsAction =
|
export type TransactionsAction =
|
||||||
| { type: "SET_LOADING"; payload: boolean }
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
| { type: "SET_ERROR"; payload: string | null }
|
| { type: "SET_ERROR"; payload: string | null }
|
||||||
| { type: "SET_PAGE_RESULT"; payload: TransactionPageResult }
|
| { type: "SET_PAGE_RESULT"; payload: TransactionPageResult }
|
||||||
|
|
@ -47,6 +55,7 @@ type TransactionsAction =
|
||||||
| { type: "SET_PAGE"; payload: number }
|
| { type: "SET_PAGE"; payload: number }
|
||||||
| { type: "SET_CATEGORIES"; payload: Category[] }
|
| { type: "SET_CATEGORIES"; payload: Category[] }
|
||||||
| { type: "SET_SOURCES"; payload: ImportSource[] }
|
| { 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_CATEGORY"; payload: { txId: number; categoryId: number | null; categoryName: string | null; categoryColor: string | null } }
|
||||||
| { type: "UPDATE_ROW_NOTES"; payload: { txId: number; notes: string } }
|
| { type: "UPDATE_ROW_NOTES"; payload: { txId: number; notes: string } }
|
||||||
| { type: "SET_AUTO_CATEGORIZING"; payload: boolean };
|
| { type: "SET_AUTO_CATEGORIZING"; payload: boolean };
|
||||||
|
|
@ -60,7 +69,7 @@ const initialFilters: TransactionFilters = {
|
||||||
uncategorizedOnly: false,
|
uncategorizedOnly: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState: TransactionsState = {
|
export const initialTransactionsState: TransactionsState = {
|
||||||
rows: [],
|
rows: [],
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
totalAmount: 0,
|
totalAmount: 0,
|
||||||
|
|
@ -75,9 +84,13 @@ const initialState: TransactionsState = {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isAutoCategorizing: false,
|
isAutoCategorizing: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
totalInDb: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function reducer(state: TransactionsState, action: TransactionsAction): TransactionsState {
|
export function transactionsReducer(
|
||||||
|
state: TransactionsState,
|
||||||
|
action: TransactionsAction
|
||||||
|
): TransactionsState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "SET_LOADING":
|
case "SET_LOADING":
|
||||||
return { ...state, isLoading: action.payload };
|
return { ...state, isLoading: action.payload };
|
||||||
|
|
@ -107,6 +120,8 @@ function reducer(state: TransactionsState, action: TransactionsAction): Transact
|
||||||
return { ...state, categories: action.payload };
|
return { ...state, categories: action.payload };
|
||||||
case "SET_SOURCES":
|
case "SET_SOURCES":
|
||||||
return { ...state, sources: action.payload };
|
return { ...state, sources: action.payload };
|
||||||
|
case "SET_TOTAL_IN_DB":
|
||||||
|
return { ...state, totalInDb: action.payload };
|
||||||
case "UPDATE_ROW_CATEGORY":
|
case "UPDATE_ROW_CATEGORY":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|
@ -136,22 +151,34 @@ function reducer(state: TransactionsState, action: TransactionsAction): Transact
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
export function useTransactions() {
|
export function useTransactions() {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(transactionsReducer, initialTransactionsState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const debouncedFiltersRef = useRef(state.filters);
|
const debouncedFiltersRef = useRef(state.filters);
|
||||||
|
|
||||||
// Load categories and sources once on mount
|
// Load categories, sources and the profile-wide transaction count once on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [cats, srcs] = await Promise.all([
|
const [cats, srcs, total] = await Promise.all([
|
||||||
getAllCategories(),
|
getAllCategories(),
|
||||||
getAllImportSources(),
|
getAllImportSources(),
|
||||||
|
getTransactionCount(),
|
||||||
]);
|
]);
|
||||||
dispatch({ type: "SET_CATEGORIES", payload: cats });
|
dispatch({ type: "SET_CATEGORIES", payload: cats });
|
||||||
dispatch({ type: "SET_SOURCES", payload: srcs });
|
dispatch({ type: "SET_SOURCES", payload: srcs });
|
||||||
|
dispatch({ type: "SET_TOTAL_IN_DB", payload: total });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,11 @@
|
||||||
"transferIcon": {
|
"transferIcon": {
|
||||||
"tooltip": "Linked to a balance account",
|
"tooltip": "Linked to a balance account",
|
||||||
"ariaLabel": "Transaction linked to a balance account"
|
"ariaLabel": "Transaction linked to a balance account"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "No transactions yet",
|
||||||
|
"description": "Import your bank statements first.",
|
||||||
|
"cta": "Import transactions"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
|
|
@ -424,6 +429,11 @@
|
||||||
"Use the split button to distribute the annual total evenly across all months",
|
"Use the split button to distribute the annual total evenly across all months",
|
||||||
"Save your budget as a template and apply it to specific months or all 12 at once"
|
"Save your budget as a template and apply it to specific months or all 12 at once"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "Your budget is built on your transactions",
|
||||||
|
"description": "Import your bank statements first.",
|
||||||
|
"cta": "Import transactions"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reports": {
|
"reports": {
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,11 @@
|
||||||
"transferIcon": {
|
"transferIcon": {
|
||||||
"tooltip": "Liée à un compte de bilan",
|
"tooltip": "Liée à un compte de bilan",
|
||||||
"ariaLabel": "Transaction liée à un compte de bilan"
|
"ariaLabel": "Transaction liée à un compte de bilan"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "Aucune transaction pour l'instant",
|
||||||
|
"description": "Importez d'abord vos relevés bancaires.",
|
||||||
|
"cta": "Importer des transactions"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
|
|
@ -424,6 +429,11 @@
|
||||||
"Utilisez le bouton de répartition pour distribuer le total annuel également sur tous les mois",
|
"Utilisez le bouton de répartition pour distribuer le total annuel également sur tous les mois",
|
||||||
"Sauvegardez votre budget comme modèle et appliquez-le à des mois spécifiques ou aux 12 mois d'un coup"
|
"Sauvegardez votre budget comme modèle et appliquez-le à des mois spécifiques ou aux 12 mois d'un coup"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "Le budget se construit sur vos transactions",
|
||||||
|
"description": "Importez d'abord vos relevés bancaires.",
|
||||||
|
"cta": "Importer des transactions"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reports": {
|
"reports": {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Calculator } from "lucide-react";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
|
import EmptyState from "../components/shared/EmptyState";
|
||||||
import FilterPanel from "../components/reports/FilterPanel";
|
import FilterPanel from "../components/reports/FilterPanel";
|
||||||
import { useBudget } from "../hooks/useBudget";
|
import { useBudget, showsEmptyState } from "../hooks/useBudget";
|
||||||
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
||||||
import YearNavigator from "../components/budget/YearNavigator";
|
import YearNavigator from "../components/budget/YearNavigator";
|
||||||
import BudgetTable from "../components/budget/BudgetTable";
|
import BudgetTable from "../components/budget/BudgetTable";
|
||||||
|
|
@ -23,6 +25,11 @@ export default function BudgetPage() {
|
||||||
|
|
||||||
const { year, rows, templates, accounts, isLoading, isSaving, error } = state;
|
const { year, rows, templates, accounts, isLoading, isSaving, error } = state;
|
||||||
|
|
||||||
|
// #353 — a profile without any transaction gets the "import first" card in
|
||||||
|
// place of the filter panel and the grid; the title, help and template
|
||||||
|
// actions stay.
|
||||||
|
const isEmptyProfile = showsEmptyState(state);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||||
|
|
@ -40,24 +47,35 @@ export default function BudgetPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterPanel
|
|
||||||
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
|
|
||||||
accountIds={accountIds}
|
|
||||||
onAccountIdsChange={setAccountIds}
|
|
||||||
accounts={accounts}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<BudgetTable
|
{isEmptyProfile ? (
|
||||||
rows={rows}
|
<EmptyState
|
||||||
onUpdatePlanned={updatePlanned}
|
icon={<Calculator size={40} />}
|
||||||
onSplitEvenly={splitEvenly}
|
title={t("budget.empty.title")}
|
||||||
/>
|
description={t("budget.empty.description")}
|
||||||
|
primaryAction={{ label: t("budget.empty.cta"), to: "/import" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FilterPanel
|
||||||
|
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
|
||||||
|
accountIds={accountIds}
|
||||||
|
onAccountIdsChange={setAccountIds}
|
||||||
|
accounts={accounts}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BudgetTable
|
||||||
|
rows={rows}
|
||||||
|
onUpdatePlanned={updatePlanned}
|
||||||
|
onSplitEvenly={splitEvenly}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Wand2, Tag } from "lucide-react";
|
import { Wand2, Tag, Receipt } from "lucide-react";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import { useTransactions } from "../hooks/useTransactions";
|
import EmptyState from "../components/shared/EmptyState";
|
||||||
|
import { useTransactions, showsEmptyState } from "../hooks/useTransactions";
|
||||||
import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
|
import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
|
||||||
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
|
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
|
||||||
import TransactionTable from "../components/transactions/TransactionTable";
|
import TransactionTable from "../components/transactions/TransactionTable";
|
||||||
|
|
@ -51,6 +52,12 @@ export default function TransactionsPage() {
|
||||||
setTimeout(() => setResultMessage(null), 4000);
|
setTimeout(() => setResultMessage(null), 4000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// #353 — a profile without any transaction gets the "import first" card in
|
||||||
|
// place of the filter bar, table and pagination. `rows.length === 0` is not
|
||||||
|
// the signal: the default filter is "since January 1st", so an empty page
|
||||||
|
// can still sit on a populated profile.
|
||||||
|
const isEmptyProfile = showsEmptyState(state);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="relative flex items-center gap-3 mb-6">
|
<div className="relative flex items-center gap-3 mb-6">
|
||||||
|
|
@ -73,53 +80,64 @@ export default function TransactionsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TransactionFilterBar
|
|
||||||
filters={state.filters}
|
|
||||||
categories={state.categories}
|
|
||||||
sources={state.sources}
|
|
||||||
onFilterChange={setFilter}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TransactionSummaryBar
|
|
||||||
totalCount={state.totalCount}
|
|
||||||
totalAmount={state.totalAmount}
|
|
||||||
incomeTotal={state.incomeTotal}
|
|
||||||
expenseTotal={state.expenseTotal}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{state.error && (
|
{state.error && (
|
||||||
<div className="mb-4 p-3 rounded-lg bg-[color-mix(in_srgb,var(--negative)_10%,var(--card))] border border-[var(--negative)] text-[var(--negative)] text-sm">
|
<div className="mb-4 p-3 rounded-lg bg-[color-mix(in_srgb,var(--negative)_10%,var(--card))] border border-[var(--negative)] text-[var(--negative)] text-sm">
|
||||||
{state.error}
|
{state.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.isLoading ? (
|
{isEmptyProfile ? (
|
||||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
<EmptyState
|
||||||
{t("common.loading")}
|
icon={<Receipt size={40} />}
|
||||||
</div>
|
title={t("transactions.empty.title")}
|
||||||
|
description={t("transactions.empty.description")}
|
||||||
|
primaryAction={{ label: t("transactions.empty.cta"), to: "/import" }}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<TransactionTable
|
<TransactionFilterBar
|
||||||
rows={state.rows}
|
filters={state.filters}
|
||||||
sort={state.sort}
|
|
||||||
categories={state.categories}
|
categories={state.categories}
|
||||||
onSort={setSort}
|
sources={state.sources}
|
||||||
onCategoryChange={updateCategory}
|
onFilterChange={setFilter}
|
||||||
onNotesChange={saveNotes}
|
|
||||||
onAddKeyword={addKeywordToCategory}
|
|
||||||
onLoadSplitChildren={loadSplitChildren}
|
|
||||||
onSaveSplit={saveSplit}
|
|
||||||
onDeleteSplit={deleteSplit}
|
|
||||||
onRowContextMenu={handleRowContextMenu}
|
|
||||||
linkedTransfersByTxId={linkedTransfersByTxId}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TransactionPagination
|
<TransactionSummaryBar
|
||||||
page={state.page}
|
|
||||||
pageSize={state.pageSize}
|
|
||||||
totalCount={state.totalCount}
|
totalCount={state.totalCount}
|
||||||
onPageChange={setPage}
|
totalAmount={state.totalAmount}
|
||||||
|
incomeTotal={state.incomeTotal}
|
||||||
|
expenseTotal={state.expenseTotal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{state.isLoading ? (
|
||||||
|
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||||
|
{t("common.loading")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TransactionTable
|
||||||
|
rows={state.rows}
|
||||||
|
sort={state.sort}
|
||||||
|
categories={state.categories}
|
||||||
|
onSort={setSort}
|
||||||
|
onCategoryChange={updateCategory}
|
||||||
|
onNotesChange={saveNotes}
|
||||||
|
onAddKeyword={addKeywordToCategory}
|
||||||
|
onLoadSplitChildren={loadSplitChildren}
|
||||||
|
onSaveSplit={saveSplit}
|
||||||
|
onDeleteSplit={deleteSplit}
|
||||||
|
onRowContextMenu={handleRowContextMenu}
|
||||||
|
linkedTransfersByTxId={linkedTransfersByTxId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TransactionPagination
|
||||||
|
page={state.page}
|
||||||
|
pageSize={state.pageSize}
|
||||||
|
totalCount={state.totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue