Files
Newbie_CRM/src/app/client-portal/projects/[id]/page.tsx
T
2026-07-01 12:08:22 +02:00

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>
)
}