From 8c6799eb1230b1de86da389437520395ed635898 Mon Sep 17 00:00:00 2001 From: Graeme Date: Wed, 1 Apr 2026 22:06:50 +0200 Subject: [PATCH] Add Invoice status enum, API + UI support, dashboard counts --- app/api/dashboard/route.ts | 25 +++++++++- app/api/invoices/[id]/route.ts | 35 +++++++++++--- app/api/invoices/route.ts | 3 +- app/dashboard/page.tsx | 14 ++++++ app/invoices/page.tsx | 47 ++++++++++++++++++- .../migration.sql | 5 ++ prisma/schema.prisma | 9 ++++ 7 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20260401195727_add_invoice_status/migration.sql diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts index b5db9be..92b7a3a 100644 --- a/app/api/dashboard/route.ts +++ b/app/api/dashboard/route.ts @@ -1,7 +1,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/auth'; import { prisma } from '@/lib/prisma'; -import type { TimeEntry, Client } from '@prisma/client'; +import type { TimeEntry, Client, Prisma } from '@prisma/client'; import { validateEnv } from '@/lib/env'; export async function GET() { @@ -105,6 +105,27 @@ export async function GET() { return sum + hours * Number(entry.client.hourlyRate); }, 0); + // Invoice counts: unpaid (not PAID) and overdue (status OVERDUE or dueDate passed and not PAID) + const now = new Date(); + const unpaidCount = await prisma.invoice.count({ + where: { userId: user.id, NOT: { status: 'PAID' } } as Prisma.InvoiceWhereInput, + }); + + const overdueCount = await prisma.invoice.count({ + where: ({ + userId: user.id, + AND: [ + { NOT: { status: 'PAID' } }, + { + OR: [ + { status: 'OVERDUE' }, + { dueDate: { lt: now } }, + ], + }, + ], + } as Prisma.InvoiceWhereInput), + }); + return Response.json({ totalEarnings: parseFloat(totalEarnings.toFixed(2)), weeklyEarnings: parseFloat(weeklyEarnings.toFixed(2)), @@ -115,6 +136,8 @@ export async function GET() { hours: parseFloat(data.hours.toFixed(2)), })), recentEntries, + unpaidCount, + overdueCount, }); } catch (error) { console.error('Error fetching dashboard data:', error); diff --git a/app/api/invoices/[id]/route.ts b/app/api/invoices/[id]/route.ts index 1a693f0..37fbd84 100644 --- a/app/api/invoices/[id]/route.ts +++ b/app/api/invoices/[id]/route.ts @@ -2,6 +2,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/auth'; import { prisma } from '@/lib/prisma'; import { validateEnv } from '@/lib/env'; +import type { Prisma, InvoiceStatus } from '@prisma/client'; export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { validateEnv(); @@ -87,10 +88,20 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st } catch { return Response.json({ error: 'Invalid JSON body' }, { status: 400 }); } - const { isPaid } = (body ?? {}) as { isPaid?: unknown }; + const { isPaid, status } = (body ?? {}) as { isPaid?: unknown; status?: unknown }; - if (typeof isPaid !== 'boolean') { - return Response.json({ error: 'Missing or invalid isPaid' }, { status: 400 }); + const allowedStatuses = ['DRAFT', 'SENT', 'PAID', 'OVERDUE']; + + if (typeof isPaid === 'undefined' && typeof status === 'undefined') { + return Response.json({ error: 'Missing update fields' }, { status: 400 }); + } + + if (typeof status !== 'undefined' && typeof status !== 'string') { + return Response.json({ error: 'Invalid status' }, { status: 400 }); + } + + if (typeof status === 'string' && !allowedStatuses.includes(status)) { + return Response.json({ error: 'Unknown status' }, { status: 400 }); } const user = await prisma.user.findUnique({ @@ -108,10 +119,20 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st } try { - const invoice = await prisma.invoice.update({ - where: { id }, - data: { isPaid }, - }); + const updateData: Prisma.InvoiceUncheckedUpdateInput = {}; + + if (typeof isPaid === 'boolean') updateData.isPaid = isPaid; + if (typeof status === 'string') updateData.status = status as InvoiceStatus; + + // Keep isPaid in sync if status is set to PAID + if (status === 'PAID') updateData.isPaid = true; + + // If marking as not paid, ensure isPaid flag reflects that + if (status && status !== 'PAID' && typeof isPaid === 'undefined') { + updateData.isPaid = false; + } + + const invoice = await prisma.invoice.update({ where: { id }, data: updateData }); return Response.json({ message: 'Invoice updated successfully', invoice }); } catch (error: unknown) { diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index bb6e0c3..ed79f69 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -29,7 +29,7 @@ export async function GET() { const now = new Date(); const invoicesWithOverdue = invoices.map((inv) => ({ ...inv, - isOverdue: !inv.isPaid && !!inv.dueDate && new Date(inv.dueDate) < now, + isOverdue: inv.status !== 'PAID' && !!inv.dueDate && new Date(inv.dueDate) < now, })); return Response.json({ invoices: invoicesWithOverdue }); @@ -123,6 +123,7 @@ export async function POST(req: Request) { clientId, userId: user.id, dueDate: due, + status: 'DRAFT', }; const invoice = await prisma.invoice.create({ diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index cff0d33..1ce25e5 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -19,6 +19,8 @@ interface DashboardData { hours: number; earnings: number; }>; + unpaidCount?: number; + overdueCount?: number; } export default function DashboardPage() { @@ -76,6 +78,18 @@ export default function DashboardPage() { +
+
+
Unpaid Invoices
+
{data.unpaidCount ?? 0}
+
+ +
+
Overdue Invoices
+
{data.overdueCount ?? 0}
+
+
+
{/* Earnings by Client */}
diff --git a/app/invoices/page.tsx b/app/invoices/page.tsx index 1126515..46cdc5d 100644 --- a/app/invoices/page.tsx +++ b/app/invoices/page.tsx @@ -21,6 +21,7 @@ interface Invoice { periodEnd?: string | null; client: { name: string }; isPaid: boolean; + status?: string; dueDate?: string | null; isOverdue?: boolean; } @@ -320,7 +321,21 @@ export default function InvoicesPage() { ${invoice.totalAmount.toFixed(2)} - {invoice.isOverdue ? 'Overdue' : invoice.isPaid ? 'Paid' : 'Unpaid'} +
+ + {invoice.status ? invoice.status.toLowerCase() : invoice.isOverdue ? 'overdue' : invoice.isPaid ? 'paid' : 'unpaid'} + +
+