- Add TRANSFER to TransactionType enum; excluded from cash flow queries - Add TransferRule model: description patterns that auto-mark transactions as transfers on upload (takes priority over budget rules) - Bulk action "Mark as transfer" in transaction table - Transfer Rules button/dialog on transactions page for managing patterns - Transfers shown with ⇄ prefix and muted color in transaction list Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import Link from 'next/link'
|
|
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
|
|
import {
|
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
} from '@/components/ui/table'
|
|
import { Pencil, ChevronLeft, ChevronRight, Trash2, Tag, StickyNote, X, ArrowLeftRight } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
import { formatCents } from '@/lib/utils/currency'
|
|
import { EditTransactionDialog } from './EditTransactionDialog'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export type TransactionRow = {
|
|
id: string
|
|
date: string
|
|
description: string
|
|
amountCents: number
|
|
type: string
|
|
category: string | null
|
|
notes: string | null
|
|
accountId: string
|
|
budgetId: string | null
|
|
account: { name: string; type: string }
|
|
budget: { id: string; name: string; color: string | null } | null
|
|
}
|
|
|
|
type BudgetOption = { id: string; name: string; color: string | null }
|
|
|
|
interface Props {
|
|
transactions: TransactionRow[]
|
|
total: number
|
|
page: number
|
|
limit: number
|
|
showAccount?: boolean
|
|
budgets: BudgetOption[]
|
|
}
|
|
|
|
export function TransactionTable({
|
|
transactions,
|
|
total,
|
|
page,
|
|
limit,
|
|
showAccount = true,
|
|
budgets,
|
|
}: Props) {
|
|
const pathname = usePathname()
|
|
const searchParams = useSearchParams()
|
|
const router = useRouter()
|
|
const [editing, setEditing] = useState<TransactionRow | null>(null)
|
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
const [bulkDialog, setBulkDialog] = useState<'delete' | 'budget' | 'notes' | 'transfer' | null>(null)
|
|
const [budgetTarget, setBudgetTarget] = useState<string>('__none__')
|
|
const [notesValue, setNotesValue] = useState('')
|
|
const [working, setWorking] = useState(false)
|
|
const totalPages = Math.ceil(total / limit)
|
|
|
|
const allIds = transactions.map((t) => t.id)
|
|
const allSelected = allIds.length > 0 && allIds.every((id) => selected.has(id))
|
|
const someSelected = selected.size > 0
|
|
|
|
function toggleAll() {
|
|
if (allSelected) {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
allIds.forEach((id) => next.delete(id))
|
|
return next
|
|
})
|
|
} else {
|
|
setSelected((prev) => new Set([...prev, ...allIds]))
|
|
}
|
|
}
|
|
|
|
function toggleOne(id: string) {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id)
|
|
else next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
function clearSelection() {
|
|
setSelected(new Set())
|
|
}
|
|
|
|
function pageHref(p: number) {
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
params.set('page', String(p))
|
|
return `${pathname}?${params.toString()}`
|
|
}
|
|
|
|
async function bulkPost(body: object) {
|
|
setWorking(true)
|
|
const res = await fetch('/api/transactions/bulk', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
setWorking(false)
|
|
if (res.ok) {
|
|
clearSelection()
|
|
setBulkDialog(null)
|
|
router.refresh()
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
await bulkPost({ action: 'delete', ids: [...selected] })
|
|
}
|
|
|
|
async function handleAssignBudget() {
|
|
await bulkPost({
|
|
action: 'assignBudget',
|
|
ids: [...selected],
|
|
budgetId: budgetTarget === '__none__' ? null : budgetTarget,
|
|
})
|
|
}
|
|
|
|
async function handleAddNotes() {
|
|
await bulkPost({ action: 'addNotes', ids: [...selected], notes: notesValue })
|
|
}
|
|
|
|
async function handleMarkTransfer() {
|
|
await bulkPost({ action: 'markTransfer', ids: [...selected] })
|
|
}
|
|
|
|
if (transactions.length === 0 && !someSelected) {
|
|
return (
|
|
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
|
No transactions found.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const colSpan = showAccount ? 8 : 7
|
|
|
|
return (
|
|
<>
|
|
{/* Bulk action bar */}
|
|
{someSelected && (
|
|
<div className="flex items-center gap-3 rounded-md border bg-muted/50 px-4 py-2 mb-2">
|
|
<span className="text-sm font-medium">{selected.size} selected</span>
|
|
<div className="flex items-center gap-2 ml-auto">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => { setNotesValue(''); setBulkDialog('notes') }}
|
|
>
|
|
<StickyNote className="h-3.5 w-3.5 mr-1.5" />Notes
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => { setBudgetTarget('__none__'); setBulkDialog('budget') }}
|
|
>
|
|
<Tag className="h-3.5 w-3.5 mr-1.5" />Budget
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setBulkDialog('transfer')}
|
|
>
|
|
<ArrowLeftRight className="h-3.5 w-3.5 mr-1.5" />Transfer
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => setBulkDialog('delete')}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 mr-1.5" />Delete
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={clearSelection}>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="rounded-md border overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-8 px-3">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border"
|
|
checked={allSelected}
|
|
onChange={toggleAll}
|
|
aria-label="Select all"
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="w-28">Date</TableHead>
|
|
<TableHead>Description</TableHead>
|
|
{showAccount && <TableHead className="w-36">Account</TableHead>}
|
|
<TableHead className="w-32 text-right">Amount</TableHead>
|
|
<TableHead className="w-32">Category</TableHead>
|
|
<TableHead className="w-32">Budget</TableHead>
|
|
<TableHead className="w-10" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{transactions.map((tx) => {
|
|
const isCredit = tx.type === 'CREDIT'
|
|
const isTransfer = tx.type === 'TRANSFER'
|
|
const isSelected = selected.has(tx.id)
|
|
const dateStr = new Date(tx.date).toLocaleDateString('en-US', {
|
|
month: 'short', day: 'numeric', year: 'numeric',
|
|
})
|
|
return (
|
|
<TableRow key={tx.id} className={isSelected ? 'bg-muted/40' : undefined}>
|
|
<TableCell className="px-3">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border"
|
|
checked={isSelected}
|
|
onChange={() => toggleOne(tx.id)}
|
|
aria-label="Select transaction"
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
|
{dateStr}
|
|
</TableCell>
|
|
<TableCell className="max-w-xs">
|
|
<div className="truncate text-sm">{tx.description}</div>
|
|
{tx.notes && (
|
|
<div className="truncate text-xs text-muted-foreground">{tx.notes}</div>
|
|
)}
|
|
</TableCell>
|
|
{showAccount && (
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{tx.account.name}
|
|
</TableCell>
|
|
)}
|
|
<TableCell className="text-right tabular-nums font-medium">
|
|
<span className={cn('text-sm', isTransfer ? 'text-muted-foreground' : isCredit ? 'text-green-600' : '')}>
|
|
{isTransfer ? '⇄ ' : isCredit ? '+' : '-'}{formatCents(tx.amountCents)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{tx.category ?? '—'}
|
|
</TableCell>
|
|
<TableCell>
|
|
{tx.budget ? (
|
|
<span className="flex items-center gap-1.5 text-sm">
|
|
{tx.budget.color && (
|
|
<span
|
|
className="inline-block h-2 w-2 rounded-full shrink-0"
|
|
style={{ backgroundColor: tx.budget.color }}
|
|
/>
|
|
)}
|
|
{tx.budget.name}
|
|
</span>
|
|
) : (
|
|
<span className="text-sm text-muted-foreground">—</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<button
|
|
onClick={() => setEditing(tx)}
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="sr-only">Edit</span>
|
|
</button>
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<div className="flex items-center justify-between py-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
{total.toLocaleString()} transaction{total !== 1 ? 's' : ''}
|
|
{totalPages > 1 && ` · page ${page} of ${totalPages}`}
|
|
</p>
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center gap-1">
|
|
{page > 1 ? (
|
|
<Link
|
|
href={pageHref(page - 1)}
|
|
className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm font-medium hover:bg-accent transition-colors"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />Prev
|
|
</Link>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm font-medium opacity-50 cursor-not-allowed">
|
|
<ChevronLeft className="h-4 w-4" />Prev
|
|
</span>
|
|
)}
|
|
{page < totalPages ? (
|
|
<Link
|
|
href={pageHref(page + 1)}
|
|
className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm font-medium hover:bg-accent transition-colors"
|
|
>
|
|
Next<ChevronRight className="h-4 w-4" />
|
|
</Link>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm font-medium opacity-50 cursor-not-allowed">
|
|
Next<ChevronRight className="h-4 w-4" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<EditTransactionDialog
|
|
transaction={editing}
|
|
onOpenChange={(open) => { if (!open) setEditing(null) }}
|
|
budgets={budgets}
|
|
/>
|
|
|
|
{/* Delete confirm dialog */}
|
|
<Dialog open={bulkDialog === 'delete'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>Delete {selected.size} transaction{selected.size !== 1 ? 's' : ''}?</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground">This cannot be undone.</p>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
|
|
<Button variant="destructive" onClick={handleDelete} disabled={working}>
|
|
{working ? 'Deleting…' : 'Delete'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Assign budget dialog */}
|
|
<Dialog open={bulkDialog === 'budget'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>Assign budget</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground">
|
|
Apply to {selected.size} selected transaction{selected.size !== 1 ? 's' : ''}.
|
|
</p>
|
|
<Select value={budgetTarget} onValueChange={(v) => setBudgetTarget(v ?? '__none__')}>
|
|
<SelectTrigger>
|
|
<SelectValue>
|
|
{budgetTarget === '__none__'
|
|
? 'No budget'
|
|
: (budgets.find((b) => b.id === budgetTarget)?.name ?? 'No budget')}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">No budget</SelectItem>
|
|
{budgets.map((b) => (
|
|
<SelectItem key={b.id} value={b.id}>
|
|
{b.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
|
|
<Button onClick={handleAssignBudget} disabled={working}>
|
|
{working ? 'Saving…' : 'Apply'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Mark as transfer dialog */}
|
|
<Dialog open={bulkDialog === 'transfer'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>Mark as transfer?</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground">
|
|
{selected.size} transaction{selected.size !== 1 ? 's' : ''} will be marked as transfers
|
|
and excluded from cash flow calculations.
|
|
</p>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
|
|
<Button onClick={handleMarkTransfer} disabled={working}>
|
|
{working ? 'Saving…' : 'Mark as transfer'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Add notes dialog */}
|
|
<Dialog open={bulkDialog === 'notes'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>Set notes</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground">
|
|
Apply to {selected.size} selected transaction{selected.size !== 1 ? 's' : ''}. Leave blank to clear.
|
|
</p>
|
|
<Textarea
|
|
placeholder="Add a note…"
|
|
value={notesValue}
|
|
onChange={(e) => setNotesValue(e.target.value)}
|
|
maxLength={500}
|
|
rows={3}
|
|
/>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
|
|
<Button onClick={handleAddNotes} disabled={working}>
|
|
{working ? 'Saving…' : 'Apply'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|