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
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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tehriehlbudget-backend",
|
||||
"version": "0.1.6",
|
||||
"version": "0.2.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user