222 lines
9.1 KiB
TypeScript
222 lines
9.1 KiB
TypeScript
"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((d) => {
|
|
if (d.project) setData({ ...d, milestones: d.milestones || [], deliverables: d.deliverables || [] })
|
|
})
|
|
.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>
|
|
)
|
|
}
|