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:
@@ -13,9 +13,10 @@ model User {
|
|||||||
passwordHash String
|
passwordHash String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
budgets Budget[]
|
budgets Budget[]
|
||||||
budgetRules BudgetRule[]
|
budgetRules BudgetRule[]
|
||||||
|
transferRules TransferRule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AccountType {
|
enum AccountType {
|
||||||
@@ -45,6 +46,7 @@ model Account {
|
|||||||
enum TransactionType {
|
enum TransactionType {
|
||||||
DEBIT
|
DEBIT
|
||||||
CREDIT
|
CREDIT
|
||||||
|
TRANSFER
|
||||||
}
|
}
|
||||||
|
|
||||||
model Transaction {
|
model Transaction {
|
||||||
@@ -100,6 +102,16 @@ model BudgetRule {
|
|||||||
@@index([budgetId])
|
@@index([budgetId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model TransferRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
pattern String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
model CsvUpload {
|
model CsvUpload {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
accountId String
|
accountId String
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
|
|||||||
JOIN "Account" a ON t."accountId" = a.id
|
JOIN "Account" a ON t."accountId" = a.id
|
||||||
WHERE a."userId" = ${userId}
|
WHERE a."userId" = ${userId}
|
||||||
AND a.type = 'BANK'
|
AND a.type = 'BANK'
|
||||||
|
AND t.type != 'TRANSFER'
|
||||||
AND t.date >= ${start}
|
AND t.date >= ${start}
|
||||||
AND t.date <= ${end}
|
AND t.date <= ${end}
|
||||||
GROUP BY t.type
|
GROUP BY t.type
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { Prisma } from '@/generated/prisma/client'
|
import { Prisma } from '@/generated/prisma/client'
|
||||||
import { TransactionFilters } from '@/components/transactions/TransactionFilters'
|
import { TransactionFilters } from '@/components/transactions/TransactionFilters'
|
||||||
import { TransactionTable } from '@/components/transactions/TransactionTable'
|
import { TransactionTable } from '@/components/transactions/TransactionTable'
|
||||||
|
import { TransferRulesButton } from '@/components/transactions/TransferRulesButton'
|
||||||
|
|
||||||
const PAGE_LIMIT = 50
|
const PAGE_LIMIT = 50
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const [transactions, total, accounts, budgets] = await Promise.all([
|
const [transactions, total, accounts, budgets, transferRules] = await Promise.all([
|
||||||
prisma.transaction.findMany({
|
prisma.transaction.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
@@ -57,6 +58,11 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
|
|||||||
select: { id: true, name: true, color: true },
|
select: { id: true, name: true, color: true },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
}),
|
}),
|
||||||
|
prisma.transferRule.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true, pattern: true },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Serialize dates for client components
|
// Serialize dates for client components
|
||||||
@@ -69,7 +75,10 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h1 className="text-2xl font-bold mb-4">Transactions</h1>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h1 className="text-2xl font-bold">Transactions</h1>
|
||||||
|
<TransferRulesButton rules={transferRules} />
|
||||||
|
</div>
|
||||||
<TransactionFilters accounts={accounts} />
|
<TransactionFilters accounts={accounts} />
|
||||||
<TransactionTable
|
<TransactionTable
|
||||||
transactions={rows}
|
transactions={rows}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const bulkSchema = z.discriminatedUnion('action', [
|
|||||||
ids: z.array(z.string()).min(1),
|
ids: z.array(z.string()).min(1),
|
||||||
notes: z.string().max(500),
|
notes: z.string().max(500),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
action: z.literal('markTransfer'),
|
||||||
|
ids: z.array(z.string()).min(1),
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
async function recomputeAccount(accountId: string) {
|
async function recomputeAccount(accountId: string) {
|
||||||
@@ -110,11 +114,19 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ updated: ids.length })
|
return NextResponse.json({ updated: ids.length })
|
||||||
}
|
}
|
||||||
|
|
||||||
// addNotes
|
if (action === 'addNotes') {
|
||||||
const { notes } = result.data
|
const { notes } = result.data
|
||||||
|
await prisma.transaction.updateMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
data: { notes: notes || null },
|
||||||
|
})
|
||||||
|
return NextResponse.json({ updated: ids.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
// markTransfer
|
||||||
await prisma.transaction.updateMany({
|
await prisma.transaction.updateMany({
|
||||||
where: { id: { in: ids } },
|
where: { id: { in: ids } },
|
||||||
data: { notes: notes || null },
|
data: { type: 'TRANSFER' },
|
||||||
})
|
})
|
||||||
return NextResponse.json({ updated: ids.length })
|
return NextResponse.json({ updated: ids.length })
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/app/api/transfer-rules/[id]/route.ts
Normal file
19
src/app/api/transfer-rules/[id]/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> }
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, { params }: Params) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const rule = await prisma.transferRule.findFirst({
|
||||||
|
where: { id, userId: session.user.id },
|
||||||
|
})
|
||||||
|
if (!rule) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
await prisma.transferRule.delete({ where: { id } })
|
||||||
|
return new NextResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
34
src/app/api/transfer-rules/route.ts
Normal file
34
src/app/api/transfer-rules/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
pattern: z.string().min(1).max(200).trim(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const rules = await prisma.transferRule.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true, pattern: true },
|
||||||
|
})
|
||||||
|
return NextResponse.json(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const result = schema.safeParse(await req.json())
|
||||||
|
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
const rule = await prisma.transferRule.create({
|
||||||
|
data: { userId: session.user.id, pattern: result.data.pattern },
|
||||||
|
select: { id: true, pattern: true },
|
||||||
|
})
|
||||||
|
return NextResponse.json(rule, { status: 201 })
|
||||||
|
}
|
||||||
@@ -76,14 +76,26 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const normalized = normalizeRows(allRows, accountId, config)
|
const normalized = normalizeRows(allRows, accountId, config)
|
||||||
|
|
||||||
// Apply budget auto-assign rules (first match wins)
|
// Apply transfer rules first, then budget rules (transfer takes priority)
|
||||||
const budgetRules = await prisma.budgetRule.findMany({
|
const [budgetRules, transferRules] = await Promise.all([
|
||||||
where: { userId: session.user.id },
|
prisma.budgetRule.findMany({
|
||||||
orderBy: { createdAt: 'asc' },
|
where: { userId: session.user.id },
|
||||||
select: { pattern: true, budgetId: true },
|
orderBy: { createdAt: 'asc' },
|
||||||
})
|
select: { pattern: true, budgetId: true },
|
||||||
|
}),
|
||||||
|
prisma.transferRule.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { pattern: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
const rowsWithBudgets = normalized.map((row) => {
|
const rowsWithBudgets = normalized.map((row) => {
|
||||||
const desc = row.description.toLowerCase()
|
const desc = row.description.toLowerCase()
|
||||||
|
for (const rule of transferRules) {
|
||||||
|
if (desc.includes(rule.pattern.toLowerCase())) {
|
||||||
|
return { ...row, type: 'TRANSFER' as const, budgetId: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const rule of budgetRules) {
|
for (const rule of budgetRules) {
|
||||||
if (desc.includes(rule.pattern.toLowerCase())) {
|
if (desc.includes(rule.pattern.toLowerCase())) {
|
||||||
return { ...row, budgetId: rule.budgetId }
|
return { ...row, budgetId: rule.budgetId }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { usePathname, useSearchParams, useRouter } from 'next/navigation'
|
|||||||
import {
|
import {
|
||||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
} from '@/components/ui/table'
|
} 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 { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
@@ -53,7 +53,7 @@ export function TransactionTable({
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [editing, setEditing] = useState<TransactionRow | null>(null)
|
const [editing, setEditing] = useState<TransactionRow | null>(null)
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
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 [budgetTarget, setBudgetTarget] = useState<string>('__none__')
|
||||||
const [notesValue, setNotesValue] = useState('')
|
const [notesValue, setNotesValue] = useState('')
|
||||||
const [working, setWorking] = useState(false)
|
const [working, setWorking] = useState(false)
|
||||||
@@ -125,6 +125,10 @@ export function TransactionTable({
|
|||||||
await bulkPost({ action: 'addNotes', ids: [...selected], notes: notesValue })
|
await bulkPost({ action: 'addNotes', ids: [...selected], notes: notesValue })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleMarkTransfer() {
|
||||||
|
await bulkPost({ action: 'markTransfer', ids: [...selected] })
|
||||||
|
}
|
||||||
|
|
||||||
if (transactions.length === 0 && !someSelected) {
|
if (transactions.length === 0 && !someSelected) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
<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
|
<Tag className="h-3.5 w-3.5 mr-1.5" />Budget
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setBulkDialog('transfer')}
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="h-3.5 w-3.5 mr-1.5" />Transfer
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -195,6 +206,7 @@ export function TransactionTable({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{transactions.map((tx) => {
|
{transactions.map((tx) => {
|
||||||
const isCredit = tx.type === 'CREDIT'
|
const isCredit = tx.type === 'CREDIT'
|
||||||
|
const isTransfer = tx.type === 'TRANSFER'
|
||||||
const isSelected = selected.has(tx.id)
|
const isSelected = selected.has(tx.id)
|
||||||
const dateStr = new Date(tx.date).toLocaleDateString('en-US', {
|
const dateStr = new Date(tx.date).toLocaleDateString('en-US', {
|
||||||
month: 'short', day: 'numeric', year: 'numeric',
|
month: 'short', day: 'numeric', year: 'numeric',
|
||||||
@@ -225,8 +237,8 @@ export function TransactionTable({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
<TableCell className="text-right tabular-nums font-medium">
|
<TableCell className="text-right tabular-nums font-medium">
|
||||||
<span className={cn('text-sm', isCredit ? 'text-green-600' : '')}>
|
<span className={cn('text-sm', isTransfer ? 'text-muted-foreground' : isCredit ? 'text-green-600' : '')}>
|
||||||
{isCredit ? '+' : '-'}{formatCents(tx.amountCents)}
|
{isTransfer ? '⇄ ' : isCredit ? '+' : '-'}{formatCents(tx.amountCents)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
@@ -356,6 +368,25 @@ export function TransactionTable({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Add notes dialog */}
|
||||||
<Dialog open={bulkDialog === 'notes'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
<Dialog open={bulkDialog === 'notes'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
||||||
<DialogContent className="max-w-sm">
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ export const transactionQuerySchema = z.object({
|
|||||||
accountId: z.string().optional(),
|
accountId: z.string().optional(),
|
||||||
dateFrom: z.string().optional(),
|
dateFrom: z.string().optional(),
|
||||||
dateTo: z.string().optional(),
|
dateTo: z.string().optional(),
|
||||||
type: z.enum(['DEBIT', 'CREDIT']).optional(),
|
type: z.enum(['DEBIT', 'CREDIT', 'TRANSFER']).optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
budgetId: z.string().optional(),
|
budgetId: z.string().optional(),
|
||||||
page: z.coerce.number().min(1).default(1),
|
page: z.coerce.number().min(1).default(1),
|
||||||
|
|||||||
Reference in New Issue
Block a user