Show transaction notes in expandable list rows
CI / test (push) Successful in 25s
CI / lint (push) Successful in 27s
CI / secrets-scan (push) Successful in 5s
CI / vuln-scan (push) Successful in 15s
CI / sast (push) Successful in 10s
CI / build-images (push) Successful in 1m59s
CI / image-scan (push) Successful in 50s
CI / push (push) Successful in 31s

Notes were write-only outside the edit form — visible nowhere in the
Transactions or Account Detail tables. Each row now has a chevron
toggle (alongside Edit/Delete) that reveals the notes in a second row,
mirroring the History page's expand pattern. Bumps both packages to
0.2.0 to keep frontend and backend in lockstep for the Harbor push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 21:43:11 -07:00
parent 4c84d2fb96
commit 7adb2182fc
6 changed files with 300 additions and 142 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tehriehlbudget-backend",
"version": "0.1.6",
"version": "0.2.0",
"description": "",
"author": "",
"private": true,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tehriehlbudget-frontend",
"private": true,
"version": "0.1.6",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -157,4 +157,48 @@ describe('AccountDetail page', () => {
expect(historyCalls.length).toBeGreaterThan(0);
expect(historyCalls[historyCalls.length - 1][0]).toContain('days=180');
});
describe('row expand reveals notes', () => {
const txnWithNotes = {
id: 't1',
description: 'Coffee',
amount: 5,
type: 'EXPENSE',
accountId: 'acc-1',
date: '2026-04-15',
notes: 'Met with Alex about Q3 plan',
account: { id: 'acc-1', name: 'Checking', type: 'CHECKING' },
};
it('hides notes until the row is expanded', () => {
txnState.transactions = [txnWithNotes];
txnState.total = 1;
renderDetail();
expect(screen.queryByText('Met with Alex about Q3 plan')).not.toBeInTheDocument();
});
it('shows notes after the toggle is clicked, and hides them on second click', () => {
txnState.transactions = [txnWithNotes];
txnState.total = 1;
renderDetail();
const toggle = screen.getByLabelText(/toggle details/i);
fireEvent.click(toggle);
expect(screen.getByText('Met with Alex about Q3 plan')).toBeInTheDocument();
fireEvent.click(toggle);
expect(screen.queryByText('Met with Alex about Q3 plan')).not.toBeInTheDocument();
});
it('shows a placeholder when expanding a transaction without notes', () => {
txnState.transactions = [{ ...txnWithNotes, notes: undefined }];
txnState.total = 1;
renderDetail();
fireEvent.click(screen.getByLabelText(/toggle details/i));
expect(screen.getByText(/^notes$/i)).toBeInTheDocument();
expect(screen.getByText('—')).toBeInTheDocument();
});
});
});
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { useAccountsStore } from '@/stores/accounts';
import { useCategoriesStore } from '@/stores/categories';
@@ -18,6 +18,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import {
ArrowLeft,
ArrowRight,
ChevronDown,
ChevronLeft,
ChevronRight,
Download,
@@ -75,6 +76,7 @@ export function AccountDetail() {
value: number;
} | null>(null);
const [exportOpen, setExportOpen] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [history, setHistory] = useState<
{
date: string;
@@ -360,76 +362,102 @@ export function AccountDetail() {
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((txn) => (
<TableRow key={txn.id}>
<TableCell className="text-sm">{formatDate(txn.date)}</TableCell>
<TableCell className="text-sm font-medium">{txn.description}</TableCell>
<TableCell>
{txn.category && (
<div className="flex items-center gap-1.5">
<div
className="size-2.5 rounded-full"
style={{ backgroundColor: txn.category.color || '#6b7280' }}
/>
<span className="text-sm">{txn.category.name}</span>
</div>
{transactions.map((txn) => {
const isOpen = expandedId === txn.id;
return (
<Fragment key={txn.id}>
<TableRow>
<TableCell className="text-sm">{formatDate(txn.date)}</TableCell>
<TableCell className="text-sm font-medium">{txn.description}</TableCell>
<TableCell>
{txn.category && (
<div className="flex items-center gap-1.5">
<div
className="size-2.5 rounded-full"
style={{ backgroundColor: txn.category.color || '#6b7280' }}
/>
<span className="text-sm">{txn.category.name}</span>
</div>
)}
</TableCell>
<TableCell className="text-sm">
{txn.type === 'TRANSFER' &&
(txn.destinationAccount || txn.destinationAccountId) ? (
<span className="inline-flex items-center gap-1">
{txn.account?.name ?? '(Deleted account)'}
<ArrowRight className="size-3 text-muted-foreground" />
{txn.destinationAccount?.name ?? '(Deleted account)'}
</span>
) : (
(txn.account?.name ?? '(Deleted account)')
)}
</TableCell>
<TableCell className="text-right text-sm font-medium">
{formatCurrency(Number(txn.amount))}
</TableCell>
<TableCell>
<Badge variant={txn.type === 'INCOME' ? 'default' : 'secondary'}>
{txn.type}
</Badge>
</TableCell>
<TableCell>
{txn.receiptPath && (
<Button
variant="ghost"
size="sm"
onClick={() => setViewingReceipt(txn.receiptPath!)}
aria-label="View receipt"
title="View receipt"
>
<Paperclip className="size-3.5" />
</Button>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setExpandedId(isOpen ? null : txn.id)}
aria-label="Toggle details"
aria-expanded={isOpen}
>
<ChevronDown
className={`size-3 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(txn)}
aria-label="Edit transaction"
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeletingTxn(txn)}
aria-label="Delete transaction"
>
<Trash2 className="size-3" />
</Button>
</div>
</TableCell>
</TableRow>
{isOpen && (
<TableRow>
<TableCell colSpan={8} className="bg-muted/30">
<div className="space-y-1 px-2 py-3">
<p className="text-xs text-muted-foreground">Notes</p>
<p className="text-sm whitespace-pre-wrap">{txn.notes || ''}</p>
</div>
</TableCell>
</TableRow>
)}
</TableCell>
<TableCell className="text-sm">
{txn.type === 'TRANSFER' &&
(txn.destinationAccount || txn.destinationAccountId) ? (
<span className="inline-flex items-center gap-1">
{txn.account?.name ?? '(Deleted account)'}
<ArrowRight className="size-3 text-muted-foreground" />
{txn.destinationAccount?.name ?? '(Deleted account)'}
</span>
) : (
(txn.account?.name ?? '(Deleted account)')
)}
</TableCell>
<TableCell className="text-right text-sm font-medium">
{formatCurrency(Number(txn.amount))}
</TableCell>
<TableCell>
<Badge variant={txn.type === 'INCOME' ? 'default' : 'secondary'}>
{txn.type}
</Badge>
</TableCell>
<TableCell>
{txn.receiptPath && (
<Button
variant="ghost"
size="sm"
onClick={() => setViewingReceipt(txn.receiptPath!)}
aria-label="View receipt"
title="View receipt"
>
<Paperclip className="size-3.5" />
</Button>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(txn)}
aria-label="Edit transaction"
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeletingTxn(txn)}
aria-label="Delete transaction"
>
<Trash2 className="size-3" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</Fragment>
);
})}
</TableBody>
</Table>
</div>
@@ -137,4 +137,59 @@ describe('Transactions page', () => {
expect(screen.getByText(/delete transaction\?/i)).toBeInTheDocument();
expect(screen.getByText(/permanently delete the expense/i)).toBeInTheDocument();
});
describe('row expand reveals notes', () => {
const txnWithNotes = {
id: 't1',
description: 'Coffee',
amount: 5,
type: 'EXPENSE',
accountId: 'acc-1',
date: '2026-04-15',
notes: 'Met with Alex about Q3 plan',
account: { id: 'acc-1', name: 'Checking', type: 'CHECKING' },
};
it('hides notes until the row is expanded', () => {
txnState.transactions = [txnWithNotes];
txnState.total = 1;
renderTxns();
expect(screen.queryByText('Met with Alex about Q3 plan')).not.toBeInTheDocument();
});
it('shows notes after the toggle is clicked, and hides them on second click', () => {
txnState.transactions = [txnWithNotes];
txnState.total = 1;
renderTxns();
const toggle = screen.getByLabelText(/toggle details/i);
fireEvent.click(toggle);
expect(screen.getByText('Met with Alex about Q3 plan')).toBeInTheDocument();
fireEvent.click(toggle);
expect(screen.queryByText('Met with Alex about Q3 plan')).not.toBeInTheDocument();
});
it('shows a placeholder when expanding a transaction without notes', () => {
txnState.transactions = [{ ...txnWithNotes, notes: undefined }];
txnState.total = 1;
renderTxns();
fireEvent.click(screen.getByLabelText(/toggle details/i));
// The Notes label is rendered, with an em-dash placeholder.
expect(screen.getByText(/^notes$/i)).toBeInTheDocument();
expect(screen.getByText('—')).toBeInTheDocument();
});
it('does not toggle expansion when the edit or delete buttons are clicked', () => {
txnState.transactions = [txnWithNotes];
txnState.total = 1;
renderTxns();
fireEvent.click(screen.getByLabelText(/edit transaction/i));
// Notes panel must not appear from clicking edit; that opens the edit dialog instead.
expect(screen.queryByText('Met with Alex about Q3 plan')).not.toBeInTheDocument();
});
});
});
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { Fragment, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
useTransactionsStore,
@@ -33,6 +33,7 @@ import {
} from '@/components/ui/dialog';
import {
Plus,
ChevronDown,
ChevronLeft,
ChevronRight,
Download,
@@ -73,6 +74,7 @@ export function Transactions() {
const [viewingReceipt, setViewingReceipt] = useState<string | null>(null);
const [deleting, setDeleting] = useState<Transaction | null>(null);
const [exportOpen, setExportOpen] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
useEffect(() => {
let changed = false;
@@ -241,76 +243,105 @@ export function Transactions() {
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((txn) => (
<TableRow key={txn.id}>
<TableCell className="text-sm">{formatDate(txn.date)}</TableCell>
<TableCell className="text-sm font-medium">{txn.description}</TableCell>
<TableCell>
{txn.category && (
<div className="flex items-center gap-1.5">
<div
className="size-2.5 rounded-full"
style={{ backgroundColor: txn.category.color || '#6b7280' }}
/>
<span className="text-sm">{txn.category.name}</span>
</div>
{transactions.map((txn) => {
const isOpen = expandedId === txn.id;
return (
<Fragment key={txn.id}>
<TableRow>
<TableCell className="text-sm">{formatDate(txn.date)}</TableCell>
<TableCell className="text-sm font-medium">{txn.description}</TableCell>
<TableCell>
{txn.category && (
<div className="flex items-center gap-1.5">
<div
className="size-2.5 rounded-full"
style={{ backgroundColor: txn.category.color || '#6b7280' }}
/>
<span className="text-sm">{txn.category.name}</span>
</div>
)}
</TableCell>
<TableCell className="text-sm">
{txn.type === 'TRANSFER' &&
(txn.destinationAccount || txn.destinationAccountId) ? (
<span className="inline-flex items-center gap-1">
{txn.account?.name ?? '(Deleted account)'}
<ArrowRight className="size-3 text-muted-foreground" />
{txn.destinationAccount?.name ?? '(Deleted account)'}
</span>
) : (
(txn.account?.name ?? '(Deleted account)')
)}
</TableCell>
<TableCell className="text-right text-sm font-medium">
$
{Number(txn.amount).toLocaleString('en-US', {
minimumFractionDigits: 2,
})}
</TableCell>
<TableCell>
<Badge variant={txn.type === 'INCOME' ? 'default' : 'secondary'}>
{txn.type}
</Badge>
</TableCell>
<TableCell>
{txn.receiptPath && (
<Button
variant="ghost"
size="sm"
onClick={() => setViewingReceipt(txn.receiptPath!)}
aria-label="View receipt"
title="View receipt"
>
<Paperclip className="size-3.5" />
</Button>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setExpandedId(isOpen ? null : txn.id)}
aria-label="Toggle details"
aria-expanded={isOpen}
>
<ChevronDown
className={`size-3 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(txn)}
aria-label="Edit transaction"
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleting(txn)}
aria-label="Delete transaction"
>
<Trash2 className="size-3" />
</Button>
</div>
</TableCell>
</TableRow>
{isOpen && (
<TableRow>
<TableCell colSpan={8} className="bg-muted/30">
<div className="space-y-1 px-2 py-3">
<p className="text-xs text-muted-foreground">Notes</p>
<p className="text-sm whitespace-pre-wrap">{txn.notes || '—'}</p>
</div>
</TableCell>
</TableRow>
)}
</TableCell>
<TableCell className="text-sm">
{txn.type === 'TRANSFER' &&
(txn.destinationAccount || txn.destinationAccountId) ? (
<span className="inline-flex items-center gap-1">
{txn.account?.name ?? '(Deleted account)'}
<ArrowRight className="size-3 text-muted-foreground" />
{txn.destinationAccount?.name ?? '(Deleted account)'}
</span>
) : (
(txn.account?.name ?? '(Deleted account)')
)}
</TableCell>
<TableCell className="text-right text-sm font-medium">
${Number(txn.amount).toLocaleString('en-US', { minimumFractionDigits: 2 })}
</TableCell>
<TableCell>
<Badge variant={txn.type === 'INCOME' ? 'default' : 'secondary'}>
{txn.type}
</Badge>
</TableCell>
<TableCell>
{txn.receiptPath && (
<Button
variant="ghost"
size="sm"
onClick={() => setViewingReceipt(txn.receiptPath!)}
aria-label="View receipt"
title="View receipt"
>
<Paperclip className="size-3.5" />
</Button>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(txn)}
aria-label="Edit transaction"
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleting(txn)}
aria-label="Delete transaction"
>
<Trash2 className="size-3" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</Fragment>
);
})}
</TableBody>
</Table>
</div>