feat(onboarding): empty states on the Transactions and Budget pages (#353) #379
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,6 +47,21 @@ export default function BudgetPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEmptyProfile ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Calculator size={40} />}
|
||||||
|
title={t("budget.empty.title")}
|
||||||
|
description={t("budget.empty.description")}
|
||||||
|
primaryAction={{ label: t("budget.empty.cta"), to: "/import" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
|
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
|
||||||
accountIds={accountIds}
|
accountIds={accountIds}
|
||||||
|
|
@ -47,17 +69,13 @@ export default function BudgetPage() {
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<BudgetTable
|
<BudgetTable
|
||||||
rows={rows}
|
rows={rows}
|
||||||
onUpdatePlanned={updatePlanned}
|
onUpdatePlanned={updatePlanned}
|
||||||
onSplitEvenly={splitEvenly}
|
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,6 +80,21 @@ export default function TransactionsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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">
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEmptyProfile ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Receipt size={40} />}
|
||||||
|
title={t("transactions.empty.title")}
|
||||||
|
description={t("transactions.empty.description")}
|
||||||
|
primaryAction={{ label: t("transactions.empty.cta"), to: "/import" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<TransactionFilterBar
|
<TransactionFilterBar
|
||||||
filters={state.filters}
|
filters={state.filters}
|
||||||
categories={state.categories}
|
categories={state.categories}
|
||||||
|
|
@ -87,12 +109,6 @@ export default function TransactionsPage() {
|
||||||
expenseTotal={state.expenseTotal}
|
expenseTotal={state.expenseTotal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{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">
|
|
||||||
{state.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state.isLoading ? (
|
{state.isLoading ? (
|
||||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||||
{t("common.loading")}
|
{t("common.loading")}
|
||||||
|
|
@ -122,6 +138,8 @@ export default function TransactionsPage() {
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{menu && (
|
{menu && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue