From f4216815e8e1193cde45194999cdf2cd2b177884 Mon Sep 17 00:00:00 2001 From: jerick Date: Mon, 20 Apr 2026 23:17:56 -0400 Subject: [PATCH] added functionality for multi-edit of transactions --- src/app/api/transactions/bulk/route.ts | 68 ++++++ .../transactions/TransactionTable.tsx | 208 +++++++++++++++++- 2 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 src/app/api/transactions/bulk/route.ts diff --git a/src/app/api/transactions/bulk/route.ts b/src/app/api/transactions/bulk/route.ts new file mode 100644 index 0000000..5257809 --- /dev/null +++ b/src/app/api/transactions/bulk/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +const bulkSchema = z.discriminatedUnion('action', [ + z.object({ + action: z.literal('delete'), + ids: z.array(z.string()).min(1), + }), + z.object({ + action: z.literal('assignBudget'), + ids: z.array(z.string()).min(1), + budgetId: z.string().nullable(), + }), + z.object({ + action: z.literal('addNotes'), + ids: z.array(z.string()).min(1), + notes: z.string().max(500), + }), +]) + +export async function POST(req: Request) { + const session = await auth() + if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body = await req.json() + const result = bulkSchema.safeParse(body) + if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 }) + + const { action, ids } = result.data + const userId = session.user.id + + // Verify all transaction IDs belong to this user + const owned = await prisma.transaction.findMany({ + where: { id: { in: ids }, account: { userId } }, + select: { id: true }, + }) + if (owned.length !== ids.length) { + return NextResponse.json({ error: 'One or more transactions not found' }, { status: 404 }) + } + + if (action === 'delete') { + await prisma.transaction.deleteMany({ where: { id: { in: ids } } }) + return NextResponse.json({ deleted: ids.length }) + } + + if (action === 'assignBudget') { + const { budgetId } = result.data + if (budgetId !== null) { + const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId } }) + if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 }) + } + await prisma.transaction.updateMany({ + where: { id: { in: ids } }, + data: { budgetId }, + }) + return NextResponse.json({ updated: ids.length }) + } + + // addNotes + const { notes } = result.data + await prisma.transaction.updateMany({ + where: { id: { in: ids } }, + data: { notes: notes || null }, + }) + return NextResponse.json({ updated: ids.length }) +} diff --git a/src/components/transactions/TransactionTable.tsx b/src/components/transactions/TransactionTable.tsx index 29facee..1297c03 100644 --- a/src/components/transactions/TransactionTable.tsx +++ b/src/components/transactions/TransactionTable.tsx @@ -2,11 +2,15 @@ import { useState } from 'react' import Link from 'next/link' -import { usePathname, useSearchParams } from 'next/navigation' +import { usePathname, useSearchParams, useRouter } from 'next/navigation' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' -import { Pencil, ChevronLeft, ChevronRight } from 'lucide-react' +import { Pencil, ChevronLeft, ChevronRight, Trash2, Tag, StickyNote, X } 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' @@ -46,16 +50,82 @@ export function TransactionTable({ }: Props) { const pathname = usePathname() const searchParams = useSearchParams() + const router = useRouter() const [editing, setEditing] = useState(null) + const [selected, setSelected] = useState>(new Set()) + const [bulkDialog, setBulkDialog] = useState<'delete' | 'budget' | 'notes' | null>(null) + const [budgetTarget, setBudgetTarget] = useState('__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()}` } - if (transactions.length === 0) { + 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 }) + } + + if (transactions.length === 0 && !someSelected) { return (
No transactions found. @@ -63,12 +133,56 @@ export function TransactionTable({ ) } + const colSpan = showAccount ? 8 : 7 + return ( <> + {/* Bulk action bar */} + {someSelected && ( +
+ {selected.size} selected +
+ + + + +
+
+ )} +
+ + + Date Description {showAccount && Account} @@ -81,11 +195,21 @@ export function TransactionTable({ {transactions.map((tx) => { const isCredit = tx.type === 'CREDIT' + const isSelected = selected.has(tx.id) const dateStr = new Date(tx.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }) return ( - + + + toggleOne(tx.id)} + aria-label="Select transaction" + /> + {dateStr} @@ -180,6 +304,82 @@ export function TransactionTable({ onOpenChange={(open) => { if (!open) setEditing(null) }} budgets={budgets} /> + + {/* Delete confirm dialog */} + { if (!o) setBulkDialog(null) }}> + + + Delete {selected.size} transaction{selected.size !== 1 ? 's' : ''}? + +

This cannot be undone.

+ + + + +
+
+ + {/* Assign budget dialog */} + { if (!o) setBulkDialog(null) }}> + + + Assign budget + +

+ Apply to {selected.size} selected transaction{selected.size !== 1 ? 's' : ''}. +

+ + + + + +
+
+ + {/* Add notes dialog */} + { if (!o) setBulkDialog(null) }}> + + + Set notes + +

+ Apply to {selected.size} selected transaction{selected.size !== 1 ? 's' : ''}. Leave blank to clear. +

+