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:
2026-04-21 20:15:07 -04:00
parent 34bf24b35d
commit 6f1376cc53
11 changed files with 267 additions and 19 deletions

View File

@@ -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">