All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
The admin upload flow accepted roundId but never wrote it to the ProjectFile record, causing all admin-uploaded files to appear under "General". Fixed the create call, the listByProject filter, and the listByProjectForStage grouping to also use the direct roundId field. Jury assignments on the project detail page are now grouped by round with per-round completion counts instead of a flat list. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1135 lines
45 KiB
TypeScript
1135 lines
45 KiB
TypeScript
'use client'
|
|
|
|
import { Suspense, use, useState } from 'react'
|
|
import Link from 'next/link'
|
|
import type { Route } from 'next'
|
|
import { useRouter } from 'next/navigation'
|
|
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 {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/tooltip'
|
|
import { FileViewer } from '@/components/shared/file-viewer'
|
|
import { FileUpload } from '@/components/shared/file-upload'
|
|
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
|
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
|
|
import { EvaluationEditSheet } from '@/components/admin/evaluation-edit-sheet'
|
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
|
import {
|
|
ArrowLeft,
|
|
Edit,
|
|
AlertCircle,
|
|
Users,
|
|
FileText,
|
|
Calendar,
|
|
BarChart3,
|
|
ThumbsUp,
|
|
ThumbsDown,
|
|
MapPin,
|
|
Waves,
|
|
GraduationCap,
|
|
Heart,
|
|
Crown,
|
|
UserPlus,
|
|
Loader2,
|
|
ScanSearch,
|
|
Eye,
|
|
Plus,
|
|
X,
|
|
} from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { formatDateOnly } from '@/lib/utils'
|
|
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
|
import { CountryDisplay } from '@/components/shared/country-display'
|
|
|
|
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',
|
|
}
|
|
|
|
// Evaluation status colors
|
|
const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
NOT_STARTED: 'outline',
|
|
DRAFT: 'secondary',
|
|
SUBMITTED: 'default',
|
|
LOCKED: 'default',
|
|
}
|
|
|
|
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|
const router = useRouter()
|
|
// Fetch project + assignments + stats in a single combined query
|
|
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery(
|
|
{ id: projectId },
|
|
{ refetchInterval: 30_000 }
|
|
)
|
|
|
|
const project = fullDetail?.project
|
|
const assignments = fullDetail?.assignments
|
|
const stats = fullDetail?.stats
|
|
|
|
// Fetch files (flat list for backward compatibility)
|
|
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
|
|
|
|
// Fetch competitions for this project's program to get rounds
|
|
const { data: competitions } = trpc.competition.list.useQuery(
|
|
{ programId: project?.programId || '' },
|
|
{ enabled: !!project?.programId }
|
|
)
|
|
|
|
// Get first competition ID to fetch full details with rounds
|
|
const competitionId = competitions?.[0]?.id
|
|
|
|
// Fetch full competition details including rounds
|
|
const { data: competition } = trpc.competition.getById.useQuery(
|
|
{ id: competitionId || '' },
|
|
{ enabled: !!competitionId }
|
|
)
|
|
|
|
// Extract all rounds from the competition
|
|
const competitionRounds = competition?.rounds || []
|
|
|
|
// Fetch requirements for all rounds in a single query (avoids dynamic hook violation)
|
|
const roundIds = competitionRounds.map((r: { id: string }) => r.id)
|
|
const { data: allRequirements = [] } = trpc.file.listRequirementsByRounds.useQuery(
|
|
{ roundIds },
|
|
{ enabled: roundIds.length > 0 }
|
|
)
|
|
|
|
const utils = trpc.useUtils()
|
|
|
|
// State for evaluation detail sheet
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
|
|
|
|
// State for add member dialog
|
|
const [addMemberOpen, setAddMemberOpen] = useState(false)
|
|
const [addMemberForm, setAddMemberForm] = useState({
|
|
email: '',
|
|
name: '',
|
|
role: 'MEMBER' as 'LEAD' | 'MEMBER' | 'ADVISOR',
|
|
title: '',
|
|
sendInvite: true,
|
|
})
|
|
|
|
// State for remove member confirmation
|
|
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
|
|
|
const addTeamMember = trpc.project.addTeamMember.useMutation({
|
|
onSuccess: () => {
|
|
toast.success('Team member added')
|
|
setAddMemberOpen(false)
|
|
setAddMemberForm({ email: '', name: '', role: 'MEMBER', title: '', sendInvite: true })
|
|
utils.project.getFullDetail.invalidate({ id: projectId })
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err.message || 'Failed to add team member')
|
|
},
|
|
})
|
|
|
|
const updateTeamMemberRole = trpc.project.updateTeamMemberRole.useMutation({
|
|
onSuccess: () => {
|
|
toast.success('Role updated')
|
|
utils.project.getFullDetail.invalidate({ id: projectId })
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err.message || 'Failed to update role')
|
|
},
|
|
})
|
|
|
|
const removeTeamMember = trpc.project.removeTeamMember.useMutation({
|
|
onSuccess: () => {
|
|
toast.success('Team member removed')
|
|
setRemovingMemberId(null)
|
|
utils.project.getFullDetail.invalidate({ id: projectId })
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err.message || 'Failed to remove team member')
|
|
},
|
|
})
|
|
|
|
if (isLoading) {
|
|
return <ProjectDetailSkeleton />
|
|
}
|
|
|
|
if (!project) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back
|
|
</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">Project Not Found</p>
|
|
<Button className="mt-4" onClick={() => router.back()}>
|
|
Back
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-4">
|
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back
|
|
</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"
|
|
clickToEnlarge
|
|
/>
|
|
<div className="space-y-1">
|
|
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
|
{project.programId ? (
|
|
<Link
|
|
href={`/admin/programs/${project.programId}`}
|
|
className="hover:underline"
|
|
>
|
|
Program
|
|
</Link>
|
|
) : (
|
|
<span>No program</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|
{project.title}
|
|
</h1>
|
|
{(() => {
|
|
const prs = (project as any).projectRoundStates ?? []
|
|
if (!prs.length) return <Badge variant="secondary">Submitted</Badge>
|
|
if (prs.some((p: any) => p.state === 'REJECTED')) return <Badge variant="destructive">Rejected</Badge>
|
|
const latest = prs[0]
|
|
return <Badge variant={latest.state === 'PASSED' ? 'default' : 'secondary'}>{latest.round.name}</Badge>
|
|
})()}
|
|
</div>
|
|
{project.teamName && (
|
|
<p className="text-muted-foreground">{project.teamName}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Button variant="outline" asChild>
|
|
<Link href={`/admin/projects/${projectId}/edit`}>
|
|
<Edit className="mr-2 h-4 w-4" />
|
|
Edit
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Stats Grid */}
|
|
{stats && (
|
|
<AnimatedCard index={0}>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">
|
|
Average Score
|
|
</CardTitle>
|
|
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
|
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{stats.averageGlobalScore?.toFixed(1) || '-'}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Range: {stats.minScore || '-'} - {stats.maxScore || '-'}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">
|
|
Recommendations
|
|
</CardTitle>
|
|
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
|
<ThumbsUp className="h-4 w-4 text-emerald-500" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{stats.yesPercentage?.toFixed(0) || 0}%
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{stats.yesVotes} yes / {stats.noVotes} no
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</AnimatedCard>
|
|
)}
|
|
|
|
{/* Project Info */}
|
|
<AnimatedCard index={1}>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
|
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
|
<FileText className="h-4 w-4 text-emerald-500" />
|
|
</div>
|
|
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>
|
|
)}
|
|
{project.wantsMentorship && (
|
|
<Badge variant="outline" className="gap-1 text-pink-600 border-pink-200 bg-pink-50">
|
|
<Heart className="h-3 w-3" />
|
|
Wants Mentorship
|
|
</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.geographicZone && project.country ? ', ' : ''}{project.country ? <CountryDisplay country={project.country} /> : null}</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>
|
|
)}
|
|
{project.foundedAt && (
|
|
<div className="flex items-start gap-2">
|
|
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">Founded</p>
|
|
<p className="text-sm">{formatDateOnly(project.foundedAt)}</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">
|
|
Phase 1 Submission
|
|
</a>
|
|
</Button>
|
|
)}
|
|
{project.phase2SubmissionUrl && (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<a href={project.phase2SubmissionUrl} target="_blank" rel="noopener noreferrer">
|
|
Phase 2 Submission
|
|
</a>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* AI-Assigned Expertise Tags */}
|
|
{project.projectTags && project.projectTags.length > 0 && (
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground mb-2">
|
|
Expertise Tags
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{project.projectTags.map((pt) => (
|
|
<Badge
|
|
key={pt.tag.id}
|
|
variant="secondary"
|
|
className="flex items-center gap-1"
|
|
style={pt.tag.color ? { backgroundColor: `${pt.tag.color}20`, borderColor: pt.tag.color } : undefined}
|
|
>
|
|
{pt.tag.name}
|
|
{pt.confidence < 1 && (
|
|
<span className="text-xs opacity-60">
|
|
{Math.round(pt.confidence * 100)}%
|
|
</span>
|
|
)}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Simple Tags (legacy) */}
|
|
{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>
|
|
)}
|
|
|
|
{/* Internal Info */}
|
|
{(project.internalComments || project.applicationStatus || project.referralSource) && (
|
|
<div className="border-t pt-4 mt-4">
|
|
<p className="text-sm font-medium text-muted-foreground mb-3">Internal Notes</p>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{project.applicationStatus && (
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Application Status</p>
|
|
<p className="text-sm">{project.applicationStatus}</p>
|
|
</div>
|
|
)}
|
|
{project.referralSource && (
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Referral Source</p>
|
|
<p className="text-sm">{project.referralSource}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{project.internalComments && (
|
|
<div className="mt-3">
|
|
<p className="text-xs text-muted-foreground">Comments</p>
|
|
<p className="text-sm whitespace-pre-wrap">{project.internalComments}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-6 text-sm pt-2">
|
|
<div>
|
|
<span className="text-muted-foreground">Created:</span>{' '}
|
|
{formatDateOnly(project.createdAt)}
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Updated:</span>{' '}
|
|
{formatDateOnly(project.updatedAt)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</AnimatedCard>
|
|
|
|
{/* Team Members Section */}
|
|
<AnimatedCard index={2}>
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
|
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
|
<Users className="h-4 w-4 text-violet-500" />
|
|
</div>
|
|
Team Members ({project.teamMembers?.length ?? 0})
|
|
</CardTitle>
|
|
<Button variant="outline" size="sm" onClick={() => setAddMemberOpen(true)}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add Member
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{project.teamMembers && project.teamMembers.length > 0 ? (
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null; nationality?: string | null; country?: string | null; institution?: string | null } }) => {
|
|
const isLastLead =
|
|
member.role === 'LEAD' &&
|
|
project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1
|
|
const details = [
|
|
member.user.nationality ? `${getCountryFlag(member.user.nationality)} ${getCountryName(member.user.nationality)}` : null,
|
|
member.user.institution,
|
|
member.user.country && member.user.country !== member.user.nationality ? `${getCountryFlag(member.user.country)} ${getCountryName(member.user.country)}` : null,
|
|
].filter(Boolean)
|
|
return (
|
|
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
|
|
{member.role === 'LEAD' ? (
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
|
<Crown className="h-5 w-5 text-yellow-500" />
|
|
</div>
|
|
) : (
|
|
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<Link href={`/admin/members/${member.user.id}`} className="font-medium text-sm truncate hover:underline text-primary">
|
|
{member.user.name || 'Unnamed'}
|
|
</Link>
|
|
<Select
|
|
value={member.role}
|
|
onValueChange={(value) =>
|
|
updateTeamMemberRole.mutate({
|
|
projectId: project.id,
|
|
userId: member.user.id,
|
|
role: value as 'LEAD' | 'MEMBER' | 'ADVISOR',
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-6 w-auto text-xs px-2 py-0 border-dashed gap-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="LEAD">Lead</SelectItem>
|
|
<SelectItem value="MEMBER">Member</SelectItem>
|
|
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{member.user.email}
|
|
</p>
|
|
{member.title && (
|
|
<p className="text-xs text-muted-foreground">{member.title}</p>
|
|
)}
|
|
{details.length > 0 && (
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{details.join(' · ')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
|
disabled={isLastLead}
|
|
onClick={() => setRemovingMemberId(member.user.id)}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
{isLastLead && (
|
|
<TooltipContent>
|
|
Cannot remove the last team lead
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No team members yet.</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</AnimatedCard>
|
|
|
|
{/* Add Member Dialog */}
|
|
<Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Add Team Member</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="member-email">Email</Label>
|
|
<Input
|
|
id="member-email"
|
|
type="email"
|
|
placeholder="member@example.com"
|
|
value={addMemberForm.email}
|
|
onChange={(e) => setAddMemberForm((f) => ({ ...f, email: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="member-name">Name</Label>
|
|
<Input
|
|
id="member-name"
|
|
placeholder="Full name"
|
|
value={addMemberForm.name}
|
|
onChange={(e) => setAddMemberForm((f) => ({ ...f, name: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="member-role">Role</Label>
|
|
<Select
|
|
value={addMemberForm.role}
|
|
onValueChange={(v) => setAddMemberForm((f) => ({ ...f, role: v as 'LEAD' | 'MEMBER' | 'ADVISOR' }))}
|
|
>
|
|
<SelectTrigger id="member-role">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="LEAD">Lead</SelectItem>
|
|
<SelectItem value="MEMBER">Member</SelectItem>
|
|
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="member-title">Title (optional)</Label>
|
|
<Input
|
|
id="member-title"
|
|
placeholder="e.g. CEO, Co-founder"
|
|
value={addMemberForm.title}
|
|
onChange={(e) => setAddMemberForm((f) => ({ ...f, title: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="member-invite"
|
|
checked={addMemberForm.sendInvite}
|
|
onCheckedChange={(checked) =>
|
|
setAddMemberForm((f) => ({ ...f, sendInvite: checked === true }))
|
|
}
|
|
/>
|
|
<Label htmlFor="member-invite" className="font-normal cursor-pointer">
|
|
Send invite email
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setAddMemberOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() =>
|
|
addTeamMember.mutate({
|
|
projectId,
|
|
email: addMemberForm.email,
|
|
name: addMemberForm.name,
|
|
role: addMemberForm.role,
|
|
title: addMemberForm.title || undefined,
|
|
sendInvite: addMemberForm.sendInvite,
|
|
})
|
|
}
|
|
disabled={addTeamMember.isPending || !addMemberForm.email || !addMemberForm.name}
|
|
>
|
|
{addTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Add Member
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Remove Member Confirmation Dialog */}
|
|
<Dialog open={!!removingMemberId} onOpenChange={(open) => { if (!open) setRemovingMemberId(null) }}>
|
|
<DialogContent className="sm:max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>Remove Team Member</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground">
|
|
Are you sure you want to remove this team member? This action cannot be undone.
|
|
</p>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setRemovingMemberId(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => {
|
|
if (removingMemberId) {
|
|
removeTeamMember.mutate({ projectId, userId: removingMemberId })
|
|
}
|
|
}}
|
|
disabled={removeTeamMember.isPending}
|
|
>
|
|
{removeTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Remove
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Mentor Assignment Section */}
|
|
{project.wantsMentorship && (
|
|
<AnimatedCard index={3}>
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
|
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
|
<Heart className="h-4 w-4 text-rose-500" />
|
|
</div>
|
|
Mentor Assignment
|
|
</CardTitle>
|
|
{!project.mentorAssignment && (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link href={`/admin/projects/${projectId}/mentor` as Route}>
|
|
<UserPlus className="mr-2 h-4 w-4" />
|
|
Assign Mentor
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{project.mentorAssignment ? (
|
|
<div className="flex items-center justify-between p-3 rounded-lg border">
|
|
<div className="flex items-center gap-3">
|
|
<UserAvatar
|
|
user={project.mentorAssignment.mentor}
|
|
avatarUrl={project.mentorAssignment.mentor.avatarUrl}
|
|
size="md"
|
|
/>
|
|
<div>
|
|
<p className="font-medium">
|
|
{project.mentorAssignment.mentor.name || 'Unnamed'}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{project.mentorAssignment.mentor.email}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Badge variant="outline">
|
|
{project.mentorAssignment.method.replace('_', ' ')}
|
|
</Badge>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
No mentor assigned yet. The applicant has requested mentorship support.
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</AnimatedCard>
|
|
)}
|
|
|
|
{/* Files Section */}
|
|
<AnimatedCard index={4}>
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
|
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
|
<FileText className="h-4 w-4 text-rose-500" />
|
|
</div>
|
|
Files
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Project documents and materials organized by competition round
|
|
</CardDescription>
|
|
</div>
|
|
<AnalyzeDocumentsButton projectId={projectId} onComplete={() => utils.file.listByProject.invalidate({ projectId })} />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* File upload */}
|
|
<div>
|
|
<p className="text-sm font-semibold mb-3">Upload Files</p>
|
|
<FileUpload
|
|
projectId={projectId}
|
|
availableRounds={competitionRounds?.map((r: any) => ({ id: r.id, name: r.name }))}
|
|
onUploadComplete={() => {
|
|
utils.file.listByProject.invalidate({ projectId })
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* All Files list — grouped by round */}
|
|
{files && files.length > 0 && (
|
|
<>
|
|
<Separator />
|
|
<FileViewer
|
|
projectId={projectId}
|
|
groupedFiles={(() => {
|
|
const groups = new Map<string, { roundId: string | null; roundName: string; sortOrder: number; files: typeof mappedFiles }>()
|
|
const mappedFiles = files.map((f) => ({
|
|
id: f.id,
|
|
fileName: f.fileName,
|
|
fileType: f.fileType as 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' | 'BUSINESS_PLAN' | 'VIDEO_PITCH' | 'SUPPORTING_DOC',
|
|
mimeType: f.mimeType,
|
|
size: f.size,
|
|
bucket: f.bucket,
|
|
objectKey: f.objectKey,
|
|
pageCount: f.pageCount,
|
|
textPreview: f.textPreview,
|
|
detectedLang: f.detectedLang,
|
|
langConfidence: f.langConfidence,
|
|
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
|
requirementId: f.requirementId,
|
|
requirement: f.requirement ? {
|
|
id: f.requirement.id,
|
|
name: f.requirement.name,
|
|
description: f.requirement.description,
|
|
isRequired: f.requirement.isRequired,
|
|
} : null,
|
|
}))
|
|
for (const f of files) {
|
|
const roundId = f.requirement?.roundId ?? f.roundId ?? null
|
|
const matchedRound = roundId ? competitionRounds.find((r: any) => r.id === roundId) : null
|
|
const roundName = f.requirement?.round?.name ?? matchedRound?.name ?? 'General'
|
|
const sortOrder = f.requirement?.round?.sortOrder ?? matchedRound?.sortOrder ?? -1
|
|
const key = roundId ?? '_general'
|
|
if (!groups.has(key)) {
|
|
groups.set(key, { roundId, roundName, sortOrder, files: [] })
|
|
}
|
|
const mapped = mappedFiles.find((m) => m.id === f.id)!
|
|
groups.get(key)!.files.push(mapped)
|
|
}
|
|
return Array.from(groups.values())
|
|
})()}
|
|
/>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</AnimatedCard>
|
|
|
|
{/* Assignments Section — grouped by round */}
|
|
{assignments && assignments.length > 0 && (() => {
|
|
// Group assignments by round
|
|
const roundGroups = new Map<string, {
|
|
roundId: string
|
|
roundName: string
|
|
assignments: typeof assignments
|
|
}>()
|
|
for (const a of assignments) {
|
|
const rId = a.round?.id ?? '_unknown'
|
|
const rName = a.round?.name ?? 'Unknown Round'
|
|
if (!roundGroups.has(rId)) {
|
|
roundGroups.set(rId, { roundId: rId, roundName: rName, assignments: [] })
|
|
}
|
|
roundGroups.get(rId)!.assignments.push(a)
|
|
}
|
|
const groups = Array.from(roundGroups.values())
|
|
|
|
return (
|
|
<AnimatedCard index={5}>
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
|
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
|
<Users className="h-4 w-4 text-violet-500" />
|
|
</div>
|
|
Jury Assignments
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{assignments.filter((a) => a.evaluation?.status === 'SUBMITTED')
|
|
.length}{' '}
|
|
of {assignments.length} evaluations completed across {groups.length} round{groups.length !== 1 ? 's' : ''}
|
|
</CardDescription>
|
|
</div>
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link href={`/admin/members`}>
|
|
Manage
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{groups.map((group) => {
|
|
const submitted = group.assignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length
|
|
return (
|
|
<div key={group.roundId}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h4 className="text-sm font-semibold">{group.roundName}</h4>
|
|
<span className="text-xs text-muted-foreground">
|
|
{submitted} of {group.assignments.length} completed
|
|
</span>
|
|
</div>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Juror</TableHead>
|
|
<TableHead>Expertise</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Score</TableHead>
|
|
<TableHead>Decision</TableHead>
|
|
<TableHead className="w-10"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{group.assignments.map((assignment) => (
|
|
<TableRow
|
|
key={assignment.id}
|
|
className={assignment.evaluation?.status === 'SUBMITTED' ? 'cursor-pointer hover:bg-muted/50' : ''}
|
|
onClick={() => {
|
|
if (assignment.evaluation?.status === 'SUBMITTED') {
|
|
setSelectedEvalAssignment(assignment)
|
|
}
|
|
}}
|
|
>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<UserAvatar
|
|
user={assignment.user}
|
|
avatarUrl={assignment.user.avatarUrl}
|
|
size="sm"
|
|
/>
|
|
<div>
|
|
<p className="font-medium text-sm">
|
|
{assignment.user.name || 'Unnamed'}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{assignment.user.email}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-1">
|
|
{assignment.user.expertiseTags?.slice(0, 2).map((tag) => (
|
|
<Badge key={tag} variant="outline" className="text-xs">
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
{(assignment.user.expertiseTags?.length || 0) > 2 && (
|
|
<Badge variant="outline" className="text-xs">
|
|
+{(assignment.user.expertiseTags?.length || 0) - 2}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant={
|
|
evalStatusColors[
|
|
assignment.evaluation?.status || 'NOT_STARTED'
|
|
] || 'secondary'
|
|
}
|
|
>
|
|
{(assignment.evaluation?.status || 'NOT_STARTED').replace(
|
|
'_',
|
|
' '
|
|
)}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
{assignment.evaluation?.globalScore !== null &&
|
|
assignment.evaluation?.globalScore !== undefined ? (
|
|
<span className="font-medium">
|
|
{assignment.evaluation.globalScore}/10
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">-</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{assignment.evaluation?.binaryDecision !== null &&
|
|
assignment.evaluation?.binaryDecision !== undefined ? (
|
|
assignment.evaluation.binaryDecision ? (
|
|
<div className="flex items-center gap-1 text-green-600">
|
|
<ThumbsUp className="h-4 w-4" />
|
|
<span className="text-sm">Yes</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1 text-red-600">
|
|
<ThumbsDown className="h-4 w-4" />
|
|
<span className="text-sm">No</span>
|
|
</div>
|
|
)
|
|
) : (
|
|
<span className="text-muted-foreground">-</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{assignment.evaluation?.status === 'SUBMITTED' && (
|
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)
|
|
})}
|
|
</CardContent>
|
|
</Card>
|
|
</AnimatedCard>
|
|
)
|
|
})()}
|
|
|
|
{/* Evaluation Detail Sheet */}
|
|
<EvaluationEditSheet
|
|
assignment={selectedEvalAssignment}
|
|
open={!!selectedEvalAssignment}
|
|
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
|
|
onSaved={() => utils.project.getFullDetail.invalidate({ id: projectId })}
|
|
category={project?.competitionCategory}
|
|
/>
|
|
|
|
{/* AI Evaluation Summary */}
|
|
{assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && (
|
|
<EvaluationSummaryCard
|
|
projectId={projectId}
|
|
roundId={assignments[0].roundId}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ProjectDetailSkeleton() {
|
|
return (
|
|
<div className="space-y-6">
|
|
<Skeleton className="h-9 w-36" />
|
|
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-8 w-64" />
|
|
<Skeleton className="h-4 w-40" />
|
|
</div>
|
|
<Skeleton className="h-10 w-24" />
|
|
</div>
|
|
|
|
<Skeleton className="h-px w-full" />
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<Card key={i}>
|
|
<CardHeader className="pb-2">
|
|
<Skeleton className="h-4 w-24" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-8 w-16" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-5 w-40" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-24 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AnalyzeDocumentsButton({ projectId, onComplete }: { projectId: string; onComplete: () => void }) {
|
|
const analyzeMutation = trpc.file.analyzeProjectFiles.useMutation({
|
|
onSuccess: (result) => {
|
|
toast.success(
|
|
`Analyzed ${result.analyzed} file${result.analyzed !== 1 ? 's' : ''}${result.failed > 0 ? ` (${result.failed} failed)` : ''}`
|
|
)
|
|
onComplete()
|
|
},
|
|
onError: (error) => {
|
|
toast.error(error.message || 'Analysis failed')
|
|
},
|
|
})
|
|
|
|
return (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => analyzeMutation.mutate({ projectId })}
|
|
disabled={analyzeMutation.isPending}
|
|
>
|
|
{analyzeMutation.isPending ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<ScanSearch className="mr-2 h-4 w-4" />
|
|
)}
|
|
{analyzeMutation.isPending ? 'Analyzing...' : 'Analyze Documents'}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
export default function ProjectDetailPage({ params }: PageProps) {
|
|
const { id } = use(params)
|
|
|
|
return (
|
|
<Suspense fallback={<ProjectDetailSkeleton />}>
|
|
<ProjectDetailContent projectId={id} />
|
|
</Suspense>
|
|
)
|
|
}
|