Add TRANSFER transaction type with bulk action and auto-rules
- 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>
This commit is contained in:
@@ -6,7 +6,7 @@ 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 } from 'lucide-react'
|
||||
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'
|
||||
@@ -53,7 +53,7 @@ export function TransactionTable({
|
||||
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' | null>(null)
|
||||
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)
|
||||
@@ -125,6 +125,10 @@ export function TransactionTable({
|
||||
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">
|
||||
@@ -156,6 +160,13 @@ export function TransactionTable({
|
||||
>
|
||||
<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"
|
||||
@@ -195,6 +206,7 @@ export function TransactionTable({
|
||||
<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',
|
||||
@@ -225,8 +237,8 @@ export function TransactionTable({
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-right tabular-nums font-medium">
|
||||
<span className={cn('text-sm', isCredit ? 'text-green-600' : '')}>
|
||||
{isCredit ? '+' : '-'}{formatCents(tx.amountCents)}
|
||||
<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">
|
||||
@@ -356,6 +368,25 @@ export function TransactionTable({
|
||||
</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">
|
||||
|
||||
24
src/components/transactions/TransferRulesButton.tsx
Normal file
24
src/components/transactions/TransferRulesButton.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeftRight } from 'lucide-react'
|
||||
import { TransferRulesDialog } from './TransferRulesDialog'
|
||||
|
||||
interface Rule {
|
||||
id: string
|
||||
pattern: string
|
||||
}
|
||||
|
||||
export function TransferRulesButton({ rules }: { rules: Rule[] }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<ArrowLeftRight className="h-4 w-4 mr-2" />
|
||||
Transfer rules
|
||||
</Button>
|
||||
<TransferRulesDialog open={open} onOpenChange={setOpen} rules={rules} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
94
src/components/transactions/TransferRulesDialog.tsx
Normal file
94
src/components/transactions/TransferRulesDialog.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Trash2, Plus } from 'lucide-react'
|
||||
|
||||
interface Rule {
|
||||
id: string
|
||||
pattern: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
rules: Rule[]
|
||||
}
|
||||
|
||||
export function TransferRulesDialog({ open, onOpenChange, rules }: Props) {
|
||||
const router = useRouter()
|
||||
const [pattern, setPattern] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
async function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!pattern.trim()) return
|
||||
setAdding(true)
|
||||
await fetch('/api/transfer-rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pattern: pattern.trim() }),
|
||||
})
|
||||
setPattern('')
|
||||
setAdding(false)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
await fetch(`/api/transfer-rules/${id}`, { method: 'DELETE' })
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Transfer rules</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Transactions whose description matches a pattern (case-insensitive) are automatically
|
||||
marked as transfers on upload and excluded from cash flow.
|
||||
</p>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No rules yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{rules.map((rule) => (
|
||||
<li
|
||||
key={rule.id}
|
||||
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-mono truncate">{rule.pattern}</span>
|
||||
<button
|
||||
onClick={() => handleDelete(rule.id)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Delete rule"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAdd} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="e.g. Fidelity Transfer, Schwab"
|
||||
value={pattern}
|
||||
onChange={(e) => setPattern(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" disabled={adding || !pattern.trim()} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user