From 6016ab2855082bd91a33ba64c8e38b599bdcedd6 Mon Sep 17 00:00:00 2001 From: JCBSComputer Date: Wed, 1 Jul 2026 11:25:08 +0200 Subject: [PATCH] Client portal: profile, activity pages + middleware fix --- src/app/client-portal/activity/page.tsx | 112 +++++++++ src/app/client-portal/api/activity/route.ts | 51 ++++ .../client-portal/api/invoices/[id]/route.ts | 48 ++++ src/app/client-portal/api/invoices/route.ts | 24 ++ .../api/milestones/[id]/approve/route.ts | 68 ++++++ src/app/client-portal/api/portal-utils.ts | 34 +++ src/app/client-portal/api/profile/route.ts | 43 ++++ .../client-portal/api/projects/[id]/route.ts | 49 ++++ src/app/client-portal/api/projects/route.ts | 23 ++ src/app/client-portal/api/support/route.ts | 45 ++++ src/app/client-portal/dashboard/page.tsx | 67 ++++++ .../database/020_client_portal.sql | 85 +++++++ src/app/client-portal/invoices/[id]/page.tsx | 147 ++++++++++++ src/app/client-portal/invoices/page.tsx | 78 +++++++ src/app/client-portal/layout.tsx | 112 +++++++++ src/app/client-portal/login/page.tsx | 127 ++++++++++ src/app/client-portal/profile/page.tsx | 184 +++++++++++++++ src/app/client-portal/projects/[id]/page.tsx | 219 ++++++++++++++++++ src/app/client-portal/projects/page.tsx | 76 ++++++ src/app/client-portal/support/page.tsx | 191 +++++++++++++++ src/lib/auth.ts | 1 + src/middleware.ts | 5 +- src/types/index.ts | 2 +- 23 files changed, 1789 insertions(+), 2 deletions(-) create mode 100644 src/app/client-portal/activity/page.tsx create mode 100644 src/app/client-portal/api/activity/route.ts create mode 100644 src/app/client-portal/api/invoices/[id]/route.ts create mode 100644 src/app/client-portal/api/invoices/route.ts create mode 100644 src/app/client-portal/api/milestones/[id]/approve/route.ts create mode 100644 src/app/client-portal/api/portal-utils.ts create mode 100644 src/app/client-portal/api/profile/route.ts create mode 100644 src/app/client-portal/api/projects/[id]/route.ts create mode 100644 src/app/client-portal/api/projects/route.ts create mode 100644 src/app/client-portal/api/support/route.ts create mode 100644 src/app/client-portal/dashboard/page.tsx create mode 100644 src/app/client-portal/database/020_client_portal.sql create mode 100644 src/app/client-portal/invoices/[id]/page.tsx create mode 100644 src/app/client-portal/invoices/page.tsx create mode 100644 src/app/client-portal/layout.tsx create mode 100644 src/app/client-portal/login/page.tsx create mode 100644 src/app/client-portal/profile/page.tsx create mode 100644 src/app/client-portal/projects/[id]/page.tsx create mode 100644 src/app/client-portal/projects/page.tsx create mode 100644 src/app/client-portal/support/page.tsx diff --git a/src/app/client-portal/activity/page.tsx b/src/app/client-portal/activity/page.tsx new file mode 100644 index 0000000..403991e --- /dev/null +++ b/src/app/client-portal/activity/page.tsx @@ -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([]) + + 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 + if (type === "invoice") return + return + } + + 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 ( +
+

Activity

+

Recent project updates and changes

+ + {events.length === 0 ? ( +
+ +

No recent activity

+
+ ) : ( +
+
+
+ {events.map((e, i) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/src/app/client-portal/api/activity/route.ts b/src/app/client-portal/api/activity/route.ts new file mode 100644 index 0000000..197f89f --- /dev/null +++ b/src/app/client-portal/api/activity/route.ts @@ -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) => ({ ...r, date: r.updated_at })), + ...invoices.rows.map((r: Record) => ({ ...r, date: r.updated_at })), + ...tickets.rows.map((r: Record) => ({ ...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 }) +} diff --git a/src/app/client-portal/api/invoices/[id]/route.ts b/src/app/client-portal/api/invoices/[id]/route.ts new file mode 100644 index 0000000..10fd960 --- /dev/null +++ b/src/app/client-portal/api/invoices/[id]/route.ts @@ -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, + }) +} diff --git a/src/app/client-portal/api/invoices/route.ts b/src/app/client-portal/api/invoices/route.ts new file mode 100644 index 0000000..0989dcb --- /dev/null +++ b/src/app/client-portal/api/invoices/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server" +import { query } from "@/lib/db" +import { getPortalSession, isPortalError } from "../portal-utils" + +export async function GET() { + const session = await getPortalSession() + if (isPortalError(session)) return session + + if (!session.customerId) { + return NextResponse.json({ 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 }) +} diff --git a/src/app/client-portal/api/milestones/[id]/approve/route.ts b/src/app/client-portal/api/milestones/[id]/approve/route.ts new file mode 100644 index 0000000..b02ba60 --- /dev/null +++ b/src/app/client-portal/api/milestones/[id]/approve/route.ts @@ -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] }) +} diff --git a/src/app/client-portal/api/portal-utils.ts b/src/app/client-portal/api/portal-utils.ts new file mode 100644 index 0000000..6bf64e8 --- /dev/null +++ b/src/app/client-portal/api/portal-utils.ts @@ -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 { + 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 +} diff --git a/src/app/client-portal/api/profile/route.ts b/src/app/client-portal/api/profile/route.ts new file mode 100644 index 0000000..2224eac --- /dev/null +++ b/src/app/client-portal/api/profile/route.ts @@ -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, + }) +} diff --git a/src/app/client-portal/api/projects/[id]/route.ts b/src/app/client-portal/api/projects/[id]/route.ts new file mode 100644 index 0000000..be6947a --- /dev/null +++ b/src/app/client-portal/api/projects/[id]/route.ts @@ -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, + }) +} diff --git a/src/app/client-portal/api/projects/route.ts b/src/app/client-portal/api/projects/route.ts new file mode 100644 index 0000000..bb1b538 --- /dev/null +++ b/src/app/client-portal/api/projects/route.ts @@ -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 }) +} diff --git a/src/app/client-portal/api/support/route.ts b/src/app/client-portal/api/support/route.ts new file mode 100644 index 0000000..01c5596 --- /dev/null +++ b/src/app/client-portal/api/support/route.ts @@ -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 }) +} diff --git a/src/app/client-portal/dashboard/page.tsx b/src/app/client-portal/dashboard/page.tsx new file mode 100644 index 0000000..791b8dc --- /dev/null +++ b/src/app/client-portal/dashboard/page.tsx @@ -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 ( +
+

