feat: propagate right-click "add as keyword" to transactions page and highlights list (#75)
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>
This commit is contained in:
parent
334f975deb
commit
3b70abdb9e
4 changed files with 83 additions and 3 deletions
|
|
@ -5,6 +5,7 @@ export interface HighlightsTopTransactionsListProps {
|
||||||
transactions: RecentTransaction[];
|
transactions: RecentTransaction[];
|
||||||
windowDays: 30 | 60 | 90;
|
windowDays: 30 | 60 | 90;
|
||||||
onWindowChange: (days: 30 | 60 | 90) => void;
|
onWindowChange: (days: 30 | 60 | 90) => void;
|
||||||
|
onContextMenuRow?: (event: React.MouseEvent, transaction: RecentTransaction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAmount(amount: number, language: string): string {
|
function formatAmount(amount: number, language: string): string {
|
||||||
|
|
@ -18,6 +19,7 @@ export default function HighlightsTopTransactionsList({
|
||||||
transactions,
|
transactions,
|
||||||
windowDays,
|
windowDays,
|
||||||
onWindowChange,
|
onWindowChange,
|
||||||
|
onContextMenuRow,
|
||||||
}: HighlightsTopTransactionsListProps) {
|
}: HighlightsTopTransactionsListProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
|
@ -50,7 +52,11 @@ export default function HighlightsTopTransactionsList({
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-[var(--border)]">
|
<ul className="divide-y divide-[var(--border)]">
|
||||||
{transactions.map((tx) => (
|
{transactions.map((tx) => (
|
||||||
<li key={tx.id} className="flex items-center gap-3 px-4 py-2 text-sm">
|
<li
|
||||||
|
key={tx.id}
|
||||||
|
onContextMenu={onContextMenuRow ? (e) => onContextMenuRow(e, tx) : undefined}
|
||||||
|
className="flex items-center gap-3 px-4 py-2 text-sm"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: tx.category_color ?? "#9ca3af" }}
|
style={{ backgroundColor: tx.category_color ?? "#9ca3af" }}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ interface TransactionTableProps {
|
||||||
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
|
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
|
||||||
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
|
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
|
||||||
onDeleteSplit: (parentId: number) => Promise<void>;
|
onDeleteSplit: (parentId: number) => Promise<void>;
|
||||||
|
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortIcon({
|
function SortIcon({
|
||||||
|
|
@ -50,6 +51,7 @@ export default function TransactionTable({
|
||||||
onLoadSplitChildren,
|
onLoadSplitChildren,
|
||||||
onSaveSplit,
|
onSaveSplit,
|
||||||
onDeleteSplit,
|
onDeleteSplit,
|
||||||
|
onRowContextMenu,
|
||||||
}: TransactionTableProps) {
|
}: TransactionTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
|
@ -135,6 +137,7 @@ export default function TransactionTable({
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<Fragment key={row.id}>
|
<Fragment key={row.id}>
|
||||||
<tr
|
<tr
|
||||||
|
onContextMenu={onRowContextMenu ? (e) => onRowContextMenu(e, row) : undefined}
|
||||||
className="hover:bg-[var(--muted)] transition-colors"
|
className="hover:bg-[var(--muted)] transition-colors"
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
|
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft, Tag } from "lucide-react";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import HubNetBalanceTile from "../components/reports/HubNetBalanceTile";
|
import HubNetBalanceTile from "../components/reports/HubNetBalanceTile";
|
||||||
import HighlightsTopMoversTable from "../components/reports/HighlightsTopMoversTable";
|
import HighlightsTopMoversTable from "../components/reports/HighlightsTopMoversTable";
|
||||||
import HighlightsTopMoversChart from "../components/reports/HighlightsTopMoversChart";
|
import HighlightsTopMoversChart from "../components/reports/HighlightsTopMoversChart";
|
||||||
import HighlightsTopTransactionsList from "../components/reports/HighlightsTopTransactionsList";
|
import HighlightsTopTransactionsList from "../components/reports/HighlightsTopTransactionsList";
|
||||||
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
|
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 { useHighlights } from "../hooks/useHighlights";
|
||||||
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
||||||
|
import type { RecentTransaction } from "../shared/types";
|
||||||
|
|
||||||
const STORAGE_KEY = "reports-viewmode-highlights";
|
const STORAGE_KEY = "reports-viewmode-highlights";
|
||||||
|
|
||||||
|
|
@ -18,9 +21,16 @@ export default function ReportsHighlightsPage() {
|
||||||
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
||||||
const { data, isLoading, error, windowDays, setWindowDays } = useHighlights();
|
const { data, isLoading, error, windowDays, setWindowDays } = useHighlights();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
|
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 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 (
|
return (
|
||||||
<div className={isLoading ? "opacity-60" : ""}>
|
<div className={isLoading ? "opacity-60" : ""}>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
|
@ -87,10 +97,35 @@ export default function ReportsHighlightsPage() {
|
||||||
transactions={data.topTransactions}
|
transactions={data.topTransactions}
|
||||||
windowDays={windowDays}
|
windowDays={windowDays}
|
||||||
onWindowChange={setWindowDays}
|
onWindowChange={setWindowDays}
|
||||||
|
onContextMenuRow={handleContextMenu}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,28 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Wand2 } from "lucide-react";
|
import { Wand2, Tag } from "lucide-react";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import { useTransactions } from "../hooks/useTransactions";
|
import { useTransactions } 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";
|
||||||
import TransactionPagination from "../components/transactions/TransactionPagination";
|
import TransactionPagination from "../components/transactions/TransactionPagination";
|
||||||
|
import ContextMenu from "../components/shared/ContextMenu";
|
||||||
|
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
|
||||||
|
import type { TransactionRow } from "../shared/types";
|
||||||
|
|
||||||
export default function TransactionsPage() {
|
export default function TransactionsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } =
|
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } =
|
||||||
useTransactions();
|
useTransactions();
|
||||||
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
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);
|
||||||
|
|
||||||
|
const handleRowContextMenu = (e: React.MouseEvent, row: TransactionRow) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMenu({ x: e.clientX, y: e.clientY, row });
|
||||||
|
};
|
||||||
|
|
||||||
const handleAutoCategorize = async () => {
|
const handleAutoCategorize = async () => {
|
||||||
setResultMessage(null);
|
setResultMessage(null);
|
||||||
|
|
@ -84,6 +94,7 @@ export default function TransactionsPage() {
|
||||||
onLoadSplitChildren={loadSplitChildren}
|
onLoadSplitChildren={loadSplitChildren}
|
||||||
onSaveSplit={saveSplit}
|
onSaveSplit={saveSplit}
|
||||||
onDeleteSplit={deleteSplit}
|
onDeleteSplit={deleteSplit}
|
||||||
|
onRowContextMenu={handleRowContextMenu}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TransactionPagination
|
<TransactionPagination
|
||||||
|
|
@ -94,6 +105,31 @@ export default function TransactionsPage() {
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue