feat(onboarding): empty states on the Transactions and Budget pages (#353) #379

Merged
maximus merged 1 commit from issue-353-transactions-budget-empty into main 2026-10-04 12:51:28 +00:00
8 changed files with 315 additions and 65 deletions
Showing only changes of commit 8eac47f69b - Show all commits

View file

@ -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);
});
});

View file

@ -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) });
} }

View 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);
});
});

View file

@ -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",

View file

@ -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": {

View file

@ -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": {

View file

@ -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>
); );
} }

View file

@ -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}
/>
</>
)}
</> </>
)} )}