diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 485d393..d3516cd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,9 +13,10 @@ model User { passwordHash String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - accounts Account[] - budgets Budget[] - budgetRules BudgetRule[] + accounts Account[] + budgets Budget[] + budgetRules BudgetRule[] + transferRules TransferRule[] } enum AccountType { @@ -45,6 +46,7 @@ model Account { enum TransactionType { DEBIT CREDIT + TRANSFER } model Transaction { @@ -100,6 +102,16 @@ model BudgetRule { @@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 { id String @id @default(cuid()) accountId String diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index ed91244..b606d72 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -32,6 +32,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se JOIN "Account" a ON t."accountId" = a.id WHERE a."userId" = ${userId} AND a.type = 'BANK' + AND t.type != 'TRANSFER' AND t.date >= ${start} AND t.date <= ${end} GROUP BY t.type diff --git a/src/app/(app)/transactions/page.tsx b/src/app/(app)/transactions/page.tsx index db10df0..0e5385b 100644 --- a/src/app/(app)/transactions/page.tsx +++ b/src/app/(app)/transactions/page.tsx @@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma' import { Prisma } from '@/generated/prisma/client' import { TransactionFilters } from '@/components/transactions/TransactionFilters' import { TransactionTable } from '@/components/transactions/TransactionTable' +import { TransferRulesButton } from '@/components/transactions/TransferRulesButton' 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({ where, include: { @@ -57,6 +58,11 @@ export default async function TransactionsPage({ searchParams }: { searchParams: select: { id: true, name: true, color: true }, orderBy: { name: 'asc' }, }), + prisma.transferRule.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: 'asc' }, + select: { id: true, pattern: true }, + }), ]) // Serialize dates for client components @@ -69,7 +75,10 @@ export default async function TransactionsPage({ searchParams }: { searchParams: return (
-

Transactions

+
+

Transactions

+ +
} + +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 }) +} diff --git a/src/app/api/transfer-rules/route.ts b/src/app/api/transfer-rules/route.ts new file mode 100644 index 0000000..baa42bb --- /dev/null +++ b/src/app/api/transfer-rules/route.ts @@ -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 }) +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index aa49955..23913ba 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -76,14 +76,26 @@ export async function POST(req: Request) { const normalized = normalizeRows(allRows, accountId, config) - // Apply budget auto-assign rules (first match wins) - const budgetRules = await prisma.budgetRule.findMany({ - where: { userId: session.user.id }, - orderBy: { createdAt: 'asc' }, - select: { pattern: true, budgetId: true }, - }) + // Apply transfer rules first, then budget rules (transfer takes priority) + const [budgetRules, transferRules] = await Promise.all([ + prisma.budgetRule.findMany({ + where: { userId: session.user.id }, + 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 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) { if (desc.includes(rule.pattern.toLowerCase())) { return { ...row, budgetId: rule.budgetId } diff --git a/src/components/transactions/TransactionTable.tsx b/src/components/transactions/TransactionTable.tsx index 6d10ad3..b7841e7 100644 --- a/src/components/transactions/TransactionTable.tsx +++ b/src/components/transactions/TransactionTable.tsx @@ -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(null) const [selected, setSelected] = useState>(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('__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 (
@@ -156,6 +160,13 @@ export function TransactionTable({ > Budget + + + + + + {/* Add notes dialog */} { if (!o) setBulkDialog(null) }}> diff --git a/src/components/transactions/TransferRulesButton.tsx b/src/components/transactions/TransferRulesButton.tsx new file mode 100644 index 0000000..be94d2c --- /dev/null +++ b/src/components/transactions/TransferRulesButton.tsx @@ -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 ( + <> + + + + ) +} diff --git a/src/components/transactions/TransferRulesDialog.tsx b/src/components/transactions/TransferRulesDialog.tsx new file mode 100644 index 0000000..b67f04d --- /dev/null +++ b/src/components/transactions/TransferRulesDialog.tsx @@ -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 ( + + + + Transfer rules + + +

+ Transactions whose description matches a pattern (case-insensitive) are automatically + marked as transfers on upload and excluded from cash flow. +

+ + {rules.length === 0 ? ( +

No rules yet.

+ ) : ( +
    + {rules.map((rule) => ( +
  • + {rule.pattern} + +
  • + ))} +
+ )} + +
+ setPattern(e.target.value)} + className="flex-1" + /> + +
+
+
+ ) +} diff --git a/src/lib/validations/transaction.ts b/src/lib/validations/transaction.ts index 67ce5db..a933955 100644 --- a/src/lib/validations/transaction.ts +++ b/src/lib/validations/transaction.ts @@ -4,7 +4,7 @@ export const transactionQuerySchema = z.object({ accountId: z.string().optional(), dateFrom: z.string().optional(), dateTo: z.string().optional(), - type: z.enum(['DEBIT', 'CREDIT']).optional(), + type: z.enum(['DEBIT', 'CREDIT', 'TRANSFER']).optional(), search: z.string().optional(), budgetId: z.string().optional(), page: z.coerce.number().min(1).default(1),