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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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] }));
|
||||
|
||||
Reference in New Issue
Block a user