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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
deleteTemplate as deleteTemplateSvc,
|
||||
} from "../services/budgetService";
|
||||
import { getAllImportSources } from "../services/transactionService";
|
||||
import { getAllImportSources, getTransactionCount } from "../services/transactionService";
|
||||
import { useReportsPeriod } from "./useReportsPeriod";
|
||||
|
||||
interface BudgetState {
|
||||
export interface BudgetState {
|
||||
year: number;
|
||||
rows: BudgetYearRow[];
|
||||
templates: BudgetTemplate[];
|
||||
|
|
@ -22,17 +22,25 @@ interface BudgetState {
|
|||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
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_SAVING"; payload: boolean }
|
||||
| { type: "SET_ERROR"; payload: string | null }
|
||||
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
|
||||
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
|
||||
| { type: "SET_TOTAL_IN_DB"; 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 {
|
||||
year: new Date().getFullYear(),
|
||||
rows: [],
|
||||
|
|
@ -41,10 +49,11 @@ function initialState(): BudgetState {
|
|||
isLoading: false,
|
||||
isSaving: false,
|
||||
error: null,
|
||||
totalInDb: null,
|
||||
};
|
||||
}
|
||||
|
||||
function reducer(state: BudgetState, action: BudgetAction): BudgetState {
|
||||
export function budgetReducer(state: BudgetState, action: BudgetAction): BudgetState {
|
||||
switch (action.type) {
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.payload };
|
||||
|
|
@ -61,6 +70,8 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
|
|||
};
|
||||
case "SET_ACCOUNTS":
|
||||
return { ...state, accounts: action.payload };
|
||||
case "SET_TOTAL_IN_DB":
|
||||
return { ...state, totalInDb: action.payload };
|
||||
case "SET_YEAR":
|
||||
return { ...state, year: action.payload };
|
||||
default:
|
||||
|
|
@ -371,9 +382,18 @@ export function buildBudgetYearRows(
|
|||
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() {
|
||||
const { accountIds } = useReportsPeriod();
|
||||
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
||||
const [state, dispatch] = useReducer(budgetReducer, undefined, initialBudgetState);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const refreshData = useCallback(async (year: number, ids: number[]) => {
|
||||
|
|
@ -407,13 +427,18 @@ export function useBudget() {
|
|||
refreshData(state.year, accountIds);
|
||||
}, [state.year, accountIds, refreshData]);
|
||||
|
||||
// Load the import-source list once, for the FilterPanel's account checkboxes
|
||||
// (same query as the Trends/Compare source filters — see useTrends.ts).
|
||||
// Load once on mount: the import-source list for the FilterPanel's account
|
||||
// checkboxes (same query as the Trends/Compare source filters — see
|
||||
// useTrends.ts) and the profile-wide transaction count (#353).
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const accounts = await getAllImportSources();
|
||||
const [accounts, total] = await Promise.all([
|
||||
getAllImportSources(),
|
||||
getTransactionCount(),
|
||||
]);
|
||||
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
|
||||
dispatch({ type: "SET_TOTAL_IN_DB", payload: total });
|
||||
} catch (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,
|
||||
getAllCategories,
|
||||
getAllImportSources,
|
||||
getTransactionCount,
|
||||
autoCategorizeTransactions,
|
||||
getSplitChildren,
|
||||
saveSplitAdjustment,
|
||||
|
|
@ -21,7 +22,7 @@ import {
|
|||
} from "../services/transactionService";
|
||||
import { createKeyword } from "../services/categoryService";
|
||||
|
||||
interface TransactionsState {
|
||||
export interface TransactionsState {
|
||||
rows: TransactionRow[];
|
||||
totalCount: number;
|
||||
totalAmount: number;
|
||||
|
|
@ -36,9 +37,16 @@ interface TransactionsState {
|
|||
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;
|
||||
}
|
||||
|
||||
type TransactionsAction =
|
||||
export type TransactionsAction =
|
||||
| { type: "SET_LOADING"; payload: boolean }
|
||||
| { type: "SET_ERROR"; payload: string | null }
|
||||
| { type: "SET_PAGE_RESULT"; payload: TransactionPageResult }
|
||||
|
|
@ -47,6 +55,7 @@ type TransactionsAction =
|
|||
| { 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 };
|
||||
|
|
@ -60,7 +69,7 @@ const initialFilters: TransactionFilters = {
|
|||
uncategorizedOnly: false,
|
||||
};
|
||||
|
||||
const initialState: TransactionsState = {
|
||||
export const initialTransactionsState: TransactionsState = {
|
||||
rows: [],
|
||||
totalCount: 0,
|
||||
totalAmount: 0,
|
||||
|
|
@ -75,9 +84,13 @@ const initialState: TransactionsState = {
|
|||
isLoading: false,
|
||||
isAutoCategorizing: false,
|
||||
error: null,
|
||||
totalInDb: null,
|
||||
};
|
||||
|
||||
function reducer(state: TransactionsState, action: TransactionsAction): TransactionsState {
|
||||
export function transactionsReducer(
|
||||
state: TransactionsState,
|
||||
action: TransactionsAction
|
||||
): TransactionsState {
|
||||
switch (action.type) {
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.payload };
|
||||
|
|
@ -107,6 +120,8 @@ function reducer(state: TransactionsState, action: TransactionsAction): Transact
|
|||
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,
|
||||
|
|
@ -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() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const [state, dispatch] = useReducer(transactionsReducer, initialTransactionsState);
|
||||
const fetchIdRef = useRef(0);
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
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(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [cats, srcs] = await Promise.all([
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -319,6 +319,11 @@
|
|||
"transferIcon": {
|
||||
"tooltip": "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": {
|
||||
|
|
@ -424,6 +429,11 @@
|
|||
"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"
|
||||
]
|
||||
},
|
||||
"empty": {
|
||||
"title": "Your budget is built on your transactions",
|
||||
"description": "Import your bank statements first.",
|
||||
"cta": "Import transactions"
|
||||
}
|
||||
},
|
||||
"reports": {
|
||||
|
|
|
|||
|
|
@ -319,6 +319,11 @@
|
|||
"transferIcon": {
|
||||
"tooltip": "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": {
|
||||
|
|
@ -424,6 +429,11 @@
|
|||
"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"
|
||||
]
|
||||
},
|
||||
"empty": {
|
||||
"title": "Le budget se construit sur vos transactions",
|
||||
"description": "Importez d'abord vos relevés bancaires.",
|
||||
"cta": "Importer des transactions"
|
||||
}
|
||||
},
|
||||
"reports": {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Calculator } from "lucide-react";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import EmptyState from "../components/shared/EmptyState";
|
||||
import FilterPanel from "../components/reports/FilterPanel";
|
||||
import { useBudget } from "../hooks/useBudget";
|
||||
import { useBudget, showsEmptyState } from "../hooks/useBudget";
|
||||
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
||||
import YearNavigator from "../components/budget/YearNavigator";
|
||||
import BudgetTable from "../components/budget/BudgetTable";
|
||||
|
|
@ -23,6 +25,11 @@ export default function BudgetPage() {
|
|||
|
||||
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 (
|
||||
<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">
|
||||
|
|
@ -40,24 +47,35 @@ export default function BudgetPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<FilterPanel
|
||||
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
|
||||
accountIds={accountIds}
|
||||
onAccountIdsChange={setAccountIds}
|
||||
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
|
||||
rows={rows}
|
||||
onUpdatePlanned={updatePlanned}
|
||||
onSplitEvenly={splitEvenly}
|
||||
/>
|
||||
{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
|
||||
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
|
||||
accountIds={accountIds}
|
||||
onAccountIdsChange={setAccountIds}
|
||||
accounts={accounts}
|
||||
/>
|
||||
|
||||
<BudgetTable
|
||||
rows={rows}
|
||||
onUpdatePlanned={updatePlanned}
|
||||
onSplitEvenly={splitEvenly}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useEffect, useState } from "react";
|
||||
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 { useTransactions } from "../hooks/useTransactions";
|
||||
import EmptyState from "../components/shared/EmptyState";
|
||||
import { useTransactions, showsEmptyState } from "../hooks/useTransactions";
|
||||
import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
|
||||
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
|
||||
import TransactionTable from "../components/transactions/TransactionTable";
|
||||
|
|
@ -51,6 +52,12 @@ export default function TransactionsPage() {
|
|||
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 (
|
||||
<div>
|
||||
<div className="relative flex items-center gap-3 mb-6">
|
||||
|
|
@ -73,53 +80,64 @@ export default function TransactionsPage() {
|
|||
)}
|
||||
</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 && (
|
||||
<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 ? (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
{t("common.loading")}
|
||||
</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" }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TransactionTable
|
||||
rows={state.rows}
|
||||
sort={state.sort}
|
||||
<TransactionFilterBar
|
||||
filters={state.filters}
|
||||
categories={state.categories}
|
||||
onSort={setSort}
|
||||
onCategoryChange={updateCategory}
|
||||
onNotesChange={saveNotes}
|
||||
onAddKeyword={addKeywordToCategory}
|
||||
onLoadSplitChildren={loadSplitChildren}
|
||||
onSaveSplit={saveSplit}
|
||||
onDeleteSplit={deleteSplit}
|
||||
onRowContextMenu={handleRowContextMenu}
|
||||
linkedTransfersByTxId={linkedTransfersByTxId}
|
||||
sources={state.sources}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
|
||||
<TransactionPagination
|
||||
page={state.page}
|
||||
pageSize={state.pageSize}
|
||||
<TransactionSummaryBar
|
||||
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