From 9bfd4e47eea99d343d47a9c29e33969fdc31e5e7 Mon Sep 17 00:00:00 2001 From: JCBSComputer Date: Wed, 1 Jul 2026 13:34:58 +0200 Subject: [PATCH] Added Client Portal(Working) --- src/app/api/auth/login/route.ts | 30 ++-- .../api/dashboard/stats/route.ts | 54 +++++++ src/app/client-portal/api/receipts/route.ts | 24 +++ src/app/client-portal/dashboard/page.tsx | 89 ++++++++++- src/app/client-portal/invoices/[id]/page.tsx | 143 +++++++++++++++++- src/app/client-portal/layout.tsx | 11 +- src/app/client-portal/login/page.tsx | 28 +++- src/app/client-portal/projects/[id]/page.tsx | 49 ++++++ src/app/client-portal/receipts/page.tsx | 75 +++++++++ 9 files changed, 461 insertions(+), 42 deletions(-) create mode 100644 src/app/client-portal/api/dashboard/stats/route.ts create mode 100644 src/app/client-portal/api/receipts/route.ts create mode 100644 src/app/client-portal/receipts/page.tsx diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 8b3d95c..a3522e2 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,4 +1,4 @@ -import { NextRequest } from "next/server" +import { NextRequest, NextResponse } from "next/server" import { comparePassword, getUserByEmail, @@ -15,10 +15,7 @@ import { import { checkRateLimit } from "@/lib/rate-limit" function jsonResponse(data: unknown, status: number, headers?: Record) { - return new Response(JSON.stringify(data), { - status, - headers: { "Content-Type": "application/json", ...headers }, - }) + return NextResponse.json(data, { status, headers }) } export async function POST(request: NextRequest) { @@ -132,20 +129,21 @@ export async function POST(request: NextRequest) { const user = mapDbUserToSessionUser(dbUser) - const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}` - - return new Response(JSON.stringify({ user }), { - status: 200, - headers: { - "Content-Type": "application/json", - "Set-Cookie": cookieStr, - }, + const isHttps = request.nextUrl.protocol === "https:" || request.headers.get("x-forwarded-proto") === "https" + const response = NextResponse.json({ user }) + response.cookies.set(SESSION_COOKIE, token, { + httpOnly: true, + sameSite: "strict", + path: "/", + maxAge: 86400, + secure: isHttps, }) + return response } catch (error) { console.error("Login error:", error) - return new Response( - JSON.stringify({ error: "Authentication service unavailable." }), - { status: 503, headers: { "Content-Type": "application/json" } } + return NextResponse.json( + { error: "Authentication service unavailable." }, + { status: 503 } ) } } diff --git a/src/app/client-portal/api/dashboard/stats/route.ts b/src/app/client-portal/api/dashboard/stats/route.ts new file mode 100644 index 0000000..d83c70a --- /dev/null +++ b/src/app/client-portal/api/dashboard/stats/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server" +import { query } from "@/lib/db" +import { getPortalSession, isPortalError } from "../../portal-utils" + +export async function GET() { + const session = await getPortalSession() + if (isPortalError(session)) return session + + if (!session.customerId) { + return NextResponse.json({ totalBilled: 0, totalPaid: 0, totalOverdue: 0, upcomingDeadlines: [] }) + } + + const totals = await query( + `SELECT + COALESCE(SUM(i.total_amount) FILTER (WHERE s.name = 'Paid'), 0) as total_paid, + COALESCE(SUM(i.total_amount) FILTER (WHERE s.name = 'Overdue'), 0) as total_overdue, + COALESCE(SUM(i.total_amount), 0) as total_billed + FROM invoices i + LEFT JOIN invoice_statuses s ON s.id = i.status_id + WHERE i.customer_id = $1 AND i.deleted_at IS NULL`, + [session.customerId], + ) + + const deadlines = await query( + `SELECT 'invoice' as type, i.id, i.invoice_number as name, i.due_date as date, s.name as status_name + FROM invoices i + LEFT JOIN invoice_statuses s ON s.id = i.status_id + WHERE i.customer_id = $1 AND i.deleted_at IS NULL AND s.name NOT IN ('Paid', 'Cancelled', 'Refunded') + AND i.due_date >= CURRENT_DATE + ORDER BY i.due_date ASC LIMIT 5`, + [session.customerId], + ) + + const milestones = await query( + `SELECT 'milestone' as type, m.id, m.name, m.due_date as date, m.status as status_name + FROM milestones m + JOIN projects p ON p.id = m.project_id + WHERE p.customer_id = $1 AND m.status NOT IN ('approved', 'completed') + AND m.due_date >= CURRENT_DATE + ORDER BY m.due_date ASC LIMIT 5`, + [session.customerId], + ) + + const allDeadlines = [...(deadlines.rows || []), ...(milestones.rows || [])] + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + .slice(0, 5) + + return NextResponse.json({ + totalBilled: Number(totals.rows[0]?.total_billed || 0), + totalPaid: Number(totals.rows[0]?.total_paid || 0), + totalOverdue: Number(totals.rows[0]?.total_overdue || 0), + upcomingDeadlines: allDeadlines, + }) +} diff --git a/src/app/client-portal/api/receipts/route.ts b/src/app/client-portal/api/receipts/route.ts new file mode 100644 index 0000000..01c8320 --- /dev/null +++ b/src/app/client-portal/api/receipts/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server" +import { query } from "@/lib/db" +import { getPortalSession, isPortalError } from "../portal-utils" + +export async function GET() { + const session = await getPortalSession() + if (isPortalError(session)) return session + + if (!session.customerId) { + return NextResponse.json({ receipts: [] }) + } + + const result = await query( + `SELECT r.id, r.receipt_number, r.file_url, r.issued_at, r.created_at, + i.invoice_number, i.total_amount, i.currency + FROM receipts r + JOIN invoices i ON i.id = r.invoice_id + WHERE i.customer_id = $1 + ORDER BY r.issued_at DESC`, + [session.customerId], + ) + + return NextResponse.json({ receipts: result.rows }) +} diff --git a/src/app/client-portal/dashboard/page.tsx b/src/app/client-portal/dashboard/page.tsx index 791b8dc..79122f7 100644 --- a/src/app/client-portal/dashboard/page.tsx +++ b/src/app/client-portal/dashboard/page.tsx @@ -2,32 +2,48 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" -import { FolderOpen, FileText, AlertCircle } from "lucide-react" +import { FolderOpen, FileText, AlertCircle, DollarSign, TrendingUp, AlertTriangle, Calendar } from "lucide-react" + +interface Deadline { + type: string + id: string + name: string + date: string + status_name: string +} export default function ClientDashboard() { const router = useRouter() const [counts, setCounts] = useState({ projects: 0, invoices: 0, tickets: 0 }) + const [stats, setStats] = useState({ totalBilled: 0, totalPaid: 0, totalOverdue: 0, upcomingDeadlines: [] as Deadline[] }) const [name, setName] = useState("") + const safeJson = (r: Response) => r.ok ? r.json() : Promise.reject() + useEffect(() => { fetch("/client-portal/api/projects", { credentials: "include" }) - .then((r) => r.json()) + .then(safeJson) .then((d) => setCounts((prev) => ({ ...prev, projects: d.projects?.length || 0 }))) .catch(() => {}) fetch("/client-portal/api/invoices", { credentials: "include" }) - .then((r) => r.json()) + .then(safeJson) .then((d) => setCounts((prev) => ({ ...prev, invoices: d.invoices?.length || 0 }))) .catch(() => {}) fetch("/client-portal/api/support", { credentials: "include" }) - .then((r) => r.json()) + .then(safeJson) .then((d) => setCounts((prev) => ({ ...prev, tickets: d.tickets?.length || 0 }))) .catch(() => {}) + fetch("/client-portal/api/dashboard/stats", { credentials: "include" }) + .then(safeJson) + .then((d) => setStats(d)) + .catch(() => {}) + fetch("/api/auth/me", { credentials: "include" }) - .then((r) => r.json()) - .then((d) => setName(d.firstName || d.email || "Client")) + .then(safeJson) + .then((d) => setName(d.user?.firstName || d.user?.email || "Client")) .catch(() => {}) }, []) @@ -37,12 +53,18 @@ export default function ClientDashboard() { { label: "Support Tickets", value: counts.tickets, icon: AlertCircle, color: "#f59e0b", href: "/client-portal/support" }, ] + const formatCurrency = (amount: number) => { + const num = Number(amount) || 0 + return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + } + return (

Welcome, {name}

Your project overview at a glance

-
+ {/* Count cards */} +
{cards.map((card) => { const Icon = card.icon return ( @@ -62,6 +84,59 @@ export default function ClientDashboard() { ) })}
+ + {/* Financial stats */} +
+
+
+ + Total Billed +
+

{formatCurrency(stats.totalBilled)}

+
+
+
+ + Total Paid +
+

{formatCurrency(stats.totalPaid)}

+
+
+
+ + Overdue +
+

{formatCurrency(stats.totalOverdue)}

+
+
+ + {/* Upcoming Deadlines */} + {stats.upcomingDeadlines.length > 0 && ( +
+
+ + Upcoming Deadlines +
+
+ {(stats.upcomingDeadlines || []).map((d, i) => ( +
+
+
+
+

{d.name}

+

{d.type} · {d.status_name?.replace("_", " ")}

+
+
+ + {new Date(d.date).toLocaleDateString()} + +
+ ))} +
+
+ )}
) } diff --git a/src/app/client-portal/invoices/[id]/page.tsx b/src/app/client-portal/invoices/[id]/page.tsx index fe76231..e409855 100644 --- a/src/app/client-portal/invoices/[id]/page.tsx +++ b/src/app/client-portal/invoices/[id]/page.tsx @@ -1,8 +1,8 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useState, useRef } from "react" import { useParams } from "next/navigation" -import { FileText, Download } from "lucide-react" +import { FileText, Download, Printer } from "lucide-react" interface InvoiceDetail { invoice: any @@ -13,6 +13,7 @@ interface InvoiceDetail { export default function InvoiceDetail() { const { id } = useParams<{ id: string }>() const [data, setData] = useState(null) + const printRef = useRef(null) useEffect(() => { fetch(`/client-portal/api/invoices/${id}`, { credentials: "include" }) @@ -23,6 +24,126 @@ export default function InvoiceDetail() { .catch(() => {}) }, [id]) + const handlePrint = () => { + const printWindow = window.open("", "_blank") + if (!printWindow || !data) return + + const { invoice, items } = data + const statusColor = invoice.status_name === "Paid" ? "#22c55e" + : invoice.status_name === "Overdue" ? "#ef4444" + : "#f59e0b" + + const rows = (items || []).map((item: any) => ` + + ${item.description || "-"} + ${item.quantity || 0} + ${invoice.currency} ${Number(item.unit_price || 0).toLocaleString()} + ${invoice.currency} ${Number(item.total_price || 0).toLocaleString()} + + `).join("") + + printWindow.document.write(` + + + Invoice ${invoice.invoice_number} + + + +
+
+

Invoice ${invoice.invoice_number}

+
+
+ ${invoice.status_name} +
+
+ +
+
+
Issued
+
${invoice.issued_date ? new Date(invoice.issued_date).toLocaleDateString() : "-"}
+
+
+
Due
+
${invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : "-"}
+
+ ${invoice.paid_at ? ` +
+
Paid
+
${new Date(invoice.paid_at).toLocaleDateString()}
+
+ ` : ""} +
+ + + + + + + + + + + + ${rows} + +
DescriptionQtyUnit PriceTotal
+ + + ${invoice.subtotal ? ` + + + + + ` : ""} + ${Number(invoice.tax_amount) > 0 ? ` + + + + + ` : ""} + ${Number(invoice.discount_amount) > 0 ? ` + + + + + ` : ""} + + + + +
Subtotal${invoice.currency} ${Number(invoice.subtotal).toLocaleString()}
Tax (${invoice.tax_percent || 0}%)${invoice.currency} ${Number(invoice.tax_amount).toLocaleString()}
Discount-${invoice.currency} ${Number(invoice.discount_amount).toLocaleString()}
Total${invoice.currency} ${Number(invoice.total_amount).toLocaleString()}
+ + + +