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:
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
setExpenses((current) =>
|
||||||
|
current.map((e) => (e.id === editingId ? payload.expense : e)),
|
||||||
|
);
|
||||||
|
setEditingId(null);
|
||||||
|
} else {
|
||||||
setExpenses((current) => [payload.expense, ...current]);
|
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'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,14 +277,25 @@ 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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{editingId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="rounded-full border border-stone-300 px-5 py-3 text-sm font-semibold text-stone-700 transition hover:border-stone-900"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy}
|
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"
|
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 ? "Saving..." : "Save expense"}
|
{busy ? (editingId ? "Updating..." : "Saving...") : editingId ? "Update expense" : "Save expense"}
|
||||||
</button>
|
</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)}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user