Implement expense tracking foundation for v1
This commit is contained in:
22
src/app/add-expense/page.tsx
Normal file
22
src/app/add-expense/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ExpenseWorkspace } from "@/components/expense-workspace";
|
||||
import { CATEGORY_OPTIONS } from "@/lib/categories";
|
||||
|
||||
export const metadata = {
|
||||
title: "Add Expense | Monthy Tracker",
|
||||
};
|
||||
|
||||
export default function AddExpensePage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="max-w-2xl space-y-3">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700">Add Expense</p>
|
||||
<h1 className="text-4xl font-semibold text-stone-950">Capture spending while it still feels fresh.</h1>
|
||||
<p className="text-lg leading-8 text-stone-600">
|
||||
This first slice focuses on fast local entry. Each saved expense appears immediately in your running history.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ExpenseWorkspace categoryOptions={CATEGORY_OPTIONS.map((option) => ({ ...option }))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/app/expenses/[id]/route.ts
Normal file
26
src/app/expenses/[id]/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { removeExpense } from "@/lib/expenses";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function DELETE(_: Request, context: RouteContext) {
|
||||
const { id } = await context.params;
|
||||
|
||||
try {
|
||||
await removeExpense(id);
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === "P2025"
|
||||
) {
|
||||
return NextResponse.json({ error: "Expense not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
30
src/app/expenses/route.ts
Normal file
30
src/app/expenses/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createExpense, listExpenses } from "@/lib/expenses";
|
||||
import { expenseInputSchema } from "@/lib/validation";
|
||||
|
||||
export async function GET() {
|
||||
const expenses = await listExpenses();
|
||||
return NextResponse.json({ expenses });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
const expense = await createExpense({
|
||||
title: parsed.data.title,
|
||||
amountCents: parsed.data.amount,
|
||||
date: parsed.data.date,
|
||||
category: parsed.data.category,
|
||||
});
|
||||
|
||||
return NextResponse.json({ expense }, { status: 201 });
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
41
src/app/globals.css
Normal file
41
src/app/globals.css
Normal file
@@ -0,0 +1,41 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: #fbfaf7;
|
||||
--color-foreground: #1c1917;
|
||||
--font-sans: var(--font-body);
|
||||
--font-heading: var(--font-heading);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-body), sans-serif;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: var(--font-heading), serif;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
transition-duration: 180ms;
|
||||
}
|
||||
15
src/app/income/page.tsx
Normal file
15
src/app/income/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export const metadata = {
|
||||
title: "Income & Paychecks | Monthy Tracker",
|
||||
};
|
||||
|
||||
export default function IncomePage() {
|
||||
return (
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-stone-500">Coming next</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold text-stone-950">Paycheck tracking lands in the next implementation slice.</h1>
|
||||
<p className="mt-4 max-w-2xl text-lg leading-8 text-stone-600">
|
||||
The data model is already prepared for paychecks. This view will add create, list, and delete flows after expense tracking is validated.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/app/layout.tsx
Normal file
47
src/app/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Fraunces, Manrope } from "next/font/google";
|
||||
|
||||
import { SiteNav } from "@/components/site-nav";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
const headingFont = Fraunces({
|
||||
variable: "--font-heading",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const bodyFont = Manrope({
|
||||
variable: "--font-body",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Monthy Tracker",
|
||||
description: "Local-first monthly expense tracking with AI insights.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${headingFont.variable} ${bodyFont.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full bg-[linear-gradient(180deg,#f8f3ea_0%,#f5efe4_28%,#fbfaf7_100%)] text-stone-950">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col px-4 py-6 sm:px-6 lg:px-8">
|
||||
<header className="mb-10 flex flex-col gap-4 rounded-[2rem] border border-white/70 bg-white/80 px-6 py-5 shadow-[0_20px_50px_rgba(120,90,50,0.08)] backdrop-blur sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-amber-700">Monthy Tracker</p>
|
||||
<p className="mt-2 text-lg text-stone-600">Track the month as it unfolds, not after it slips away.</p>
|
||||
</div>
|
||||
<SiteNav />
|
||||
</header>
|
||||
<main className="flex-1 pb-10">{children}</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
94
src/app/page.tsx
Normal file
94
src/app/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import Link from "next/link";
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
|
||||
import { getCategoryLabel } from "@/lib/categories";
|
||||
import { getMonthLabel } from "@/lib/date";
|
||||
import { getExpenseDashboardPreview } from "@/lib/expenses";
|
||||
import { formatCurrencyFromCents } from "@/lib/money";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Home() {
|
||||
noStore();
|
||||
const preview = await getExpenseDashboardPreview();
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<section className="grid gap-6 rounded-[2rem] border border-stone-200 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.26),_transparent_32%),linear-gradient(135deg,#fffaf2,#f3efe7)] p-8 shadow-[0_28px_70px_rgba(120,90,50,0.10)] lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700">Monthly Expense Tracker</p>
|
||||
<h1 className="max-w-3xl text-5xl font-semibold leading-tight text-stone-950">
|
||||
A calm local-first home for everyday spending.
|
||||
</h1>
|
||||
<p className="max-w-2xl text-lg leading-8 text-stone-600">
|
||||
The dashboard is starting with the expense-tracking slice first: fast entry, visible history, and a live pulse on the current month.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/add-expense"
|
||||
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800"
|
||||
>
|
||||
Add an expense
|
||||
</Link>
|
||||
<Link
|
||||
href="/income"
|
||||
className="rounded-full border border-stone-300 bg-white px-5 py-3 text-sm font-semibold text-stone-800 transition hover:border-stone-900"
|
||||
>
|
||||
View paycheck plan
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.75rem] border border-white/80 bg-white/90 p-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">This month</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">{getMonthLabel(preview.month)}</h2>
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<article className="rounded-3xl bg-stone-950 px-4 py-5 text-white">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-300">Total spent</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(preview.totalSpentCents)}</p>
|
||||
</article>
|
||||
<article className="rounded-3xl bg-amber-50 px-4 py-5 text-stone-950">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-amber-700">Entries logged</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{preview.expenseCount}</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Recent expense pulse</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Latest entries</h2>
|
||||
</div>
|
||||
<Link href="/add-expense" className="text-sm font-semibold text-amber-800 transition hover:text-stone-950">
|
||||
Manage expenses
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3">
|
||||
{preview.recentExpenses.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-10 text-center text-stone-600">
|
||||
No expenses recorded yet. Start with one quick entry.
|
||||
</div>
|
||||
) : (
|
||||
preview.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"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold text-stone-950">{expense.title}</p>
|
||||
<p className="mt-1 text-sm text-stone-600">
|
||||
{expense.date} · {getCategoryLabel(expense.category)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user