Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 64 additions & 6 deletions __tests__/InvoicesPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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({
Expand All @@ -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,
Expand All @@ -56,7 +58,7 @@ describe('Invoices page', () => {
<InvoicesPage />
</QueryClientProvider>,
);
expect(await screen.findByText(/invoices/i)).toBeInTheDocument();
expect(await screen.findByRole('heading', { name: /invoices/i })).toBeInTheDocument();
});

it('shows invoice as unpaid by default', async () => {
Expand All @@ -66,6 +68,62 @@ describe('Invoices page', () => {
<InvoicesPage />
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<InvoicesPage />
</QueryClientProvider>,
);

// 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<HTMLInputElement>;
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 } });
}
Comment on lines +113 to +120
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fail fast when required date inputs are missing.

The conditional guard can defer failures to later assertions, making the cause less clear. Assert the expected inputs exist before filling them.

💡 Suggested test-hardening diff
-    if (dateInputs.length >= 2) {
-      fireEvent.change(dateInputs[0], { target: { value: today } });
-      fireEvent.change(dateInputs[1], { target: { value: today } });
-    }
+    expect(dateInputs).toHaveLength(2);
+    fireEvent.change(dateInputs[0], { target: { value: today } });
+    fireEvent.change(dateInputs[1], { target: { value: today } });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// fill required date inputs (native validation prevents submit otherwise)
const { container } = rendered;
const dateInputs = container.querySelectorAll('input[type="date"]') as NodeListOf<HTMLInputElement>;
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 } });
}
// fill required date inputs (native validation prevents submit otherwise)
const { container } = rendered;
const dateInputs = container.querySelectorAll('input[type="date"]') as NodeListOf<HTMLInputElement>;
const today = new Date().toISOString().slice(0, 10);
expect(dateInputs).toHaveLength(2);
fireEvent.change(dateInputs[0], { target: { value: today } });
fireEvent.change(dateInputs[1], { target: { value: today } });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@__tests__/InvoicesPage.test.tsx` around lines 113 - 120, The test currently
guards filling date inputs with an if which hides missing elements; change to
fail fast by asserting the expected inputs exist before using them: add an
assertion on dateInputs (e.g.,
expect(dateInputs.length).toBeGreaterThanOrEqual(2) or toBe(2)) right after
querying, then proceed to call fireEvent.change on dateInputs[0] and
dateInputs[1]; reference variables/mymbols: dateInputs, fireEvent.change, and
the existing today variable so the test clearly fails when the required date
inputs are not present.


// 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);
});
});
9 changes: 5 additions & 4 deletions app/api/dashboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion app/api/invoices/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 30 additions & 10 deletions app/api/invoices/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 },
});

Expand Down
34 changes: 27 additions & 7 deletions app/invoices/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export default function InvoicesPage() {
clientId: '',
startDate: '',
endDate: '',
hourlyRate: ''
});
const queryClient = useQueryClient();

Expand All @@ -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');
}
Expand All @@ -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');
}
Expand All @@ -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();
Expand Down Expand Up @@ -190,12 +191,20 @@ export default function InvoicesPage() {
<label className="block text-sm font-semibold text-slate-900 mb-2">Client</label>
<select
value={formData.clientId}
onChange={(e) => setFormData({ ...formData, clientId: e.target.value })}
onChange={(e) => {
const clientId = e.target.value;
const client = clientsData?.clients?.find((c: Client) => c.id === clientId);
setFormData({
...formData,
clientId,
hourlyRate: client ? String(client.hourlyRate) : '',
});
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900"
required
>
<option value="">Select client...</option>
{clientsData?.clients.map((client: Client) => (
{clientsData?.clients?.map((client: Client) => (
<option key={client.id} value={client.id}>
{client.name} (${client.hourlyRate}/hr)
</option>
Expand Down Expand Up @@ -226,6 +235,17 @@ export default function InvoicesPage() {
</div>

<div className="md:col-span-3">
<div className="mb-4">
<label className="block text-sm font-semibold text-slate-900 mb-2">Hourly Rate ($)</label>
<input
type="number"
value={formData.hourlyRate}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
className="px-6 py-3 bg-slate-900 text-white rounded-lg hover:bg-slate-800 transition-colors"
Expand Down Expand Up @@ -260,8 +280,8 @@ export default function InvoicesPage() {
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{invoicesData?.invoices.length > 0 ? (
invoicesData?.invoices.map((invoice: Invoice) => (
{invoicesData?.invoices?.length > 0 ? (
invoicesData?.invoices?.map((invoice: Invoice) => (
<tr key={invoice.id} className="hover:bg-slate-50">
<td className="px-6 py-4 text-slate-900">{invoice.client.name}</td>
<td className="px-6 py-4 text-slate-600">
Expand Down
4 changes: 2 additions & 2 deletions app/timer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export default function TimerPage() {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};

const selectedClient = clientsData?.find((c: Client) => c.id === selectedClientId);
const selectedClient = clientsData?.clients?.find((c: Client) => c.id === selectedClientId);
const currentEarnings = selectedClient ? (seconds / 3600) * selectedClient.hourlyRate : 0;

if (clientsLoading) {
Expand Down Expand Up @@ -144,7 +144,7 @@ export default function TimerPage() {
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-900 disabled:bg-slate-100"
>
<option value="">Choose a client...</option>
{clientsData?.map((client: Client) => (
{clientsData?.clients?.map((client: Client) => (
<option key={client.id} value={client.id}>
{client.name} (${client.hourlyRate}/hr)
</option>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Add hourlyRate column to Invoice
ALTER TABLE "Invoice" ADD COLUMN "hourlyRate" NUMERIC(10,2) NOT NULL DEFAULT 0;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- Convert Client.hourlyRate from DOUBLE PRECISION to NUMERIC(10,2), set default and not-null
-- Add updatedAt TIMESTAMP(3) NOT NULL with DEFAULT CURRENT_TIMESTAMP

-- 1) Ensure no NULLs remain in hourlyRate
UPDATE "Client" SET "hourlyRate" = 0 WHERE "hourlyRate" IS NULL;

-- 2) Alter column type to NUMERIC(10,2) with rounding to 2 decimal places
ALTER TABLE "Client" ALTER COLUMN "hourlyRate" TYPE NUMERIC(10,2) USING ROUND("hourlyRate"::numeric, 2);

-- 3) Set default to 0
ALTER TABLE "Client" ALTER COLUMN "hourlyRate" SET DEFAULT 0;

-- 4) Enforce NOT NULL (Prisma model requires non-nullable Decimal)
ALTER TABLE "Client" ALTER COLUMN "hourlyRate" SET NOT NULL;

-- 5) Add updatedAt column with millisecond precision and default current timestamp
ALTER TABLE "Client" ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

-- Optional: backfill updatedAt for existing rows (set to createdAt if present)
UPDATE "Client" SET "updatedAt" = COALESCE("updatedAt", "createdAt") WHERE "updatedAt" IS NULL;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Client" ALTER COLUMN "updatedAt" DROP DEFAULT;
6 changes: 5 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ model Client {
id String @id @default(cuid())
name String
email String
hourlyRate Float

hourlyRate Decimal @db.Decimal(10, 2) @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Comment thread
grmbyrn marked this conversation as resolved.

userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
Expand All @@ -52,6 +54,8 @@ model Invoice {
id String @id @default(cuid())
totalHours Float
totalAmount Float
// Hourly rate used when the invoice was generated (persisted for historical accuracy)
hourlyRate Decimal @db.Decimal(10, 2) @default(0)
periodStart DateTime?
periodEnd DateTime?
createdAt DateTime @default(now())
Expand Down
Loading