Add paycheck tracking workflow for v1
This commit is contained in:
@@ -14,9 +14,9 @@
|
|||||||
## 3. Expense and paycheck workflows
|
## 3. Expense and paycheck workflows
|
||||||
|
|
||||||
- [x] 3.1 Implement expense API routes for create, list, and delete operations.
|
- [x] 3.1 Implement expense API routes for create, list, and delete operations.
|
||||||
- [ ] 3.2 Implement paycheck API routes for create, list, and delete operations.
|
- [x] 3.2 Implement paycheck API routes for create, list, and delete operations.
|
||||||
- [x] 3.3 Build the `Add Expense` view with form submission, validation feedback, and expense listing.
|
- [x] 3.3 Build the `Add Expense` view with form submission, validation feedback, and expense listing.
|
||||||
- [ ] 3.4 Build the `Income/Paychecks` view with form submission, validation feedback, and paycheck listing.
|
- [x] 3.4 Build the `Income/Paychecks` view with form submission, validation feedback, and paycheck listing.
|
||||||
|
|
||||||
## 4. Dashboard and insights
|
## 4. Dashboard and insights
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
|
import { PaycheckWorkspace } from "@/components/paycheck-workspace";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Income & Paychecks | Monthy Tracker",
|
title: "Income & Paychecks | Monthy Tracker",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function IncomePage() {
|
export default function IncomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
<div className="space-y-8">
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-stone-500">Coming next</p>
|
<header className="max-w-2xl space-y-3">
|
||||||
<h1 className="mt-3 text-4xl font-semibold text-stone-950">Paycheck tracking lands in the next implementation slice.</h1>
|
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-emerald-700">Income & Paychecks</p>
|
||||||
<p className="mt-4 max-w-2xl text-lg leading-8 text-stone-600">
|
<h1 className="text-4xl font-semibold text-stone-950">Capture income on real pay dates, not rough monthly averages.</h1>
|
||||||
The data model is already prepared for paychecks. This view will add create, list, and delete flows after expense tracking is validated.
|
<p className="text-lg leading-8 text-stone-600">
|
||||||
</p>
|
This slice tracks each paycheck as a distinct event so later dashboard and AI guidance can reason about cash timing accurately.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<PaycheckWorkspace />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/app/paychecks/[id]/route.ts
Normal file
23
src/app/paychecks/[id]/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { removePaycheck } from "@/lib/paychecks";
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, context: RouteContext) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removePaycheck(id);
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
|
||||||
|
return NextResponse.json({ error: "Paycheck not found." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/app/paychecks/route.ts
Normal file
28
src/app/paychecks/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { createPaycheck, listPaychecks } from "@/lib/paychecks";
|
||||||
|
import { paycheckInputSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const paychecks = await listPaychecks();
|
||||||
|
return NextResponse.json({ paychecks });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const payload = await request.json();
|
||||||
|
const parsed = paycheckInputSchema.safeParse(payload);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid paycheck payload." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paycheck = await createPaycheck({
|
||||||
|
amountCents: parsed.data.amount,
|
||||||
|
payDate: parsed.data.payDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ paycheck }, { status: 201 });
|
||||||
|
}
|
||||||
166
src/components/paycheck-workspace.tsx
Normal file
166
src/components/paycheck-workspace.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import { formatCurrencyFromCents } from "@/lib/money";
|
||||||
|
|
||||||
|
type PaycheckRecord = {
|
||||||
|
id: string;
|
||||||
|
amountCents: number;
|
||||||
|
payDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaycheckWorkspace() {
|
||||||
|
const [paychecks, setPaychecks] = useState<PaycheckRecord[]>([]);
|
||||||
|
const [formState, setFormState] = useState({
|
||||||
|
amount: "",
|
||||||
|
payDate: new Date().toISOString().slice(0, 10),
|
||||||
|
});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadPaychecks() {
|
||||||
|
const response = await fetch("/paychecks", { cache: "no-store" });
|
||||||
|
const payload = (await response.json()) as { paychecks?: PaycheckRecord[] };
|
||||||
|
setPaychecks(payload.paychecks ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadPaychecks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalIncome = useMemo(
|
||||||
|
() => paychecks.reduce((sum, paycheck) => sum + paycheck.amountCents, 0),
|
||||||
|
[paychecks],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch("/paychecks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(formState),
|
||||||
|
});
|
||||||
|
|
||||||
|
setBusy(false);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
setError(payload?.error ?? "Could not save the paycheck.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { paycheck: PaycheckRecord };
|
||||||
|
setPaychecks((current) => [payload.paycheck, ...current]);
|
||||||
|
setFormState((current) => ({ ...current, amount: "" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch(`/paychecks/${id}`, { method: "DELETE" });
|
||||||
|
|
||||||
|
setBusy(false);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError("Could not delete the paycheck.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPaychecks((current) => current.filter((paycheck) => paycheck.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<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>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">Income entry</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Record each paycheck on the date it lands</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-emerald-50 px-4 py-3 text-right">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700">Tracked paychecks</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-stone-950">{formatCurrencyFromCents(totalIncome)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="grid gap-4 md:grid-cols-2" onSubmit={handleSubmit}>
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
||||||
|
Amount
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
inputMode="decimal"
|
||||||
|
value={formState.amount}
|
||||||
|
onChange={(event) => setFormState((current) => ({ ...current, amount: event.target.value }))}
|
||||||
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
||||||
|
placeholder="1800.00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
||||||
|
Pay date
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="date"
|
||||||
|
value={formState.payDate}
|
||||||
|
onChange={(event) => setFormState((current) => ({ ...current, payDate: event.target.value }))}
|
||||||
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-rose-700">{error}</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{busy ? "Saving..." : "Save paycheck"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[2rem] border border-stone-200 bg-[#f6fbf6] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||||
|
<div className="mb-5">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Income history</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Recent paychecks</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{paychecks.length === 0 ? (
|
||||||
|
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600">
|
||||||
|
No paychecks yet. Add the next deposit to start cash-flow tracking.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
paychecks.map((paycheck) => (
|
||||||
|
<article
|
||||||
|
key={paycheck.id}
|
||||||
|
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-stone-950">Paycheck</p>
|
||||||
|
<p className="mt-1 text-sm text-stone-600">{paycheck.payDate}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(paycheck.amountCents)}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(paycheck.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"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/lib/paychecks.test.ts
Normal file
23
src/lib/paychecks.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { paycheckInputSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
describe("paycheckInputSchema", () => {
|
||||||
|
it("accepts valid paycheck payloads", () => {
|
||||||
|
const parsed = paycheckInputSchema.parse({
|
||||||
|
amount: "1800.00",
|
||||||
|
payDate: "2026-03-20",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.amount).toBe(180000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid pay dates", () => {
|
||||||
|
const parsed = paycheckInputSchema.safeParse({
|
||||||
|
amount: "1800.00",
|
||||||
|
payDate: "2026-02-31",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
src/lib/paychecks.ts
Normal file
20
src/lib/paychecks.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function listPaychecks() {
|
||||||
|
return db.paycheck.findMany({
|
||||||
|
orderBy: [{ payDate: "desc" }, { createdAt: "desc" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPaycheck(input: { amountCents: number; payDate: string }) {
|
||||||
|
return db.paycheck.create({
|
||||||
|
data: {
|
||||||
|
amountCents: input.amountCents,
|
||||||
|
payDate: input.payDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePaycheck(id: string) {
|
||||||
|
return db.paycheck.delete({ where: { id } });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user