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:
@@ -8,7 +8,7 @@ COPY prisma ./prisma
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run prisma:generate && npm run build
|
RUN npm run prisma:generate && rm -rf .next/node_modules && npm run build
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS runner
|
FROM node:22-bookworm-slim AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
serverExternalPackages: ["@prisma/client"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RecurringExpense" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"amountCents" INTEGER NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"dayOfMonth" INTEGER NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
@@ -50,6 +50,17 @@ model MerchantCorrection {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model RecurringExpense {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
amountCents Int
|
||||||
|
category Category
|
||||||
|
dayOfMonth Int // 1–28, capped so it is valid in every month including February
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model MonthlyInsight {
|
model MonthlyInsight {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
month String @unique
|
month String @unique
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ExpenseWorkspace } from "@/components/expense-workspace";
|
import { ExpenseWorkspace } from "@/components/expense-workspace";
|
||||||
|
import { RecurringExpenseManager } from "@/components/recurring-expense-manager";
|
||||||
import { CATEGORY_OPTIONS } from "@/lib/categories";
|
import { CATEGORY_OPTIONS } from "@/lib/categories";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@@ -16,6 +17,8 @@ export default function AddExpensePage() {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<RecurringExpenseManager categoryOptions={CATEGORY_OPTIONS.map((option) => ({ ...option }))} />
|
||||||
|
|
||||||
<ExpenseWorkspace categoryOptions={CATEGORY_OPTIONS.map((option) => ({ ...option }))} />
|
<ExpenseWorkspace categoryOptions={CATEGORY_OPTIONS.map((option) => ({ ...option }))} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json(dashboard);
|
return NextResponse.json(dashboard);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[dashboard] snapshot error:", error);
|
console.error("[dashboard] snapshot error:", error);
|
||||||
return NextResponse.json({ error: "Could not load the dashboard." }, { status: 500 });
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
return NextResponse.json({ error: "Could not load the dashboard.", detail: msg }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { isDateInMonth, isValidMonthKey } from "@/lib/date";
|
||||||
import { createExpense, listExpenses } from "@/lib/expenses";
|
import { createExpense, listExpenses } from "@/lib/expenses";
|
||||||
|
import { getProjectedRecurringExpenses, listActiveRecurringExpenses } from "@/lib/recurring-expenses";
|
||||||
import { expenseInputSchema } from "@/lib/validation";
|
import { expenseInputSchema } from "@/lib/validation";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
const expenses = await listExpenses();
|
const url = new URL(request.url);
|
||||||
return NextResponse.json({ expenses });
|
const rawMonth = url.searchParams.get("month");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expenses = await listExpenses();
|
||||||
|
|
||||||
|
if (rawMonth && isValidMonthKey(rawMonth)) {
|
||||||
|
const definitions = await listActiveRecurringExpenses();
|
||||||
|
const projected = getProjectedRecurringExpenses(definitions, rawMonth);
|
||||||
|
const monthReal = expenses
|
||||||
|
.filter((e) => isDateInMonth(e.date, rawMonth))
|
||||||
|
.map((e) => ({ ...e, isRecurring: false as const }));
|
||||||
|
const all = [...monthReal, ...projected].sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
return NextResponse.json({ expenses: all });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ expenses });
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
return NextResponse.json({ error: "Could not load expenses.", detail: msg }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
|||||||
51
src/app/recurring-expenses/[id]/route.ts
Normal file
51
src/app/recurring-expenses/[id]/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { deactivateRecurringExpense, updateRecurringExpense } from "@/lib/recurring-expenses";
|
||||||
|
import { recurringExpenseInputSchema } 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 = recurringExpenseInputSchema.safeParse(payload);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid recurring expense payload." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recurringExpense = await updateRecurringExpense(id, {
|
||||||
|
title: parsed.data.title,
|
||||||
|
amountCents: parsed.data.amount,
|
||||||
|
dayOfMonth: parsed.data.dayOfMonth,
|
||||||
|
category: parsed.data.category,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ recurringExpense });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
|
||||||
|
return NextResponse.json({ error: "Recurring expense not found." }, { status: 404 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, context: RouteContext) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deactivateRecurringExpense(id);
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
|
||||||
|
return NextResponse.json({ error: "Recurring expense not found." }, { status: 404 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/recurring-expenses/route.ts
Normal file
30
src/app/recurring-expenses/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { createRecurringExpense, listActiveRecurringExpenses } from "@/lib/recurring-expenses";
|
||||||
|
import { recurringExpenseInputSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const recurringExpenses = await listActiveRecurringExpenses();
|
||||||
|
return NextResponse.json({ recurringExpenses });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const payload = await request.json();
|
||||||
|
const parsed = recurringExpenseInputSchema.safeParse(payload);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid recurring expense payload." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recurringExpense = await createRecurringExpense({
|
||||||
|
title: parsed.data.title,
|
||||||
|
amountCents: parsed.data.amount,
|
||||||
|
dayOfMonth: parsed.data.dayOfMonth,
|
||||||
|
category: parsed.data.category,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ recurringExpense }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
||||||
|
import { getCurrentMonthKey } from "@/lib/date";
|
||||||
import { formatCurrencyFromCents } from "@/lib/money";
|
import { formatCurrencyFromCents } from "@/lib/money";
|
||||||
|
|
||||||
type SuggestionResponse = {
|
type SuggestionResponse = {
|
||||||
@@ -19,6 +20,7 @@ type ExpenseRecord = {
|
|||||||
amountCents: number;
|
amountCents: number;
|
||||||
date: string;
|
date: string;
|
||||||
category: CategoryValue;
|
category: CategoryValue;
|
||||||
|
isRecurring?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CategoryOption = {
|
type CategoryOption = {
|
||||||
@@ -53,9 +55,10 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadExpenses() {
|
async function loadExpenses() {
|
||||||
const response = await fetch("/expenses", { cache: "no-store" });
|
const month = getCurrentMonthKey();
|
||||||
const payload = (await response.json()) as { expenses?: ExpenseRecord[] };
|
const response = await fetch(`/expenses?month=${month}`, { cache: "no-store" });
|
||||||
setExpenses(payload.expenses ?? []);
|
const payload = (await response.json().catch(() => null)) as { expenses?: ExpenseRecord[] } | null;
|
||||||
|
setExpenses(payload?.expenses ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadExpenses();
|
void loadExpenses();
|
||||||
@@ -335,27 +338,38 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4"
|
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-stone-950">{expense.title}</p>
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-semibold text-stone-950">{expense.title}</p>
|
||||||
|
{expense.isRecurring && (
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-700">
|
||||||
|
Recurring
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="mt-1 text-sm text-stone-600">
|
<p className="mt-1 text-sm text-stone-600">
|
||||||
{expense.date} · {getCategoryLabel(expense.category)}
|
{expense.date} · {getCategoryLabel(expense.category)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="mr-2 font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
|
<p className="mr-2 font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
|
||||||
<button
|
{!expense.isRecurring && (
|
||||||
type="button"
|
<>
|
||||||
onClick={() => handleEdit(expense)}
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
onClick={() => handleEdit(expense)}
|
||||||
Edit
|
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"
|
||||||
</button>
|
>
|
||||||
<button
|
Edit
|
||||||
type="button"
|
</button>
|
||||||
onClick={() => handleDelete(expense.id)}
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
onClick={() => handleDelete(expense.id)}
|
||||||
Delete
|
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"
|
||||||
</button>
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type DashboardSnapshot = {
|
|||||||
largestExpense: { title: string; amountCents: number; date: string; category: string } | null;
|
largestExpense: { title: string; amountCents: number; date: string; category: string } | null;
|
||||||
};
|
};
|
||||||
categoryBreakdown: Array<{ category: string; amountCents: number }>;
|
categoryBreakdown: Array<{ category: string; amountCents: number }>;
|
||||||
recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string }>;
|
recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string; isRecurring?: true }>;
|
||||||
chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
|
chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
|
||||||
paySchedule: { amountCents: number; anchorDate: string; projectedDates: string[] } | null;
|
paySchedule: { amountCents: number; anchorDate: string; projectedDates: string[] } | null;
|
||||||
};
|
};
|
||||||
@@ -445,7 +445,14 @@ export function HomeDashboard() {
|
|||||||
snapshot.recentExpenses.map((expense) => (
|
snapshot.recentExpenses.map((expense) => (
|
||||||
<article key={expense.id} className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-4">
|
<article key={expense.id} className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-stone-950">{expense.title}</p>
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-semibold text-stone-950">{expense.title}</p>
|
||||||
|
{expense.isRecurring && (
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-700">
|
||||||
|
Recurring
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="mt-1 text-sm text-stone-600">
|
<p className="mt-1 text-sm text-stone-600">
|
||||||
{expense.date} · {getCategoryLabel(expense.category as never)}
|
{expense.date} · {getCategoryLabel(expense.category as never)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: "/", label: "Dashboard" },
|
{ href: "/", label: "Dashboard" },
|
||||||
@@ -7,17 +10,26 @@ const links = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function SiteNav() {
|
export function SiteNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex flex-wrap gap-3 text-sm font-semibold text-stone-700">
|
<nav className="flex flex-wrap gap-3 text-sm font-semibold text-stone-700">
|
||||||
{links.map((link) => (
|
{links.map((link) => {
|
||||||
<Link
|
const isActive = link.href === "/" ? pathname === "/" : pathname.startsWith(link.href);
|
||||||
key={link.href}
|
return (
|
||||||
href={link.href}
|
<Link
|
||||||
className="rounded-full border border-stone-300/80 bg-white/80 px-4 py-2 transition hover:border-stone-900 hover:text-stone-950"
|
key={link.href}
|
||||||
>
|
href={link.href}
|
||||||
{link.label}
|
className={
|
||||||
</Link>
|
isActive
|
||||||
))}
|
? "rounded-full border border-stone-900 bg-stone-900 px-4 py-2 text-white transition hover:bg-stone-700 hover:border-stone-700"
|
||||||
|
: "rounded-full border border-stone-300/80 bg-white/80 px-4 py-2 transition hover:border-stone-900 hover:text-stone-950"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import {
|
|||||||
isDateInMonth,
|
isDateInMonth,
|
||||||
} from "@/lib/date";
|
} from "@/lib/date";
|
||||||
import { getActiveSchedule, getProjectedPayDates, type PaySchedule } from "@/lib/pay-schedule";
|
import { getActiveSchedule, getProjectedPayDates, type PaySchedule } from "@/lib/pay-schedule";
|
||||||
|
import {
|
||||||
|
getProjectedRecurringExpenses,
|
||||||
|
listActiveRecurringExpenses,
|
||||||
|
type ProjectedRecurringExpense,
|
||||||
|
} from "@/lib/recurring-expenses";
|
||||||
|
|
||||||
export type DashboardSnapshot = {
|
export type DashboardSnapshot = {
|
||||||
month: string;
|
month: string;
|
||||||
@@ -30,7 +35,7 @@ export type DashboardSnapshot = {
|
|||||||
largestExpense: { title: string; amountCents: number; date: string; category: string } | null;
|
largestExpense: { title: string; amountCents: number; date: string; category: string } | null;
|
||||||
};
|
};
|
||||||
categoryBreakdown: Array<{ category: string; amountCents: number }>;
|
categoryBreakdown: Array<{ category: string; amountCents: number }>;
|
||||||
recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string }>;
|
recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string; isRecurring?: true }>;
|
||||||
chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
|
chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
|
||||||
paySchedule: {
|
paySchedule: {
|
||||||
amountCents: number;
|
amountCents: number;
|
||||||
@@ -45,8 +50,10 @@ export function buildDashboardSnapshot(input: {
|
|||||||
paychecks: Paycheck[];
|
paychecks: Paycheck[];
|
||||||
paySchedule?: PaySchedule | null;
|
paySchedule?: PaySchedule | null;
|
||||||
insight?: MonthlyInsight | null;
|
insight?: MonthlyInsight | null;
|
||||||
|
projectedRecurring?: ProjectedRecurringExpense[];
|
||||||
}): DashboardSnapshot {
|
}): DashboardSnapshot {
|
||||||
const monthExpenses = input.expenses.filter((expense) => isDateInMonth(expense.date, input.month));
|
const monthExpenses = input.expenses.filter((expense) => isDateInMonth(expense.date, input.month));
|
||||||
|
const recurringExpenses = input.projectedRecurring ?? [];
|
||||||
const monthPaychecks = input.paychecks.filter((paycheck) => isDateInMonth(paycheck.payDate, input.month));
|
const monthPaychecks = input.paychecks.filter((paycheck) => isDateInMonth(paycheck.payDate, input.month));
|
||||||
|
|
||||||
// Project biweekly pay dates; suppress any that already have a manual paycheck on the same date
|
// Project biweekly pay dates; suppress any that already have a manual paycheck on the same date
|
||||||
@@ -63,7 +70,12 @@ export function buildDashboardSnapshot(input: {
|
|||||||
...projectedPaychecks,
|
...projectedPaychecks,
|
||||||
];
|
];
|
||||||
|
|
||||||
const expensesCents = monthExpenses.reduce((sum, expense) => sum + expense.amountCents, 0);
|
const allMonthExpenses = [
|
||||||
|
...monthExpenses.map((e) => ({ ...e, isRecurring: undefined as true | undefined })),
|
||||||
|
...recurringExpenses,
|
||||||
|
];
|
||||||
|
|
||||||
|
const expensesCents = allMonthExpenses.reduce((sum, expense) => sum + expense.amountCents, 0);
|
||||||
const paychecksCents = allPaychecks.reduce((sum, p) => sum + p.amountCents, 0);
|
const paychecksCents = allPaychecks.reduce((sum, p) => sum + p.amountCents, 0);
|
||||||
const netCashFlowCents = paychecksCents - expensesCents;
|
const netCashFlowCents = paychecksCents - expensesCents;
|
||||||
|
|
||||||
@@ -72,7 +84,7 @@ export function buildDashboardSnapshot(input: {
|
|||||||
: getDaysInMonth(input.month);
|
: getDaysInMonth(input.month);
|
||||||
|
|
||||||
const categoryTotals = new Map<string, number>();
|
const categoryTotals = new Map<string, number>();
|
||||||
for (const expense of monthExpenses) {
|
for (const expense of allMonthExpenses) {
|
||||||
categoryTotals.set(expense.category, (categoryTotals.get(expense.category) ?? 0) + expense.amountCents);
|
categoryTotals.set(expense.category, (categoryTotals.get(expense.category) ?? 0) + expense.amountCents);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +95,13 @@ export function buildDashboardSnapshot(input: {
|
|||||||
const highestCategory = categoryBreakdown[0] ?? null;
|
const highestCategory = categoryBreakdown[0] ?? null;
|
||||||
|
|
||||||
const largestExpense =
|
const largestExpense =
|
||||||
monthExpenses
|
allMonthExpenses
|
||||||
.slice()
|
.slice()
|
||||||
.sort((left, right) => right.amountCents - left.amountCents || right.date.localeCompare(left.date))[0] ?? null;
|
.sort((left, right) => right.amountCents - left.amountCents || right.date.localeCompare(left.date))[0] ?? null;
|
||||||
|
|
||||||
const dailyMap = new Map<string, { expensesCents: number; paychecksCents: number }>();
|
const dailyMap = new Map<string, { expensesCents: number; paychecksCents: number }>();
|
||||||
|
|
||||||
for (const expense of monthExpenses) {
|
for (const expense of allMonthExpenses) {
|
||||||
const current = dailyMap.get(expense.date) ?? { expensesCents: 0, paychecksCents: 0 };
|
const current = dailyMap.get(expense.date) ?? { expensesCents: 0, paychecksCents: 0 };
|
||||||
current.expensesCents += expense.amountCents;
|
current.expensesCents += expense.amountCents;
|
||||||
dailyMap.set(expense.date, current);
|
dailyMap.set(expense.date, current);
|
||||||
@@ -136,9 +148,16 @@ export function buildDashboardSnapshot(input: {
|
|||||||
? { amountCents: input.paySchedule.amountCents, anchorDate: input.paySchedule.anchorDate, projectedDates }
|
? { amountCents: input.paySchedule.amountCents, anchorDate: input.paySchedule.anchorDate, projectedDates }
|
||||||
: null,
|
: null,
|
||||||
categoryBreakdown,
|
categoryBreakdown,
|
||||||
recentExpenses: monthExpenses
|
recentExpenses: allMonthExpenses
|
||||||
.slice()
|
.slice()
|
||||||
.sort((left, right) => right.date.localeCompare(left.date) || right.createdAt.getTime() - left.createdAt.getTime())
|
.sort((left, right) => {
|
||||||
|
const dateDiff = right.date.localeCompare(left.date);
|
||||||
|
if (dateDiff !== 0) return dateDiff;
|
||||||
|
// Real expenses have createdAt; projected recurring don't — sort them after real ones
|
||||||
|
const leftTime = "createdAt" in left ? (left.createdAt as Date).getTime() : 0;
|
||||||
|
const rightTime = "createdAt" in right ? (right.createdAt as Date).getTime() : 0;
|
||||||
|
return rightTime - leftTime;
|
||||||
|
})
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((expense) => ({
|
.map((expense) => ({
|
||||||
id: expense.id,
|
id: expense.id,
|
||||||
@@ -146,18 +165,22 @@ export function buildDashboardSnapshot(input: {
|
|||||||
amountCents: expense.amountCents,
|
amountCents: expense.amountCents,
|
||||||
date: expense.date,
|
date: expense.date,
|
||||||
category: expense.category,
|
category: expense.category,
|
||||||
|
...(expense.isRecurring ? { isRecurring: true as const } : {}),
|
||||||
})),
|
})),
|
||||||
chart,
|
chart,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardSnapshot(month = getCurrentMonthKey()) {
|
export async function getDashboardSnapshot(month = getCurrentMonthKey()) {
|
||||||
const [expenses, paychecks, insight, paySchedule] = await Promise.all([
|
const [expenses, paychecks, insight, paySchedule, recurringDefinitions] = await Promise.all([
|
||||||
db.expense.findMany({ orderBy: [{ date: "desc" }, { createdAt: "desc" }] }),
|
db.expense.findMany({ orderBy: [{ date: "desc" }, { createdAt: "desc" }] }),
|
||||||
db.paycheck.findMany({ orderBy: [{ payDate: "desc" }, { createdAt: "desc" }] }),
|
db.paycheck.findMany({ orderBy: [{ payDate: "desc" }, { createdAt: "desc" }] }),
|
||||||
db.monthlyInsight.findUnique({ where: { month } }),
|
db.monthlyInsight.findUnique({ where: { month } }),
|
||||||
getActiveSchedule(),
|
getActiveSchedule(),
|
||||||
|
listActiveRecurringExpenses(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return buildDashboardSnapshot({ month, expenses, paychecks, paySchedule, insight });
|
const projectedRecurring = getProjectedRecurringExpenses(recurringDefinitions, month);
|
||||||
|
|
||||||
|
return buildDashboardSnapshot({ month, expenses, paychecks, paySchedule, insight, projectedRecurring });
|
||||||
}
|
}
|
||||||
|
|||||||
87
src/lib/recurring-expenses.ts
Normal file
87
src/lib/recurring-expenses.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { Category } from "@prisma/client";
|
||||||
|
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export type RecurringExpense = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
amountCents: number;
|
||||||
|
category: string;
|
||||||
|
dayOfMonth: number;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectedRecurringExpense = {
|
||||||
|
id: string;
|
||||||
|
recurringId: string;
|
||||||
|
title: string;
|
||||||
|
amountCents: number;
|
||||||
|
category: string;
|
||||||
|
date: string;
|
||||||
|
isRecurring: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listActiveRecurringExpenses(): Promise<RecurringExpense[]> {
|
||||||
|
return db.recurringExpense.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRecurringExpense(input: {
|
||||||
|
title: string;
|
||||||
|
amountCents: number;
|
||||||
|
category: Category;
|
||||||
|
dayOfMonth: number;
|
||||||
|
}): Promise<RecurringExpense> {
|
||||||
|
return db.recurringExpense.create({
|
||||||
|
data: {
|
||||||
|
title: input.title.trim(),
|
||||||
|
amountCents: input.amountCents,
|
||||||
|
category: input.category,
|
||||||
|
dayOfMonth: input.dayOfMonth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRecurringExpense(
|
||||||
|
id: string,
|
||||||
|
input: { title: string; amountCents: number; category: Category; dayOfMonth: number },
|
||||||
|
): Promise<RecurringExpense> {
|
||||||
|
return db.recurringExpense.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
title: input.title.trim(),
|
||||||
|
amountCents: input.amountCents,
|
||||||
|
category: input.category,
|
||||||
|
dayOfMonth: input.dayOfMonth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivateRecurringExpense(id: string): Promise<void> {
|
||||||
|
await db.recurringExpense.update({ where: { id }, data: { active: false } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function — no DB access. Projects recurring expense definitions into
|
||||||
|
* virtual expense objects for the given month (YYYY-MM).
|
||||||
|
*/
|
||||||
|
export function getProjectedRecurringExpenses(
|
||||||
|
definitions: RecurringExpense[],
|
||||||
|
month: string,
|
||||||
|
): ProjectedRecurringExpense[] {
|
||||||
|
return definitions.map((def) => {
|
||||||
|
const day = String(def.dayOfMonth).padStart(2, "0");
|
||||||
|
const date = `${month}-${day}`;
|
||||||
|
return {
|
||||||
|
id: `recurring-${def.id}-${month}`,
|
||||||
|
recurringId: def.id,
|
||||||
|
title: def.title,
|
||||||
|
amountCents: def.amountCents,
|
||||||
|
category: def.category,
|
||||||
|
date,
|
||||||
|
isRecurring: true as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -35,5 +35,17 @@ export const monthQuerySchema = z.object({
|
|||||||
month: z.string().refine(isValidMonthKey, "Use a YYYY-MM month."),
|
month: z.string().refine(isValidMonthKey, "Use a YYYY-MM month."),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const recurringExpenseInputSchema = z.object({
|
||||||
|
title: z.string().trim().min(1, "Title is required.").max(80, "Keep titles under 80 characters."),
|
||||||
|
amount: amountSchema,
|
||||||
|
dayOfMonth: z
|
||||||
|
.number({ invalid_type_error: "Day must be a number." })
|
||||||
|
.int()
|
||||||
|
.min(1, "Day must be at least 1.")
|
||||||
|
.max(28, "Day must be 28 or less (works across all months)."),
|
||||||
|
category: z.nativeEnum(Category, { message: "Choose a valid category." }),
|
||||||
|
});
|
||||||
|
|
||||||
export type ExpenseInput = z.infer<typeof expenseInputSchema>;
|
export type ExpenseInput = z.infer<typeof expenseInputSchema>;
|
||||||
export type PaycheckInput = z.infer<typeof paycheckInputSchema>;
|
export type PaycheckInput = z.infer<typeof paycheckInputSchema>;
|
||||||
|
export type RecurringExpenseInput = z.infer<typeof recurringExpenseInputSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user