diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51b6c05..2d532aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: build: @@ -84,4 +84,3 @@ jobs: - name: Build run: npm run build - diff --git a/__tests__/InvoicesPage.test.tsx b/__tests__/InvoicesPage.test.tsx index f19a864..0bc6e09 100644 --- a/__tests__/InvoicesPage.test.tsx +++ b/__tests__/InvoicesPage.test.tsx @@ -14,27 +14,32 @@ beforeEach(() => { if (urlStr.endsWith('api/clients') || urlStr === 'api/clients') { return Promise.resolve({ ok: true, - json: () => Promise.resolve({ clients: [{ id: '1', name: 'Test Client', hourlyRate: 100 }] }), + json: () => + Promise.resolve({ clients: [{ id: '1', name: 'Test Client', hourlyRate: 100 }] }), }); } if (urlStr.endsWith('api/invoices') || urlStr === 'api/invoices') { return Promise.resolve({ ok: true, - json: () => Promise.resolve({ - invoices: [ - { - id: '1', - totalHours: 10, - totalAmount: 1000, - createdAt: new Date().toISOString(), - client: { name: 'Test Client' }, - isPaid: paid, - }, - ], - }), + json: () => + Promise.resolve({ + invoices: [ + { + id: '1', + totalHours: 10, + totalAmount: 1000, + createdAt: new Date().toISOString(), + client: { name: 'Test Client' }, + isPaid: paid, + }, + ], + }), }); } - if ((urlStr.endsWith('/api/invoices/') || urlStr.includes('/api/invoices/')) && options?.method === 'PATCH') { + if ( + (urlStr.endsWith('/api/invoices/') || urlStr.includes('/api/invoices/')) && + options?.method === 'PATCH' + ) { paid = true; return Promise.resolve({ ok: true, @@ -83,10 +88,16 @@ describe('Invoices page', () => { if (urlStr.endsWith('api/clients') || urlStr === 'api/clients') { return Promise.resolve({ ok: true, - json: () => Promise.resolve({ clients: [{ id: '1', name: 'Test Client', hourlyRate: 100 }] }), + json: () => + Promise.resolve({ clients: [{ id: '1', name: 'Test Client', hourlyRate: 100 }] }), }); } - if ((urlStr.endsWith('/api/invoices') || urlStr === 'api/invoices' || urlStr.endsWith('api/invoices')) && options?.method === 'POST') { + if ( + (urlStr.endsWith('/api/invoices') || + urlStr === 'api/invoices' || + urlStr.endsWith('api/invoices')) && + options?.method === 'POST' + ) { lastPostBody = JSON.parse(String(options.body)); return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); } @@ -112,7 +123,9 @@ describe('Invoices page', () => { // fill required date inputs (native validation prevents submit otherwise) const { container } = rendered; - const dateInputs = container.querySelectorAll('input[type="date"]') as NodeListOf; + const dateInputs = container.querySelectorAll( + 'input[type="date"]', + ) as NodeListOf; const today = new Date().toISOString().slice(0, 10); if (dateInputs.length >= 2) { fireEvent.change(dateInputs[0], { target: { value: today } }); diff --git a/app/api/invoices/[id]/route.ts b/app/api/invoices/[id]/route.ts index a317125..1a693f0 100644 --- a/app/api/invoices/[id]/route.ts +++ b/app/api/invoices/[id]/route.ts @@ -63,18 +63,24 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str }; }); - return Response.json({ invoice, entries }); + const now = new Date(); + const invoiceWithOverdue = { + ...invoice, + isOverdue: !invoice.isPaid && !!invoice.dueDate && new Date(invoice.dueDate) < now, + }; + + return Response.json({ invoice: invoiceWithOverdue, entries }); } -export async function PATCH(req: Request, {params}: { params: Promise<{id: string}> }) { +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { validateEnv(); const session = await getServerSession(authOptions); - if(!session || !session.user?.email){ + if (!session || !session.user?.email) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } - const {id} = await params; + const { id } = await params; let body: unknown; try { body = await req.json(); @@ -83,8 +89,8 @@ export async function PATCH(req: Request, {params}: { params: Promise<{id: strin } const { isPaid } = (body ?? {}) as { isPaid?: unknown }; - if(typeof isPaid !== 'boolean'){ - return Response.json({error: 'Missing or invalid isPaid'}, {status: 400}); + if (typeof isPaid !== 'boolean') { + return Response.json({ error: 'Missing or invalid isPaid' }, { status: 400 }); } const user = await prisma.user.findUnique({ @@ -112,4 +118,4 @@ export async function PATCH(req: Request, {params}: { params: Promise<{id: strin console.error(error); return Response.json({ error: 'Invoice not found or update failed' }, { status: 404 }); } -} \ No newline at end of file +} diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index 33a2858..bb6e0c3 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -26,7 +26,13 @@ export async function GET() { orderBy: { createdAt: 'desc' }, }); - return Response.json({ invoices }); + const now = new Date(); + const invoicesWithOverdue = invoices.map((inv) => ({ + ...inv, + isOverdue: !inv.isPaid && !!inv.dueDate && new Date(inv.dueDate) < now, + })); + + return Response.json({ invoices: invoicesWithOverdue }); } export async function POST(req: Request) { @@ -37,7 +43,7 @@ export async function POST(req: Request) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } - const { clientId, startDate, endDate, hourlyRate } = await req.json(); + const { clientId, startDate, endDate, hourlyRate, dueDate } = await req.json(); if (!clientId || !startDate || !endDate) { return Response.json({ error: 'Missing required fields' }, { status: 400 }); @@ -105,6 +111,9 @@ export async function POST(req: Request) { const totalAmount = totalHours * rate; + // default dueDate to 30 days from now if not provided + const due = dueDate ? new Date(dueDate) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + const invoiceData: Prisma.InvoiceUncheckedCreateInput = { totalHours: parseFloat(totalHours.toFixed(2)), totalAmount: parseFloat(totalAmount.toFixed(2)), @@ -113,6 +122,7 @@ export async function POST(req: Request) { periodEnd: to, clientId, userId: user.id, + dueDate: due, }; const invoice = await prisma.invoice.create({ diff --git a/app/invoices/page.tsx b/app/invoices/page.tsx index 75c9628..1126515 100644 --- a/app/invoices/page.tsx +++ b/app/invoices/page.tsx @@ -4,6 +4,8 @@ import { useState } from 'react'; import { jsPDF } from 'jspdf'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +const DEFAULT_DUE_DATE = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + interface Client { id: string; name: string; @@ -19,6 +21,8 @@ interface Invoice { periodEnd?: string | null; client: { name: string }; isPaid: boolean; + dueDate?: string | null; + isOverdue?: boolean; } interface InvoiceEntry { @@ -41,6 +45,7 @@ const buildInvoicePdf = (invoice: Invoice, entries: InvoiceEntry[]) => { ? new Date(invoice.periodStart).toLocaleDateString() : 'N/A'; const periodEnd = invoice.periodEnd ? new Date(invoice.periodEnd).toLocaleDateString() : 'N/A'; + const dueDate = invoice.dueDate ? new Date(invoice.dueDate).toLocaleDateString() : 'N/A'; doc.setFontSize(18); doc.text('Worklog Invoice', 14, 20); @@ -48,11 +53,12 @@ const buildInvoicePdf = (invoice: Invoice, entries: InvoiceEntry[]) => { doc.setFontSize(12); doc.text(`Client: ${invoice.client.name}`, 14, 32); doc.text(`Period: ${periodStart} - ${periodEnd}`, 14, 40); - doc.text(`Created: ${createdAt}`, 14, 48); - doc.text(`Total Hours: ${invoice.totalHours.toFixed(2)}`, 14, 56); - doc.text(`Total Amount: $${invoice.totalAmount.toFixed(2)}`, 14, 64); + doc.text(`Due: ${dueDate}`, 14, 48); + doc.text(`Created: ${createdAt}`, 14, 56); + doc.text(`Total Hours: ${invoice.totalHours.toFixed(2)}`, 14, 64); + doc.text(`Total Amount: $${invoice.totalAmount.toFixed(2)}`, 14, 72); - let y = 76; + let y = 84; doc.setFontSize(11); doc.text('Entries', 14, y); y += 8; @@ -82,7 +88,8 @@ export default function InvoicesPage() { clientId: '', startDate: '', endDate: '', - hourlyRate: '' + hourlyRate: '', + dueDate: DEFAULT_DUE_DATE, }); const queryClient = useQueryClient(); @@ -126,7 +133,13 @@ export default function InvoicesPage() { }); if (res.ok) { - setFormData({ clientId: '', startDate: '', endDate: '', hourlyRate: '' }); + setFormData({ + clientId: '', + startDate: '', + endDate: '', + hourlyRate: '', + dueDate: DEFAULT_DUE_DATE, + }); queryClient.invalidateQueries({ queryKey: ['invoices'] }); } else { const error = await res.json(); @@ -236,7 +249,9 @@ export default function InvoicesPage() {
- +
+
+ + setFormData({ ...formData, dueDate: e.target.value })} + className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900" + /> +