Compare commits

...

2 Commits

Author SHA1 Message Date
JCBSComputer b75351112e Merge branch 'main' of https://git.coastit.co.za/CRM_ENVIROMENT/Newbie_CRM
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 11:25:15 +02:00
JCBSComputer 6016ab2855 Client portal: profile, activity pages + middleware fix 2026-07-01 11:25:08 +02:00
23 changed files with 1789 additions and 2 deletions
+112
View File
@@ -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] })
}
+34
View File
@@ -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 })
}
+67
View File
@@ -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>
)
}
+78
View File
@@ -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>
)
}
+112
View File
@@ -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>
)
}
+127
View File
@@ -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>
)
}
+184
View File
@@ -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>
)
}
+76
View File
@@ -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>
)
}
+191
View File
@@ -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>
)
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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;