Wire the ContextMenu + AddKeywordDialog pair onto the remaining per-transaction surfaces. No new business logic — pure composition of #69 / #74 pieces. - HighlightsTopTransactionsList: optional onContextMenuRow prop, ReportsHighlightsPage renders ContextMenu + AddKeywordDialog on right-click - TransactionTable: optional onRowContextMenu prop on each <tr>; TransactionsPage handles it and opens the dialog pre-filled with the row description + current category - Aggregate tables (HighlightsTopMoversTable, ComparePeriodTable, MonthlyTrendsTable, CategoryOverTimeTable) are intentionally NOT wired: they show category / month aggregates, not individual transactions, so there is no keyword to extract from a row — the dialog would be nonsensical there Fixes #75 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
131 lines
4.9 KiB
TypeScript
131 lines
4.9 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router-dom";
|
|
import { ArrowLeft, Tag } from "lucide-react";
|
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
|
import HubNetBalanceTile from "../components/reports/HubNetBalanceTile";
|
|
import HighlightsTopMoversTable from "../components/reports/HighlightsTopMoversTable";
|
|
import HighlightsTopMoversChart from "../components/reports/HighlightsTopMoversChart";
|
|
import HighlightsTopTransactionsList from "../components/reports/HighlightsTopTransactionsList";
|
|
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
|
|
import ContextMenu from "../components/shared/ContextMenu";
|
|
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
|
|
import { useHighlights } from "../hooks/useHighlights";
|
|
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
|
import type { RecentTransaction } from "../shared/types";
|
|
|
|
const STORAGE_KEY = "reports-viewmode-highlights";
|
|
|
|
export default function ReportsHighlightsPage() {
|
|
const { t } = useTranslation();
|
|
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
|
const { data, isLoading, error, windowDays, setWindowDays } = useHighlights();
|
|
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
|
|
const [menu, setMenu] = useState<{ x: number; y: number; tx: RecentTransaction } | null>(null);
|
|
const [pending, setPending] = useState<RecentTransaction | null>(null);
|
|
|
|
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
|
|
|
const handleContextMenu = (e: React.MouseEvent, tx: RecentTransaction) => {
|
|
e.preventDefault();
|
|
setMenu({ x: e.clientX, y: e.clientY, tx });
|
|
};
|
|
|
|
return (
|
|
<div className={isLoading ? "opacity-60" : ""}>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Link
|
|
to={`/reports${preserveSearch}`}
|
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
|
|
aria-label={t("reports.hub.title")}
|
|
>
|
|
<ArrowLeft size={18} />
|
|
</Link>
|
|
<h1 className="text-2xl font-bold">{t("reports.hub.highlights")}</h1>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
|
<PeriodSelector
|
|
value={period}
|
|
onChange={setPeriod}
|
|
customDateFrom={from}
|
|
customDateTo={to}
|
|
onCustomDateChange={setCustomDates}
|
|
/>
|
|
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{data && (
|
|
<div className="flex flex-col gap-6">
|
|
<section>
|
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
|
|
{t("reports.highlights.balances")}
|
|
</h2>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<HubNetBalanceTile
|
|
label={t("reports.highlights.netBalanceCurrent")}
|
|
amount={data.netBalanceCurrent}
|
|
series={data.monthlyBalanceSeries.map((m) => m.netBalance)}
|
|
/>
|
|
<HubNetBalanceTile
|
|
label={t("reports.highlights.netBalanceYtd")}
|
|
amount={data.netBalanceYtd}
|
|
series={data.monthlyBalanceSeries.map((m) => m.netBalance)}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
|
|
{t("reports.highlights.topMovers")}
|
|
</h2>
|
|
{viewMode === "chart" ? (
|
|
<HighlightsTopMoversChart movers={data.topMovers} />
|
|
) : (
|
|
<HighlightsTopMoversTable movers={data.topMovers} />
|
|
)}
|
|
</section>
|
|
|
|
<section>
|
|
<HighlightsTopTransactionsList
|
|
transactions={data.topTransactions}
|
|
windowDays={windowDays}
|
|
onWindowChange={setWindowDays}
|
|
onContextMenuRow={handleContextMenu}
|
|
/>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{menu && (
|
|
<ContextMenu
|
|
x={menu.x}
|
|
y={menu.y}
|
|
header={menu.tx.description}
|
|
onClose={() => setMenu(null)}
|
|
items={[
|
|
{
|
|
icon: <Tag size={14} />,
|
|
label: t("reports.keyword.addFromTransaction"),
|
|
onClick: () => setPending(menu.tx),
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
|
|
{pending && (
|
|
<AddKeywordDialog
|
|
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
|
|
onClose={() => setPending(null)}
|
|
onApplied={() => setPending(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|