Add transaction CSV export and an activity history page

Adds an Export CSV button on the transactions list and on each account
view, opening a modal that combines quick-range presets (last 30/60/90
days, this year, all time) with custom start/end date pickers. The
export pulls every matching transaction (not just the current page) and
deliberately omits any receipt path.

Adds a History page in the side menu that lists every recorded
create/update/delete with a color-coded badge, expandable JSON
snapshot, and filters by entity, action, account, and date. Each
account view gets a History button that deeplinks the page filtered to
that account, so a balance discrepancy can be traced back to a deleted
transaction without scrolling the global log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 13:53:35 -07:00
parent 4b3b1c71f9
commit d65e86585d
11 changed files with 882 additions and 16 deletions
+2
View File
@@ -11,6 +11,7 @@ import { Accounts } from '@/pages/Accounts';
import { AccountDetail } from '@/pages/AccountDetail';
import { Transactions } from '@/pages/Transactions';
import { Categories } from '@/pages/Categories';
import { Activity } from '@/pages/Activity';
import { PWAUpdatePrompt } from '@/components/PWAUpdatePrompt';
function App() {
@@ -40,6 +41,7 @@ function App() {
<Route path="/accounts/:id" element={<AccountDetail />} />
<Route path="/transactions" element={<Transactions />} />
<Route path="/categories" element={<Categories />} />
<Route path="/activity" element={<Activity />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -9,6 +9,7 @@ import {
Wallet,
ArrowLeftRight,
Tags,
History,
LogOut,
Menu,
X,
@@ -23,6 +24,7 @@ const navItems = [
{ to: '/accounts', label: 'Accounts', icon: Wallet },
{ to: '/transactions', label: 'Transactions', icon: ArrowLeftRight },
{ to: '/categories', label: 'Categories', icon: Tags },
{ to: '/activity', label: 'History', icon: History },
];
function ThemeToggle() {
@@ -0,0 +1,127 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ExportTransactionsDialog } from './ExportTransactionsDialog';
import type { Transaction } from '@/stores/transactions';
const fetchAllTransactions = vi.fn();
vi.mock('@/stores/transactions', () => ({
useTransactionsStore: {
getState: () => ({ fetchAllTransactions }),
},
}));
const sampleTxn = (overrides: Partial<Transaction> = {}): Transaction => ({
id: 'txn-1',
userId: 'u',
accountId: 'acc-1',
destinationAccountId: null,
amount: 50,
type: 'EXPENSE',
description: 'Coffee',
date: '2026-04-15T12:00:00.000Z',
createdAt: '2026-04-15T12:00:00.000Z',
updatedAt: '2026-04-15T12:00:00.000Z',
account: { id: 'acc-1', name: 'Checking', type: 'CHECKING' },
category: { id: 'c1', name: 'Food', color: '#abc' },
...overrides,
});
describe('ExportTransactionsDialog', () => {
let originalCreateObjectURL: typeof URL.createObjectURL;
let originalRevokeObjectURL: typeof URL.revokeObjectURL;
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-04-25T12:00:00Z'));
fetchAllTransactions.mockReset();
fetchAllTransactions.mockResolvedValue([sampleTxn()]);
originalCreateObjectURL = URL.createObjectURL;
originalRevokeObjectURL = URL.revokeObjectURL;
URL.createObjectURL = vi.fn(() => 'blob:mock');
URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
vi.useRealTimers();
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
});
function setup(overrides: Partial<React.ComponentProps<typeof ExportTransactionsDialog>> = {}) {
const onOpenChange = vi.fn();
const utils = render(
<ExportTransactionsDialog
open
onOpenChange={onOpenChange}
baseFilters={{}}
{...overrides}
/>,
);
return { onOpenChange, ...utils };
}
it('defaults to "Last 30 days" with computed start/end dates', () => {
setup();
const startInput = screen.getByLabelText(/start/i) as HTMLInputElement;
const endInput = screen.getByLabelText(/end/i) as HTMLInputElement;
expect(endInput.value).toBe('2026-04-25');
expect(startInput.value).toBe('2026-03-26');
});
it('updates the date inputs when a different preset is clicked', () => {
setup();
fireEvent.click(screen.getByRole('button', { name: /last 60 days/i }));
const startInput = screen.getByLabelText(/start/i) as HTMLInputElement;
expect(startInput.value).toBe('2026-02-24');
});
it('clears the date inputs when "All time" is selected', () => {
setup();
fireEvent.click(screen.getByRole('button', { name: /all time/i }));
const startInput = screen.getByLabelText(/start/i) as HTMLInputElement;
const endInput = screen.getByLabelText(/end/i) as HTMLInputElement;
expect(startInput.value).toBe('');
expect(endInput.value).toBe('');
});
it('calls fetchAllTransactions with the resolved filters and triggers a download', async () => {
const clickSpy = vi.fn();
const fakeAnchor = { href: '', download: '', click: clickSpy };
const realCreateElement = document.createElement.bind(document);
const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'a') return fakeAnchor as unknown as HTMLAnchorElement;
return realCreateElement(tag);
});
setup({ baseFilters: { accountId: 'acc-1' }, accountName: 'Checking' });
fireEvent.click(screen.getByRole('button', { name: /^export$/i }));
await waitFor(() => expect(fetchAllTransactions).toHaveBeenCalled());
expect(fetchAllTransactions).toHaveBeenCalledWith(
expect.objectContaining({
accountId: 'acc-1',
startDate: '2026-03-26',
endDate: '2026-04-25',
}),
);
expect(clickSpy).toHaveBeenCalled();
expect(fakeAnchor.download).toContain('checking');
expect(fakeAnchor.download).toContain('2026-04-25');
createElementSpy.mockRestore();
});
it('omits the date range from the request when "All time" is chosen', async () => {
setup();
fireEvent.click(screen.getByRole('button', { name: /all time/i }));
fireEvent.click(screen.getByRole('button', { name: /^export$/i }));
await waitFor(() => expect(fetchAllTransactions).toHaveBeenCalled());
const args = fetchAllTransactions.mock.calls[0][0];
expect(args.startDate).toBeUndefined();
expect(args.endDate).toBeUndefined();
});
});
@@ -0,0 +1,213 @@
import { useEffect, useMemo, useState } from 'react';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
useTransactionsStore,
type Transaction,
type TransactionFilters,
} from '@/stores/transactions';
import { encodeCsv, downloadCsv } from '@/lib/csv';
import { todayInputValue, formatDate } from '@/lib/dates';
type Preset = '30d' | '60d' | '90d' | 'ytd' | 'all' | 'custom';
const PRESETS: { value: Preset; label: string }[] = [
{ value: '30d', label: 'Last 30 days' },
{ value: '60d', label: 'Last 60 days' },
{ value: '90d', label: 'Last 90 days' },
{ value: 'ytd', label: 'This year' },
{ value: 'all', label: 'All time' },
];
function shiftDays(today: string, days: number): string {
const [y, m, d] = today.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
dt.setUTCDate(dt.getUTCDate() - days);
return dt.toISOString().slice(0, 10);
}
function rangeForPreset(preset: Preset, today: string): { start: string; end: string } {
switch (preset) {
case '30d':
return { start: shiftDays(today, 30), end: today };
case '60d':
return { start: shiftDays(today, 60), end: today };
case '90d':
return { start: shiftDays(today, 90), end: today };
case 'ytd': {
const year = today.slice(0, 4);
return { start: `${year}-01-01`, end: today };
}
case 'all':
return { start: '', end: '' };
default:
return { start: '', end: '' };
}
}
function slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'all';
}
function rowsForCsv(transactions: Transaction[]): string[][] {
const header = [
'Date',
'Description',
'Category',
'Account',
'Destination Account',
'Amount',
'Type',
'Notes',
];
const data = transactions.map((t) => [
formatDate(t.date),
t.description ?? '',
t.category?.name ?? '',
t.account?.name ?? '',
t.destinationAccount?.name ?? '',
Number(t.amount).toFixed(2),
t.type,
t.notes ?? '',
]);
return [header, ...data];
}
export interface ExportTransactionsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
baseFilters: TransactionFilters;
accountName?: string;
}
export function ExportTransactionsDialog({
open,
onOpenChange,
baseFilters,
accountName,
}: ExportTransactionsDialogProps) {
const today = useMemo(() => todayInputValue(), []);
const [preset, setPreset] = useState<Preset>('30d');
const [startDate, setStartDate] = useState(() => rangeForPreset('30d', today).start);
const [endDate, setEndDate] = useState(() => rangeForPreset('30d', today).end);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset to default preset whenever the dialog opens
useEffect(() => {
if (open) {
const next = rangeForPreset('30d', today);
setPreset('30d');
setStartDate(next.start);
setEndDate(next.end);
setError(null);
}
}, [open, today]);
const choosePreset = (p: Preset) => {
setPreset(p);
const r = rangeForPreset(p, today);
setStartDate(r.start);
setEndDate(r.end);
};
const handleExport = async () => {
setPending(true);
setError(null);
try {
const filters: TransactionFilters = { ...baseFilters };
if (preset !== 'all') {
if (startDate) filters.startDate = startDate;
if (endDate) filters.endDate = endDate;
}
const transactions = await useTransactionsStore
.getState()
.fetchAllTransactions(filters);
const csv = encodeCsv(rowsForCsv(transactions));
const namePart = accountName ? `-${slugify(accountName)}` : '';
downloadCsv(`transactions${namePart}-${today}.csv`, csv);
onOpenChange(false);
} catch (e) {
setError(e instanceof Error ? e.message : 'Export failed');
} finally {
setPending(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Export transactions</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<p className="mb-2 text-sm font-medium">Date range</p>
<div className="flex flex-wrap gap-2">
{PRESETS.map((p) => (
<Button
key={p.value}
type="button"
size="sm"
variant={preset === p.value ? 'default' : 'outline'}
onClick={() => choosePreset(p.value)}
>
{p.label}
</Button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Start</span>
<Input
type="date"
value={startDate}
disabled={preset === 'all'}
onChange={(e) => {
setStartDate(e.target.value);
setPreset('custom');
}}
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">End</span>
<Input
type="date"
value={endDate}
disabled={preset === 'all'}
onChange={(e) => {
setEndDate(e.target.value);
setPreset('custom');
}}
/>
</label>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button onClick={handleExport} disabled={pending}>
{pending ? 'Exporting…' : 'Export'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { encodeCsv, downloadCsv } from './csv';
describe('encodeCsv', () => {
it('encodes a plain row', () => {
expect(encodeCsv([['a', 'b', 'c']])).toBe('a,b,c');
});
it('encodes multiple rows separated by newlines', () => {
expect(encodeCsv([['a', 'b'], ['c', 'd']])).toBe('a,b\nc,d');
});
it('quotes a cell containing a comma', () => {
expect(encodeCsv([['hello, world', 'x']])).toBe('"hello, world",x');
});
it('escapes quotes by doubling and wrapping the cell', () => {
expect(encodeCsv([['he said "hi"', 'x']])).toBe('"he said ""hi""",x');
});
it('quotes a cell containing a newline', () => {
expect(encodeCsv([['line1\nline2', 'x']])).toBe('"line1\nline2",x');
});
it('treats null and undefined as empty cells', () => {
expect(
encodeCsv([[null as unknown as string, undefined as unknown as string, 'ok']]),
).toBe(',,ok');
});
it('handles numbers cast to strings', () => {
expect(encodeCsv([[String(42), String(3.14)]])).toBe('42,3.14');
});
});
describe('downloadCsv', () => {
let originalCreateObjectURL: typeof URL.createObjectURL;
let originalRevokeObjectURL: typeof URL.revokeObjectURL;
let createElementSpy: ReturnType<typeof vi.spyOn>;
let clickSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
originalCreateObjectURL = URL.createObjectURL;
originalRevokeObjectURL = URL.revokeObjectURL;
URL.createObjectURL = vi.fn(() => 'blob:mock');
URL.revokeObjectURL = vi.fn();
clickSpy = vi.fn();
createElementSpy = vi.spyOn(document, 'createElement');
});
afterEach(() => {
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
createElementSpy.mockRestore();
});
it('builds a Blob, sets the filename on a temporary anchor, and clicks it', () => {
const fakeAnchor = {
href: '',
download: '',
click: clickSpy,
};
createElementSpy.mockReturnValue(fakeAnchor as unknown as HTMLElement);
downloadCsv('test.csv', 'a,b\n1,2');
expect(URL.createObjectURL).toHaveBeenCalled();
expect(fakeAnchor.download).toBe('test.csv');
expect(fakeAnchor.href).toBe('blob:mock');
expect(clickSpy).toHaveBeenCalled();
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock');
});
});
+25
View File
@@ -0,0 +1,25 @@
export function encodeCsv(rows: (string | null | undefined)[][]): string {
return rows
.map((row) =>
row
.map((cell) => {
const v = cell ?? '';
if (/[",\n\r]/.test(v)) {
return `"${v.replace(/"/g, '""')}"`;
}
return v;
})
.join(','),
)
.join('\n');
}
export function downloadCsv(filename: string, csv: string): void {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
@@ -25,6 +25,8 @@ import {
ArrowRight,
ChevronLeft,
ChevronRight,
Download,
History as HistoryIcon,
Paperclip,
Pencil,
Trash2,
@@ -32,6 +34,7 @@ import {
import { TransactionForm } from '@/components/TransactionForm';
import { ReceiptViewer } from '@/components/ReceiptViewer';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ExportTransactionsDialog } from '@/components/ExportTransactionsDialog';
import { Input } from '@/components/ui/input';
import { api } from '@/lib/api';
import { formatDate, todayInputValue } from '@/lib/dates';
@@ -74,6 +77,7 @@ export function AccountDetail() {
const [deletingValuation, setDeletingValuation] = useState<
{ id: string; date: string; value: number } | null
>(null);
const [exportOpen, setExportOpen] = useState(false);
const [history, setHistory] = useState<
{
date: string;
@@ -204,10 +208,26 @@ export function AccountDetail() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => navigate('/accounts')}>
<ArrowLeft className="mr-1 size-4" /> Back
</Button>
<div className="flex flex-1 justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/activity?accountId=${id}`)}
>
<HistoryIcon className="mr-1 size-4" /> History
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setExportOpen(true)}
>
<Download className="mr-1 size-4" /> Export CSV
</Button>
</div>
</div>
{account ? (
@@ -560,6 +580,13 @@ export function AccountDetail() {
if (deletingValuation) await handleDeleteValuation(deletingValuation.id);
}}
/>
<ExportTransactionsDialog
open={exportOpen}
onOpenChange={setExportOpen}
baseFilters={{ accountId: id }}
accountName={account?.name}
/>
</div>
);
}
@@ -0,0 +1,304 @@
import { Fragment, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
useActivityStore,
type ActivityFilters,
type ActivityLogEntry,
type EntityType,
type ActivityAction,
} from '@/stores/activity';
import { useAccountsStore } from '@/stores/accounts';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
const ENTITY_TYPES: { value: EntityType; label: string }[] = [
{ value: 'TRANSACTION', label: 'Transaction' },
{ value: 'ACCOUNT', label: 'Account' },
{ value: 'ACCOUNT_VALUATION', label: 'Valuation' },
];
const ACTIONS: { value: ActivityAction; label: string }[] = [
{ value: 'CREATE', label: 'Create' },
{ value: 'UPDATE', label: 'Update' },
{ value: 'DELETE', label: 'Delete' },
];
function actionBadgeVariant(
action: ActivityAction,
): 'default' | 'secondary' | 'destructive' {
if (action === 'CREATE') return 'default';
if (action === 'DELETE') return 'destructive';
return 'secondary';
}
function entityLabel(type: EntityType): string {
return ENTITY_TYPES.find((e) => e.value === type)?.label ?? type;
}
export function Activity() {
const { activities, total, page, loading, fetchActivities } =
useActivityStore();
const { accounts, fetchAccounts } = useAccountsStore();
const [searchParams, setSearchParams] = useSearchParams();
const [filters, setFilters] = useState<ActivityFilters>(() => {
const accountId = searchParams.get('accountId');
return accountId ? { accountId } : {};
});
const [expanded, setExpanded] = useState<string | null>(null);
// Strip the deeplink param after we've seeded state — same pattern as Transactions.tsx
useEffect(() => {
if (searchParams.get('accountId')) {
searchParams.delete('accountId');
setSearchParams(searchParams, { replace: true });
}
}, [searchParams, setSearchParams]);
useEffect(() => {
fetchAccounts();
}, [fetchAccounts]);
useEffect(() => {
fetchActivities(filters, 1);
}, [fetchActivities, filters]);
const totalPages = Math.ceil(total / 20);
const accountName = (id: string | null | undefined) =>
id ? accounts.find((a) => a.id === id)?.name ?? '(Deleted account)' : '';
const summarize = (entry: ActivityLogEntry): string => {
if (entry.summary) return entry.summary;
return `${entityLabel(entry.entityType)} ${entry.entityId.slice(0, 8)}`;
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">History</h1>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<Select
value={filters.entityType ?? 'all'}
onValueChange={(v) =>
setFilters((f) => ({
...f,
entityType: v === 'all' ? undefined : (v as EntityType),
}))
}
>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="All entities">
{(v: any) =>
v === 'all' || !v ? 'All entities' : entityLabel(v as EntityType)
}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All entities</SelectItem>
{ENTITY_TYPES.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.action ?? 'all'}
onValueChange={(v) =>
setFilters((f) => ({
...f,
action: v === 'all' ? undefined : (v as ActivityAction),
}))
}
>
<SelectTrigger className="w-full sm:w-[150px]">
<SelectValue placeholder="All actions">
{(v: any) => {
if (v === 'all' || !v) return 'All actions';
return (
ACTIONS.find((a) => a.value === v)?.label ?? String(v)
);
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All actions</SelectItem>
{ACTIONS.map((a) => (
<SelectItem key={a.value} value={a.value}>
{a.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.accountId ?? 'all'}
onValueChange={(v) =>
setFilters((f) => ({
...f,
accountId: v === 'all' ? undefined : v,
}))
}
>
<SelectTrigger className="w-full sm:w-[200px]">
<SelectValue placeholder="All accounts">
{(v: any) =>
v === 'all' || !v
? 'All accounts'
: accounts.find((a) => a.id === v)?.name ?? 'All accounts'
}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All accounts</SelectItem>
{accounts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="date"
aria-label="Start date"
className="w-full sm:w-[160px]"
value={filters.startDate ?? ''}
onChange={(e) =>
setFilters((f) => ({ ...f, startDate: e.target.value || undefined }))
}
/>
<Input
type="date"
aria-label="End date"
className="w-full sm:w-[160px]"
value={filters.endDate ?? ''}
onChange={(e) =>
setFilters((f) => ({ ...f, endDate: e.target.value || undefined }))
}
/>
</div>
{loading && activities.length === 0 ? (
<p className="text-muted-foreground">Loading...</p>
) : activities.length === 0 ? (
<p className="text-muted-foreground">No activity yet.</p>
) : (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Timestamp</TableHead>
<TableHead>Action</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Summary</TableHead>
<TableHead>Account</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{activities.map((entry) => {
const isOpen = expanded === entry.id;
return (
<Fragment key={entry.id}>
<TableRow
onClick={() => setExpanded(isOpen ? null : entry.id)}
className="cursor-pointer"
>
<TableCell className="text-sm whitespace-nowrap">
{new Date(entry.createdAt).toLocaleString()}
</TableCell>
<TableCell>
<Badge variant={actionBadgeVariant(entry.action)}>
{entry.action}
</Badge>
</TableCell>
<TableCell className="text-sm">
{entityLabel(entry.entityType)}
</TableCell>
<TableCell className="text-sm">
{summarize(entry)}
</TableCell>
<TableCell className="text-sm">
{entry.accountId ? accountName(entry.accountId) : ''}
{entry.destinationAccountId && (
<>
{' → '}
{accountName(entry.destinationAccountId)}
</>
)}
</TableCell>
<TableCell>
<ChevronDown
className={`size-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</TableCell>
</TableRow>
{isOpen && (
<TableRow>
<TableCell colSpan={6} className="bg-muted/30">
<pre className="overflow-x-auto rounded p-3 text-xs">
{entry.snapshot
? JSON.stringify(entry.snapshot, null, 2)
: '(no snapshot)'}
</pre>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => fetchActivities(filters, page - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => fetchActivities(filters, page + 1)}
>
<ChevronRight className="size-4" />
</Button>
</div>
)}
</>
)}
</div>
);
}
@@ -35,6 +35,7 @@ import {
Plus,
ChevronLeft,
ChevronRight,
Download,
Trash2,
Paperclip,
Pencil,
@@ -43,6 +44,7 @@ import {
import { TransactionForm } from '@/components/TransactionForm';
import { ReceiptViewer } from '@/components/ReceiptViewer';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ExportTransactionsDialog } from '@/components/ExportTransactionsDialog';
import { formatDate } from '@/lib/dates';
const TRANSACTION_TYPES = ['INCOME', 'EXPENSE', 'TRANSFER'] as const;
@@ -70,6 +72,7 @@ export function Transactions() {
const [editing, setEditing] = useState<Transaction | null>(null);
const [viewingReceipt, setViewingReceipt] = useState<string | null>(null);
const [deleting, setDeleting] = useState<Transaction | null>(null);
const [exportOpen, setExportOpen] = useState(false);
useEffect(() => {
let changed = false;
@@ -118,22 +121,27 @@ export function Transactions() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-2xl font-bold">Transactions</h1>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger render={<Button />}>
<Plus className="mr-2 size-4" /> Add Transaction
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Transaction</DialogTitle></DialogHeader>
<TransactionForm
accounts={accounts}
categories={categories}
onSubmit={handleCreate}
onCancel={() => setCreateOpen(false)}
/>
</DialogContent>
</Dialog>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setExportOpen(true)}>
<Download className="mr-2 size-4" /> Export CSV
</Button>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger render={<Button />}>
<Plus className="mr-2 size-4" /> Add Transaction
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Transaction</DialogTitle></DialogHeader>
<TransactionForm
accounts={accounts}
categories={categories}
onSubmit={handleCreate}
onCancel={() => setCreateOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
</div>
{/* Filters */}
@@ -352,6 +360,12 @@ export function Transactions() {
if (deleting) await deleteTransaction(deleting.id);
}}
/>
<ExportTransactionsDialog
open={exportOpen}
onOpenChange={setExportOpen}
baseFilters={filters}
/>
</div>
);
}
@@ -0,0 +1,65 @@
import { create } from 'zustand';
import { api } from '@/lib/api';
export type EntityType = 'TRANSACTION' | 'ACCOUNT' | 'ACCOUNT_VALUATION';
export type ActivityAction = 'CREATE' | 'UPDATE' | 'DELETE';
export interface ActivityLogEntry {
id: string;
userId: string;
entityType: EntityType;
entityId: string;
action: ActivityAction;
accountId: string | null;
destinationAccountId: string | null;
summary: string | null;
snapshot: Record<string, unknown> | null;
createdAt: string;
}
export interface ActivityFilters {
entityType?: EntityType;
action?: ActivityAction;
accountId?: string;
startDate?: string;
endDate?: string;
}
interface ActivityState {
activities: ActivityLogEntry[];
total: number;
page: number;
loading: boolean;
fetchActivities: (filters?: ActivityFilters, page?: number) => Promise<void>;
}
export const useActivityStore = create<ActivityState>((set) => ({
activities: [],
total: 0,
page: 1,
loading: false,
fetchActivities: async (filters = {}, page = 1) => {
set({ loading: true });
const params = new URLSearchParams();
params.set('page', String(page));
params.set('limit', '20');
Object.entries(filters).forEach(([key, value]) => {
if (value) params.set(key, value);
});
const result = await api.get<{
data: ActivityLogEntry[];
total: number;
page: number;
limit: number;
}>(`/activity?${params.toString()}`);
set({
activities: result.data,
total: result.total,
page: result.page,
loading: false,
});
},
}));
@@ -34,6 +34,7 @@ interface TransactionsState {
page: number;
loading: boolean;
fetchTransactions: (filters?: TransactionFilters, page?: number) => Promise<void>;
fetchAllTransactions: (filters?: TransactionFilters) => Promise<Transaction[]>;
createTransaction: (data: Partial<Transaction>) => Promise<void>;
updateTransaction: (id: string, data: Partial<Transaction>) => Promise<void>;
deleteTransaction: (id: string) => Promise<void>;
@@ -69,6 +70,18 @@ export const useTransactionsStore = create<TransactionsState>((set) => ({
});
},
fetchAllTransactions: async (filters = {}) => {
const params = new URLSearchParams();
params.set('all', 'true');
Object.entries(filters).forEach(([key, value]) => {
if (value) params.set(key, String(value));
});
const result = await api.get<{ data: Transaction[] }>(
`/transactions?${params.toString()}`,
);
return result.data;
},
createTransaction: async (data) => {
const transaction = await api.post<Transaction>('/transactions', data);
set((state) => ({ transactions: [transaction, ...state.transactions] }));