Simpl-Resultat/src/components/transactions/TransactionTable.tsx
le king fu fd44d8702b feat(onboarding): the Transactions page's step card and its guided entry
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>
2026-10-08 08:56:44 -04:00

341 lines
14 KiB
TypeScript

import { Fragment, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split, Link2 } from "lucide-react";
import type {
TransactionRow,
TransactionSort,
Category,
SplitChild,
} from "../../shared/types";
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
import CategoryCombobox from "../shared/CategoryCombobox";
import SplitAdjustmentModal from "./SplitAdjustmentModal";
interface TransactionTableProps {
rows: TransactionRow[];
sort: TransactionSort;
categories: Category[];
onSort: (column: TransactionSort["column"]) => void;
onCategoryChange: (txId: number, categoryId: number | null) => void;
onNotesChange: (txId: number, notes: string) => void;
onAddKeyword: (categoryId: number, keyword: string) => Promise<void>;
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
onDeleteSplit: (parentId: number) => Promise<void>;
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void;
/**
* Issue #142 — when supplied, a small Link2 icon appears next to the
* description for every transaction whose id is a key in the map. The
* icon's tooltip lists the linked accounts. The lookup is intentionally
* done by the parent (one batch SELECT, in-memory `.has()` thereafter)
* to avoid an N+1 hit on the table render.
*/
linkedTransfersByTxId?: Map<number, LinkedTransferTooltipRow[]>;
/**
* Highlight the category of the first row for the getting-started card
* (#399). The ring sits on a wrapper: `CategoryCombobox` stays as it is.
*/
onboardingTarget?: boolean;
}
/** `data-onboarding-target` of the highlighted category. */
export const TRANSACTION_CATEGORY_TARGET = "transaction-category";
function SortIcon({
column,
sort,
}: {
column: TransactionSort["column"];
sort: TransactionSort;
}) {
if (sort.column !== column)
return <span className="ml-1 text-[var(--muted-foreground)] opacity-30">&#8597;</span>;
return sort.direction === "asc" ? (
<ChevronUp size={14} className="ml-1 inline" />
) : (
<ChevronDown size={14} className="ml-1 inline" />
);
}
export default function TransactionTable({
rows,
sort,
categories,
onSort,
onCategoryChange,
onNotesChange,
onAddKeyword,
onLoadSplitChildren,
onSaveSplit,
onDeleteSplit,
onRowContextMenu,
linkedTransfersByTxId,
onboardingTarget,
}: TransactionTableProps) {
const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<number | null>(null);
const [editingNotes, setEditingNotes] = useState("");
const [keywordRowId, setKeywordRowId] = useState<number | null>(null);
const [keywordText, setKeywordText] = useState("");
const [keywordSaved, setKeywordSaved] = useState<number | null>(null);
const [splitRow, setSplitRow] = useState<TransactionRow | null>(null);
const noCategoryExtra = useMemo(
() => [{ value: "", label: t("transactions.table.noCategory") }],
[t]
);
if (rows.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
<p>{t("transactions.noTransactions")}</p>
</div>
);
}
const columns: Array<{
key: TransactionSort["column"];
label: string;
align: string;
}> = [
{ key: "date", label: t("transactions.date"), align: "text-left" },
{ key: "description", label: t("transactions.description"), align: "text-left" },
{ key: "amount", label: t("transactions.amount"), align: "text-right" },
{ key: "category_name", label: t("transactions.category"), align: "text-left" },
];
const toggleNotes = (row: TransactionRow) => {
if (expandedId === row.id) {
setExpandedId(null);
} else {
setExpandedId(row.id);
setEditingNotes(row.notes ?? "");
}
};
const handleNotesSave = (txId: number) => {
onNotesChange(txId, editingNotes);
setExpandedId(null);
};
const toggleKeyword = (row: TransactionRow) => {
if (keywordRowId === row.id) {
setKeywordRowId(null);
} else {
setKeywordRowId(row.id);
setKeywordText(row.description);
}
};
const handleKeywordSave = async (row: TransactionRow) => {
if (!row.category_id || !keywordText.trim()) return;
await onAddKeyword(row.category_id, keywordText);
setKeywordRowId(null);
setKeywordSaved(row.id);
setTimeout(() => setKeywordSaved(null), 2000);
};
return (
<div className="overflow-x-auto rounded-xl border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--muted)]">
{columns.map((col) => (
<th
key={col.key}
onClick={() => onSort(col.key)}
className={`px-3 py-2 ${col.align} text-xs font-medium text-[var(--muted-foreground)] cursor-pointer select-none hover:text-[var(--foreground)] transition-colors`}
>
{col.label}
<SortIcon column={col.key} sort={sort} />
</th>
))}
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
{rows.map((row, index) => (
<Fragment key={row.id}>
<tr
onContextMenu={onRowContextMenu ? (e) => onRowContextMenu(e, row) : undefined}
className="hover:bg-[var(--muted)] transition-colors"
>
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
<td className="px-3 py-2 max-w-xs">
<div className="flex items-center gap-1.5">
<span className="truncate" title={row.description}>
{row.description}
</span>
{linkedTransfersByTxId?.has(row.id) && (
<span
className="inline-flex items-center text-[var(--primary)] shrink-0"
title={
// Build a human-readable list: "TFSA (in), RRSP (out)".
(() => {
const links = linkedTransfersByTxId.get(row.id) ?? [];
const parts = links.map(
(l) =>
`${l.account_name} (${t(`balance.transfers.direction.${l.direction}`)})`
);
return `${t("transactions.transferIcon.tooltip")}: ${parts.join(", ")}`;
})()
}
aria-label={t("transactions.transferIcon.ariaLabel")}
>
<Link2 size={12} />
</span>
)}
</div>
</td>
<td
className={`px-3 py-2 text-right font-mono whitespace-nowrap ${
row.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}`}
>
{row.amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
{/*
Every row has the wrapper and only the first one's carries
the ring (#399): the ring coming or going never remounts
the combobox, so it never drops the focus.
*/}
<div
className="rounded-lg"
{...onboardingTargetAttributes(
TRANSACTION_CATEGORY_TARGET,
onboardingTarget && index === 0
)}
>
<CategoryCombobox
categories={categories}
value={row.category_id}
onChange={(id) => onCategoryChange(row.id, id)}
placeholder={t("transactions.table.noCategory")}
compact
extraOptions={noCategoryExtra}
activeExtra={row.category_id === null ? "" : null}
onExtraSelect={() => onCategoryChange(row.id, null)}
/>
</div>
{row.category_id !== null && (
<>
<button
onClick={() => toggleKeyword(row)}
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
keywordSaved === row.id
? "text-[var(--positive)]"
: "text-[var(--muted-foreground)]"
}`}
title={keywordSaved === row.id ? t("transactions.keywordAdded") : t("transactions.addKeyword")}
>
<Tag size={14} />
</button>
<button
onClick={() => setSplitRow(row)}
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
row.is_split
? "text-orange-500"
: "text-[var(--muted-foreground)]"
}`}
title={t("transactions.splitAdjustment")}
>
<Split size={14} />
</button>
</>
)}
</div>
</td>
<td className="px-3 py-2 text-center">
<button
onClick={() => toggleNotes(row)}
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors ${
row.notes
? "text-orange-500"
: "text-[var(--muted-foreground)]"
}`}
title={t("transactions.notes.placeholder")}
>
<MessageSquare size={14} />
</button>
</td>
</tr>
{expandedId === row.id && (
<tr>
<td colSpan={5} className="px-3 py-2 bg-[var(--muted)]">
<div className="flex gap-2">
<textarea
value={editingNotes}
onChange={(e) => setEditingNotes(e.target.value)}
placeholder={t("transactions.notes.placeholder")}
rows={2}
className="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)] resize-none"
/>
<button
onClick={() => handleNotesSave(row.id)}
className="px-4 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity self-end"
>
{t("common.save")}
</button>
</div>
</td>
</tr>
)}
{keywordRowId === row.id && row.category_id !== null && (
<tr>
<td colSpan={5} className="px-3 py-2 bg-[var(--muted)]">
<div className="flex items-center gap-2">
<Tag size={14} className="text-[var(--muted-foreground)] shrink-0" />
<span className="text-xs px-2 py-0.5 rounded bg-[var(--border)] text-[var(--foreground)] shrink-0">
{row.category_name}
</span>
<input
type="text"
value={keywordText}
onChange={(e) => setKeywordText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleKeywordSave(row);
if (e.key === "Escape") setKeywordRowId(null);
}}
placeholder={t("transactions.keywordPlaceholder")}
className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
autoFocus
/>
<button
onClick={() => handleKeywordSave(row)}
disabled={!keywordText.trim()}
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity disabled:opacity-50"
>
{t("common.save")}
</button>
<button
onClick={() => setKeywordRowId(null)}
className="px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--border)] transition-colors"
>
{t("common.cancel")}
</button>
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
{splitRow && (
<SplitAdjustmentModal
transaction={splitRow}
categories={categories}
onLoadChildren={onLoadSplitChildren}
onSave={onSaveSplit}
onDelete={onDeleteSplit}
onClose={() => setSplitRow(null)}
/>
)}
</div>
);
}