Welcome, {name}

+

Your project overview at a glance

+ +
+ {cards.map((card) => { + const Icon = card.icon + return ( + + ) + })} +
+
+ ) +} diff --git a/src/app/client-portal/database/020_client_portal.sql b/src/app/client-portal/database/020_client_portal.sql new file mode 100644 index 0000000..1240092 --- /dev/null +++ b/src/app/client-portal/database/020_client_portal.sql @@ -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); diff --git a/src/app/client-portal/invoices/[id]/page.tsx b/src/app/client-portal/invoices/[id]/page.tsx new file mode 100644 index 0000000..44bb33e --- /dev/null +++ b/src/app/client-portal/invoices/[id]/page.tsx @@ -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(null) + + useEffect(() => { + fetch(`/client-portal/api/invoices/${id}`, { credentials: "include" }) + .then((r) => r.json()) + .then(setData) + .catch(() => {}) + }, [id]) + + if (!data) { + return ( +
+
+
+ ) + } + + const { invoice, items, receipts } = data + + return ( +
+

Invoice {invoice.invoice_number}

+ + {invoice.status_name} + + + {/* Invoice details */} +
+
+ Issued +

+ {invoice.issued_date ? new Date(invoice.issued_date).toLocaleDateString() : "-"} +

+
+
+ Due +

+ {invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : "-"} +

+
+
+ + {/* Line items */} +

Items

+
+ + + + + + + + + + + {items.map((item, i) => ( + + + + + + + ))} + + + {invoice.subtotal && ( + + + + + )} + {invoice.tax_amount > 0 && ( + + + + + )} + + + + + +
DescriptionQtyUnit PriceTotal
{item.description}{item.quantity} + {invoice.currency} {Number(item.unit_price).toLocaleString()} + + {invoice.currency} {Number(item.total_price).toLocaleString()} +
Subtotal + {invoice.currency} {Number(invoice.subtotal).toLocaleString()} +
Tax ({invoice.tax_percent}%) + {invoice.currency} {Number(invoice.tax_amount).toLocaleString()} +
Total + {invoice.currency} {Number(invoice.total_amount).toLocaleString()} +
+
+ + {/* Receipts */} + {receipts.length > 0 && ( + <> +

Receipts

+
+ {receipts.map((r) => ( +
+
+ +
+ {r.receipt_number} +

+ {new Date(r.issued_at).toLocaleDateString()} +

+
+
+ {r.file_url && ( + + + Download + + )} +
+ ))} +
+ + )} +
+ ) +} diff --git a/src/app/client-portal/invoices/page.tsx b/src/app/client-portal/invoices/page.tsx new file mode 100644 index 0000000..d1fe515 --- /dev/null +++ b/src/app/client-portal/invoices/page.tsx @@ -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([]) + + useEffect(() => { + fetch("/client-portal/api/invoices", { credentials: "include" }) + .then((r) => r.json()) + .then((d) => setInvoices(d.invoices || [])) + .catch(() => {}) + }, []) + + return ( +
+

Invoices

+

View and download your invoices and receipts

+ + {invoices.length === 0 ? ( +
+ +

No invoices yet

+
+ ) : ( +
+ {invoices.map((inv) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/app/client-portal/layout.tsx b/src/app/client-portal/layout.tsx new file mode 100644 index 0000000..2d9c2dd --- /dev/null +++ b/src/app/client-portal/layout.tsx @@ -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(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 ( +
+
+
+ ) + } + + if (!auth) return null + + if (pathname === "/client-portal/login") return <>{children} + + return ( +
+ {/* Mobile overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} /> + )} + + {/* Sidebar */} + + + {/* Main content */} +
+
+ + Client Portal +
+ +
+ {children} +
+
+
+ ) +} diff --git a/src/app/client-portal/login/page.tsx b/src/app/client-portal/login/page.tsx new file mode 100644 index 0000000..fd8f575 --- /dev/null +++ b/src/app/client-portal/login/page.tsx @@ -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 ( +
+
+
+ ) + } + + return ( +
+
+
+
+ Client + Portal +
+

Sign in to view your projects and invoices

+
+ +
+
+ + 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 + /> +
+ +
+ +
+ 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 + /> + +
+
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+ ) +} diff --git a/src/app/client-portal/profile/page.tsx b/src/app/client-portal/profile/page.tsx new file mode 100644 index 0000000..57e25af --- /dev/null +++ b/src/app/client-portal/profile/page.tsx @@ -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(null) + const [contacts, setContacts] = useState([]) + + 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 ( +
+
+
+ ) + } + + const isCompany = profile.customer_type === "company" + const name = isCompany ? profile.company_name : `${profile.first_name || ""} ${profile.last_name || ""}`.trim() + + return ( +
+

Profile

+

Your account and company information

+ +
+ {/* Main info card */} +
+
+
+
+ +
+
+

{name || "Unnamed"}

+
+ + {profile.status_name} + + {isCompany ? "Company" : "Individual"} +
+
+
+ +
+ {isCompany && profile.industry && ( +
+ +
+

Industry

+

{profile.industry}

+
+
+ )} + {isCompany && profile.registration_number && ( +
+ +
+

Registration

+

{profile.registration_number}

+
+
+ )} + {profile.tax_id && ( +
+ +
+

Tax / VAT

+

{profile.tax_id}

+
+
+ )} + {isCompany && profile.company_size && ( +
+ +
+

Company Size

+

{profile.company_size}

+
+
+ )} + {isCompany && profile.annual_revenue && ( +
+ +
+

Annual Revenue

+

{Number(profile.annual_revenue).toLocaleString()}

+
+
+ )} + {profile.website && ( +
+ +
+

Website

+

{profile.website}

+
+
+ )} + {!isCompany && profile.job_title && ( +
+ +
+

Job Title

+

{profile.job_title}

+
+
+ )} +
+
+ + {profile.customer_notes && ( +
+

Notes

+

{profile.customer_notes}

+
+ )} +
+ + {/* Contacts sidebar */} +
+ {contacts.map((c, i) => ( +
+
+ {c.type === "email" ? : + c.type === "phone" || c.type === "mobile" ? : + c.type === "website" ? : + } +
+

{c.label || c.type}

+

{c.value}

+
+
+ {c.is_primary && ( + Primary + )} +
+ ))} + {contacts.length === 0 && ( +
+ No contact info +
+ )} +
+
+
+ ) +} diff --git a/src/app/client-portal/projects/[id]/page.tsx b/src/app/client-portal/projects/[id]/page.tsx new file mode 100644 index 0000000..c7e2d6a --- /dev/null +++ b/src/app/client-portal/projects/[id]/page.tsx @@ -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 = { + 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([]) + const [loadingMilestone, setLoadingMilestone] = useState(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 ( +
+
+
+ ) + } + + const { project, milestones, deliverables } = data + const overallProgress = milestones.length + ? Math.round(milestones.reduce((sum, m) => sum + m.progress, 0) / milestones.length) + : 0 + + return ( +
+

{project.name}

+ {project.description && ( +

{project.description}

+ )} + + {/* Overall progress bar */} +
+
+ Overall Progress + {overallProgress}% +
+
+
+
+
+ + {/* Milestones */} +

Milestones

+
+ {milestones.length === 0 ? ( +

No milestones yet

+ ) : ( + 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 ( +
+ + + {expanded.includes(m.id) && ( +
+ {m.description && ( +

{m.description}

+ )} + + {/* Deliverables */} + {milestoneDeliverables.length > 0 && ( +
+ Deliverables + {milestoneDeliverables.map((d) => ( +
+
+ + {d.name} + + {d.status.replace("_", " ")} + +
+ {d.file_url && ( + + View + + )} +
+ ))} +
+ )} + + {/* Approve/Reject buttons */} + {m.status === "awaiting_approval" && ( +
+ + +
+ )} +
+ )} +
+ ) + }) + )} +
+
+ ) +} diff --git a/src/app/client-portal/projects/page.tsx b/src/app/client-portal/projects/page.tsx new file mode 100644 index 0000000..a82645d --- /dev/null +++ b/src/app/client-portal/projects/page.tsx @@ -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 = { + 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([]) + + useEffect(() => { + fetch("/client-portal/api/projects", { credentials: "include" }) + .then((r) => r.json()) + .then((d) => setProjects(d.projects || [])) + .catch(() => {}) + }, []) + + return ( +
+

Projects

+

View your project milestones and progress

+ + {projects.length === 0 ? ( +
+ +

No projects yet

+
+ ) : ( +
+ {projects.map((p) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/app/client-portal/support/page.tsx b/src/app/client-portal/support/page.tsx new file mode 100644 index 0000000..c1107fd --- /dev/null +++ b/src/app/client-portal/support/page.tsx @@ -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([]) + 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 ( +
+
+
+

Support

+

Submit and track support requests

+
+ +
+ + {/* Submit form */} + {showForm && ( +
+
+ + 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 + /> +
+ +
+ +