Files
monthlytracker/src/components/recurring-expense-manager.tsx
Vijayakanth Manoharan 012385e9e1 Add dark mode with theme toggle and OpenSpec change
- Add @custom-variant dark in globals.css for class-based dark mode
- Add ThemeToggle component with localStorage persistence and system preference fallback
- Inject blocking inline script in layout to prevent flash on load
- Apply dark: variants across all components (layout, site-nav, home-dashboard, expense-workspace, paycheck-workspace, recurring-expense-manager) and page headers
- Create openspec/changes/theming-dark-mode with proposal, design, and tasks artifacts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:04:20 -04:00

278 lines
11 KiB
TypeScript

"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)] dark:border-amber-900/40 dark:bg-amber-900/10">
<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 dark:text-amber-400">Fixed monthly costs</p>
<h2 className="mt-2 text-2xl font-semibold text-stone-950 dark:text-white">Recurring expenses</h2>
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">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 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white"
>
+ 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 dark:border-amber-900/40 dark:bg-stone-900"
>
<p className="text-sm font-semibold text-stone-700 dark:text-stone-300 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 dark:text-stone-300 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 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
placeholder="Rent, car insurance, EMI..."
/>
</label>
<label className="grid gap-1.5 text-sm font-medium text-stone-700 dark:text-stone-300">
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 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
placeholder="1200.00"
/>
</label>
<label className="grid gap-1.5 text-sm font-medium text-stone-700 dark:text-stone-300">
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 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
placeholder="1"
/>
</label>
<label className="grid gap-1.5 text-sm font-medium text-stone-700 dark:text-stone-300 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 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
>
{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 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
>
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 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white dark:disabled:bg-stone-700 dark:disabled:text-stone-500"
>
{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 dark:border-amber-800/50 dark:text-stone-400">
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 dark:border-amber-700/50 dark:bg-amber-900/20"
: "border-amber-200 bg-white dark:border-amber-900/30 dark:bg-stone-900"
}`}
>
<div>
<p className="font-semibold text-stone-950 dark:text-white">{def.title}</p>
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">
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 dark:text-white">{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 dark:border-stone-600 dark:text-stone-400 dark:hover:border-stone-300 dark:hover:text-stone-200"
>
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 dark:border-stone-600 dark:text-stone-400 dark:hover:border-rose-500 dark:hover:text-rose-400"
>
Delete
</button>
</>
)}
</div>
</article>
))}
</div>
)}
</section>
);
}