diff --git a/__tests__/InvoicesPage.test.tsx b/__tests__/InvoicesPage.test.tsx index bbd7b4b..f19a864 100644 --- a/__tests__/InvoicesPage.test.tsx +++ b/__tests__/InvoicesPage.test.tsx @@ -1,6 +1,7 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import InvoicesPage from '../app/invoices/page'; +import userEvent from '@testing-library/user-event'; // Mock fetch for clients and invoices let paid = false; @@ -9,13 +10,14 @@ beforeEach(() => { paid = false; global.fetch = jest.fn((url, options) => { - if (url === 'api/clients') { + const urlStr = String(url); + if (urlStr.endsWith('api/clients') || urlStr === 'api/clients') { return Promise.resolve({ ok: true, json: () => Promise.resolve({ clients: [{ id: '1', name: 'Test Client', hourlyRate: 100 }] }), }); } - if (url === 'api/invoices') { + if (urlStr.endsWith('api/invoices') || urlStr === 'api/invoices') { return Promise.resolve({ ok: true, json: () => Promise.resolve({ @@ -32,7 +34,7 @@ beforeEach(() => { }), }); } - if (url.startsWith('/api/invoices/') && options?.method === 'PATCH') { + if ((urlStr.endsWith('/api/invoices/') || urlStr.includes('/api/invoices/')) && options?.method === 'PATCH') { paid = true; return Promise.resolve({ ok: true, @@ -56,7 +58,7 @@ describe('Invoices page', () => { , ); - expect(await screen.findByText(/invoices/i)).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: /invoices/i })).toBeInTheDocument(); }); it('shows invoice as unpaid by default', async () => { @@ -66,6 +68,62 @@ describe('Invoices page', () => { , ); - expect(await screen.findByText(/unpaid/i)).toBeInTheDocument(); + expect(await screen.findByText(/^Unpaid$/i)).toBeInTheDocument(); + }); + + it('prefills hourly rate from selected client and sends it on create', async () => { + const queryClient = new QueryClient(); + const user = userEvent.setup(); + + // capture POST body + type InvoicePostBody = { hourlyRate?: number; [key: string]: unknown }; + let lastPostBody: InvoicePostBody | null = null; + (global.fetch as jest.Mock).mockImplementation((url, options) => { + const urlStr = String(url); + if (urlStr.endsWith('api/clients') || urlStr === 'api/clients') { + return Promise.resolve({ + ok: true, + 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') { + lastPostBody = JSON.parse(String(options.body)); + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + if (urlStr.endsWith('api/invoices') || urlStr === 'api/invoices') { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ invoices: [] }) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + + const rendered = render( + + + , + ); + + // wait for client select (combobox) to appear and pick the client + const select = await screen.findByRole('combobox'); + await user.selectOptions(select, '1'); + + // hourly rate input should be prefilled (find by displayed value) + const rateInput = await screen.findByDisplayValue('100'); + expect((rateInput as HTMLInputElement).value).toBe('100'); + + // fill required date inputs (native validation prevents submit otherwise) + const { container } = rendered; + 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 } }); + fireEvent.change(dateInputs[1], { target: { value: today } }); + } + + // click generate invoice + const button = screen.getByRole('button', { name: /generate invoice/i }); + await user.click(button); + + expect(lastPostBody).not.toBeNull(); + expect(Number(lastPostBody!.hourlyRate)).toBe(100); }); }); diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts index e074077..b5db9be 100644 --- a/app/api/dashboard/route.ts +++ b/app/api/dashboard/route.ts @@ -43,7 +43,8 @@ export async function GET() { const startTime = new Date(entry.startTime as Date); const endTime = new Date(entry.endTime as Date); const hours = (endTime.getTime() - startTime.getTime()) / (1000 * 60 * 60); - const earnings = hours * entry.client.hourlyRate; + const rate = Number(entry.client.hourlyRate); + const earnings = hours * rate; totalEarnings += earnings; @@ -66,7 +67,7 @@ export async function GET() { const startTime = new Date(entry.startTime as Date); const endTime = new Date(entry.endTime as Date); const hours = (endTime.getTime() - startTime.getTime()) / (1000 * 60 * 60); - const earnings = hours * entry.client.hourlyRate; + const earnings = hours * Number(entry.client.hourlyRate); return { id: entry.id, @@ -88,7 +89,7 @@ export async function GET() { const startTime = new Date(entry.startTime as Date); const endTime = new Date(entry.endTime as Date); const hours = (endTime.getTime() - startTime.getTime()) / (1000 * 60 * 60); - return sum + hours * entry.client.hourlyRate; + return sum + hours * Number(entry.client.hourlyRate); }, 0); // Calculate monthly earnings @@ -101,7 +102,7 @@ export async function GET() { const startTime = new Date(entry.startTime as Date); const endTime = new Date(entry.endTime as Date); const hours = (endTime.getTime() - startTime.getTime()) / (1000 * 60 * 60); - return sum + hours * entry.client.hourlyRate; + return sum + hours * Number(entry.client.hourlyRate); }, 0); return Response.json({ diff --git a/app/api/invoices/[id]/route.ts b/app/api/invoices/[id]/route.ts index 7b0d5d6..a317125 100644 --- a/app/api/invoices/[id]/route.ts +++ b/app/api/invoices/[id]/route.ts @@ -52,7 +52,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str const start = new Date(entry.startTime as Date); const end = new Date(entry.endTime as Date); const hours = (end.getTime() - start.getTime()) / (1000 * 60 * 60); - const amount = hours * invoice.client.hourlyRate; + const amount = hours * Number(invoice.hourlyRate); return { id: entry.id, diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index fc134a0..33a2858 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/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 } from '@prisma/client'; export async function GET() { validateEnv(); @@ -36,7 +37,7 @@ export async function POST(req: Request) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } - const { clientId, startDate, endDate } = await req.json(); + const { clientId, startDate, endDate, hourlyRate } = await req.json(); if (!clientId || !startDate || !endDate) { return Response.json({ error: 'Missing required fields' }, { status: 400 }); @@ -86,17 +87,36 @@ export async function POST(req: Request) { return sum + hours; }, 0); - const totalAmount = totalHours * client.hourlyRate; + // Validate override hourlyRate if provided, otherwise validate and use client's stored rate + let rate: number; + if (typeof hourlyRate !== 'undefined' && hourlyRate !== null && hourlyRate !== '') { + const parsed = Number(hourlyRate); + if (!Number.isFinite(parsed) || parsed < 0) { + return Response.json({ error: 'Invalid hourlyRate override' }, { status: 400 }); + } + rate = parsed; + } else { + const clientRate = Number(client.hourlyRate); + if (!Number.isFinite(clientRate) || clientRate < 0) { + return Response.json({ error: 'Client has invalid hourlyRate' }, { status: 400 }); + } + rate = clientRate; + } + + const totalAmount = totalHours * rate; + + const invoiceData: Prisma.InvoiceUncheckedCreateInput = { + totalHours: parseFloat(totalHours.toFixed(2)), + totalAmount: parseFloat(totalAmount.toFixed(2)), + hourlyRate: parseFloat(rate.toFixed(2)), + periodStart: from, + periodEnd: to, + clientId, + userId: user.id, + }; const invoice = await prisma.invoice.create({ - data: { - totalHours: parseFloat(totalHours.toFixed(2)), - totalAmount: parseFloat(totalAmount.toFixed(2)), - periodStart: from, - periodEnd: to, - clientId, - userId: user.id, - }, + data: invoiceData, include: { client: true }, }); diff --git a/app/invoices/page.tsx b/app/invoices/page.tsx index c0e012e..75c9628 100644 --- a/app/invoices/page.tsx +++ b/app/invoices/page.tsx @@ -82,6 +82,7 @@ export default function InvoicesPage() { clientId: '', startDate: '', endDate: '', + hourlyRate: '' }); const queryClient = useQueryClient(); @@ -92,7 +93,7 @@ export default function InvoicesPage() { } = useQuery({ queryKey: ['clients'], queryFn: async () => { - const res = await fetch('api/clients'); + const res = await fetch('/api/clients'); if (!res.ok) { throw new Error('Failed to fetch clients'); } @@ -107,7 +108,7 @@ export default function InvoicesPage() { } = useQuery({ queryKey: ['invoices'], queryFn: async () => { - const res = await fetch('api/invoices'); + const res = await fetch('/api/invoices'); if (!res.ok) { throw new Error('Failed to fetch invoices'); } @@ -125,7 +126,7 @@ export default function InvoicesPage() { }); if (res.ok) { - setFormData({ clientId: '', startDate: '', endDate: '' }); + setFormData({ clientId: '', startDate: '', endDate: '', hourlyRate: '' }); queryClient.invalidateQueries({ queryKey: ['invoices'] }); } else { const error = await res.json(); @@ -190,12 +191,20 @@ export default function InvoicesPage() { setFormData({ ...formData, hourlyRate: 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" + min="0" + step="0.01" + /> +