The link of the "categorize by hand" step carries `guided`, which the
Transactions page reads through `readOnboardingNavState` and turns into
start filters: `useTransactions(initialFilters?)` puts them over the
defaults through `initTransactionsState`, at mount only, so the card
consuming the navigation state changes nothing. The guided entry lists
the uncategorized transactions whatever their date (the default filter
starts on January 1st, and in January the sample statements fall in the
previous year). When its first page comes back empty, the reducer drops
"uncategorized only" once: a new filters object, so the existing filter
effect reads again, and the loading placeholder meanwhile. The first
page and any filter the user sets disarm the fallback, so it never loops
and never drops a filter behind the user's back.
`transactionsCardModel(filters, checklist)` says which sentence matches
the list, names the filter by its own label, points at the first row's
category, and says "Done." once the step is checked, which the card
needs to offer the Budget step or the end of the path. The label loses
its capital through an i18next `lowercase` formatter used by the
sentence itself, `{{filter, lowercase}}`.
TransactionTable wraps every row's category in the same element and
puts the ring on the first one only, so the ring coming or going never
remounts the combobox; CategoryCombobox is unchanged. CARD_PAGES gets
/transactions, and the page renders the card first and highlights its
target only while the card shows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
7.4 KiB
TypeScript
201 lines
7.4 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useLocation } from "react-router-dom";
|
|
import { Wand2, Tag, Receipt } from "lucide-react";
|
|
import { PageHelp } from "../components/shared/PageHelp";
|
|
import EmptyState from "../components/shared/EmptyState";
|
|
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
|
import { useOnboarding } from "../contexts/OnboardingContext";
|
|
import {
|
|
readOnboardingNavState,
|
|
showsStepCard,
|
|
transactionsCardModel,
|
|
} from "../utils/onboardingPath";
|
|
import {
|
|
useTransactions,
|
|
showsEmptyState,
|
|
GUIDED_ENTRY_FILTERS,
|
|
} from "../hooks/useTransactions";
|
|
import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
|
|
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
|
|
import TransactionTable from "../components/transactions/TransactionTable";
|
|
import TransactionPagination from "../components/transactions/TransactionPagination";
|
|
import ContextMenu from "../components/shared/ContextMenu";
|
|
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
|
|
import {
|
|
listAllLinkedTransfersForTooltip,
|
|
type LinkedTransferTooltipRow,
|
|
} from "../services/balance.service";
|
|
import type { TransactionRow } from "../shared/types";
|
|
|
|
export default function TransactionsPage() {
|
|
const { t } = useTranslation();
|
|
const location = useLocation();
|
|
// The link of the "categorize by hand" step opens the guided entry (#399),
|
|
// read like any navigation state through `readOnboardingNavState` (CWE-476).
|
|
// The hook takes its start filters at mount only: the card consuming the
|
|
// navigation state afterwards changes nothing.
|
|
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } =
|
|
useTransactions(
|
|
readOnboardingNavState(location.state).guided ? GUIDED_ENTRY_FILTERS : undefined
|
|
);
|
|
const onboarding = useOnboarding();
|
|
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
|
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
|
|
const [pending, setPending] = useState<TransactionRow | null>(null);
|
|
// Issue #142 — single batch lookup for the inlined transfer icon. One
|
|
// SELECT on mount gives us a Map<txId, links[]> the table consults via
|
|
// `.has()` per row. Avoids an N+1 hit on the rendered page.
|
|
const [linkedTransfersByTxId, setLinkedTransfersByTxId] = useState<
|
|
Map<number, LinkedTransferTooltipRow[]>
|
|
>(new Map());
|
|
|
|
useEffect(() => {
|
|
listAllLinkedTransfersForTooltip()
|
|
.then(setLinkedTransfersByTxId)
|
|
.catch(() => setLinkedTransfersByTxId(new Map()));
|
|
}, []);
|
|
|
|
const handleRowContextMenu = (e: React.MouseEvent, row: TransactionRow) => {
|
|
e.preventDefault();
|
|
setMenu({ x: e.clientX, y: e.clientY, row });
|
|
};
|
|
|
|
const handleAutoCategorize = async () => {
|
|
setResultMessage(null);
|
|
const count = await autoCategorize();
|
|
if (count > 0) {
|
|
setResultMessage(t("transactions.autoCategorizeResult", { count }));
|
|
} else {
|
|
setResultMessage(t("transactions.autoCategorizeNone"));
|
|
}
|
|
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);
|
|
|
|
// The getting-started card follows the list's filter (#399). Its target is
|
|
// highlighted only while the card is on screen: a ring needs the card that
|
|
// explains it.
|
|
const cardModel = transactionsCardModel(state.filters, onboarding.checklist);
|
|
const target = showsStepCard(onboarding.mode, onboarding.cardPage, location.pathname)
|
|
? cardModel.target
|
|
: null;
|
|
|
|
return (
|
|
<div>
|
|
<OnboardingStepCard model={cardModel} />
|
|
|
|
<div className="relative flex items-center gap-3 mb-6">
|
|
<h1 className="text-2xl font-bold">{t("transactions.title")}</h1>
|
|
<PageHelp helpKey="transactions" />
|
|
<button
|
|
onClick={handleAutoCategorize}
|
|
disabled={state.isAutoCategorizing}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
|
|
>
|
|
<Wand2 size={16} />
|
|
{state.isAutoCategorizing
|
|
? t("common.loading")
|
|
: t("transactions.autoCategorize")}
|
|
</button>
|
|
{resultMessage && (
|
|
<span className="text-sm text-[var(--muted-foreground)]">
|
|
{resultMessage}
|
|
</span>
|
|
)}
|
|
</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
|
|
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.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}
|
|
onboardingTarget={target === "firstRowCategory"}
|
|
/>
|
|
|
|
<TransactionPagination
|
|
page={state.page}
|
|
pageSize={state.pageSize}
|
|
totalCount={state.totalCount}
|
|
onPageChange={setPage}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{menu && (
|
|
<ContextMenu
|
|
x={menu.x}
|
|
y={menu.y}
|
|
header={menu.row.description}
|
|
onClose={() => setMenu(null)}
|
|
items={[
|
|
{
|
|
icon: <Tag size={14} />,
|
|
label: t("reports.keyword.addFromTransaction"),
|
|
onClick: () => setPending(menu.row),
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
|
|
{pending && (
|
|
<AddKeywordDialog
|
|
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
|
|
initialCategoryId={pending.category_id}
|
|
onClose={() => setPending(null)}
|
|
onApplied={() => setPending(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|