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 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 17:20:13 -04:00
parent 27bb8df513
commit e8c23405e7
3 changed files with 123 additions and 16 deletions

View File

@@ -1,12 +1,44 @@
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { removeExpense } from "@/lib/expenses"; import { removeExpense, updateExpense } from "@/lib/expenses";
import { expenseInputSchema } from "@/lib/validation";
type RouteContext = { type RouteContext = {
params: Promise<{ id: string }>; 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) { export async function DELETE(_: Request, context: RouteContext) {
const { id } = await context.params; const { id } = await context.params;

View File

@@ -43,6 +43,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
date: new Date().toISOString().slice(0, 10), date: new Date().toISOString().slice(0, 10),
category: (categoryOptions[0]?.value as CategoryValue | undefined) ?? "MISC", category: (categoryOptions[0]?.value as CategoryValue | undefined) ?? "MISC",
}); });
const [editingId, setEditingId] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [suggestionMessage, setSuggestionMessage] = useState<string | null>(null); const [suggestionMessage, setSuggestionMessage] = useState<string | null>(null);
@@ -96,6 +97,34 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
setNeedsSuggestionConfirmation(suggestion.requiresConfirmation); 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<HTMLFormElement>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -107,8 +136,9 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
setBusy(true); setBusy(true);
setError(null); setError(null);
const response = await fetch("/expenses", { const isEditing = editingId !== null;
method: "POST", const response = await fetch(isEditing ? `/expenses/${editingId}` : "/expenses", {
method: isEditing ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(formState), body: JSON.stringify(formState),
}); });
@@ -117,12 +147,20 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
if (!response.ok) { if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string } | null; 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; return;
} }
const payload = (await response.json()) as { expense: ExpenseRecord }; 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: "" })); setFormState((current) => ({ ...current, title: "", amount: "" }));
setSuggestionMessage(null); setSuggestionMessage(null);
@@ -151,8 +189,12 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]"> <section className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
<div className="mb-6 flex items-center justify-between gap-4"> <div className="mb-6 flex items-center justify-between gap-4">
<div> <div>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700">Daily entry</p> <p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700">
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Log today&apos;s spend in seconds</h2> {editingId ? "Edit expense" : "Daily entry"}
</p>
<h2 className="mt-2 text-3xl font-semibold text-stone-950">
{editingId ? "Update this entry" : "Log today\u2019s spend in seconds"}
</h2>
</div> </div>
<div className="rounded-2xl bg-amber-50 px-4 py-3 text-right"> <div className="rounded-2xl bg-amber-50 px-4 py-3 text-right">
<p className="text-xs uppercase tracking-[0.2em] text-amber-700">Current list total</p> <p className="text-xs uppercase tracking-[0.2em] text-amber-700">Current list total</p>
@@ -235,13 +277,24 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
<div className="md:col-span-2 flex items-center justify-between gap-3"> <div className="md:col-span-2 flex items-center justify-between gap-3">
<p className="text-sm text-rose-700">{error}</p> <p className="text-sm text-rose-700">{error}</p>
<button <div className="flex gap-2">
type="submit" {editingId ? (
disabled={busy} <button
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400" type="button"
> onClick={handleCancelEdit}
{busy ? "Saving..." : "Save expense"} className="rounded-full border border-stone-300 px-5 py-3 text-sm font-semibold text-stone-700 transition hover:border-stone-900"
</button> >
Cancel
</button>
) : null}
<button
type="submit"
disabled={busy}
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
>
{busy ? (editingId ? "Updating..." : "Saving...") : editingId ? "Update expense" : "Save expense"}
</button>
</div>
</div> </div>
</form> </form>
</section> </section>
@@ -269,8 +322,15 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
{expense.date} · {getCategoryLabel(expense.category)} {expense.date} · {getCategoryLabel(expense.category)}
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-2">
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p> <p className="mr-2 font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
<button
type="button"
onClick={() => handleEdit(expense)}
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-stone-900 hover:text-stone-900"
>
Edit
</button>
<button <button
type="button" type="button"
onClick={() => handleDelete(expense.id)} onClick={() => handleDelete(expense.id)}

View File

@@ -29,6 +29,21 @@ export async function removeExpense(id: string) {
return db.expense.delete({ where: { id } }); return db.expense.delete({ where: { id } });
} }
export async function updateExpense(
id: string,
input: { title: string; amountCents: number; date: string; category: Category },
) {
return db.expense.update({
where: { id },
data: {
title: input.title.trim(),
amountCents: input.amountCents,
date: input.date,
category: input.category,
},
});
}
export async function getExpenseDashboardPreview(month = getCurrentMonthKey()) { export async function getExpenseDashboardPreview(month = getCurrentMonthKey()) {
const expenses = await listExpenses(); const expenses = await listExpenses();
const monthExpenses = expenses.filter((expense) => isDateInMonth(expense.date, month)); const monthExpenses = expenses.filter((expense) => isDateInMonth(expense.date, month));