889 lines
30 KiB
TypeScript
889 lines
30 KiB
TypeScript
'use client'
|
|
|
|
import { Suspense, use, useState, useEffect } from 'react'
|
|
import Link from 'next/link'
|
|
import type { Route } from 'next'
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog'
|
|
import { FileViewer } from '@/components/shared/file-viewer'
|
|
import { MentorChat } from '@/components/shared/mentor-chat'
|
|
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
|
import {
|
|
ArrowLeft,
|
|
AlertCircle,
|
|
Users,
|
|
MapPin,
|
|
Waves,
|
|
GraduationCap,
|
|
Crown,
|
|
Mail,
|
|
Phone,
|
|
Calendar,
|
|
FileText,
|
|
ExternalLink,
|
|
MessageSquare,
|
|
StickyNote,
|
|
Plus,
|
|
Pencil,
|
|
Trash2,
|
|
Loader2,
|
|
Target,
|
|
CheckCircle2,
|
|
Circle,
|
|
Eye,
|
|
EyeOff,
|
|
} from 'lucide-react'
|
|
import { formatDateOnly, getInitials } from '@/lib/utils'
|
|
import { toast } from 'sonner'
|
|
|
|
interface PageProps {
|
|
params: Promise<{ id: string }>
|
|
}
|
|
|
|
// Status badge colors
|
|
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
SUBMITTED: 'secondary',
|
|
ELIGIBLE: 'default',
|
|
ASSIGNED: 'default',
|
|
SEMIFINALIST: 'default',
|
|
FINALIST: 'default',
|
|
REJECTED: 'destructive',
|
|
}
|
|
|
|
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|
const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({
|
|
projectId,
|
|
})
|
|
|
|
const { data: mentorMessages, isLoading: messagesLoading } = trpc.mentor.getMessages.useQuery({
|
|
projectId,
|
|
})
|
|
|
|
const utils = trpc.useUtils()
|
|
const sendMessage = trpc.mentor.sendMessage.useMutation({
|
|
onSuccess: () => {
|
|
utils.mentor.getMessages.invalidate({ projectId })
|
|
},
|
|
})
|
|
|
|
// Track view when project loads
|
|
const trackView = trpc.mentor.trackView.useMutation()
|
|
useEffect(() => {
|
|
if (project?.mentorAssignment?.id) {
|
|
trackView.mutate({ mentorAssignmentId: project.mentorAssignment.id })
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [project?.mentorAssignment?.id])
|
|
|
|
if (isLoading) {
|
|
return <ProjectDetailSkeleton />
|
|
}
|
|
|
|
if (error || !project) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<Button variant="ghost" asChild className="-ml-4">
|
|
<Link href={'/mentor' as Route}>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back to Dashboard
|
|
</Link>
|
|
</Button>
|
|
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
|
<p className="mt-2 font-medium">
|
|
{error?.message || 'Project Not Found'}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
You may not have access to view this project.
|
|
</p>
|
|
<Button asChild className="mt-4">
|
|
<Link href={'/mentor' as Route}>Back to Dashboard</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
|
|
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
|
|
const mentorAssignmentId = project.mentorAssignment?.id
|
|
const programId = project.round?.program?.id
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-4">
|
|
<Button variant="ghost" asChild className="-ml-4">
|
|
<Link href={'/mentor' as Route}>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back to Dashboard
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
<div className="flex items-start gap-4">
|
|
<ProjectLogoWithUrl
|
|
project={project}
|
|
size="lg"
|
|
fallback="initials"
|
|
/>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<span>
|
|
{project.round?.program?.year} Edition
|
|
</span>
|
|
{project.round && (
|
|
<>
|
|
<span>-</span>
|
|
<span>{project.round.name}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|
{project.title}
|
|
</h1>
|
|
{project.status && (
|
|
<Badge variant={statusColors[project.status] || 'secondary'}>
|
|
{project.status.replace('_', ' ')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{project.teamName && (
|
|
<p className="text-muted-foreground">{project.teamName}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{project.assignedAt && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Calendar className="h-4 w-4" />
|
|
<span>Assigned to you on {formatDateOnly(project.assignedAt)}</span>
|
|
</div>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
{/* Milestones Section */}
|
|
{programId && mentorAssignmentId && (
|
|
<MilestonesSection
|
|
programId={programId}
|
|
mentorAssignmentId={mentorAssignmentId}
|
|
/>
|
|
)}
|
|
|
|
{/* Private Notes Section */}
|
|
{mentorAssignmentId && (
|
|
<NotesSection mentorAssignmentId={mentorAssignmentId} />
|
|
)}
|
|
|
|
{/* Project Info */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Project Information</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* Category & Ocean Issue badges */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{project.competitionCategory && (
|
|
<Badge variant="outline" className="gap-1">
|
|
<GraduationCap className="h-3 w-3" />
|
|
{project.competitionCategory === 'STARTUP' ? 'Start-up' : 'Business Concept'}
|
|
</Badge>
|
|
)}
|
|
{project.oceanIssue && (
|
|
<Badge variant="outline" className="gap-1">
|
|
<Waves className="h-3 w-3" />
|
|
{project.oceanIssue.replace(/_/g, ' ')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{project.description && (
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground mb-1">
|
|
Description
|
|
</p>
|
|
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Location & Institution */}
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
{(project.country || project.geographicZone) && (
|
|
<div className="flex items-start gap-2">
|
|
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">Location</p>
|
|
<p className="text-sm">
|
|
{[project.geographicZone, project.country].filter(Boolean).join(', ')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{project.institution && (
|
|
<div className="flex items-start gap-2">
|
|
<GraduationCap className="h-4 w-4 text-muted-foreground mt-0.5" />
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">Institution</p>
|
|
<p className="text-sm">{project.institution}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Submission URLs */}
|
|
{(project.phase1SubmissionUrl || project.phase2SubmissionUrl) && (
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium text-muted-foreground">Submission Links</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{project.phase1SubmissionUrl && (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<a href={project.phase1SubmissionUrl} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="mr-2 h-4 w-4" />
|
|
Phase 1 Submission
|
|
</a>
|
|
</Button>
|
|
)}
|
|
{project.phase2SubmissionUrl && (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<a href={project.phase2SubmissionUrl} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="mr-2 h-4 w-4" />
|
|
Phase 2 Submission
|
|
</a>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{project.tags && project.tags.length > 0 && (
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{project.tags.map((tag) => (
|
|
<Badge key={tag} variant="secondary">
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Team Members Section */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<Users className="h-5 w-5" />
|
|
Team Members ({project.teamMembers?.length || 0})
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Contact information for the project team
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* Team Lead */}
|
|
{teamLead && (
|
|
<div className="p-4 rounded-lg border bg-muted/30">
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100">
|
|
<Crown className="h-6 w-6 text-yellow-600" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<p className="font-medium">{teamLead.user.name || 'Unnamed'}</p>
|
|
<Badge variant="secondary" className="text-xs">Team Lead</Badge>
|
|
</div>
|
|
{teamLead.title && (
|
|
<p className="text-sm text-muted-foreground mb-2">{teamLead.title}</p>
|
|
)}
|
|
<div className="flex flex-wrap gap-4 text-sm">
|
|
<a
|
|
href={`mailto:${teamLead.user.email}`}
|
|
className="flex items-center gap-1 text-primary hover:underline"
|
|
>
|
|
<Mail className="h-4 w-4" />
|
|
{teamLead.user.email}
|
|
</a>
|
|
{teamLead.user.phoneNumber && (
|
|
<a
|
|
href={`tel:${teamLead.user.phoneNumber}`}
|
|
className="flex items-center gap-1 text-primary hover:underline"
|
|
>
|
|
<Phone className="h-4 w-4" />
|
|
{teamLead.user.phoneNumber}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Other Team Members */}
|
|
{otherMembers.length > 0 && (
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{otherMembers.map((member) => (
|
|
<div key={member.id} className="flex items-start gap-3 p-3 rounded-lg border">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
|
<span className="text-sm font-medium">
|
|
{getInitials(member.user.name || member.user.email)}
|
|
</span>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<p className="font-medium text-sm truncate">
|
|
{member.user.name || 'Unnamed'}
|
|
</p>
|
|
<Badge variant="outline" className="text-xs">
|
|
{member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
|
</Badge>
|
|
</div>
|
|
{member.title && (
|
|
<p className="text-xs text-muted-foreground">{member.title}</p>
|
|
)}
|
|
<a
|
|
href={`mailto:${member.user.email}`}
|
|
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1 mt-1"
|
|
>
|
|
<Mail className="h-3 w-3" />
|
|
{member.user.email}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!project.teamMembers?.length && (
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
No team members listed for this project.
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Files Section */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<FileText className="h-5 w-5" />
|
|
Project Files
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Documents and materials submitted by the team
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{project.files && project.files.length > 0 ? (
|
|
<FileViewer
|
|
projectId={projectId}
|
|
files={project.files.map((f) => ({
|
|
id: f.id,
|
|
fileName: f.fileName,
|
|
fileType: f.fileType,
|
|
mimeType: f.mimeType,
|
|
size: f.size,
|
|
bucket: f.bucket,
|
|
objectKey: f.objectKey,
|
|
version: f.version,
|
|
}))}
|
|
/>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground text-center py-6">
|
|
No files have been uploaded for this project yet.
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Messaging Section */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<MessageSquare className="h-5 w-5" />
|
|
Messages
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Communicate with the project team
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<MentorChat
|
|
messages={mentorMessages || []}
|
|
currentUserId={project.mentorAssignment?.mentor?.id || ''}
|
|
onSendMessage={async (message) => {
|
|
await sendMessage.mutateAsync({ projectId, message })
|
|
}}
|
|
isLoading={messagesLoading}
|
|
isSending={sendMessage.isPending}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Milestones Section
|
|
// =============================================================================
|
|
|
|
function MilestonesSection({
|
|
programId,
|
|
mentorAssignmentId,
|
|
}: {
|
|
programId: string
|
|
mentorAssignmentId: string
|
|
}) {
|
|
const { data: milestones, isLoading } = trpc.mentor.getMilestones.useQuery({ programId })
|
|
const utils = trpc.useUtils()
|
|
|
|
const completeMutation = trpc.mentor.completeMilestone.useMutation({
|
|
onSuccess: (data) => {
|
|
utils.mentor.getMilestones.invalidate({ programId })
|
|
if (data.allRequiredDone) {
|
|
toast.success('All required milestones completed!')
|
|
} else {
|
|
toast.success('Milestone completed')
|
|
}
|
|
},
|
|
onError: (e) => toast.error(e.message),
|
|
})
|
|
|
|
const uncompleteMutation = trpc.mentor.uncompleteMilestone.useMutation({
|
|
onSuccess: () => {
|
|
utils.mentor.getMilestones.invalidate({ programId })
|
|
toast.success('Milestone unchecked')
|
|
},
|
|
onError: (e) => toast.error(e.message),
|
|
})
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-5 w-32" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-8 w-full" />
|
|
<Skeleton className="h-8 w-full" />
|
|
<Skeleton className="h-8 w-full" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
if (!milestones || milestones.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const completedCount = milestones.filter(
|
|
(m) => m.myCompletions.length > 0
|
|
).length
|
|
const totalRequired = milestones.filter((m) => m.isRequired).length
|
|
const requiredCompleted = milestones.filter(
|
|
(m) => m.isRequired && m.myCompletions.length > 0
|
|
).length
|
|
|
|
const handleToggle = (milestoneId: string, isCompleted: boolean) => {
|
|
if (isCompleted) {
|
|
uncompleteMutation.mutate({ milestoneId, mentorAssignmentId })
|
|
} else {
|
|
completeMutation.mutate({ milestoneId, mentorAssignmentId })
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<Target className="h-5 w-5" />
|
|
Milestones
|
|
</CardTitle>
|
|
<Badge variant="secondary">
|
|
{completedCount}/{milestones.length} done
|
|
</Badge>
|
|
</div>
|
|
{totalRequired > 0 && (
|
|
<CardDescription>
|
|
{requiredCompleted}/{totalRequired} required milestones completed
|
|
</CardDescription>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{milestones.map((milestone) => {
|
|
const isCompleted = milestone.myCompletions.length > 0
|
|
const isPending = completeMutation.isPending || uncompleteMutation.isPending
|
|
|
|
return (
|
|
<div
|
|
key={milestone.id}
|
|
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
|
isCompleted ? 'bg-green-50/50 border-green-200 dark:bg-green-950/20 dark:border-green-900' : ''
|
|
}`}
|
|
>
|
|
<Checkbox
|
|
checked={isCompleted}
|
|
disabled={isPending}
|
|
onCheckedChange={() => handleToggle(milestone.id, isCompleted)}
|
|
className="mt-0.5"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<p className={`text-sm font-medium ${isCompleted ? 'line-through text-muted-foreground' : ''}`}>
|
|
{milestone.name}
|
|
</p>
|
|
{milestone.isRequired && (
|
|
<Badge variant="outline" className="text-xs">Required</Badge>
|
|
)}
|
|
</div>
|
|
{milestone.description && (
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{milestone.description}
|
|
</p>
|
|
)}
|
|
{isCompleted && milestone.myCompletions[0] && (
|
|
<p className="text-xs text-green-600 mt-1">
|
|
Completed {formatDateOnly(milestone.myCompletions[0].completedAt)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{isCompleted ? (
|
|
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0 mt-0.5" />
|
|
) : (
|
|
<Circle className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Notes Section
|
|
// =============================================================================
|
|
|
|
function NotesSection({ mentorAssignmentId }: { mentorAssignmentId: string }) {
|
|
const [isAdding, setIsAdding] = useState(false)
|
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
|
const [noteContent, setNoteContent] = useState('')
|
|
const [isVisibleToAdmin, setIsVisibleToAdmin] = useState(true)
|
|
|
|
const { data: notes, isLoading } = trpc.mentor.getNotes.useQuery({ mentorAssignmentId })
|
|
const utils = trpc.useUtils()
|
|
|
|
const createMutation = trpc.mentor.createNote.useMutation({
|
|
onSuccess: () => {
|
|
utils.mentor.getNotes.invalidate({ mentorAssignmentId })
|
|
toast.success('Note saved')
|
|
resetForm()
|
|
},
|
|
onError: (e) => toast.error(e.message),
|
|
})
|
|
|
|
const updateMutation = trpc.mentor.updateNote.useMutation({
|
|
onSuccess: () => {
|
|
utils.mentor.getNotes.invalidate({ mentorAssignmentId })
|
|
toast.success('Note updated')
|
|
resetForm()
|
|
},
|
|
onError: (e) => toast.error(e.message),
|
|
})
|
|
|
|
const deleteMutation = trpc.mentor.deleteNote.useMutation({
|
|
onSuccess: () => {
|
|
utils.mentor.getNotes.invalidate({ mentorAssignmentId })
|
|
toast.success('Note deleted')
|
|
setDeleteId(null)
|
|
},
|
|
onError: (e) => toast.error(e.message),
|
|
})
|
|
|
|
const resetForm = () => {
|
|
setIsAdding(false)
|
|
setEditingId(null)
|
|
setNoteContent('')
|
|
setIsVisibleToAdmin(true)
|
|
}
|
|
|
|
const handleEdit = (note: { id: string; content: string; isVisibleToAdmin: boolean }) => {
|
|
setEditingId(note.id)
|
|
setNoteContent(note.content)
|
|
setIsVisibleToAdmin(note.isVisibleToAdmin)
|
|
setIsAdding(false)
|
|
}
|
|
|
|
const handleSubmit = () => {
|
|
if (!noteContent.trim()) return
|
|
|
|
if (editingId) {
|
|
updateMutation.mutate({
|
|
noteId: editingId,
|
|
content: noteContent.trim(),
|
|
isVisibleToAdmin,
|
|
})
|
|
} else {
|
|
createMutation.mutate({
|
|
mentorAssignmentId,
|
|
content: noteContent.trim(),
|
|
isVisibleToAdmin,
|
|
})
|
|
}
|
|
}
|
|
|
|
const isPending = createMutation.isPending || updateMutation.isPending
|
|
|
|
return (
|
|
<>
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<StickyNote className="h-5 w-5" />
|
|
Private Notes
|
|
</CardTitle>
|
|
{!isAdding && !editingId && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setIsAdding(true)
|
|
setEditingId(null)
|
|
setNoteContent('')
|
|
setIsVisibleToAdmin(true)
|
|
}}
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add Note
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<CardDescription>
|
|
Personal notes about this mentorship (private to you unless shared with admin)
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* Add/Edit form */}
|
|
{(isAdding || editingId) && (
|
|
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
|
|
<Textarea
|
|
value={noteContent}
|
|
onChange={(e) => setNoteContent(e.target.value)}
|
|
placeholder="Write your note..."
|
|
rows={4}
|
|
className="resize-none"
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="visible-to-admin"
|
|
checked={isVisibleToAdmin}
|
|
onCheckedChange={(checked) =>
|
|
setIsVisibleToAdmin(checked === true)
|
|
}
|
|
/>
|
|
<Label htmlFor="visible-to-admin" className="text-sm flex items-center gap-1">
|
|
{isVisibleToAdmin ? (
|
|
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
|
|
) : (
|
|
<EyeOff className="h-3.5 w-3.5 text-muted-foreground" />
|
|
)}
|
|
Visible to admins
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2 justify-end">
|
|
<Button variant="outline" size="sm" onClick={resetForm}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSubmit}
|
|
disabled={!noteContent.trim() || isPending}
|
|
>
|
|
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{editingId ? 'Update' : 'Save'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Notes list */}
|
|
{isLoading ? (
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-20 w-full" />
|
|
<Skeleton className="h-20 w-full" />
|
|
</div>
|
|
) : notes && notes.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{notes.map((note) => (
|
|
<div
|
|
key={note.id}
|
|
className="p-4 rounded-lg border space-y-2"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span>{formatDateOnly(note.createdAt)}</span>
|
|
{note.isVisibleToAdmin ? (
|
|
<Badge variant="outline" className="text-xs gap-1">
|
|
<Eye className="h-3 w-3" />
|
|
Admin visible
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="outline" className="text-xs gap-1">
|
|
<EyeOff className="h-3 w-3" />
|
|
Private
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => handleEdit(note)}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => setDeleteId(note.id)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<p className="text-sm whitespace-pre-wrap">{note.content}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
!isAdding && (
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
No notes yet. Click "Add Note" to start taking notes.
|
|
</p>
|
|
)
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Delete confirmation */}
|
|
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Note</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete this note? This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => deleteId && deleteMutation.mutate({ noteId: deleteId })}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Skeletons
|
|
// =============================================================================
|
|
|
|
function ProjectDetailSkeleton() {
|
|
return (
|
|
<div className="space-y-6">
|
|
<Skeleton className="h-9 w-36" />
|
|
|
|
<div className="flex items-start gap-4">
|
|
<Skeleton className="h-16 w-16 rounded-lg" />
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-8 w-64" />
|
|
<Skeleton className="h-4 w-40" />
|
|
</div>
|
|
</div>
|
|
|
|
<Skeleton className="h-px w-full" />
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-5 w-40" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-24 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-5 w-32" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-20 w-full" />
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<Skeleton className="h-16 w-full" />
|
|
<Skeleton className="h-16 w-full" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function MentorProjectDetailPage({ params }: PageProps) {
|
|
const { id } = use(params)
|
|
|
|
return (
|
|
<Suspense fallback={<ProjectDetailSkeleton />}>
|
|
<ProjectDetailContent projectId={id} />
|
|
</Suspense>
|
|
)
|
|
}
|