added functionality for multi-edit of transactions
This commit is contained in:
68
src/app/api/transactions/bulk/route.ts
Normal file
68
src/app/api/transactions/bulk/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname, useSearchParams } from 'next/navigation'
|
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 } 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 { formatCents } from '@/lib/utils/currency'
|
||||||
import { EditTransactionDialog } from './EditTransactionDialog'
|
import { EditTransactionDialog } from './EditTransactionDialog'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -46,16 +50,82 @@ export function TransactionTable({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
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 [bulkDialog, setBulkDialog] = useState<'delete' | 'budget' | 'notes' | null>(null)
|
||||||
|
const [budgetTarget, setBudgetTarget] = useState<string>('__none__')
|
||||||
|
const [notesValue, setNotesValue] = useState('')
|
||||||
|
const [working, setWorking] = useState(false)
|
||||||
const totalPages = Math.ceil(total / limit)
|
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) {
|
function pageHref(p: number) {
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
params.set('page', String(p))
|
params.set('page', String(p))
|
||||||
return `${pathname}?${params.toString()}`
|
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 (
|
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">
|
||||||
No transactions found.
|
No transactions found.
|
||||||
@@ -63,12 +133,56 @@ export function TransactionTable({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const colSpan = showAccount ? 8 : 7
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Bulk action bar */}
|
||||||
|
{someSelected && (
|
||||||
|
<div className="flex items-center gap-3 rounded-md border bg-muted/50 px-4 py-2 mb-2">
|
||||||
|
<span className="text-sm font-medium">{selected.size} selected</span>
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => { setNotesValue(''); setBulkDialog('notes') }}
|
||||||
|
>
|
||||||
|
<StickyNote className="h-3.5 w-3.5 mr-1.5" />Notes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => { setBudgetTarget('__none__'); setBulkDialog('budget') }}
|
||||||
|
>
|
||||||
|
<Tag className="h-3.5 w-3.5 mr-1.5" />Budget
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setBulkDialog('delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-1.5" />Delete
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={clearSelection}>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="rounded-md border overflow-hidden">
|
<div className="rounded-md border overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead className="w-8 px-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={toggleAll}
|
||||||
|
aria-label="Select all"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead className="w-28">Date</TableHead>
|
<TableHead className="w-28">Date</TableHead>
|
||||||
<TableHead>Description</TableHead>
|
<TableHead>Description</TableHead>
|
||||||
{showAccount && <TableHead className="w-36">Account</TableHead>}
|
{showAccount && <TableHead className="w-36">Account</TableHead>}
|
||||||
@@ -81,11 +195,21 @@ export function TransactionTable({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{transactions.map((tx) => {
|
{transactions.map((tx) => {
|
||||||
const isCredit = tx.type === 'CREDIT'
|
const isCredit = tx.type === 'CREDIT'
|
||||||
|
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',
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<TableRow key={tx.id}>
|
<TableRow key={tx.id} className={isSelected ? 'bg-muted/40' : undefined}>
|
||||||
|
<TableCell className="px-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleOne(tx.id)}
|
||||||
|
aria-label="Select transaction"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{dateStr}
|
{dateStr}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -180,6 +304,82 @@ export function TransactionTable({
|
|||||||
onOpenChange={(open) => { if (!open) setEditing(null) }}
|
onOpenChange={(open) => { if (!open) setEditing(null) }}
|
||||||
budgets={budgets}
|
budgets={budgets}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirm dialog */}
|
||||||
|
<Dialog open={bulkDialog === 'delete'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete {selected.size} transaction{selected.size !== 1 ? 's' : ''}?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">This cannot be undone.</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={working}>
|
||||||
|
{working ? 'Deleting…' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Assign budget dialog */}
|
||||||
|
<Dialog open={bulkDialog === 'budget'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Assign budget</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Apply to {selected.size} selected transaction{selected.size !== 1 ? 's' : ''}.
|
||||||
|
</p>
|
||||||
|
<Select value={budgetTarget} onValueChange={setBudgetTarget}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue>
|
||||||
|
{budgetTarget === '__none__'
|
||||||
|
? 'No budget'
|
||||||
|
: (budgets.find((b) => b.id === budgetTarget)?.name ?? 'No budget')}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">No budget</SelectItem>
|
||||||
|
{budgets.map((b) => (
|
||||||
|
<SelectItem key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
|
||||||
|
<Button onClick={handleAssignBudget} disabled={working}>
|
||||||
|
{working ? 'Saving…' : 'Apply'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Add notes dialog */}
|
||||||
|
<Dialog open={bulkDialog === 'notes'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Set notes</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Apply to {selected.size} selected transaction{selected.size !== 1 ? 's' : ''}. Leave blank to clear.
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add a note…"
|
||||||
|
value={notesValue}
|
||||||
|
onChange={(e) => setNotesValue(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
|
||||||
|
<Button onClick={handleAddNotes} disabled={working}>
|
||||||
|
{working ? 'Saving…' : 'Apply'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user