feat: propagate right-click add-as-keyword to transactions + highlights list (#75) #94

Merged
maximus merged 1 commit from issue-75-propagate-context-menu into main 2026-04-14 19:23:02 +00:00
4 changed files with 83 additions and 3 deletions

View file

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

View file

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

View file

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

View file

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