Added Client Portal(Working)
Build & Auto-Repair / build (push) Has been cancelled

This commit is contained in:
JCBSComputer
2026-07-01 13:34:58 +02:00
parent 00bda54695
commit 9bfd4e47ee
9 changed files with 461 additions and 42 deletions
+14 -16
View File
@@ -1,4 +1,4 @@
import { NextRequest } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { import {
comparePassword, comparePassword,
getUserByEmail, getUserByEmail,
@@ -15,10 +15,7 @@ import {
import { checkRateLimit } from "@/lib/rate-limit" import { checkRateLimit } from "@/lib/rate-limit"
function jsonResponse(data: unknown, status: number, headers?: Record<string, string>) { function jsonResponse(data: unknown, status: number, headers?: Record<string, string>) {
return new Response(JSON.stringify(data), { return NextResponse.json(data, { status, headers })
status,
headers: { "Content-Type": "application/json", ...headers },
})
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -132,20 +129,21 @@ export async function POST(request: NextRequest) {
const user = mapDbUserToSessionUser(dbUser) const user = mapDbUserToSessionUser(dbUser)
const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}` const isHttps = request.nextUrl.protocol === "https:" || request.headers.get("x-forwarded-proto") === "https"
const response = NextResponse.json({ user })
return new Response(JSON.stringify({ user }), { response.cookies.set(SESSION_COOKIE, token, {
status: 200, httpOnly: true,
headers: { sameSite: "strict",
"Content-Type": "application/json", path: "/",
"Set-Cookie": cookieStr, maxAge: 86400,
}, secure: isHttps,
}) })
return response
} catch (error) { } catch (error) {
console.error("Login error:", error) console.error("Login error:", error)
return new Response( return NextResponse.json(
JSON.stringify({ error: "Authentication service unavailable." }), { error: "Authentication service unavailable." },
{ status: 503, headers: { "Content-Type": "application/json" } } { status: 503 }
) )
} }
} }
@@ -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,
})
}
@@ -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 })
}
+82 -7
View File
@@ -2,32 +2,48 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" 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() { export default function ClientDashboard() {
const router = useRouter() const router = useRouter()
const [counts, setCounts] = useState({ projects: 0, invoices: 0, tickets: 0 }) 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 [name, setName] = useState("")
const safeJson = (r: Response) => r.ok ? r.json() : Promise.reject()
useEffect(() => { useEffect(() => {
fetch("/client-portal/api/projects", { credentials: "include" }) fetch("/client-portal/api/projects", { credentials: "include" })
.then((r) => r.json()) .then(safeJson)
.then((d) => setCounts((prev) => ({ ...prev, projects: d.projects?.length || 0 }))) .then((d) => setCounts((prev) => ({ ...prev, projects: d.projects?.length || 0 })))
.catch(() => {}) .catch(() => {})
fetch("/client-portal/api/invoices", { credentials: "include" }) fetch("/client-portal/api/invoices", { credentials: "include" })
.then((r) => r.json()) .then(safeJson)
.then((d) => setCounts((prev) => ({ ...prev, invoices: d.invoices?.length || 0 }))) .then((d) => setCounts((prev) => ({ ...prev, invoices: d.invoices?.length || 0 })))
.catch(() => {}) .catch(() => {})
fetch("/client-portal/api/support", { credentials: "include" }) fetch("/client-portal/api/support", { credentials: "include" })
.then((r) => r.json()) .then(safeJson)
.then((d) => setCounts((prev) => ({ ...prev, tickets: d.tickets?.length || 0 }))) .then((d) => setCounts((prev) => ({ ...prev, tickets: d.tickets?.length || 0 })))
.catch(() => {}) .catch(() => {})
fetch("/client-portal/api/dashboard/stats", { credentials: "include" })
.then(safeJson)
.then((d) => setStats(d))
.catch(() => {})
fetch("/api/auth/me", { credentials: "include" }) fetch("/api/auth/me", { credentials: "include" })
.then((r) => r.json()) .then(safeJson)
.then((d) => setName(d.firstName || d.email || "Client")) .then((d) => setName(d.user?.firstName || d.user?.email || "Client"))
.catch(() => {}) .catch(() => {})
}, []) }, [])
@@ -37,12 +53,18 @@ export default function ClientDashboard() {
{ label: "Support Tickets", value: counts.tickets, icon: AlertCircle, color: "#f59e0b", href: "/client-portal/support" }, { 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 ( return (
<div> <div>
<h1 className="text-xl font-bold text-white mb-1">Welcome, {name}</h1> <h1 className="text-xl font-bold text-white mb-1">Welcome, {name}</h1>
<p className="text-sm text-[#6a6a75] mb-6">Your project overview at a glance</p> <p className="text-sm text-[#6a6a75] mb-6">Your project overview at a glance</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> {/* Count cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
{cards.map((card) => { {cards.map((card) => {
const Icon = card.icon const Icon = card.icon
return ( return (
@@ -62,6 +84,59 @@ export default function ClientDashboard() {
) )
})} })}
</div> </div>
{/* Financial stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-5">
<div className="flex items-center gap-2 mb-2">
<DollarSign className="h-4 w-4 text-[#1BB0CE]" />
<span className="text-xs text-[#6a6a75] uppercase tracking-wider">Total Billed</span>
</div>
<p className="text-xl font-bold text-white">{formatCurrency(stats.totalBilled)}</p>
</div>
<div className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-5">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="h-4 w-4 text-emerald-400" />
<span className="text-xs text-[#6a6a75] uppercase tracking-wider">Total Paid</span>
</div>
<p className="text-xl font-bold text-emerald-400">{formatCurrency(stats.totalPaid)}</p>
</div>
<div className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-5">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-4 w-4 text-red-400" />
<span className="text-xs text-[#6a6a75] uppercase tracking-wider">Overdue</span>
</div>
<p className="text-xl font-bold text-red-400">{formatCurrency(stats.totalOverdue)}</p>
</div>
</div>
{/* Upcoming Deadlines */}
{stats.upcomingDeadlines.length > 0 && (
<div className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-5">
<div className="flex items-center gap-2 mb-4">
<Calendar className="h-4 w-4 text-[#f59e0b]" />
<span className="text-sm font-semibold text-white">Upcoming Deadlines</span>
</div>
<div className="space-y-3">
{(stats.upcomingDeadlines || []).map((d, i) => (
<div key={`${d.type}-${d.id}-${i}`} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${
d.type === "invoice" ? "bg-[#a855f7]" : "bg-[#1BB0CE]"
}`} />
<div>
<p className="text-sm text-white">{d.name}</p>
<p className="text-[11px] text-[#6a6a75] capitalize">{d.type} &middot; {d.status_name?.replace("_", " ")}</p>
</div>
</div>
<span className="text-xs text-[#6a6a75]">
{new Date(d.date).toLocaleDateString()}
</span>
</div>
))}
</div>
</div>
)}
</div> </div>
) )
} }
+135 -8
View File
@@ -1,8 +1,8 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState, useRef } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import { FileText, Download } from "lucide-react" import { FileText, Download, Printer } from "lucide-react"
interface InvoiceDetail { interface InvoiceDetail {
invoice: any invoice: any
@@ -13,6 +13,7 @@ interface InvoiceDetail {
export default function InvoiceDetail() { export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const [data, setData] = useState<InvoiceDetail | null>(null) const [data, setData] = useState<InvoiceDetail | null>(null)
const printRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
fetch(`/client-portal/api/invoices/${id}`, { credentials: "include" }) fetch(`/client-portal/api/invoices/${id}`, { credentials: "include" })
@@ -23,6 +24,126 @@ export default function InvoiceDetail() {
.catch(() => {}) .catch(() => {})
}, [id]) }, [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) => `
<tr>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; color: #374151;">${item.description || "-"}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; color: #374151; text-align: right;">${item.quantity || 0}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; color: #374151; text-align: right;">${invoice.currency} ${Number(item.unit_price || 0).toLocaleString()}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; color: #374151; text-align: right;">${invoice.currency} ${Number(item.total_price || 0).toLocaleString()}</td>
</tr>
`).join("")
printWindow.document.write(`
<html>
<head>
<title>Invoice ${invoice.invoice_number}</title>
<style>
@page { margin: 20mm; }
body { font-family: Arial, Helvetica, sans-serif; color: #111; padding: 0; margin: 0; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 2px solid #1BB0CE; }
.header h1 { font-size: 24px; margin: 0; }
.header .status { font-size: 12px; font-weight: 600; padding: 4px 12px; border-radius: 999px; display: inline-block; }
.meta { display: flex; gap: 40px; margin-bottom: 30px; }
.meta-item dt { font-size: 11px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.meta-item dd { font-size: 14px; font-weight: 600; margin: 0; color: #111; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
th { background: #f9fafb; text-align: left; padding: 10px 12px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #6b7280; border-bottom: 2px solid #e5e7eb; }
th:last-child, td:last-child { text-align: right; }
.totals { width: 300px; margin-left: auto; }
.totals td { padding: 6px 12px; font-size: 13px; }
.totals .grand-total td { font-size: 16px; font-weight: 700; padding-top: 12px; border-top: 2px solid #111; }
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; text-align: center; }
@media print { .no-print { display: none; } }
</style>
</head>
<body>
<div class="header">
<div>
<h1>Invoice ${invoice.invoice_number}</h1>
</div>
<div>
<span class="status" style="background: ${statusColor}15; color: ${statusColor}; border: 1px solid ${statusColor}30;">${invoice.status_name}</span>
</div>
</div>
<dl class="meta">
<div class="meta-item">
<dt>Issued</dt>
<dd>${invoice.issued_date ? new Date(invoice.issued_date).toLocaleDateString() : "-"}</dd>
</div>
<div class="meta-item">
<dt>Due</dt>
<dd>${invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : "-"}</dd>
</div>
${invoice.paid_at ? `
<div class="meta-item">
<dt>Paid</dt>
<dd>${new Date(invoice.paid_at).toLocaleDateString()}</dd>
</div>
` : ""}
</dl>
<table>
<thead>
<tr>
<th>Description</th>
<th style="text-align: right;">Qty</th>
<th style="text-align: right;">Unit Price</th>
<th style="text-align: right;">Total</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
<table class="totals">
${invoice.subtotal ? `
<tr>
<td style="color: #6b7280;">Subtotal</td>
<td style="text-align: right;">${invoice.currency} ${Number(invoice.subtotal).toLocaleString()}</td>
</tr>
` : ""}
${Number(invoice.tax_amount) > 0 ? `
<tr>
<td style="color: #6b7280;">Tax (${invoice.tax_percent || 0}%)</td>
<td style="text-align: right;">${invoice.currency} ${Number(invoice.tax_amount).toLocaleString()}</td>
</tr>
` : ""}
${Number(invoice.discount_amount) > 0 ? `
<tr>
<td style="color: #6b7280;">Discount</td>
<td style="text-align: right;">-${invoice.currency} ${Number(invoice.discount_amount).toLocaleString()}</td>
</tr>
` : ""}
<tr class="grand-total">
<td style="color: #111;">Total</td>
<td style="text-align: right; color: #1BB0CE;">${invoice.currency} ${Number(invoice.total_amount).toLocaleString()}</td>
</tr>
</table>
<div class="footer">
<p>Generated from Client Portal &mdash; ${new Date().toLocaleDateString()}</p>
</div>
<script>
window.onload = function() { window.print(); window.close(); }
<${"/script"}>
</body>
</html>
`)
printWindow.document.close()
}
if (!data) { if (!data) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@@ -34,8 +155,17 @@ export default function InvoiceDetail() {
const { invoice, items, receipts } = data const { invoice, items, receipts } = data
return ( return (
<div> <div ref={printRef}>
<h1 className="text-xl font-bold text-white mb-1">Invoice {invoice.invoice_number}</h1> <div className="flex items-center justify-between mb-1">
<h1 className="text-xl font-bold text-white">Invoice {invoice.invoice_number}</h1>
<button
onClick={handlePrint}
className="flex items-center gap-1.5 bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 text-white text-xs px-3 py-2 rounded-lg transition-all"
>
<Printer className="h-3.5 w-3.5" />
Print / PDF
</button>
</div>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${ <span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
invoice.status_name === "Paid" ? "bg-emerald-500/10 text-emerald-400" : invoice.status_name === "Paid" ? "bg-emerald-500/10 text-emerald-400" :
invoice.status_name === "Overdue" ? "bg-red-500/10 text-red-400" : invoice.status_name === "Overdue" ? "bg-red-500/10 text-red-400" :
@@ -44,7 +174,6 @@ export default function InvoiceDetail() {
{invoice.status_name} {invoice.status_name}
</span> </span>
{/* Invoice details */}
<div className="grid grid-cols-2 gap-4 mt-6 mb-6"> <div className="grid grid-cols-2 gap-4 mt-6 mb-6">
<div className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-4"> <div className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-4">
<span className="text-[11px] text-[#6a6a75] uppercase tracking-wider">Issued</span> <span className="text-[11px] text-[#6a6a75] uppercase tracking-wider">Issued</span>
@@ -60,9 +189,8 @@ export default function InvoiceDetail() {
</div> </div>
</div> </div>
{/* Line items */}
<h2 className="text-base font-semibold text-white mb-3">Items</h2> <h2 className="text-base font-semibold text-white mb-3">Items</h2>
<div className="bg-[#0d1117] border border-[#1a1a24] rounded-xl overflow-hidden mb-6"> <div className="bg-[#0d1117] border border-[#1a1a24] rounded-xl overflow-hidden mb-6 print:hidden">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-[#1a1a24]"> <tr className="border-b border-[#1a1a24]">
@@ -113,7 +241,6 @@ export default function InvoiceDetail() {
</table> </table>
</div> </div>
{/* Receipts */}
{receipts.length > 0 && ( {receipts.length > 0 && (
<> <>
<h2 className="text-base font-semibold text-white mb-3">Receipts</h2> <h2 className="text-base font-semibold text-white mb-3">Receipts</h2>
+6 -5
View File
@@ -2,12 +2,13 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter, usePathname } from "next/navigation" import { useRouter, usePathname } from "next/navigation"
import { Home, FolderOpen, FileText, LifeBuoy, User, Activity, LogOut, Menu, X } from "lucide-react" import { Home, FolderOpen, FileText, LifeBuoy, User, Activity, Receipt, LogOut, Menu, X } from "lucide-react"
const navItems = [ const navItems = [
{ href: "/client-portal/dashboard", label: "Dashboard", icon: Home }, { href: "/client-portal/dashboard", label: "Dashboard", icon: Home },
{ href: "/client-portal/projects", label: "Projects", icon: FolderOpen }, { href: "/client-portal/projects", label: "Projects", icon: FolderOpen },
{ href: "/client-portal/invoices", label: "Invoices", icon: FileText }, { href: "/client-portal/invoices", label: "Invoices", icon: FileText },
{ href: "/client-portal/receipts", label: "Receipts", icon: Receipt },
{ href: "/client-portal/support", label: "Support", icon: LifeBuoy }, { href: "/client-portal/support", label: "Support", icon: LifeBuoy },
{ href: "/client-portal/activity", label: "Activity", icon: Activity }, { href: "/client-portal/activity", label: "Activity", icon: Activity },
{ href: "/client-portal/profile", label: "Profile", icon: User }, { href: "/client-portal/profile", label: "Profile", icon: User },
@@ -21,9 +22,9 @@ export default function ClientPortalLayout({ children }: { children: React.React
useEffect(() => { useEffect(() => {
fetch("/api/auth/me", { credentials: "include" }) fetch("/api/auth/me", { credentials: "include" })
.then((r) => r.json()) .then((r) => r.ok ? r.json() : Promise.reject())
.then((data) => { .then((data) => {
if (data.role === "client") setAuth(true) if (data.user?.role === "client") setAuth(true)
else { setAuth(false); router.push("/client-portal/login") } else { setAuth(false); router.push("/client-portal/login") }
}) })
.catch(() => { setAuth(false); router.push("/client-portal/login") }) .catch(() => { setAuth(false); router.push("/client-portal/login") })
@@ -34,6 +35,8 @@ export default function ClientPortalLayout({ children }: { children: React.React
router.push("/client-portal/login") router.push("/client-portal/login")
} }
if (pathname === "/client-portal/login") return <>{children}</>
if (auth === null) { if (auth === null) {
return ( return (
<div className="flex min-h-screen bg-[#0a0a0f] items-center justify-center"> <div className="flex min-h-screen bg-[#0a0a0f] items-center justify-center">
@@ -44,8 +47,6 @@ export default function ClientPortalLayout({ children }: { children: React.React
if (!auth) return null if (!auth) return null
if (pathname === "/client-portal/login") return <>{children}</>
return ( return (
<div className="flex min-h-screen bg-[#0a0a0f]"> <div className="flex min-h-screen bg-[#0a0a0f]">
{/* Mobile overlay */} {/* Mobile overlay */}
+22 -6
View File
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Eye, EyeOff } from "lucide-react" import { Eye, EyeOff, AlertCircle } from "lucide-react"
export default function ClientLoginPage() { export default function ClientLoginPage() {
const router = useRouter() const router = useRouter()
@@ -12,12 +12,13 @@ export default function ClientLoginPage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [checking, setChecking] = useState(true) const [checking, setChecking] = useState(true)
const [debugInfo, setDebugInfo] = useState("")
useEffect(() => { useEffect(() => {
fetch("/api/auth/me", { credentials: "include" }) fetch("/api/auth/me", { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { .then((data) => {
if (data.role === "client") router.push("/client-portal/dashboard") if (data.user?.role === "client") router.push("/client-portal/dashboard")
else setChecking(false) else setChecking(false)
}) })
.catch(() => setChecking(false)) .catch(() => setChecking(false))
@@ -27,6 +28,7 @@ export default function ClientLoginPage() {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
setError("") setError("")
setDebugInfo("")
try { try {
const res = await fetch("/api/auth/login", { const res = await fetch("/api/auth/login", {
@@ -36,19 +38,27 @@ export default function ClientLoginPage() {
}) })
const data = await res.json() const data = await res.json()
console.log("LOGIN RESPONSE:", res.status, data)
if (!res.ok) { if (!res.ok) {
setError(data.error || "Login failed") setError(data.error || "Login failed")
setDebugInfo(`HTTP ${res.status}`)
return return
} }
if (data.user?.role !== "client") { if (data.user?.role !== "client") {
setError("This portal is for clients only") setError("This portal is for clients only")
setDebugInfo(`User role: ${data.user?.role}`)
return return
} }
router.push("/client-portal/dashboard") setError("Login successful! Redirecting...")
} catch { setDebugInfo("")
setTimeout(() => { window.location.href = "/client-portal/dashboard" }, 300)
} catch (err) {
console.error("LOGIN FETCH ERROR:", err)
setError("Connection error") setError("Connection error")
setDebugInfo("Check network tab for details")
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -108,8 +118,14 @@ export default function ClientLoginPage() {
</div> </div>
{error && ( {error && (
<div className="text-xs text-red-400 bg-red-400/5 border border-red-400/10 rounded-lg px-3 py-2"> <div className={"text-sm bg-opacity-10 border rounded-lg px-3 py-2 " + (error === "Login successful! Redirecting..."
{error} ? "text-[#1BB0CE] bg-[#1BB0CE]/5 border-[#1BB0CE]/20"
: "text-red-400 bg-red-400/5 border-red-400/10")}>
<div className="flex items-center gap-2">
{error !== "Login successful! Redirecting..." && <AlertCircle className="h-4 w-4 shrink-0" />}
<span className="font-medium">{error}</span>
</div>
{debugInfo && <div className="mt-1 text-xs opacity-60">{debugInfo}</div>}
</div> </div>
)} )}
@@ -116,6 +116,55 @@ export default function ProjectDetail() {
</div> </div>
</div> </div>
{/* Timeline view */}
{milestones.length > 0 && (() => {
const sorted = [...milestones].sort((a, b) => new Date(a.due_date).getTime() - new Date(b.due_date).getTime())
const start = new Date(sorted[0].due_date)
const end = new Date(sorted[sorted.length - 1].due_date)
const range = Math.max(end.getTime() - start.getTime(), 1)
const today = new Date()
return (
<div className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-5 mb-6">
<h2 className="text-base font-semibold text-white mb-4">Timeline</h2>
<div className="relative">
{sorted.map((m, i) => {
const mDate = new Date(m.due_date)
const leftPct = Math.max(((mDate.getTime() - start.getTime()) / range) * 100, 0)
const widthPct = Math.max(((mDate.getTime() - Math.max(start.getTime(), (sorted[i-1]?.due_date ? new Date(sorted[i-1].due_date).getTime() : start.getTime()))) / range) * 100, 5)
const si = statusIcon[m.status] || { color: "#6a6a75" }
const isOverdue = mDate < today && m.status !== "approved" && m.status !== "completed"
return (
<div key={m.id} className="flex items-center gap-3 mb-3 last:mb-0">
<div className="w-24 flex-shrink-0 text-right">
<span className="text-[10px] text-[#6a6a75]">{mDate.toLocaleDateString()}</span>
</div>
<div className="flex-1 h-7 bg-[#1a1a24] rounded-md overflow-hidden relative">
<div
className="h-full rounded-md transition-all flex items-center px-2"
style={{
width: `${Math.min(Math.max(m.progress, (widthPct > 50 ? 10 : 5)), 100)}%`,
backgroundColor: isOverdue ? "#ef444420" : `${si.color}20`,
borderLeft: `3px solid ${isOverdue ? "#ef4444" : si.color}`,
}}
>
<span className="text-[10px] font-medium truncate" style={{ color: isOverdue ? "#ef4444" : si.color }}>
{m.name}
</span>
</div>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] text-[#6a6a75]">
{m.progress}%
</span>
</div>
</div>
)
})}
</div>
</div>
)
})()}
{/* Milestones */} {/* Milestones */}
<h2 className="text-base font-semibold text-white mb-3">Milestones</h2> <h2 className="text-base font-semibold text-white mb-3">Milestones</h2>
<div className="space-y-2 mb-6"> <div className="space-y-2 mb-6">
+75
View File
@@ -0,0 +1,75 @@
"use client"
import { useEffect, useState } from "react"
import { FileText, Download } from "lucide-react"
interface Receipt {
id: string
receipt_number: string
file_url: string
issued_at: string
invoice_number: string
total_amount: number
currency: string
}
export default function ClientReceipts() {
const [receipts, setReceipts] = useState<Receipt[]>([])
useEffect(() => {
fetch("/client-portal/api/receipts", { credentials: "include" })
.then((r) => r.json())
.then((d) => setReceipts(d.receipts || []))
.catch(() => {})
}, [])
return (
<div>
<h1 className="text-xl font-bold text-white mb-1">Receipts</h1>
<p className="text-sm text-[#6a6a75] mb-6">View all your payment receipts in one place</p>
{receipts.length === 0 ? (
<div className="text-center py-16">
<FileText className="h-10 w-10 text-[#2a2a35] mx-auto mb-3" />
<p className="text-sm text-[#6a6a75]">No receipts yet</p>
</div>
) : (
<div className="space-y-3">
{(receipts || []).map((r) => (
<div key={r.id} className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-5 flex items-center justify-between hover:border-[#1BB0CE]/30 transition-all">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-[#1BB0CE]/10 flex items-center justify-center">
<FileText className="h-5 w-5 text-[#1BB0CE]" />
</div>
<div>
<p className="text-white font-medium">{r.receipt_number}</p>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-xs text-[#6a6a75]">{r.invoice_number}</span>
<span className="text-xs text-[#2a2a35]">&middot;</span>
<span className="text-xs text-[#8a8a95]">
{r.currency} {Number(r.total_amount).toLocaleString()}
</span>
<span className="text-xs text-[#2a2a35]">&middot;</span>
<span className="text-xs text-[#6a6a75]">
{new Date(r.issued_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
{r.file_url && (
<a
href={r.file_url}
target="_blank"
className="flex items-center gap-1.5 text-xs text-[#1BB0CE] hover:underline px-3 py-1.5 border border-[#1BB0CE]/20 rounded-lg hover:bg-[#1BB0CE]/5 transition-all"
>
<Download className="h-3.5 w-3.5" />
Download
</a>
)}
</div>
))}
</div>
)}
</div>
)
}