This commit is contained in:
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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} · {d.status_name?.replace("_", " ")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[#6a6a75]">
|
||||||
|
{new Date(d.date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 — ${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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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]">·</span>
|
||||||
|
<span className="text-xs text-[#8a8a95]">
|
||||||
|
{r.currency} {Number(r.total_amount).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[#2a2a35]">·</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user