Compare commits
2 Commits
545065b527
...
b75351112e
| Author | SHA1 | Date | |
|---|---|---|---|
| b75351112e | |||
| 6016ab2855 |
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Activity, GitMerge, FileText, LifeBuoy, ChevronRight } from "lucide-react"
|
||||
|
||||
interface Event {
|
||||
id: string
|
||||
event_type: "milestone" | "invoice" | "ticket"
|
||||
date: string
|
||||
name?: string
|
||||
status?: string
|
||||
progress?: number
|
||||
project_name?: string
|
||||
invoice_number?: string
|
||||
total_amount?: number
|
||||
currency?: string
|
||||
status_name?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export default function ClientActivity() {
|
||||
const router = useRouter()
|
||||
const [events, setEvents] = useState<Event[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/client-portal/api/activity", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setEvents(d.events || []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
if (type === "milestone") return <GitMerge className="h-4 w-4 text-[#1BB0CE]" />
|
||||
if (type === "invoice") return <FileText className="h-4 w-4 text-[#a855f7]" />
|
||||
return <LifeBuoy className="h-4 w-4 text-[#f59e0b]" />
|
||||
}
|
||||
|
||||
const getStatusColor = (type: string, status: string) => {
|
||||
if (status === "approved" || status === "Paid" || status === "resolved" || status === "closed") return "text-emerald-400"
|
||||
if (status === "rejected" || status === "Overdue") return "text-red-400"
|
||||
return "text-amber-400"
|
||||
}
|
||||
|
||||
const handleClick = (e: Event) => {
|
||||
if (e.event_type === "milestone") router.push(`/client-portal/projects/${e.id.split("-")[0] || ""}`)
|
||||
else if (e.event_type === "invoice") router.push(`/client-portal/invoices/${e.id}`)
|
||||
else if (e.event_type === "ticket") router.push(`/client-portal/support`)
|
||||
}
|
||||
|
||||
const formatDate = (d: string) => {
|
||||
const date = new Date(d)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
if (diff < 86400000) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
if (diff < 604800000) return date.toLocaleDateString([], { weekday: "short" })
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Activity</h1>
|
||||
<p className="text-sm text-[#6a6a75] mb-6">Recent project updates and changes</p>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<Activity className="h-10 w-10 text-[#2a2a35] mx-auto mb-3" />
|
||||
<p className="text-sm text-[#6a6a75]">No recent activity</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="absolute left-[17px] top-2 bottom-2 w-px bg-[#1a1a24]" />
|
||||
<div className="space-y-0">
|
||||
{events.map((e, i) => (
|
||||
<button
|
||||
key={`${e.event_type}-${e.id}-${i}`}
|
||||
onClick={() => handleClick(e)}
|
||||
className="w-full flex items-start gap-4 py-3 pl-0 pr-4 text-left hover:bg-[#0d1117]/50 rounded-xl transition-all group"
|
||||
>
|
||||
<div className="relative z-10 w-9 h-9 rounded-full bg-[#0a0a0f] border-2 border-[#1a1a24] flex items-center justify-center flex-shrink-0">
|
||||
{getIcon(e.event_type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm text-white font-medium truncate">
|
||||
{e.event_type === "milestone" && (e.name || "Milestone updated")}
|
||||
{e.event_type === "invoice" && `${e.invoice_number || "Invoice"} — ${e.currency || ""} ${Number(e.total_amount || 0).toLocaleString()}`}
|
||||
{e.event_type === "ticket" && (e.title || "Ticket created")}
|
||||
</p>
|
||||
<span className="text-[11px] text-[#6a6a75] flex-shrink-0">{formatDate(e.date)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`text-[11px] capitalize ${getStatusColor(e.event_type, e.status || e.status_name || "")}`}>
|
||||
{(e.status || e.status_name || "updated").replace("_", " ")}
|
||||
</span>
|
||||
{e.event_type === "milestone" && e.project_name && (
|
||||
<>
|
||||
<span className="text-[#2a2a35] text-[10px]">•</span>
|
||||
<span className="text-[11px] text-[#6a6a75] truncate">{e.project_name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-[#2a2a35] group-hover:text-[#1BB0CE] transition-colors flex-shrink-0 mt-1" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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({ events: [] })
|
||||
}
|
||||
|
||||
const milestones = await query(
|
||||
`SELECT m.id, m.name, m.status, m.progress, m.updated_at, p.name as project_name,
|
||||
'milestone' as event_type
|
||||
FROM milestones m
|
||||
JOIN projects p ON p.id = m.project_id
|
||||
WHERE p.customer_id = $1 AND p.deleted_at IS NULL
|
||||
ORDER BY m.updated_at DESC NULLS LAST
|
||||
LIMIT 20`,
|
||||
[session.customerId],
|
||||
)
|
||||
|
||||
const invoices = await query(
|
||||
`SELECT i.id, i.invoice_number, i.total_amount, i.currency, s.name as status_name,
|
||||
i.updated_at, 'invoice' as event_type
|
||||
FROM invoices i
|
||||
LEFT JOIN invoice_statuses s ON s.id = i.status_id
|
||||
WHERE i.customer_id = $1
|
||||
ORDER BY i.updated_at DESC NULLS LAST
|
||||
LIMIT 20`,
|
||||
[session.customerId],
|
||||
)
|
||||
|
||||
const tickets = await query(
|
||||
`SELECT id, title, status, created_at, 'ticket' as event_type
|
||||
FROM bug_reports
|
||||
WHERE reported_by = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20`,
|
||||
[session.userId],
|
||||
)
|
||||
|
||||
const events = [
|
||||
...milestones.rows.map((r: Record<string, unknown>) => ({ ...r, date: r.updated_at })),
|
||||
...invoices.rows.map((r: Record<string, unknown>) => ({ ...r, date: r.updated_at })),
|
||||
...tickets.rows.map((r: Record<string, unknown>) => ({ ...r, date: r.created_at })),
|
||||
].sort((a, b) => new Date(b.date as string).getTime() - new Date(a.date as string).getTime()).slice(0, 30)
|
||||
|
||||
return NextResponse.json({ events })
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
import { getPortalSession, isPortalError } from "../../portal-utils"
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getPortalSession()
|
||||
if (isPortalError(session)) return session
|
||||
const { id } = await params
|
||||
|
||||
if (!session.customerId) {
|
||||
return NextResponse.json({ error: "No customer linked" }, { status: 404 })
|
||||
}
|
||||
|
||||
const invoice = await query(
|
||||
`SELECT i.id, i.invoice_number, i.subtotal, i.tax_percent, i.tax_amount,
|
||||
i.discount_percent, i.discount_amount, i.total_amount, i.currency,
|
||||
i.issued_date, i.due_date, i.paid_at, s.name as status_name
|
||||
FROM invoices i
|
||||
LEFT JOIN invoice_statuses s ON s.id = i.status_id
|
||||
WHERE i.id = $1 AND i.customer_id = $2`,
|
||||
[id, session.customerId],
|
||||
)
|
||||
|
||||
if (!invoice.rows[0]) {
|
||||
return NextResponse.json({ error: "Invoice not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const items = await query(
|
||||
`SELECT ii.description, ii.quantity, ii.unit_price, ii.discount_percent, ii.total_price
|
||||
FROM invoice_items ii
|
||||
WHERE ii.invoice_id = $1`,
|
||||
[id],
|
||||
)
|
||||
|
||||
const receipts = await query(
|
||||
`SELECT id, receipt_number, file_url, issued_at
|
||||
FROM receipts
|
||||
WHERE invoice_id = $1
|
||||
ORDER BY issued_at DESC`,
|
||||
[id],
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
invoice: invoice.rows[0],
|
||||
items: items.rows,
|
||||
receipts: receipts.rows,
|
||||
})
|
||||
}
|
||||
@@ -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({ invoices: [] })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`SELECT i.id, i.invoice_number, i.total_amount, i.currency, i.status_id,
|
||||
i.issued_date, i.due_date, i.paid_at, s.name as status_name
|
||||
FROM invoices i
|
||||
LEFT JOIN invoice_statuses s ON s.id = i.status_id
|
||||
WHERE i.customer_id = $1
|
||||
ORDER BY i.issued_date DESC NULLS LAST`,
|
||||
[session.customerId],
|
||||
)
|
||||
|
||||
return NextResponse.json({ invoices: result.rows })
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
import { getPortalSession, isPortalError } from "../../../portal-utils"
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await getPortalSession()
|
||||
if (isPortalError(session)) return session
|
||||
const { id } = await params
|
||||
|
||||
if (!session.customerId) {
|
||||
return NextResponse.json({ error: "No customer linked" }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { action, review_notes } = body
|
||||
|
||||
if (!["approve", "reject"].includes(action)) {
|
||||
return NextResponse.json({ error: "Action must be 'approve' or 'reject'" }, { status: 400 })
|
||||
}
|
||||
|
||||
const milestone = await query(
|
||||
`SELECT m.id, m.status, m.project_id
|
||||
FROM milestones m
|
||||
JOIN projects p ON p.id = m.project_id
|
||||
WHERE m.id = $1 AND p.customer_id = $2 AND p.deleted_at IS NULL`,
|
||||
[id, session.customerId],
|
||||
)
|
||||
|
||||
if (!milestone.rows[0]) {
|
||||
return NextResponse.json({ error: "Milestone not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
if (milestone.rows[0].status !== "awaiting_approval") {
|
||||
return NextResponse.json(
|
||||
{ error: "Milestone is not awaiting approval" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const newStatus = action === "approve" ? "approved" : "rejected"
|
||||
const newProgress = action === "approve" ? 100 : 0
|
||||
|
||||
await query(
|
||||
`UPDATE milestones
|
||||
SET status = $1, progress = $2, updated_at = NOW()
|
||||
WHERE id = $3`,
|
||||
[newStatus, newProgress, id],
|
||||
)
|
||||
|
||||
const deliverableStatus = action === "approve" ? "approved" : "rejected"
|
||||
await query(
|
||||
`UPDATE deliverables
|
||||
SET status = $1, reviewed_at = NOW(), review_notes = $2, updated_at = NOW()
|
||||
WHERE milestone_id = $3 AND status = 'pending_review'`,
|
||||
[deliverableStatus, review_notes || null, id],
|
||||
)
|
||||
|
||||
const updated = await query(
|
||||
`SELECT id, name, description, progress, status, due_date, created_at
|
||||
FROM milestones WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
|
||||
return NextResponse.json({ milestone: updated.rows[0] })
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export interface PortalSession {
|
||||
userId: string
|
||||
role: string
|
||||
customerId: string | null
|
||||
}
|
||||
|
||||
export async function getPortalSession(): Promise<PortalSession | NextResponse> {
|
||||
const user = await getSessionUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) as NextResponse
|
||||
}
|
||||
if (user.role !== "client") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 }) as NextResponse
|
||||
}
|
||||
|
||||
const link = await query(
|
||||
"SELECT customer_id FROM client_customer_links WHERE user_id = $1",
|
||||
[user.id],
|
||||
)
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
customerId: link.rows[0]?.customer_id || null,
|
||||
}
|
||||
}
|
||||
|
||||
export function isPortalError(session: PortalSession | NextResponse): session is NextResponse {
|
||||
return session instanceof NextResponse
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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({ error: "No customer linked" }, { status: 404 })
|
||||
}
|
||||
|
||||
const customer = await query(
|
||||
`SELECT c.id, c.customer_type, c.source, c.notes as customer_notes,
|
||||
cs.name as status_name, cs.color as status_color,
|
||||
cc.company_name, cc.registration_number, cc.tax_id,
|
||||
cc.website, cc.industry, cc.company_size, cc.annual_revenue,
|
||||
ic.first_name, ic.last_name, ic.job_title
|
||||
FROM customers c
|
||||
LEFT JOIN customer_statuses cs ON cs.id = c.status_id
|
||||
LEFT JOIN company_customers cc ON cc.customer_id = c.id
|
||||
LEFT JOIN individual_customers ic ON ic.customer_id = c.id
|
||||
WHERE c.id = $1 AND c.deleted_at IS NULL`,
|
||||
[session.customerId],
|
||||
)
|
||||
|
||||
if (!customer.rows[0]) {
|
||||
return NextResponse.json({ error: "Customer not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const contacts = await query(
|
||||
`SELECT type, value, label, is_primary
|
||||
FROM contact_information
|
||||
WHERE customer_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY is_primary DESC, type ASC`,
|
||||
[session.customerId],
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
profile: customer.rows[0],
|
||||
contacts: contacts.rows,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
import { getPortalSession, isPortalError } from "../../portal-utils"
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getPortalSession()
|
||||
if (isPortalError(session)) return session
|
||||
const { id } = await params
|
||||
|
||||
if (!session.customerId) {
|
||||
return NextResponse.json({ error: "No customer linked" }, { status: 404 })
|
||||
}
|
||||
|
||||
const project = await query(
|
||||
`SELECT id, name, description, status, start_date, end_date, budget, currency,
|
||||
created_at, updated_at
|
||||
FROM projects
|
||||
WHERE id = $1 AND customer_id = $2 AND deleted_at IS NULL`,
|
||||
[id, session.customerId],
|
||||
)
|
||||
|
||||
if (!project.rows[0]) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const milestones = await query(
|
||||
`SELECT id, name, description, progress, status, due_date, created_at
|
||||
FROM milestones
|
||||
WHERE project_id = $1
|
||||
ORDER BY due_date NULLS LAST, created_at ASC`,
|
||||
[id],
|
||||
)
|
||||
|
||||
const deliverables = await query(
|
||||
`SELECT d.id, d.name, d.description, d.file_url, d.file_type, d.status,
|
||||
d.review_notes, d.created_at, d.milestone_id
|
||||
FROM deliverables d
|
||||
JOIN milestones m ON m.id = d.milestone_id
|
||||
WHERE m.project_id = $1
|
||||
ORDER BY d.created_at DESC`,
|
||||
[id],
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
project: project.rows[0],
|
||||
milestones: milestones.rows,
|
||||
deliverables: deliverables.rows,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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({ projects: [] })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`SELECT id, name, description, status, start_date, end_date, budget, currency,
|
||||
created_at, updated_at
|
||||
FROM projects
|
||||
WHERE customer_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC`,
|
||||
[session.customerId],
|
||||
)
|
||||
|
||||
return NextResponse.json({ projects: result.rows })
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, 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
|
||||
|
||||
const tickets = await query(
|
||||
`SELECT id, title, description, severity, status, created_at, updated_at,
|
||||
resolution_notes
|
||||
FROM bug_reports
|
||||
WHERE reported_by = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[session.userId],
|
||||
)
|
||||
|
||||
return NextResponse.json({ tickets: tickets.rows })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getPortalSession()
|
||||
if (isPortalError(session)) return session
|
||||
|
||||
const { title, description, severity } = await request.json()
|
||||
|
||||
if (!title || !description) {
|
||||
return NextResponse.json(
|
||||
{ error: "Title and description are required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const validSeverities = ["low", "medium", "high", "critical"]
|
||||
const sev = validSeverities.includes(severity) ? severity : "medium"
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO bug_reports (reported_by, title, description, severity, page_url)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, title, description, severity, status, created_at`,
|
||||
[session.userId, title, description, sev, "/client-portal/support"],
|
||||
)
|
||||
|
||||
return NextResponse.json({ ticket: result.rows[0] }, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { FolderOpen, FileText, AlertCircle } from "lucide-react"
|
||||
|
||||
export default function ClientDashboard() {
|
||||
const router = useRouter()
|
||||
const [counts, setCounts] = useState({ projects: 0, invoices: 0, tickets: 0 })
|
||||
const [name, setName] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/client-portal/api/projects", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setCounts((prev) => ({ ...prev, projects: d.projects?.length || 0 })))
|
||||
.catch(() => {})
|
||||
|
||||
fetch("/client-portal/api/invoices", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setCounts((prev) => ({ ...prev, invoices: d.invoices?.length || 0 })))
|
||||
.catch(() => {})
|
||||
|
||||
fetch("/client-portal/api/support", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setCounts((prev) => ({ ...prev, tickets: d.tickets?.length || 0 })))
|
||||
.catch(() => {})
|
||||
|
||||
fetch("/api/auth/me", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setName(d.firstName || d.email || "Client"))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const cards = [
|
||||
{ label: "Active Projects", value: counts.projects, icon: FolderOpen, color: "#1BB0CE", href: "/client-portal/projects" },
|
||||
{ label: "Invoices", value: counts.invoices, icon: FileText, color: "#a855f7", href: "/client-portal/invoices" },
|
||||
{ label: "Support Tickets", value: counts.tickets, icon: AlertCircle, color: "#f59e0b", href: "/client-portal/support" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon
|
||||
return (
|
||||
<button
|
||||
key={card.label}
|
||||
onClick={() => router.push(card.href)}
|
||||
className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-5 text-left hover:border-[#1BB0CE]/30 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ backgroundColor: `${card.color}15` }}>
|
||||
<Icon className="h-5 w-5" style={{ color: card.color }} />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">{card.value}</span>
|
||||
</div>
|
||||
<p className="text-sm text-[#8a8a95]">{card.label}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
-- ============================================================================
|
||||
-- Migration 020: Client Portal — roles, projects, milestones, deliverables
|
||||
-- ============================================================================
|
||||
|
||||
-- 1. Add CLIENT role (hierarchy_level=5, after DEVELOPER=4)
|
||||
INSERT INTO roles (id, name, display_name, description, hierarchy_level, is_system) VALUES
|
||||
('00000005-0000-0000-0000-000000000000', 'CLIENT', 'Client',
|
||||
'Portal user. Can view own projects, milestones, invoices, and submit support tickets.',
|
||||
5, TRUE)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 2. Link client users to customer records
|
||||
CREATE TABLE IF NOT EXISTS client_customer_links (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_client_customer_links_customer ON client_customer_links(customer_id);
|
||||
|
||||
-- 3. Projects table
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'on_hold', 'completed', 'cancelled')),
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
budget NUMERIC(12,2),
|
||||
currency VARCHAR(3) DEFAULT 'ZAR',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_customer ON projects(customer_id) WHERE deleted_at IS NULL;
|
||||
|
||||
-- 4. Milestones table
|
||||
CREATE TABLE IF NOT EXISTS milestones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
progress INTEGER NOT NULL DEFAULT 0 CHECK (progress >= 0 AND progress <= 100),
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'in_progress', 'awaiting_approval', 'approved', 'rejected', 'completed')),
|
||||
due_date DATE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_milestones_project ON milestones(project_id);
|
||||
|
||||
-- 5. Deliverables table
|
||||
CREATE TABLE IF NOT EXISTS deliverables (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
milestone_id UUID NOT NULL REFERENCES milestones(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
file_url TEXT,
|
||||
file_type VARCHAR(50),
|
||||
file_size BIGINT,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending_review'
|
||||
CHECK (status IN ('pending_review', 'approved', 'rejected')),
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
review_notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deliverables_milestone ON deliverables(milestone_id);
|
||||
|
||||
-- 6. Receipts table
|
||||
CREATE TABLE IF NOT EXISTS receipts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
receipt_number VARCHAR(100) NOT NULL,
|
||||
file_url TEXT,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_receipts_invoice ON receipts(invoice_id);
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { FileText, Download } from "lucide-react"
|
||||
|
||||
interface InvoiceDetail {
|
||||
invoice: any
|
||||
items: any[]
|
||||
receipts: any[]
|
||||
}
|
||||
|
||||
export default function InvoiceDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [data, setData] = useState<InvoiceDetail | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/client-portal/api/invoices/${id}`, { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then(setData)
|
||||
.catch(() => {})
|
||||
}, [id])
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-2 border-[#1BB0CE] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { invoice, items, receipts } = data
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Invoice {invoice.invoice_number}</h1>
|
||||
<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 === "Overdue" ? "bg-red-500/10 text-red-400" :
|
||||
"bg-amber-500/10 text-amber-400"
|
||||
}`}>
|
||||
{invoice.status_name}
|
||||
</span>
|
||||
|
||||
{/* Invoice details */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-6 mb-6">
|
||||
<div className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-4">
|
||||
<span className="text-[11px] text-[#6a6a75] uppercase tracking-wider">Issued</span>
|
||||
<p className="text-white text-sm mt-1">
|
||||
{invoice.issued_date ? new Date(invoice.issued_date).toLocaleDateString() : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-4">
|
||||
<span className="text-[11px] text-[#6a6a75] uppercase tracking-wider">Due</span>
|
||||
<p className="text-white text-sm mt-1">
|
||||
{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line items */}
|
||||
<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">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1a1a24]">
|
||||
<th className="text-left text-[11px] text-[#6a6a75] uppercase tracking-wider px-4 py-3">Description</th>
|
||||
<th className="text-right text-[11px] text-[#6a6a75] uppercase tracking-wider px-4 py-3">Qty</th>
|
||||
<th className="text-right text-[11px] text-[#6a6a75] uppercase tracking-wider px-4 py-3">Unit Price</th>
|
||||
<th className="text-right text-[11px] text-[#6a6a75] uppercase tracking-wider px-4 py-3">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={i} className="border-b border-[#1a1a24]/50">
|
||||
<td className="text-sm text-white px-4 py-3">{item.description}</td>
|
||||
<td className="text-sm text-white px-4 py-3 text-right">{item.quantity}</td>
|
||||
<td className="text-sm text-white px-4 py-3 text-right">
|
||||
{invoice.currency} {Number(item.unit_price).toLocaleString()}
|
||||
</td>
|
||||
<td className="text-sm text-white px-4 py-3 text-right">
|
||||
{invoice.currency} {Number(item.total_price).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{invoice.subtotal && (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-right text-sm text-[#8a8a95] px-4 py-2">Subtotal</td>
|
||||
<td className="text-right text-sm text-white px-4 py-2">
|
||||
{invoice.currency} {Number(invoice.subtotal).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{invoice.tax_amount > 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-right text-sm text-[#8a8a95] px-4 py-2">Tax ({invoice.tax_percent}%)</td>
|
||||
<td className="text-right text-sm text-white px-4 py-2">
|
||||
{invoice.currency} {Number(invoice.tax_amount).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr className="border-t border-[#1a1a24]">
|
||||
<td colSpan={3} className="text-right text-sm font-semibold text-white px-4 py-3">Total</td>
|
||||
<td className="text-right text-sm font-bold text-[#1BB0CE] px-4 py-3">
|
||||
{invoice.currency} {Number(invoice.total_amount).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Receipts */}
|
||||
{receipts.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-base font-semibold text-white mb-3">Receipts</h2>
|
||||
<div className="space-y-2">
|
||||
{receipts.map((r) => (
|
||||
<div key={r.id} className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-4 w-4 text-[#1BB0CE]" />
|
||||
<div>
|
||||
<span className="text-sm text-white">{r.receipt_number}</span>
|
||||
<p className="text-[11px] text-[#6a6a75]">
|
||||
{new Date(r.issued_at).toLocaleDateString()}
|
||||
</p>
|
||||
</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"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { FileText, ChevronRight } from "lucide-react"
|
||||
|
||||
interface Invoice {
|
||||
id: string
|
||||
invoice_number: string
|
||||
total_amount: number
|
||||
currency: string
|
||||
status_name: string
|
||||
issued_date: string
|
||||
due_date: string
|
||||
paid_at: string
|
||||
}
|
||||
|
||||
export default function ClientInvoices() {
|
||||
const router = useRouter()
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/client-portal/api/invoices", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setInvoices(d.invoices || []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Invoices</h1>
|
||||
<p className="text-sm text-[#6a6a75] mb-6">View and download your invoices and receipts</p>
|
||||
|
||||
{invoices.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 invoices yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{invoices.map((inv) => (
|
||||
<button
|
||||
key={inv.id}
|
||||
onClick={() => router.push(`/client-portal/invoices/${inv.id}`)}
|
||||
className="w-full bg-[#0d1117] border border-[#1a1a24] rounded-xl p-5 text-left hover:border-[#1BB0CE]/30 transition-all group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="text-white font-semibold">{inv.invoice_number}</h3>
|
||||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${
|
||||
inv.status_name === "Paid" ? "bg-emerald-500/10 text-emerald-400" :
|
||||
inv.status_name === "Overdue" ? "bg-red-500/10 text-red-400" :
|
||||
"bg-amber-500/10 text-amber-400"
|
||||
}`}>
|
||||
{inv.status_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 mt-1">
|
||||
<span className="text-sm text-white font-medium">
|
||||
{inv.currency} {Number(inv.total_amount).toLocaleString()}
|
||||
</span>
|
||||
{inv.issued_date && (
|
||||
<span className="text-[11px] text-[#6a6a75]">
|
||||
Issued: {new Date(inv.issued_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#2a2a35] group-hover:text-[#1BB0CE] transition-colors" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import { Home, FolderOpen, FileText, LifeBuoy, User, Activity, LogOut, Menu, X } from "lucide-react"
|
||||
|
||||
const navItems = [
|
||||
{ href: "/client-portal/dashboard", label: "Dashboard", icon: Home },
|
||||
{ href: "/client-portal/projects", label: "Projects", icon: FolderOpen },
|
||||
{ href: "/client-portal/invoices", label: "Invoices", icon: FileText },
|
||||
{ href: "/client-portal/support", label: "Support", icon: LifeBuoy },
|
||||
{ href: "/client-portal/activity", label: "Activity", icon: Activity },
|
||||
{ href: "/client-portal/profile", label: "Profile", icon: User },
|
||||
]
|
||||
|
||||
export default function ClientPortalLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [auth, setAuth] = useState<boolean | null>(null)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/me", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.role === "client") setAuth(true)
|
||||
else { setAuth(false); router.push("/client-portal/login") }
|
||||
})
|
||||
.catch(() => { setAuth(false); router.push("/client-portal/login") })
|
||||
}, [router])
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch("/api/auth/logout", { method: "POST" })
|
||||
router.push("/client-portal/login")
|
||||
}
|
||||
|
||||
if (auth === null) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#0a0a0f] items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-[#1BB0CE] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!auth) return null
|
||||
|
||||
if (pathname === "/client-portal/login") return <>{children}</>
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#0a0a0f]">
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={`fixed lg:static inset-y-0 left-0 z-50 w-64 bg-[#0d1117] border-r border-[#1a1a24] transform transition-transform duration-200 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"} lg:translate-x-0 flex flex-col`}>
|
||||
<div className="p-5 border-b border-[#1a1a24]">
|
||||
<div className="text-lg font-bold">
|
||||
<span className="text-[#1BB0CE]">Client</span>
|
||||
<span className="text-white"> Portal</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = pathname.startsWith(item.href)
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => { router.push(item.href); setSidebarOpen(false) }}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-all ${
|
||||
active
|
||||
? "bg-[#1BB0CE]/10 text-[#1BB0CE] border border-[#1BB0CE]/20"
|
||||
: "text-[#8a8a95] hover:text-white hover:bg-[#1a1a24]"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-[#1a1a24]">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-[#8a8a95] hover:text-red-400 hover:bg-red-400/5 transition-all"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-h-screen">
|
||||
<header className="sticky top-0 z-30 bg-[#0d1117]/80 backdrop-blur-md border-b border-[#1a1a24] px-4 lg:px-6 h-14 flex items-center">
|
||||
<button className="lg:hidden mr-3 text-[#8a8a95] hover:text-white" onClick={() => setSidebarOpen(true)}>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="text-sm text-[#6a6a75]">Client Portal</span>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-4 lg:p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Eye, EyeOff } from "lucide-react"
|
||||
|
||||
export default function ClientLoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [checking, setChecking] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/me", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.role === "client") router.push("/client-portal/dashboard")
|
||||
else setChecking(false)
|
||||
})
|
||||
.catch(() => setChecking(false))
|
||||
}, [router])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Login failed")
|
||||
return
|
||||
}
|
||||
|
||||
if (data.user?.role !== "client") {
|
||||
setError("This portal is for clients only")
|
||||
return
|
||||
}
|
||||
|
||||
router.push("/client-portal/dashboard")
|
||||
} catch {
|
||||
setError("Connection error")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#0a0a0f] items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-[#1BB0CE] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#0a0a0f] items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-2xl font-bold mb-1">
|
||||
<span className="text-[#1BB0CE]">Client</span>
|
||||
<span className="text-white"> Portal</span>
|
||||
</div>
|
||||
<p className="text-sm text-[#6a6a75]">Sign in to view your projects and invoices</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-[#8a8a95] mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2.5 text-sm text-white placeholder-[#6a6a75] outline-none focus:border-[#1BB0CE]/50"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-[#8a8a95] mb-1.5">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2.5 pr-10 text-sm text-white placeholder-[#6a6a75] outline-none focus:border-[#1BB0CE]/50"
|
||||
placeholder="Enter password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#6a6a75] hover:text-white"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/5 border border-red-400/10 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 text-white rounded-lg px-4 py-2.5 text-sm font-medium transition-all disabled:opacity-40"
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Building2, Mail, Phone, Globe, FileText, Hash, Users, DollarSign, CalendarDays } from "lucide-react"
|
||||
|
||||
interface Profile {
|
||||
id: string
|
||||
customer_type: string
|
||||
source: string
|
||||
customer_notes: string
|
||||
status_name: string
|
||||
status_color: string
|
||||
company_name: string
|
||||
registration_number: string
|
||||
tax_id: string
|
||||
website: string
|
||||
industry: string
|
||||
company_size: string
|
||||
annual_revenue: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
job_title: string
|
||||
}
|
||||
|
||||
interface Contact {
|
||||
type: string
|
||||
value: string
|
||||
label: string
|
||||
is_primary: boolean
|
||||
}
|
||||
|
||||
export default function ClientProfile() {
|
||||
const [profile, setProfile] = useState<Profile | null>(null)
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/client-portal/api/profile", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setProfile(d.profile || null)
|
||||
setContacts(d.contacts || [])
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="w-6 h-6 border-2 border-[#1BB0CE] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isCompany = profile.customer_type === "company"
|
||||
const name = isCompany ? profile.company_name : `${profile.first_name || ""} ${profile.last_name || ""}`.trim()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Profile</h1>
|
||||
<p className="text-sm text-[#6a6a75] mb-6">Your account and company information</p>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main info card */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-6">
|
||||
<div className="flex items-center gap-4 mb-5">
|
||||
<div className="w-14 h-14 rounded-xl bg-[#1BB0CE]/10 flex items-center justify-center">
|
||||
<Building2 className="h-7 w-7 text-[#1BB0CE]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{name || "Unnamed"}</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full" style={{ backgroundColor: `${profile.status_color}15`, color: profile.status_color }}>
|
||||
{profile.status_name}
|
||||
</span>
|
||||
<span className="text-[11px] text-[#6a6a75]">{isCompany ? "Company" : "Individual"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{isCompany && profile.industry && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Hash className="h-4 w-4 text-[#6a6a75]" />
|
||||
<div>
|
||||
<p className="text-[#6a6a75] text-[11px]">Industry</p>
|
||||
<p className="text-white">{profile.industry}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isCompany && profile.registration_number && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<FileText className="h-4 w-4 text-[#6a6a75]" />
|
||||
<div>
|
||||
<p className="text-[#6a6a75] text-[11px]">Registration</p>
|
||||
<p className="text-white">{profile.registration_number}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{profile.tax_id && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<FileText className="h-4 w-4 text-[#6a6a75]" />
|
||||
<div>
|
||||
<p className="text-[#6a6a75] text-[11px]">Tax / VAT</p>
|
||||
<p className="text-white">{profile.tax_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isCompany && profile.company_size && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Users className="h-4 w-4 text-[#6a6a75]" />
|
||||
<div>
|
||||
<p className="text-[#6a6a75] text-[11px]">Company Size</p>
|
||||
<p className="text-white">{profile.company_size}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isCompany && profile.annual_revenue && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<DollarSign className="h-4 w-4 text-[#6a6a75]" />
|
||||
<div>
|
||||
<p className="text-[#6a6a75] text-[11px]">Annual Revenue</p>
|
||||
<p className="text-white">{Number(profile.annual_revenue).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{profile.website && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Globe className="h-4 w-4 text-[#6a6a75]" />
|
||||
<div>
|
||||
<p className="text-[#6a6a75] text-[11px]">Website</p>
|
||||
<p className="text-[#1BB0CE]">{profile.website}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isCompany && profile.job_title && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Hash className="h-4 w-4 text-[#6a6a75]" />
|
||||
<div>
|
||||
<p className="text-[#6a6a75] text-[11px]">Job Title</p>
|
||||
<p className="text-white">{profile.job_title}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.customer_notes && (
|
||||
<div className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-5">
|
||||
<h3 className="text-sm font-medium text-white mb-2">Notes</h3>
|
||||
<p className="text-sm text-[#8a8a95]">{profile.customer_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contacts sidebar */}
|
||||
<div className="space-y-3">
|
||||
{contacts.map((c, i) => (
|
||||
<div key={i} className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{c.type === "email" ? <Mail className="h-4 w-4 text-[#1BB0CE]" /> :
|
||||
c.type === "phone" || c.type === "mobile" ? <Phone className="h-4 w-4 text-[#1BB0CE]" /> :
|
||||
c.type === "website" ? <Globe className="h-4 w-4 text-[#1BB0CE]" /> :
|
||||
<Hash className="h-4 w-4 text-[#1BB0CE]" />}
|
||||
<div>
|
||||
<p className="text-[11px] text-[#6a6a75]">{c.label || c.type}</p>
|
||||
<p className="text-sm text-white">{c.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
{c.is_primary && (
|
||||
<span className="text-[10px] text-[#1BB0CE] mt-1.5 block">Primary</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{contacts.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-[#6a6a75] bg-[#0d1117] border border-[#1a1a24] rounded-xl">
|
||||
No contact info
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { CheckCircle2, XCircle, Clock, ChevronDown, ChevronUp, FileText, ExternalLink } from "lucide-react"
|
||||
|
||||
interface Milestone {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
progress: number
|
||||
status: string
|
||||
due_date: string
|
||||
}
|
||||
|
||||
interface Deliverable {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
file_url: string
|
||||
file_type: string
|
||||
status: string
|
||||
review_notes: string
|
||||
milestone_id: string
|
||||
}
|
||||
|
||||
const statusIcon: Record<string, { icon: typeof Clock; color: string }> = {
|
||||
pending: { icon: Clock, color: "#6a6a75" },
|
||||
in_progress: { icon: Clock, color: "#3b82f6" },
|
||||
awaiting_approval: { icon: Clock, color: "#f59e0b" },
|
||||
approved: { icon: CheckCircle2, color: "#22c55e" },
|
||||
rejected: { icon: XCircle, color: "#ef4444" },
|
||||
completed: { icon: CheckCircle2, color: "#22c55e" },
|
||||
}
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [data, setData] = useState<{ project: any; milestones: Milestone[]; deliverables: Deliverable[] } | null>(null)
|
||||
const [expanded, setExpanded] = useState<string[]>([])
|
||||
const [loadingMilestone, setLoadingMilestone] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/client-portal/api/projects/${id}`, { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then(setData)
|
||||
.catch(() => {})
|
||||
}, [id])
|
||||
|
||||
const handleAction = async (milestoneId: string, action: "approve" | "reject") => {
|
||||
setLoadingMilestone(milestoneId)
|
||||
try {
|
||||
const res = await fetch(`/client-portal/api/milestones/${milestoneId}/approve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
milestones: prev.milestones.map((m) =>
|
||||
m.id === milestoneId ? { ...m, ...updated.milestone } : m,
|
||||
),
|
||||
deliverables: prev.deliverables.map((d) =>
|
||||
d.milestone_id === milestoneId
|
||||
? { ...d, status: action === "approve" ? "approved" : "rejected" }
|
||||
: d,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setLoadingMilestone(null)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpanded((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-2 border-[#1BB0CE] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { project, milestones, deliverables } = data
|
||||
const overallProgress = milestones.length
|
||||
? Math.round(milestones.reduce((sum, m) => sum + m.progress, 0) / milestones.length)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">{project.name}</h1>
|
||||
{project.description && (
|
||||
<p className="text-sm text-[#8a8a95] mb-4">{project.description}</p>
|
||||
)}
|
||||
|
||||
{/* Overall progress bar */}
|
||||
<div className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-5 mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-[#8a8a95]">Overall Progress</span>
|
||||
<span className="text-lg font-bold text-white">{overallProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-[#1a1a24] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[#1BB0CE] to-emerald-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${overallProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Milestones */}
|
||||
<h2 className="text-base font-semibold text-white mb-3">Milestones</h2>
|
||||
<div className="space-y-2 mb-6">
|
||||
{milestones.length === 0 ? (
|
||||
<p className="text-sm text-[#6a6a75]">No milestones yet</p>
|
||||
) : (
|
||||
milestones.map((m) => {
|
||||
const si = statusIcon[m.status] || { icon: Clock, color: "#6a6a75" }
|
||||
const Icon = si.icon
|
||||
const milestoneDeliverables = deliverables.filter((d) => d.milestone_id === m.id)
|
||||
|
||||
return (
|
||||
<div key={m.id} className="bg-[#0d1117] border border-[#1a1a24] rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleExpanded(m.id)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-[#1a1a24]/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-5 w-5" style={{ color: si.color }} />
|
||||
<div className="text-left">
|
||||
<span className="text-white text-sm font-medium">{m.name}</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<div className="h-1.5 w-20 bg-[#1a1a24] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${m.progress}%`, backgroundColor: si.color }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#6a6a75]">{m.progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{expanded.includes(m.id) ? (
|
||||
<ChevronUp className="h-4 w-4 text-[#6a6a75]" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-[#6a6a75]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded.includes(m.id) && (
|
||||
<div className="px-4 pb-4 border-t border-[#1a1a24] pt-3">
|
||||
{m.description && (
|
||||
<p className="text-sm text-[#8a8a95] mb-3">{m.description}</p>
|
||||
)}
|
||||
|
||||
{/* Deliverables */}
|
||||
{milestoneDeliverables.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
<span className="text-[11px] text-[#6a6a75] uppercase tracking-wider">Deliverables</span>
|
||||
{milestoneDeliverables.map((d) => (
|
||||
<div key={d.id} className="flex items-center justify-between bg-[#1a1a24] rounded-lg px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5 text-[#6a6a75]" />
|
||||
<span className="text-sm text-white">{d.name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${
|
||||
d.status === "approved" ? "bg-emerald-500/10 text-emerald-400" :
|
||||
d.status === "rejected" ? "bg-red-500/10 text-red-400" :
|
||||
"bg-amber-500/10 text-amber-400"
|
||||
}`}>
|
||||
{d.status.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
{d.file_url && (
|
||||
<a href={d.file_url} target="_blank" className="text-[#1BB0CE] hover:underline text-xs flex items-center gap-1">
|
||||
<ExternalLink className="h-3 w-3" /> View
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approve/Reject buttons */}
|
||||
{m.status === "awaiting_approval" && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => handleAction(m.id, "approve")}
|
||||
disabled={loadingMilestone === m.id}
|
||||
className="flex items-center gap-1.5 text-xs bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 rounded-lg px-3 py-1.5 hover:bg-emerald-500/20 transition-all disabled:opacity-40"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(m.id, "reject")}
|
||||
disabled={loadingMilestone === m.id}
|
||||
className="flex items-center gap-1.5 text-xs bg-red-500/10 text-red-400 border border-red-500/20 rounded-lg px-3 py-1.5 hover:bg-red-500/20 transition-all disabled:opacity-40"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { FolderOpen, ChevronRight } from "lucide-react"
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
active: "bg-emerald-500/10 text-emerald-400",
|
||||
on_hold: "bg-amber-500/10 text-amber-400",
|
||||
completed: "bg-blue-500/10 text-blue-400",
|
||||
cancelled: "bg-red-500/10 text-red-400",
|
||||
}
|
||||
|
||||
export default function ClientProjects() {
|
||||
const router = useRouter()
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/client-portal/api/projects", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setProjects(d.projects || []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Projects</h1>
|
||||
<p className="text-sm text-[#6a6a75] mb-6">View your project milestones and progress</p>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<FolderOpen className="h-10 w-10 text-[#2a2a35] mx-auto mb-3" />
|
||||
<p className="text-sm text-[#6a6a75]">No projects yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => router.push(`/client-portal/projects/${p.id}`)}
|
||||
className="w-full bg-[#0d1117] border border-[#1a1a24] rounded-xl p-5 text-left hover:border-[#1BB0CE]/30 transition-all group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="text-white font-semibold">{p.name}</h3>
|
||||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${statusColors[p.status] || "bg-zinc-500/10 text-zinc-400"}`}>
|
||||
{p.status.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
{p.description && (
|
||||
<p className="text-sm text-[#8a8a95] line-clamp-1">{p.description}</p>
|
||||
)}
|
||||
<div className="flex gap-4 mt-2 text-[11px] text-[#6a6a75]">
|
||||
{p.start_date && <span>Start: {new Date(p.start_date).toLocaleDateString()}</span>}
|
||||
{p.end_date && <span>Due: {new Date(p.end_date).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-[#2a2a35] group-hover:text-[#1BB0CE] transition-colors" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { LifeBuoy, Send } from "lucide-react"
|
||||
|
||||
interface Ticket {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
severity: string
|
||||
status: string
|
||||
created_at: string
|
||||
resolution_notes: string
|
||||
}
|
||||
|
||||
export default function ClientSupport() {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [severity, setSeverity] = useState("medium")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitError, setSubmitError] = useState("")
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const loadTickets = () => {
|
||||
setLoading(true)
|
||||
fetch("/client-portal/api/support", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setTickets(d.tickets || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { loadTickets() }, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setSubmitError("")
|
||||
|
||||
try {
|
||||
const res = await fetch("/client-portal/api/support", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, description, severity }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
setSubmitError(data.error || "Failed to submit")
|
||||
return
|
||||
}
|
||||
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
setSeverity("medium")
|
||||
setShowForm(false)
|
||||
loadTickets()
|
||||
} catch {
|
||||
setSubmitError("Connection error")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Support</h1>
|
||||
<p className="text-sm text-[#6a6a75]">Submit and track support requests</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 text-white text-sm px-4 py-2 rounded-lg transition-all"
|
||||
>
|
||||
{showForm ? "Cancel" : "New Ticket"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Submit form */}
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-5 mb-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-[#8a8a95] mb-1">Title</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-white placeholder-[#6a6a75] outline-none focus:border-[#1BB0CE]/50"
|
||||
placeholder="Brief summary of the issue"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-[#8a8a95] mb-1">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-white placeholder-[#6a6a75] outline-none focus:border-[#1BB0CE]/50 resize-none"
|
||||
placeholder="Detailed description of your issue"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-[#8a8a95] mb-1">Severity</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={(e) => setSeverity(e.target.value)}
|
||||
className="w-full bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-white outline-none focus:border-[#1BB0CE]/50"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="text-xs text-red-400 bg-red-400/5 border border-red-400/10 rounded-lg px-3 py-2">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-2 bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 text-white text-sm px-4 py-2 rounded-lg transition-all disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
{submitting ? "Submitting..." : "Submit Ticket"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Tickets list */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="w-6 h-6 border-2 border-[#1BB0CE] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<LifeBuoy className="h-10 w-10 text-[#2a2a35] mx-auto mb-3" />
|
||||
<p className="text-sm text-[#6a6a75]">No support tickets yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tickets.map((t) => (
|
||||
<div key={t.id} className="bg-[#0d1117] border border-[#1a1a24] rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className="text-white text-sm font-medium">{t.title}</h3>
|
||||
<div className="flex gap-2">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${
|
||||
t.severity === "critical" ? "bg-red-500/10 text-red-400" :
|
||||
t.severity === "high" ? "bg-orange-500/10 text-orange-400" :
|
||||
t.severity === "medium" ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-zinc-500/10 text-zinc-400"
|
||||
}`}>
|
||||
{t.severity}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${
|
||||
t.status === "closed" ? "bg-emerald-500/10 text-emerald-400" :
|
||||
t.status === "resolved" ? "bg-blue-500/10 text-blue-400" :
|
||||
t.status === "in_progress" ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-zinc-500/10 text-zinc-400"
|
||||
}`}>
|
||||
{t.status.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-[#8a8a95] line-clamp-2">{t.description}</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-[10px] text-[#6a6a75]">
|
||||
{new Date(t.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{t.resolution_notes && (
|
||||
<div className="mt-2 text-xs text-[#6a6a75] bg-[#1a1a24] rounded-lg px-3 py-2">
|
||||
<span className="text-[#8a8a95]">Resolution: </span>{t.resolution_notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -186,6 +186,7 @@ export function mapDbUserToSessionUser(
|
||||
ADMIN: "admin",
|
||||
SALES_USER: "sales",
|
||||
DEVELOPER: "dev",
|
||||
CLIENT: "client",
|
||||
};
|
||||
|
||||
const avatarUrl = dbUser.avatar_url as string | null
|
||||
|
||||
+4
-1
@@ -3,6 +3,8 @@ import type { NextRequest } from "next/server"
|
||||
|
||||
const publicRoutes = [
|
||||
"/login",
|
||||
"/client-portal/login",
|
||||
"/client-portal/api",
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/_next/static",
|
||||
@@ -29,7 +31,8 @@ export async function middleware(request: NextRequest) {
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
const loginUrl = new URL("/login", request.url)
|
||||
const loginPath = pathname.startsWith("/client-portal") ? "/client-portal/login" : "/login"
|
||||
const loginUrl = new URL(loginPath, request.url)
|
||||
loginUrl.searchParams.set("redirect", pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ export type LeadStatus =
|
||||
| "pending"
|
||||
| "closed"
|
||||
| "ignored";
|
||||
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
|
||||
export type UserRole = "super_admin" | "admin" | "sales" | "dev" | "client";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user