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
25 changes: 24 additions & 1 deletion app/api/dashboard/route.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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)),
Expand All @@ -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);
Expand Down
35 changes: 28 additions & 7 deletions app/api/invoices/[id]/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, InvoiceStatus } from '@prisma/client';

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
validateEnv();
Expand Down Expand Up @@ -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({
Expand All @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion app/api/invoices/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -123,6 +123,7 @@ export async function POST(req: Request) {
clientId,
userId: user.id,
dueDate: due,
status: 'DRAFT',
};

const invoice = await prisma.invoice.create({
Expand Down
14 changes: 14 additions & 0 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ interface DashboardData {
hours: number;
earnings: number;
}>;
unpaidCount?: number;
overdueCount?: number;
}

export default function DashboardPage() {
Expand Down Expand Up @@ -76,6 +78,18 @@ export default function DashboardPage() {
</div>
</div>

<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-white rounded-lg shadow-md p-6">
<div className="text-slate-600 text-sm font-semibold mb-2">Unpaid Invoices</div>
<div className="text-4xl font-bold text-slate-900">{data.unpaidCount ?? 0}</div>
</div>

<div className="bg-white rounded-lg shadow-md p-6">
<div className="text-slate-600 text-sm font-semibold mb-2">Overdue Invoices</div>
<div className="text-4xl font-bold text-red-600">{data.overdueCount ?? 0}</div>
</div>
</div>

<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Earnings by Client */}
<div className="lg:col-span-1">
Expand Down
47 changes: 46 additions & 1 deletion app/invoices/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface Invoice {
periodEnd?: string | null;
client: { name: string };
isPaid: boolean;
status?: string;
dueDate?: string | null;
isOverdue?: boolean;
}
Expand Down Expand Up @@ -320,7 +321,21 @@ export default function InvoicesPage() {
${invoice.totalAmount.toFixed(2)}
</td>
<td className="px-6 py-4 text-center">
{invoice.isOverdue ? 'Overdue' : invoice.isPaid ? 'Paid' : 'Unpaid'}
<div className="inline-flex items-center space-x-2">
<span
className={`px-2 py-1 text-sm rounded-full font-semibold text-white ${
invoice.status === 'PAID'
? 'bg-green-600'
: invoice.status === 'SENT'
? 'bg-blue-600'
: invoice.status === 'OVERDUE'
? 'bg-red-600'
: 'bg-slate-500'
}`}
>
{invoice.status ? invoice.status.toLowerCase() : invoice.isOverdue ? 'overdue' : invoice.isPaid ? 'paid' : 'unpaid'}
</span>
</div>
</td>
<td className="px-6 py-4 text-right space-x-2">
<button
Expand Down Expand Up @@ -356,6 +371,36 @@ export default function InvoicesPage() {
>
{invoice.isPaid ? 'Mark as Unpaid' : 'Mark as Paid'}
</button>
<select
value={invoice.status || (invoice.isPaid ? 'PAID' : 'DRAFT')}
onChange={async (e) => {
const newStatus = e.target.value;
try {
const res = await fetch(`/api/invoices/${invoice.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});

if (!res.ok) {
const err = await res.json().catch(() => null);
alert(err?.error || 'Failed to update status');
return;
}

queryClient.invalidateQueries({ queryKey: ['invoices'] });
} catch (err) {
console.error('Network error updating status', err);
alert('Network error');
}
}}
className="ml-2 px-2 py-1 border rounded-lg text-sm"
>
<option value="DRAFT">draft</option>
<option value="SENT">sent</option>
<option value="PAID">paid</option>
<option value="OVERDUE">overdue</option>
</select>
<button
onClick={() => handleView(invoice.id)}
className="px-3 py-2 text-sm bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition-colors"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "InvoiceStatus" AS ENUM ('DRAFT', 'SENT', 'PAID', 'OVERDUE');

-- AlterTable
ALTER TABLE "Invoice" ADD COLUMN "status" "InvoiceStatus" NOT NULL DEFAULT 'DRAFT';
9 changes: 9 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ model Invoice {
periodStart DateTime?
periodEnd DateTime?
createdAt DateTime @default(now())
// Status of the invoice: draft, sent, paid, overdue
status InvoiceStatus @default(DRAFT)

clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
Expand All @@ -68,4 +70,11 @@ model Invoice {
isPaid Boolean @default(false)
// Optional due date for the invoice; if not provided, default applied in server logic
dueDate DateTime?
}

enum InvoiceStatus {
DRAFT
SENT
PAID
OVERDUE
}
Loading