From e8c23405e79cecc8951d0bb248a5fd240cd57120 Mon Sep 17 00:00:00 2001 From: Vijayakanth Manoharan Date: Mon, 23 Mar 2026 17:20:13 -0400 Subject: [PATCH] Add expense editing with inline form and PATCH API route - Add updateExpense() to lib/expenses.ts - Add PATCH /expenses/:id route with validation and P2025 not-found handling - Edit button on each expense card pre-fills form; cancel restores add mode - Submit dynamically PATCHes or POSTs depending on edit state Co-Authored-By: Claude Sonnet 4.6 --- src/app/expenses/[id]/route.ts | 34 ++++++++++- src/components/expense-workspace.tsx | 90 +++++++++++++++++++++++----- src/lib/expenses.ts | 15 +++++ 3 files changed, 123 insertions(+), 16 deletions(-) diff --git a/src/app/expenses/[id]/route.ts b/src/app/expenses/[id]/route.ts index 24cff41..3fe2bd0 100644 --- a/src/app/expenses/[id]/route.ts +++ b/src/app/expenses/[id]/route.ts @@ -1,12 +1,44 @@ import { Prisma } from "@prisma/client"; import { NextResponse } from "next/server"; -import { removeExpense } from "@/lib/expenses"; +import { removeExpense, updateExpense } from "@/lib/expenses"; +import { expenseInputSchema } from "@/lib/validation"; type RouteContext = { params: Promise<{ id: string }>; }; +export async function PATCH(request: Request, context: RouteContext) { + const { id } = await context.params; + const payload = await request.json(); + const parsed = expenseInputSchema.safeParse(payload); + + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? "Invalid expense payload." }, + { status: 400 }, + ); + } + + try { + const expense = await updateExpense(id, { + title: parsed.data.title, + amountCents: parsed.data.amount, + date: parsed.data.date, + category: parsed.data.category, + }); + return NextResponse.json({ expense }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2025" + ) { + return NextResponse.json({ error: "Expense not found." }, { status: 404 }); + } + throw error; + } +} + export async function DELETE(_: Request, context: RouteContext) { const { id } = await context.params; diff --git a/src/components/expense-workspace.tsx b/src/components/expense-workspace.tsx index 0a7856d..246d23f 100644 --- a/src/components/expense-workspace.tsx +++ b/src/components/expense-workspace.tsx @@ -43,6 +43,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) { date: new Date().toISOString().slice(0, 10), category: (categoryOptions[0]?.value as CategoryValue | undefined) ?? "MISC", }); + const [editingId, setEditingId] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [suggestionMessage, setSuggestionMessage] = useState(null); @@ -96,6 +97,34 @@ export function ExpenseWorkspace({ categoryOptions }: Props) { setNeedsSuggestionConfirmation(suggestion.requiresConfirmation); } + function handleEdit(expense: ExpenseRecord) { + setEditingId(expense.id); + setFormState({ + title: expense.title, + amount: (expense.amountCents / 100).toFixed(2), + date: expense.date, + category: expense.category, + }); + setSuggestionMessage(null); + setNeedsSuggestionConfirmation(false); + setLastSuggestedMerchant(""); + setError(null); + } + + function handleCancelEdit() { + setEditingId(null); + setFormState({ + title: "", + amount: "", + date: new Date().toISOString().slice(0, 10), + category: (categoryOptions[0]?.value as CategoryValue | undefined) ?? "MISC", + }); + setSuggestionMessage(null); + setNeedsSuggestionConfirmation(false); + setLastSuggestedMerchant(""); + setError(null); + } + async function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -107,8 +136,9 @@ export function ExpenseWorkspace({ categoryOptions }: Props) { setBusy(true); setError(null); - const response = await fetch("/expenses", { - method: "POST", + const isEditing = editingId !== null; + const response = await fetch(isEditing ? `/expenses/${editingId}` : "/expenses", { + method: isEditing ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(formState), }); @@ -117,12 +147,20 @@ export function ExpenseWorkspace({ categoryOptions }: Props) { if (!response.ok) { const payload = (await response.json().catch(() => null)) as { error?: string } | null; - setError(payload?.error ?? "Could not save the expense."); + setError(payload?.error ?? (isEditing ? "Could not update the expense." : "Could not save the expense.")); return; } const payload = (await response.json()) as { expense: ExpenseRecord }; - setExpenses((current) => [payload.expense, ...current]); + + if (isEditing) { + setExpenses((current) => + current.map((e) => (e.id === editingId ? payload.expense : e)), + ); + setEditingId(null); + } else { + setExpenses((current) => [payload.expense, ...current]); + } setFormState((current) => ({ ...current, title: "", amount: "" })); setSuggestionMessage(null); @@ -151,8 +189,12 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
-

Daily entry

-

Log today's spend in seconds

+

+ {editingId ? "Edit expense" : "Daily entry"} +

+

+ {editingId ? "Update this entry" : "Log today\u2019s spend in seconds"} +

Current list total

@@ -235,13 +277,24 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {

{error}

- +
+ {editingId ? ( + + ) : null} + +
@@ -269,8 +322,15 @@ export function ExpenseWorkspace({ categoryOptions }: Props) { {expense.date} ยท {getCategoryLabel(expense.category)}

-
-

{formatCurrencyFromCents(expense.amountCents)}

+
+

{formatCurrencyFromCents(expense.amountCents)}

+