Add recurring expenses with active nav tab highlighting
- Add RecurringExpense model to Prisma schema with migration - Add lib/recurring-expenses.ts: CRUD + virtual projection per month - Add /recurring-expenses API routes (GET, POST, PATCH, DELETE) - Merge projected recurring expenses into dashboard totals and expense list - Add RecurringExpenseManager component to /add-expense page - Show amber "Recurring" badge on projected items; hide edit/delete for them - Highlight active nav tab using usePathname() with hover state - Fix Turbopack/Prisma stub issue by adding serverExternalPackages to next.config.ts - Clear stale Turbopack stub in Dockerfile before each build Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
277
src/components/recurring-expense-manager.tsx
Normal file
277
src/components/recurring-expense-manager.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
||||
import { formatCurrencyFromCents } from "@/lib/money";
|
||||
|
||||
type RecurringDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
amountCents: number;
|
||||
category: string;
|
||||
dayOfMonth: number;
|
||||
};
|
||||
|
||||
type CategoryOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
categoryOptions: CategoryOption[];
|
||||
};
|
||||
|
||||
const emptyForm = { title: "", amount: "", dayOfMonth: "1", category: "MISC" as CategoryValue };
|
||||
|
||||
export function RecurringExpenseManager({ categoryOptions }: Props) {
|
||||
const [definitions, setDefinitions] = useState<RecurringDefinition[]>([]);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [formState, setFormState] = useState(emptyForm);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const res = await fetch("/recurring-expenses", { cache: "no-store" });
|
||||
const payload = (await res.json()) as { recurringExpenses?: RecurringDefinition[] };
|
||||
setDefinitions(payload.recurringExpenses ?? []);
|
||||
}
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
function openAdd() {
|
||||
setEditingId(null);
|
||||
setFormState(emptyForm);
|
||||
setError(null);
|
||||
setShowAddForm(true);
|
||||
}
|
||||
|
||||
function openEdit(def: RecurringDefinition) {
|
||||
setShowAddForm(false);
|
||||
setEditingId(def.id);
|
||||
setFormState({
|
||||
title: def.title,
|
||||
amount: (def.amountCents / 100).toFixed(2),
|
||||
dayOfMonth: String(def.dayOfMonth),
|
||||
category: def.category as CategoryValue,
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
setShowAddForm(false);
|
||||
setEditingId(null);
|
||||
setFormState(emptyForm);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
const isEditing = editingId !== null;
|
||||
const url = isEditing ? `/recurring-expenses/${editingId}` : "/recurring-expenses";
|
||||
const response = await fetch(url, {
|
||||
method: isEditing ? "PATCH" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: formState.title,
|
||||
amount: formState.amount,
|
||||
dayOfMonth: parseInt(formState.dayOfMonth, 10),
|
||||
category: formState.category,
|
||||
}),
|
||||
});
|
||||
|
||||
setBusy(false);
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
setError(payload?.error ?? "Could not save.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { recurringExpense: RecurringDefinition };
|
||||
|
||||
if (isEditing) {
|
||||
setDefinitions((current) =>
|
||||
current.map((d) => (d.id === editingId ? payload.recurringExpense : d)),
|
||||
);
|
||||
} else {
|
||||
setDefinitions((current) => [...current, payload.recurringExpense]);
|
||||
}
|
||||
|
||||
cancelForm();
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setBusy(true);
|
||||
const response = await fetch(`/recurring-expenses/${id}`, { method: "DELETE" });
|
||||
setBusy(false);
|
||||
|
||||
if (!response.ok) {
|
||||
setError("Could not delete.");
|
||||
return;
|
||||
}
|
||||
|
||||
setDefinitions((current) => current.filter((d) => d.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[2rem] border border-amber-200 bg-amber-50 p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700">Fixed monthly costs</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Recurring expenses</h2>
|
||||
<p className="mt-1 text-sm text-stone-600">These appear automatically in every month without manual entry.</p>
|
||||
</div>
|
||||
{!showAddForm && editingId === null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAdd}
|
||||
className="shrink-0 rounded-full bg-stone-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-stone-700"
|
||||
>
|
||||
+ Add recurring
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(showAddForm || editingId !== null) && (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="mb-5 grid gap-3 rounded-2xl border border-amber-200 bg-white p-5 md:grid-cols-2"
|
||||
>
|
||||
<p className="text-sm font-semibold text-stone-700 md:col-span-2">
|
||||
{editingId ? "Edit recurring expense" : "New recurring expense"}
|
||||
</p>
|
||||
|
||||
<label className="grid gap-1.5 text-sm font-medium text-stone-700 md:col-span-2">
|
||||
Title
|
||||
<input
|
||||
required
|
||||
value={formState.title}
|
||||
onChange={(e) => setFormState((s) => ({ ...s, title: e.target.value }))}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900"
|
||||
placeholder="Rent, car insurance, EMI..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1.5 text-sm font-medium text-stone-700">
|
||||
Amount
|
||||
<input
|
||||
required
|
||||
inputMode="decimal"
|
||||
value={formState.amount}
|
||||
onChange={(e) => setFormState((s) => ({ ...s, amount: e.target.value }))}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900"
|
||||
placeholder="1200.00"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1.5 text-sm font-medium text-stone-700">
|
||||
Day of month
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
min={1}
|
||||
max={28}
|
||||
value={formState.dayOfMonth}
|
||||
onChange={(e) => setFormState((s) => ({ ...s, dayOfMonth: e.target.value }))}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900"
|
||||
placeholder="1"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1.5 text-sm font-medium text-stone-700 md:col-span-2">
|
||||
Category
|
||||
<select
|
||||
value={formState.category}
|
||||
onChange={(e) => setFormState((s) => ({ ...s, category: e.target.value as CategoryValue }))}
|
||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900"
|
||||
>
|
||||
{categoryOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-rose-700">{error}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelForm}
|
||||
className="rounded-full border border-stone-300 px-4 py-2.5 text-sm font-semibold text-stone-700 transition hover:border-stone-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="rounded-full bg-stone-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||
>
|
||||
{busy ? "Saving..." : editingId ? "Update" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!showAddForm && editingId === null && error && (
|
||||
<p className="mb-3 text-sm text-rose-700">{error}</p>
|
||||
)}
|
||||
|
||||
{definitions.length === 0 && !showAddForm ? (
|
||||
<div className="rounded-3xl border border-dashed border-amber-300 px-4 py-6 text-sm text-stone-600">
|
||||
No recurring expenses yet. Add fixed monthly costs like rent or EMI.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{definitions.map((def) => (
|
||||
<article
|
||||
key={def.id}
|
||||
className={`flex items-center justify-between gap-4 rounded-3xl border px-4 py-4 ${
|
||||
editingId === def.id
|
||||
? "border-amber-300 bg-amber-100/50"
|
||||
: "border-amber-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold text-stone-950">{def.title}</p>
|
||||
<p className="mt-1 text-sm text-stone-600">
|
||||
Day {def.dayOfMonth} each month · {getCategoryLabel(def.category as CategoryValue)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="mr-2 font-semibold text-stone-950">{formatCurrencyFromCents(def.amountCents)}</p>
|
||||
{editingId !== def.id && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(def)}
|
||||
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
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void handleDelete(def.id)}
|
||||
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-rose-400 hover:text-rose-600 disabled:cursor-not-allowed"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user