Client portal: profile, activity pages + middleware fix
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user