Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,7 @@ import {
|
||||
ListChecks,
|
||||
BarChart3,
|
||||
Loader2,
|
||||
Bot,
|
||||
Crown,
|
||||
UserPlus,
|
||||
X,
|
||||
@@ -891,8 +892,8 @@ export default function AwardDetailPage({
|
||||
</TableCell>
|
||||
<TableCell>{e.project.country || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs">
|
||||
{e.method === 'MANUAL' ? 'Manual' : 'Auto'}
|
||||
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs gap-1">
|
||||
{e.method === 'MANUAL' ? 'Manual' : <><Bot className="h-3 w-3" />AI Assessed</>}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
{award.useAiEligibility && (
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { ArrowLeft, PlayCircle } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
export default function AssignmentsDashboardPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const competitionId = params.competitionId as string
|
||||
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
|
||||
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
|
||||
|
||||
const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({
|
||||
id: competitionId,
|
||||
})
|
||||
|
||||
const { data: unassignedQueue, isLoading: isLoadingQueue } =
|
||||
trpc.roundAssignment.unassignedQueue.useQuery(
|
||||
{ roundId: selectedRoundId, requiredReviews: 3 },
|
||||
{ enabled: !!selectedRoundId }
|
||||
)
|
||||
|
||||
const rounds = competition?.rounds || []
|
||||
const currentRound = rounds.find((r) => r.id === selectedRoundId)
|
||||
|
||||
if (isLoadingCompetition) {
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!competition) {
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||
<p>Competition not found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||
<Button variant="ghost" onClick={() => router.back()} className="mb-4" aria-label="Back to competition details">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Competition
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Assignment Dashboard</h1>
|
||||
<p className="text-muted-foreground">Manage jury assignments for rounds</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Select Round</CardTitle>
|
||||
<CardDescription>Choose a round to view and manage assignments</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
|
||||
<SelectTrigger className="w-full sm:w-[300px]">
|
||||
<SelectValue placeholder="Select a round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rounds.length === 0 ? (
|
||||
<div className="px-2 py-1 text-sm text-muted-foreground">No rounds available</div>
|
||||
) : (
|
||||
rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.name} ({round.roundType})
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedRoundId && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setPreviewSheetOpen(true)}>
|
||||
<PlayCircle className="mr-2 h-4 w-4" />
|
||||
Generate Assignments
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="coverage" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="coverage">Coverage Report</TabsTrigger>
|
||||
<TabsTrigger value="unassigned">Unassigned Queue</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="coverage" className="mt-6">
|
||||
<CoverageReport roundId={selectedRoundId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="unassigned" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Unassigned Projects</CardTitle>
|
||||
<CardDescription>
|
||||
Projects with fewer than 3 assignments
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingQueue ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : unassignedQueue && unassignedQueue.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{unassignedQueue.map((project: any) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex justify-between items-center p-3 border rounded-md"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{project.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.competitionCategory || 'No category'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{project.assignmentCount || 0} / 3 assignments
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All projects have sufficient assignments
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<AssignmentPreviewSheet
|
||||
roundId={selectedRoundId}
|
||||
open={previewSheetOpen}
|
||||
onOpenChange={setPreviewSheetOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import type { Route } from 'next';
|
||||
|
||||
export default function AwardDetailPage({
|
||||
params: paramsPromise
|
||||
}: {
|
||||
params: Promise<{ competitionId: string; awardId: string }>;
|
||||
}) {
|
||||
const params = use(paramsPromise);
|
||||
const router = useRouter();
|
||||
const { data: award, isLoading } = trpc.specialAward.get.useQuery({
|
||||
id: params.awardId
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Loading...</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!award) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Award Not Found</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
router.push(`/admin/competitions/${params.competitionId}/awards` as Route)
|
||||
}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold">{award.name}</h1>
|
||||
<p className="text-muted-foreground">{award.description || 'No description'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="eligible">Eligible Projects</TabsTrigger>
|
||||
<TabsTrigger value="winners">Winners</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Award Information</CardTitle>
|
||||
<CardDescription>Configuration and settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Scoring Mode</p>
|
||||
<Badge variant="outline" className="mt-1">
|
||||
{award.scoringMode}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">AI Eligibility</p>
|
||||
<Badge variant="outline" className="mt-1">
|
||||
{award.useAiEligibility ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Status</p>
|
||||
<Badge variant={award.status === 'DRAFT' ? 'secondary' : 'default'} className="mt-1">
|
||||
{award.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Program</p>
|
||||
<p className="mt-1 text-sm">{award.program?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="eligible" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Eligible Projects</CardTitle>
|
||||
<CardDescription>
|
||||
Projects that qualify for this award ({award?.eligibleCount || 0})
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-muted-foreground">
|
||||
{award?.eligibleCount || 0} eligible projects
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="winners" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Award Winners</CardTitle>
|
||||
<CardDescription>Selected winners for this award</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{award?.winnerProject ? (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div>
|
||||
<p className="font-medium">{award.winnerProject.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{award.winnerProject.teamName}</p>
|
||||
</div>
|
||||
<Badge className="mt-2">Winner</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground">No winner selected yet</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { use, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Route } from 'next';
|
||||
|
||||
export default function NewAwardPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
|
||||
const params = use(paramsPromise);
|
||||
const router = useRouter();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
useAiEligibility: false,
|
||||
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||
});
|
||||
|
||||
const { data: competition } = trpc.competition.getById.useQuery({
|
||||
id: params.competitionId
|
||||
});
|
||||
|
||||
const { data: juryGroups } = trpc.juryGroup.list.useQuery({
|
||||
competitionId: params.competitionId
|
||||
});
|
||||
|
||||
const createMutation = trpc.specialAward.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.specialAward.list.invalidate();
|
||||
toast.success('Award created successfully');
|
||||
router.push(`/admin/competitions/${params.competitionId}/awards` as Route);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Award name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!competition?.programId) {
|
||||
toast.error('Competition data not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
createMutation.mutate({
|
||||
programId: competition.programId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
scoringMode: formData.scoringMode,
|
||||
useAiEligibility: formData.useAiEligibility
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push(`/admin/competitions/${params.competitionId}/awards` as Route)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Create Special Award</h1>
|
||||
<p className="text-muted-foreground">Define a new award for this competition</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Award Details</CardTitle>
|
||||
<CardDescription>Configure the award properties and eligibility</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Award Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g., Best Innovation Award"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Describe the award criteria and purpose"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoringMode">Scoring Mode</Label>
|
||||
<Select
|
||||
value={formData.scoringMode}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, scoringMode: value as 'PICK_WINNER' | 'RANKED' | 'SCORED' })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="scoringMode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
|
||||
<SelectItem value="RANKED">Ranked</SelectItem>
|
||||
<SelectItem value="SCORED">Scored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="useAiEligibility"
|
||||
checked={formData.useAiEligibility}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, useAiEligibility: checked as boolean })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="useAiEligibility" className="font-normal">
|
||||
Use AI-based eligibility assessment
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/admin/competitions/${params.competitionId}/awards` as Route)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Award'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowLeft, Plus } from 'lucide-react';
|
||||
import type { Route } from 'next';
|
||||
|
||||
export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
|
||||
const params = use(paramsPromise);
|
||||
const router = useRouter();
|
||||
const { data: competition } = trpc.competition.getById.useQuery({
|
||||
id: params.competitionId
|
||||
});
|
||||
|
||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({
|
||||
programId: competition?.programId
|
||||
}, {
|
||||
enabled: !!competition?.programId
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Special Awards</h1>
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Special Awards</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage special awards and prizes for this competition
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/admin/competitions/${params.competitionId}/awards/new` as Route}>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Award
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{awards?.map((award) => (
|
||||
<Link
|
||||
key={award.id}
|
||||
href={`/admin/competitions/${params.competitionId}/awards/${award.id}` as Route}
|
||||
>
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-start justify-between">
|
||||
<span className="line-clamp-1">{award.name}</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{award.description || 'No description'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{award.scoringMode}</Badge>
|
||||
<Badge variant={award.status === 'DRAFT' ? 'secondary' : 'default'}>
|
||||
{award.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{awards?.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">No awards created yet</p>
|
||||
<Link href={`/admin/competitions/${params.competitionId}/awards/new` as Route}>
|
||||
<Button variant="link">Create your first award</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { ResultsPanel } from '@/components/admin/deliberation/results-panel';
|
||||
import type { Route } from 'next';
|
||||
|
||||
export default function DeliberationSessionPage({
|
||||
params: paramsPromise
|
||||
}: {
|
||||
params: Promise<{ competitionId: string; sessionId: string }>;
|
||||
}) {
|
||||
const params = use(paramsPromise);
|
||||
const router = useRouter();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery({
|
||||
sessionId: params.sessionId
|
||||
});
|
||||
|
||||
const openVotingMutation = trpc.deliberation.openVoting.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.deliberation.getSession.invalidate();
|
||||
toast.success('Voting opened');
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
const closeVotingMutation = trpc.deliberation.closeVoting.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.deliberation.getSession.invalidate();
|
||||
toast.success('Voting closed');
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Loading...</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Session Not Found</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
router.push(`/admin/competitions/${params.competitionId}/deliberation` as Route)
|
||||
}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold">Deliberation Session</h1>
|
||||
<Badge>{session.status}</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{session.round?.name} - {session.category}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="setup" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="setup">Setup</TabsTrigger>
|
||||
<TabsTrigger value="voting">Voting Control</TabsTrigger>
|
||||
<TabsTrigger value="results">Results</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="setup" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Session Configuration</CardTitle>
|
||||
<CardDescription>Deliberation settings and participants</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Mode</p>
|
||||
<p className="mt-1">
|
||||
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Tie Break Method</p>
|
||||
<p className="mt-1">{session.tieBreakMethod}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Show Collective Rankings
|
||||
</p>
|
||||
<p className="mt-1">{session.showCollectiveRankings ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Show Prior Jury Data</p>
|
||||
<p className="mt-1">{session.showPriorJuryData ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Participants ({session.participants?.length || 0})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{session.participants?.map((participant: any) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{participant.user?.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{participant.user?.email}</p>
|
||||
</div>
|
||||
<Badge variant={participant.hasVoted ? 'default' : 'outline'}>
|
||||
{participant.hasVoted ? 'Voted' : 'Pending'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="voting" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Voting Controls</CardTitle>
|
||||
<CardDescription>Manage the voting window for jury members</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Button
|
||||
onClick={() => openVotingMutation.mutate({ sessionId: params.sessionId })}
|
||||
disabled={
|
||||
openVotingMutation.isPending || session.status !== 'DELIB_OPEN'
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
Open Voting
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => closeVotingMutation.mutate({ sessionId: params.sessionId })}
|
||||
disabled={
|
||||
closeVotingMutation.isPending || session.status !== 'DELIB_VOTING'
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
Close Voting
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Voting Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{session.participants?.map((participant: any) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<span>{participant.user?.name}</span>
|
||||
<Badge variant={participant.hasVoted ? 'default' : 'secondary'}>
|
||||
{participant.hasVoted ? 'Submitted' : 'Not Voted'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="results" className="space-y-4">
|
||||
<ResultsPanel sessionId={params.sessionId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
'use client';
|
||||
|
||||
import { use, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ArrowLeft, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Route } from 'next';
|
||||
|
||||
export default function DeliberationListPage({
|
||||
params: paramsPromise
|
||||
}: {
|
||||
params: Promise<{ competitionId: string }>;
|
||||
}) {
|
||||
const params = use(paramsPromise);
|
||||
const router = useRouter();
|
||||
const utils = trpc.useUtils();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
roundId: '',
|
||||
category: 'STARTUP' as 'STARTUP' | 'BUSINESS_CONCEPT',
|
||||
mode: 'SINGLE_WINNER_VOTE' as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING',
|
||||
tieBreakMethod: 'TIE_RUNOFF' as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK',
|
||||
showCollectiveRankings: false,
|
||||
showPriorJuryData: false,
|
||||
participantUserIds: [] as string[]
|
||||
});
|
||||
|
||||
const { data: sessions = [], isLoading } = trpc.deliberation.listSessions.useQuery(
|
||||
{ competitionId: params.competitionId },
|
||||
{ enabled: !!params.competitionId }
|
||||
);
|
||||
|
||||
// Get rounds for this competition
|
||||
const { data: competition } = trpc.competition.getById.useQuery(
|
||||
{ id: params.competitionId },
|
||||
{ enabled: !!params.competitionId }
|
||||
);
|
||||
const rounds = competition?.rounds || [];
|
||||
|
||||
// TODO: Add getJuryMembers endpoint if needed for participant selection
|
||||
const juryMembers: any[] = [];
|
||||
|
||||
const createSessionMutation = trpc.deliberation.createSession.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.deliberation.listSessions.invalidate({ competitionId: params.competitionId });
|
||||
toast.success('Deliberation session created');
|
||||
setCreateDialogOpen(false);
|
||||
router.push(
|
||||
`/admin/competitions/${params.competitionId}/deliberation/${data.id}` as Route
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
const handleCreateSession = () => {
|
||||
if (!formData.roundId) {
|
||||
toast.error('Please select a round');
|
||||
return;
|
||||
}
|
||||
|
||||
createSessionMutation.mutate({
|
||||
competitionId: params.competitionId,
|
||||
roundId: formData.roundId,
|
||||
category: formData.category,
|
||||
mode: formData.mode,
|
||||
tieBreakMethod: formData.tieBreakMethod,
|
||||
showCollectiveRankings: formData.showCollectiveRankings,
|
||||
showPriorJuryData: formData.showPriorJuryData,
|
||||
participantUserIds: formData.participantUserIds
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
DELIB_OPEN: 'outline',
|
||||
DELIB_VOTING: 'default',
|
||||
DELIB_TALLYING: 'secondary',
|
||||
DELIB_LOCKED: 'destructive'
|
||||
};
|
||||
return <Badge variant={variants[status] || 'outline'}>{status}</Badge>;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-8 w-8 shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-96 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-32 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Back to competition details">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Deliberation Sessions</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage final jury deliberations and winner selection
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Session
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{sessions?.map((session: any) => (
|
||||
<Link
|
||||
key={session.id}
|
||||
href={
|
||||
`/admin/competitions/${params.competitionId}/deliberation/${session.id}` as Route
|
||||
}
|
||||
>
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{session.round?.name} - {session.category}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{getStatusBadge(session.status)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||
<span>{session.participants?.length || 0} participants</span>
|
||||
<span>•</span>
|
||||
<span>Tie break: {session.tieBreakMethod}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sessions?.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">No deliberation sessions yet</p>
|
||||
<Button variant="link" onClick={() => setCreateDialogOpen(true)}>
|
||||
Create your first session
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create Session Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Deliberation Session</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set up a new deliberation session for final winner selection
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="round">Round *</Label>
|
||||
<Select value={formData.roundId} onValueChange={(value) => setFormData({ ...formData, roundId: value })}>
|
||||
<SelectTrigger id="round">
|
||||
<SelectValue placeholder="Select round" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rounds?.map((round: any) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category *</Label>
|
||||
<Select
|
||||
value={formData.category}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, category: value as 'STARTUP' | 'BUSINESS_CONCEPT' })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="category">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="STARTUP">Startup</SelectItem>
|
||||
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mode">Voting Mode *</Label>
|
||||
<Select
|
||||
value={formData.mode}
|
||||
onValueChange={(value) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
mode: value as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING'
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SINGLE_WINNER_VOTE">Single Winner Vote</SelectItem>
|
||||
<SelectItem value="FULL_RANKING">Full Ranking</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tieBreak">Tie Break Method *</Label>
|
||||
<Select
|
||||
value={formData.tieBreakMethod}
|
||||
onValueChange={(value) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
tieBreakMethod: value as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK'
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="tieBreak">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="TIE_RUNOFF">Runoff Vote</SelectItem>
|
||||
<SelectItem value="TIE_ADMIN_DECIDES">Admin Decides</SelectItem>
|
||||
<SelectItem value="SCORE_FALLBACK">Score Fallback</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showCollective"
|
||||
checked={formData.showCollectiveRankings}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, showCollectiveRankings: checked as boolean })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="showCollective" className="font-normal">
|
||||
Show collective rankings during voting
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showPrior"
|
||||
checked={formData.showPriorJuryData}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, showPriorJuryData: checked as boolean })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="showPrior" className="font-normal">
|
||||
Show prior jury evaluation data
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateSession} disabled={createSessionMutation.isPending}>
|
||||
{createSessionMutation.isPending ? 'Creating...' : 'Create Session'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { JuryMembersTable } from '@/components/admin/jury/jury-members-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export default function JuryGroupDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const juryGroupId = params.juryGroupId as string
|
||||
|
||||
const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery({ id: juryGroupId })
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!juryGroup) {
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||
<p>Jury group not found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||
<Button variant="ghost" onClick={() => router.back()} className="mb-4" aria-label="Back to jury groups list">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Juries
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{juryGroup.name}</h1>
|
||||
<p className="text-muted-foreground">{juryGroup.slug}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-lg px-4 py-2">
|
||||
{juryGroup.defaultCapMode}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="members" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="members">Members</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="members" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Jury Members</CardTitle>
|
||||
<CardDescription>
|
||||
Manage the members of this jury group
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JuryMembersTable
|
||||
juryGroupId={juryGroupId}
|
||||
members={juryGroup.members}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Jury Group Settings</CardTitle>
|
||||
<CardDescription>
|
||||
View and edit settings for this jury group
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Name</h3>
|
||||
<p className="text-base font-medium">{juryGroup.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Slug</h3>
|
||||
<p className="text-base font-medium">{juryGroup.slug}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Default Max Assignments</h3>
|
||||
<p className="text-base font-medium">{juryGroup.defaultMaxAssignments}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Default Cap Mode</h3>
|
||||
<Badge variant="secondary">{juryGroup.defaultCapMode}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Soft Cap Buffer</h3>
|
||||
<p className="text-base font-medium">{juryGroup.softCapBuffer}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Allow Juror Cap Adjustment</h3>
|
||||
<Badge variant={juryGroup.allowJurorCapAdjustment ? 'default' : 'secondary'}>
|
||||
{juryGroup.allowJurorCapAdjustment ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Allow Ratio Adjustment</h3>
|
||||
<Badge variant={juryGroup.allowJurorRatioAdjustment ? 'default' : 'secondary'}>
|
||||
{juryGroup.allowJurorRatioAdjustment ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Category Quotas Enabled</h3>
|
||||
<Badge variant={juryGroup.categoryQuotasEnabled ? 'default' : 'secondary'}>
|
||||
{juryGroup.categoryQuotasEnabled ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{juryGroup.description && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">Description</h3>
|
||||
<p className="text-base">{juryGroup.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { ArrowLeft, Plus, Users } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function JuriesListPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const competitionId = params.competitionId as string
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const { data: juryGroups, isLoading } = trpc.juryGroup.list.useQuery({ competitionId })
|
||||
|
||||
const createMutation = trpc.juryGroup.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.juryGroup.list.invalidate({ competitionId })
|
||||
toast.success('Jury group created')
|
||||
setCreateOpen(false)
|
||||
setFormData({ name: '', description: '' })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Name is required')
|
||||
return
|
||||
}
|
||||
const slug = formData.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
createMutation.mutate({
|
||||
competitionId,
|
||||
name: formData.name.trim(),
|
||||
slug,
|
||||
description: formData.description.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-40" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.back()}
|
||||
className="mb-4"
|
||||
aria-label="Back to competition details"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Jury Groups</h1>
|
||||
<p className="text-muted-foreground">Manage jury groups and members for this competition</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Jury Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{juryGroups && juryGroups.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Users className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground text-center">No jury groups yet. Create one to get started.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{juryGroups?.map((group) => (
|
||||
<Link
|
||||
key={group.id}
|
||||
href={`/admin/competitions/${competitionId}/juries/${group.id}` as Route}
|
||||
>
|
||||
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
{group.name}
|
||||
<Badge variant="secondary">{group.defaultCapMode}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>{group.slug}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Members</span>
|
||||
<span className="font-medium">{group._count.members}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Assignments</span>
|
||||
<span className="font-medium">{group._count.assignments || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Default Max</span>
|
||||
<span className="font-medium">{group.defaultMaxAssignments}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Jury Group Dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Jury Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new jury group for this competition. You can add members after creation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="jury-name">Name *</Label>
|
||||
<Input
|
||||
id="jury-name"
|
||||
placeholder="e.g. Main Jury Panel"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="jury-description">Description</Label>
|
||||
<Textarea
|
||||
id="jury-description"
|
||||
placeholder="Optional description of this jury group's role"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { LiveControlPanel } from '@/components/admin/live/live-control-panel';
|
||||
import type { Route } from 'next';
|
||||
|
||||
export default function LiveFinalsPage({
|
||||
params: paramsPromise
|
||||
}: {
|
||||
params: Promise<{ competitionId: string; roundId: string }>;
|
||||
}) {
|
||||
const params = use(paramsPromise);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push(`/admin/competitions/${params.competitionId}` as Route)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Live Finals Control</h1>
|
||||
<p className="text-muted-foreground">Manage live ceremony presentation and voting</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LiveControlPanel roundId={params.roundId} competitionId={params.competitionId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
||||
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
||||
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
|
||||
|
||||
const roundTypeColors: Record<string, string> = {
|
||||
INTAKE: 'bg-gray-100 text-gray-700',
|
||||
FILTERING: 'bg-amber-100 text-amber-700',
|
||||
EVALUATION: 'bg-blue-100 text-blue-700',
|
||||
SUBMISSION: 'bg-purple-100 text-purple-700',
|
||||
MENTORING: 'bg-teal-100 text-teal-700',
|
||||
LIVE_FINAL: 'bg-red-100 text-red-700',
|
||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||
}
|
||||
|
||||
export default function RoundDetailPage() {
|
||||
const params = useParams()
|
||||
const competitionId = params.competitionId as string
|
||||
const roundId = params.roundId as string
|
||||
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: round, isLoading } = trpc.round.getById.useQuery({ id: roundId })
|
||||
|
||||
// Update local config when round data changes
|
||||
if (round && !hasChanges) {
|
||||
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
|
||||
if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
|
||||
setConfig(roundConfig)
|
||||
}
|
||||
}
|
||||
|
||||
const updateMutation = trpc.round.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.getById.invalidate({ id: roundId })
|
||||
toast.success('Round configuration saved')
|
||||
setHasChanges(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleConfigChange = (newConfig: Record<string, unknown>) => {
|
||||
setConfig(newConfig)
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate({
|
||||
id: roundId,
|
||||
configJson: config,
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<div>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!round) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/admin/competitions/${competitionId}` as Route}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competition details">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Round Not Found</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The requested round does not exist
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Link href={`/admin/competitions/${competitionId}` as Route} className="mt-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competition details">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-bold truncate">{round.name}</h1>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-mono">{round.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="config" className="space-y-4">
|
||||
<TabsList className="w-full sm:w-auto overflow-x-auto">
|
||||
<TabsTrigger value="config">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="projects">Projects</TabsTrigger>
|
||||
<TabsTrigger value="windows">Submission Windows</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Config Tab */}
|
||||
<TabsContent value="config" className="space-y-4">
|
||||
<RoundConfigForm
|
||||
roundType={round.roundType}
|
||||
config={config}
|
||||
onChange={handleConfigChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Projects Tab */}
|
||||
<TabsContent value="projects" className="space-y-4">
|
||||
<ProjectStatesTable competitionId={competitionId} roundId={roundId} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Submission Windows Tab */}
|
||||
<TabsContent value="windows" className="space-y-4">
|
||||
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
531
src/app/(admin)/admin/competitions/[id]/page.tsx
Normal file
531
src/app/(admin)/admin/competitions/[id]/page.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
Layers,
|
||||
Users,
|
||||
FileBox,
|
||||
ClipboardList,
|
||||
Settings,
|
||||
MoreHorizontal,
|
||||
Archive,
|
||||
Loader2,
|
||||
Plus,
|
||||
} from 'lucide-react'
|
||||
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
|
||||
|
||||
const ROUND_TYPES = [
|
||||
{ value: 'INTAKE', label: 'Intake' },
|
||||
{ value: 'FILTERING', label: 'Filtering' },
|
||||
{ value: 'EVALUATION', label: 'Evaluation' },
|
||||
{ value: 'SUBMISSION', label: 'Submission' },
|
||||
{ value: 'MENTORING', label: 'Mentoring' },
|
||||
{ value: 'LIVE_FINAL', label: 'Live Final' },
|
||||
{ value: 'DELIBERATION', label: 'Deliberation' },
|
||||
] as const
|
||||
|
||||
const statusConfig = {
|
||||
DRAFT: {
|
||||
label: 'Draft',
|
||||
bgClass: 'bg-gray-100 text-gray-700',
|
||||
dotClass: 'bg-gray-500',
|
||||
},
|
||||
ACTIVE: {
|
||||
label: 'Active',
|
||||
bgClass: 'bg-emerald-100 text-emerald-700',
|
||||
dotClass: 'bg-emerald-500',
|
||||
},
|
||||
CLOSED: {
|
||||
label: 'Closed',
|
||||
bgClass: 'bg-blue-100 text-blue-700',
|
||||
dotClass: 'bg-blue-500',
|
||||
},
|
||||
ARCHIVED: {
|
||||
label: 'Archived',
|
||||
bgClass: 'bg-muted text-muted-foreground',
|
||||
dotClass: 'bg-muted-foreground',
|
||||
},
|
||||
} as const
|
||||
|
||||
const roundTypeColors: Record<string, string> = {
|
||||
INTAKE: 'bg-gray-100 text-gray-700',
|
||||
FILTERING: 'bg-amber-100 text-amber-700',
|
||||
EVALUATION: 'bg-blue-100 text-blue-700',
|
||||
SUBMISSION: 'bg-purple-100 text-purple-700',
|
||||
MENTORING: 'bg-teal-100 text-teal-700',
|
||||
LIVE_FINAL: 'bg-red-100 text-red-700',
|
||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||
}
|
||||
|
||||
export default function CompetitionDetailPage() {
|
||||
const params = useParams()
|
||||
const competitionId = params.id as string
|
||||
const utils = trpc.useUtils()
|
||||
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
||||
const [roundForm, setRoundForm] = useState({
|
||||
name: '',
|
||||
roundType: '' as string,
|
||||
})
|
||||
|
||||
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
|
||||
id: competitionId,
|
||||
})
|
||||
|
||||
const updateMutation = trpc.competition.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Competition updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const createRoundMutation = trpc.round.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Round created')
|
||||
setAddRoundOpen(false)
|
||||
setRoundForm({ name: '', roundType: '' })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleStatusChange = (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
|
||||
updateMutation.mutate({ id: competitionId, status: newStatus })
|
||||
}
|
||||
|
||||
const handleCreateRound = () => {
|
||||
if (!roundForm.name.trim() || !roundForm.roundType) {
|
||||
toast.error('Name and type are required')
|
||||
return
|
||||
}
|
||||
const slug = roundForm.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
const nextOrder = competition?.rounds.length ?? 0
|
||||
createRoundMutation.mutate({
|
||||
competitionId,
|
||||
name: roundForm.name.trim(),
|
||||
slug,
|
||||
roundType: roundForm.roundType as any,
|
||||
sortOrder: nextOrder,
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<div>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!competition) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={"/admin/competitions" as Route}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Competition Not Found</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The requested competition does not exist
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const status = competition.status as keyof typeof statusConfig
|
||||
const config = statusConfig[status] || statusConfig.DRAFT
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Link href={"/admin/competitions" as Route} className="mt-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-bold truncate">{competition.name}</h1>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors shrink-0',
|
||||
config.bgClass,
|
||||
'hover:opacity-80'
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{(['DRAFT', 'ACTIVE', 'CLOSED'] as const).map((s) => (
|
||||
<DropdownMenuItem
|
||||
key={s}
|
||||
onClick={() => handleStatusChange(s)}
|
||||
disabled={competition.status === s || updateMutation.isPending}
|
||||
>
|
||||
{s.charAt(0) + s.slice(1).toLowerCase()}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange('ARCHIVED')}
|
||||
disabled={competition.status === 'ARCHIVED' || updateMutation.isPending}
|
||||
>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-mono">{competition.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8" aria-label="More actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
|
||||
<ClipboardList className="h-4 w-4 mr-2" />
|
||||
Assignments
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/competitions/${competitionId}/deliberation` as Route}>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Deliberation
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange('ARCHIVED')}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Rounds</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{competition.rounds.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">Juries</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{competition.juryGroups.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileBox className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm font-medium">Windows</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{competition.submissionWindows.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-sm font-medium">Category</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold mt-1 truncate">{competition.categoryMode}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="overview" className="space-y-4">
|
||||
<TabsList className="w-full sm:w-auto overflow-x-auto">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="rounds">Rounds</TabsTrigger>
|
||||
<TabsTrigger value="juries">Juries</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<CompetitionTimeline
|
||||
competitionId={competitionId}
|
||||
rounds={competition.rounds}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Rounds Tab */}
|
||||
<TabsContent value="rounds" className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold">Rounds ({competition.rounds.length})</h2>
|
||||
<Button size="sm" variant="outline" className="w-full sm:w-auto" onClick={() => setAddRoundOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Round
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{competition.rounds.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No rounds configured. Add rounds to define the competition flow.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{competition.rounds.map((round, index) => (
|
||||
<Link
|
||||
key={round.id}
|
||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
||||
>
|
||||
<Card className="hover:shadow-sm transition-shadow cursor-pointer">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{round.name}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{round.slug}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px] shrink-0',
|
||||
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
|
||||
)}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] shrink-0 hidden sm:inline-flex"
|
||||
>
|
||||
{round.status.replace('ROUND_', '')}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Juries Tab */}
|
||||
<TabsContent value="juries" className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold">Jury Groups ({competition.juryGroups.length})</h2>
|
||||
<Link href={`/admin/competitions/${competitionId}/juries` as Route}>
|
||||
<Button size="sm" variant="outline" className="w-full sm:w-auto">
|
||||
<Users className="h-4 w-4 mr-1" />
|
||||
Manage Juries
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{competition.juryGroups.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No jury groups configured. Create jury groups to assign evaluators.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{competition.juryGroups.map((group) => (
|
||||
<Link
|
||||
key={group.id}
|
||||
href={`/admin/competitions/${competitionId}/juries/${group.id}` as Route}
|
||||
>
|
||||
<Card className="hover:shadow-sm transition-shadow cursor-pointer h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">{group.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{group._count.members} members</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Cap: {group.defaultCapMode}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Settings Tab */}
|
||||
<TabsContent value="settings" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Competition Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Category Mode</label>
|
||||
<p className="text-sm mt-1">{competition.categoryMode}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Startup Finalists</label>
|
||||
<p className="text-sm mt-1">{competition.startupFinalistCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Concept Finalists</label>
|
||||
<p className="text-sm mt-1">{competition.conceptFinalistCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Notifications</label>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{competition.notifyOnRoundAdvance && (
|
||||
<Badge variant="secondary" className="text-[10px]">Round Advance</Badge>
|
||||
)}
|
||||
{competition.notifyOnDeadlineApproach && (
|
||||
<Badge variant="secondary" className="text-[10px]">Deadline Approach</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{competition.deadlineReminderDays && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">
|
||||
Reminder Days
|
||||
</label>
|
||||
<p className="text-sm mt-1">
|
||||
{(competition.deadlineReminderDays as number[]).join(', ')} days before deadline
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Add Round Dialog */}
|
||||
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Round</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new round to this competition. It will be appended to the current round sequence.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="round-name">Name *</Label>
|
||||
<Input
|
||||
id="round-name"
|
||||
placeholder="e.g. Initial Screening"
|
||||
value={roundForm.name}
|
||||
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="round-type">Round Type *</Label>
|
||||
<Select
|
||||
value={roundForm.roundType}
|
||||
onValueChange={(value) => setRoundForm({ ...roundForm, roundType: value })}
|
||||
>
|
||||
<SelectTrigger id="round-type">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROUND_TYPES.map((rt) => (
|
||||
<SelectItem key={rt.value} value={rt.value}>
|
||||
{rt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateRound} disabled={createRoundMutation.isPending}>
|
||||
{createRoundMutation.isPending ? 'Creating...' : 'Create Round'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
307
src/app/(admin)/admin/competitions/new/page.tsx
Normal file
307
src/app/(admin)/admin/competitions/new/page.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
|
||||
import type { StepConfig } from '@/components/ui/sidebar-stepper'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { BasicsSection } from '@/components/admin/competition/sections/basics-section'
|
||||
import { RoundsSection } from '@/components/admin/competition/sections/rounds-section'
|
||||
import { JuryGroupsSection } from '@/components/admin/competition/sections/jury-groups-section'
|
||||
import { ReviewSection } from '@/components/admin/competition/sections/review-section'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
|
||||
type WizardRound = {
|
||||
tempId: string
|
||||
name: string
|
||||
slug: string
|
||||
roundType: string
|
||||
sortOrder: number
|
||||
configJson: Record<string, unknown>
|
||||
}
|
||||
|
||||
type WizardJuryGroup = {
|
||||
tempId: string
|
||||
name: string
|
||||
slug: string
|
||||
defaultMaxAssignments: number
|
||||
defaultCapMode: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
type WizardState = {
|
||||
programId: string
|
||||
name: string
|
||||
slug: string
|
||||
categoryMode: string
|
||||
startupFinalistCount: number
|
||||
conceptFinalistCount: number
|
||||
notifyOnRoundAdvance: boolean
|
||||
notifyOnDeadlineApproach: boolean
|
||||
deadlineReminderDays: number[]
|
||||
rounds: WizardRound[]
|
||||
juryGroups: WizardJuryGroup[]
|
||||
}
|
||||
|
||||
const defaultRounds: WizardRound[] = [
|
||||
{
|
||||
tempId: crypto.randomUUID(),
|
||||
name: 'Intake',
|
||||
slug: 'intake',
|
||||
roundType: 'INTAKE',
|
||||
sortOrder: 0,
|
||||
configJson: {},
|
||||
},
|
||||
{
|
||||
tempId: crypto.randomUUID(),
|
||||
name: 'Filtering',
|
||||
slug: 'filtering',
|
||||
roundType: 'FILTERING',
|
||||
sortOrder: 1,
|
||||
configJson: {},
|
||||
},
|
||||
{
|
||||
tempId: crypto.randomUUID(),
|
||||
name: 'Evaluation (Jury 1)',
|
||||
slug: 'evaluation-jury-1',
|
||||
roundType: 'EVALUATION',
|
||||
sortOrder: 2,
|
||||
configJson: {},
|
||||
},
|
||||
{
|
||||
tempId: crypto.randomUUID(),
|
||||
name: 'Submission',
|
||||
slug: 'submission',
|
||||
roundType: 'SUBMISSION',
|
||||
sortOrder: 3,
|
||||
configJson: {},
|
||||
},
|
||||
{
|
||||
tempId: crypto.randomUUID(),
|
||||
name: 'Evaluation (Jury 2)',
|
||||
slug: 'evaluation-jury-2',
|
||||
roundType: 'EVALUATION',
|
||||
sortOrder: 4,
|
||||
configJson: {},
|
||||
},
|
||||
{
|
||||
tempId: crypto.randomUUID(),
|
||||
name: 'Mentoring',
|
||||
slug: 'mentoring',
|
||||
roundType: 'MENTORING',
|
||||
sortOrder: 5,
|
||||
configJson: {},
|
||||
},
|
||||
{
|
||||
tempId: crypto.randomUUID(),
|
||||
name: 'Live Final',
|
||||
slug: 'live-final',
|
||||
roundType: 'LIVE_FINAL',
|
||||
sortOrder: 6,
|
||||
configJson: {},
|
||||
},
|
||||
{
|
||||
tempId: crypto.randomUUID(),
|
||||
name: 'Deliberation',
|
||||
slug: 'deliberation',
|
||||
roundType: 'DELIBERATION',
|
||||
sortOrder: 7,
|
||||
configJson: {},
|
||||
},
|
||||
]
|
||||
|
||||
export default function NewCompetitionPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { currentEdition } = useEdition()
|
||||
const paramProgramId = searchParams.get('programId')
|
||||
const programId = paramProgramId || currentEdition?.id || ''
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
const [state, setState] = useState<WizardState>({
|
||||
programId,
|
||||
name: '',
|
||||
slug: '',
|
||||
categoryMode: 'SHARED',
|
||||
startupFinalistCount: 3,
|
||||
conceptFinalistCount: 3,
|
||||
notifyOnRoundAdvance: true,
|
||||
notifyOnDeadlineApproach: true,
|
||||
deadlineReminderDays: [7, 3, 1],
|
||||
rounds: defaultRounds,
|
||||
juryGroups: [],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (programId) {
|
||||
setState((prev) => ({ ...prev, programId }))
|
||||
}
|
||||
}, [programId])
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (isDirty) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [isDirty])
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const createCompetitionMutation = trpc.competition.create.useMutation()
|
||||
const createRoundMutation = trpc.round.create.useMutation()
|
||||
const createJuryGroupMutation = trpc.juryGroup.create.useMutation()
|
||||
|
||||
const handleStateChange = (updates: Partial<WizardState>) => {
|
||||
setState((prev) => ({ ...prev, ...updates }))
|
||||
setIsDirty(true)
|
||||
|
||||
// Auto-generate slug from name if name changed
|
||||
if (updates.name !== undefined && updates.slug === undefined) {
|
||||
const autoSlug = updates.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||
setState((prev) => ({ ...prev, slug: autoSlug }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!state.name.trim()) {
|
||||
toast.error('Competition name is required')
|
||||
setCurrentStep(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (!state.slug.trim()) {
|
||||
toast.error('Competition slug is required')
|
||||
setCurrentStep(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (state.rounds.length === 0) {
|
||||
toast.error('At least one round is required')
|
||||
setCurrentStep(1)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Create competition
|
||||
const competition = await createCompetitionMutation.mutateAsync({
|
||||
programId: state.programId,
|
||||
name: state.name,
|
||||
slug: state.slug,
|
||||
categoryMode: state.categoryMode,
|
||||
startupFinalistCount: state.startupFinalistCount,
|
||||
conceptFinalistCount: state.conceptFinalistCount,
|
||||
notifyOnRoundAdvance: state.notifyOnRoundAdvance,
|
||||
notifyOnDeadlineApproach: state.notifyOnDeadlineApproach,
|
||||
deadlineReminderDays: state.deadlineReminderDays,
|
||||
})
|
||||
|
||||
// Create rounds
|
||||
for (const round of state.rounds) {
|
||||
await createRoundMutation.mutateAsync({
|
||||
competitionId: competition.id,
|
||||
name: round.name,
|
||||
slug: round.slug,
|
||||
roundType: round.roundType as any,
|
||||
sortOrder: round.sortOrder,
|
||||
configJson: round.configJson,
|
||||
})
|
||||
}
|
||||
|
||||
// Create jury groups
|
||||
for (const group of state.juryGroups) {
|
||||
await createJuryGroupMutation.mutateAsync({
|
||||
competitionId: competition.id,
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
defaultMaxAssignments: group.defaultMaxAssignments,
|
||||
defaultCapMode: group.defaultCapMode as any,
|
||||
sortOrder: group.sortOrder,
|
||||
})
|
||||
}
|
||||
|
||||
toast.success('Competition created successfully')
|
||||
setIsDirty(false)
|
||||
utils.competition.list.invalidate()
|
||||
router.push(`/admin/competitions/${competition.id}` as Route)
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to create competition')
|
||||
}
|
||||
}
|
||||
|
||||
const steps: StepConfig[] = [
|
||||
{
|
||||
title: 'Basics',
|
||||
description: 'Name and settings',
|
||||
isValid: !!state.name && !!state.slug,
|
||||
},
|
||||
{
|
||||
title: 'Rounds',
|
||||
description: 'Configure rounds',
|
||||
isValid: state.rounds.length > 0,
|
||||
},
|
||||
{
|
||||
title: 'Jury Groups',
|
||||
description: 'Add jury groups',
|
||||
isValid: true, // Optional
|
||||
},
|
||||
{
|
||||
title: 'Review',
|
||||
description: 'Confirm and create',
|
||||
isValid: !!state.name && !!state.slug && state.rounds.length > 0,
|
||||
},
|
||||
]
|
||||
|
||||
const canSubmit = steps.every((s) => s.isValid)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={'/admin/competitions' as Route}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">New Competition</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a multi-round competition workflow
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wizard */}
|
||||
<SidebarStepper
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
onStepChange={setCurrentStep}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={
|
||||
createCompetitionMutation.isPending ||
|
||||
createRoundMutation.isPending ||
|
||||
createJuryGroupMutation.isPending
|
||||
}
|
||||
submitLabel="Create Competition"
|
||||
canSubmit={canSubmit}
|
||||
>
|
||||
<BasicsSection state={state} onChange={handleStateChange} />
|
||||
<RoundsSection rounds={state.rounds} onChange={(rounds) => handleStateChange({ rounds })} />
|
||||
<JuryGroupsSection
|
||||
juryGroups={state.juryGroups}
|
||||
onChange={(juryGroups) => handleStateChange({ juryGroups })}
|
||||
/>
|
||||
<ReviewSection state={state} />
|
||||
</SidebarStepper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,201 +1,201 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Plus,
|
||||
Layers,
|
||||
Calendar,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
|
||||
const statusConfig = {
|
||||
DRAFT: {
|
||||
label: 'Draft',
|
||||
bgClass: 'bg-gray-100 text-gray-700',
|
||||
dotClass: 'bg-gray-500',
|
||||
},
|
||||
ACTIVE: {
|
||||
label: 'Active',
|
||||
bgClass: 'bg-emerald-100 text-emerald-700',
|
||||
dotClass: 'bg-emerald-500',
|
||||
},
|
||||
CLOSED: {
|
||||
label: 'Closed',
|
||||
bgClass: 'bg-blue-100 text-blue-700',
|
||||
dotClass: 'bg-blue-500',
|
||||
},
|
||||
ARCHIVED: {
|
||||
label: 'Archived',
|
||||
bgClass: 'bg-muted text-muted-foreground',
|
||||
dotClass: 'bg-muted-foreground',
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function PipelineListPage() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id
|
||||
|
||||
const { data: pipelines, isLoading } = trpc.pipeline.list.useQuery(
|
||||
{ programId: programId! },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
if (!programId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Pipelines</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select an edition to view pipelines
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Calendar className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Edition Selected</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select an edition from the sidebar to view its pipelines
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Pipelines</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage evaluation pipelines for {currentEdition?.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create Pipeline
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-20 mt-1" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4 mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && (!pipelines || pipelines.length === 0) && (
|
||||
<Card className="border-2 border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="rounded-full bg-primary/10 p-4 mb-4">
|
||||
<Workflow className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Pipelines Yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||||
Pipelines organize your project evaluation workflow into tracks and stages.
|
||||
Create your first pipeline to get started with managing project evaluations.
|
||||
</p>
|
||||
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Your First Pipeline
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pipeline Cards */}
|
||||
{pipelines && pipelines.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{pipelines.map((pipeline) => {
|
||||
const status = pipeline.status as keyof typeof statusConfig
|
||||
const config = statusConfig[status] || statusConfig.DRAFT
|
||||
const description = (pipeline.settingsJson as Record<string, unknown> | null)?.description as string | undefined
|
||||
|
||||
return (
|
||||
<Link key={pipeline.id} href={`/admin/rounds/pipeline/${pipeline.id}` as Route}>
|
||||
<Card className="group cursor-pointer hover:shadow-md transition-shadow h-full flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base leading-tight">
|
||||
{pipeline.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px] shrink-0 flex items-center gap-1.5',
|
||||
config.bgClass
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', config.dotClass)} />
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="mt-auto">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">
|
||||
{pipeline._count.tracks === 0
|
||||
? 'No tracks'
|
||||
: pipeline._count.tracks === 1
|
||||
? '1 track'
|
||||
: `${pipeline._count.tracks} tracks`}
|
||||
</span>
|
||||
</div>
|
||||
<span>Updated {formatDistanceToNow(new Date(pipeline.updatedAt))} ago</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Plus,
|
||||
Medal,
|
||||
Calendar,
|
||||
Users,
|
||||
Layers,
|
||||
FileBox,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
|
||||
const statusConfig = {
|
||||
DRAFT: {
|
||||
label: 'Draft',
|
||||
bgClass: 'bg-gray-100 text-gray-700',
|
||||
dotClass: 'bg-gray-500',
|
||||
},
|
||||
ACTIVE: {
|
||||
label: 'Active',
|
||||
bgClass: 'bg-emerald-100 text-emerald-700',
|
||||
dotClass: 'bg-emerald-500',
|
||||
},
|
||||
CLOSED: {
|
||||
label: 'Closed',
|
||||
bgClass: 'bg-blue-100 text-blue-700',
|
||||
dotClass: 'bg-blue-500',
|
||||
},
|
||||
ARCHIVED: {
|
||||
label: 'Archived',
|
||||
bgClass: 'bg-muted text-muted-foreground',
|
||||
dotClass: 'bg-muted-foreground',
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function CompetitionListPage() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id
|
||||
|
||||
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
|
||||
{ programId: programId! },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
if (!programId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Competitions</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select an edition to view competitions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Calendar className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Edition Selected</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select an edition from the sidebar to view its competitions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-4 sm:px-0">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Competitions</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage competitions for {currentEdition?.name}
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
|
||||
<Button size="sm" className="w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Competition
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-20 mt-1" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4 mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && (!competitions || competitions.length === 0) && (
|
||||
<Card className="border-2 border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="rounded-full bg-primary/10 p-4 mb-4">
|
||||
<Medal className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Competitions Yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||||
Competitions organize your multi-round evaluation workflow with jury groups,
|
||||
submission windows, and scoring. Create your first competition to get started.
|
||||
</p>
|
||||
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Your First Competition
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Competition Cards */}
|
||||
{competitions && competitions.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{competitions.map((competition) => {
|
||||
const status = competition.status as keyof typeof statusConfig
|
||||
const config = statusConfig[status] || statusConfig.DRAFT
|
||||
|
||||
return (
|
||||
<Link key={competition.id} href={`/admin/competitions/${competition.id}` as Route}>
|
||||
<Card className="group cursor-pointer hover:shadow-md transition-shadow h-full flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base leading-tight">
|
||||
{competition.name}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">
|
||||
{competition.slug}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px] shrink-0 flex items-center gap-1.5',
|
||||
config.bgClass
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', config.dotClass)} />
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="mt-auto">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
<span>{competition._count.rounds} rounds</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span>{competition._count.juryGroups} juries</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileBox className="h-3.5 w-3.5" />
|
||||
<span>{competition._count.submissionWindows} windows</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Updated {formatDistanceToNow(new Date(competition.updatedAt))} ago
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -290,21 +290,21 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
|
||||
const {
|
||||
edition,
|
||||
activeStageCount,
|
||||
totalStageCount,
|
||||
activeRoundCount,
|
||||
totalRoundCount,
|
||||
projectCount,
|
||||
newProjectsThisWeek,
|
||||
totalJurors,
|
||||
activeJurors,
|
||||
evaluationStats,
|
||||
totalAssignments,
|
||||
recentStages,
|
||||
recentRounds,
|
||||
latestProjects,
|
||||
categoryBreakdown,
|
||||
oceanIssueBreakdown,
|
||||
recentActivity,
|
||||
pendingCOIs,
|
||||
draftStages,
|
||||
draftRounds,
|
||||
unassignedProjects,
|
||||
} = data
|
||||
|
||||
@@ -318,25 +318,25 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
|
||||
const invitedJurors = totalJurors - activeJurors
|
||||
|
||||
// Compute per-stage eval stats
|
||||
const stagesWithEvalStats = recentStages.map((stage: typeof recentStages[number]) => {
|
||||
const submitted = stage.assignments.filter(
|
||||
// Compute per-round eval stats
|
||||
const roundsWithEvalStats = recentRounds.map((round: typeof recentRounds[number]) => {
|
||||
const submitted = round.assignments.filter(
|
||||
(a: { evaluation: { status: string } | null }) => a.evaluation?.status === 'SUBMITTED'
|
||||
).length
|
||||
const total = stage._count.assignments
|
||||
const total = round._count.assignments
|
||||
const percent = total > 0 ? Math.round((submitted / total) * 100) : 0
|
||||
return { ...stage, submittedEvals: submitted, totalEvals: total, evalPercent: percent }
|
||||
return { ...round, submittedEvals: submitted, totalEvals: total, evalPercent: percent }
|
||||
})
|
||||
|
||||
// Upcoming deadlines from stages
|
||||
// Upcoming deadlines from rounds
|
||||
const now = new Date()
|
||||
const deadlines: { label: string; stageName: string; date: Date }[] = []
|
||||
for (const stage of recentStages) {
|
||||
if (stage.windowCloseAt && new Date(stage.windowCloseAt) > now) {
|
||||
const deadlines: { label: string; roundName: string; date: Date }[] = []
|
||||
for (const round of recentRounds) {
|
||||
if (round.windowCloseAt && new Date(round.windowCloseAt) > now) {
|
||||
deadlines.push({
|
||||
label: 'Window closes',
|
||||
stageName: stage.name,
|
||||
date: new Date(stage.windowCloseAt),
|
||||
roundName: round.name,
|
||||
date: new Date(round.windowCloseAt),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -381,10 +381,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Stages</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalStageCount}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">Rounds</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalRoundCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{activeStageCount} active stage{activeStageCount !== 1 ? 's' : ''}
|
||||
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
@@ -467,13 +467,13 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Link href="/admin/rounds/pipelines" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
||||
<Link href="/admin/competitions" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
||||
<div className="rounded-xl bg-blue-50 p-2.5 transition-colors group-hover:bg-blue-100">
|
||||
<Plus className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Pipelines</p>
|
||||
<p className="text-xs text-muted-foreground">Manage stages & pipelines</p>
|
||||
<p className="text-sm font-medium">Competitions</p>
|
||||
<p className="text-xs text-muted-foreground">Manage rounds & competitions</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
|
||||
@@ -500,7 +500,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-6 lg:col-span-7">
|
||||
{/* Stages Card (enhanced) */}
|
||||
{/* Rounds Card (enhanced) */}
|
||||
<AnimatedCard index={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -510,14 +510,14 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||
<CircleDot className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
Stages
|
||||
Rounds
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Pipeline stages in {edition.name}
|
||||
Competition rounds in {edition.name}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/rounds/pipelines"
|
||||
href="/admin/competitions"
|
||||
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
View all <ArrowRight className="h-3.5 w-3.5" />
|
||||
@@ -525,48 +525,48 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stagesWithEvalStats.length === 0 ? (
|
||||
{roundsWithEvalStats.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No stages created yet
|
||||
No rounds created yet
|
||||
</p>
|
||||
<Link
|
||||
href="/admin/rounds/pipelines"
|
||||
href="/admin/competitions"
|
||||
className="mt-4 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Set up your pipeline
|
||||
Set up your competition
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{stagesWithEvalStats.map((stage) => (
|
||||
{roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number]) => (
|
||||
<div
|
||||
key={stage.id}
|
||||
key={round.id}
|
||||
className="block"
|
||||
>
|
||||
<div className="rounded-lg border p-4 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1.5 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{stage.name}</p>
|
||||
<StatusBadge status={stage.status} />
|
||||
<p className="font-medium">{round.name}</p>
|
||||
<StatusBadge status={round.status} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{stage._count.projectStageStates} projects · {stage._count.assignments} assignments
|
||||
{stage.totalEvals > 0 && (
|
||||
<> · {stage.evalPercent}% evaluated</>
|
||||
{round._count.projectRoundStates} projects · {round._count.assignments} assignments
|
||||
{round.totalEvals > 0 && (
|
||||
<> · {round.evalPercent}% evaluated</>
|
||||
)}
|
||||
</p>
|
||||
{stage.windowOpenAt && stage.windowCloseAt && (
|
||||
{round.windowOpenAt && round.windowCloseAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Window: {formatDateOnly(stage.windowOpenAt)} – {formatDateOnly(stage.windowCloseAt)}
|
||||
Window: {formatDateOnly(round.windowOpenAt)} – {formatDateOnly(round.windowCloseAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{stage.totalEvals > 0 && (
|
||||
<Progress value={stage.evalPercent} className="mt-3 h-1.5" gradient />
|
||||
{round.totalEvals > 0 && (
|
||||
<Progress value={round.evalPercent} className="mt-3 h-1.5" gradient />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -682,7 +682,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{pendingCOIs > 0 && (
|
||||
<Link href="/admin/rounds/pipelines" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
<Link href="/admin/competitions" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-sm">COI declarations to review</span>
|
||||
@@ -699,16 +699,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
<Badge variant="warning">{unassignedProjects}</Badge>
|
||||
</Link>
|
||||
)}
|
||||
{draftStages > 0 && (
|
||||
<Link href="/admin/rounds/pipelines" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
{draftRounds > 0 && (
|
||||
<Link href="/admin/competitions" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleDot className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm">Draft stages to activate</span>
|
||||
<span className="text-sm">Draft rounds to activate</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{draftStages}</Badge>
|
||||
<Badge variant="secondary">{draftRounds}</Badge>
|
||||
</Link>
|
||||
)}
|
||||
{pendingCOIs === 0 && unassignedProjects === 0 && draftStages === 0 && (
|
||||
{pendingCOIs === 0 && unassignedProjects === 0 && draftRounds === 0 && (
|
||||
<div className="flex flex-col items-center py-4 text-center">
|
||||
<CheckCircle2 className="h-6 w-6 text-emerald-500" />
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">All caught up!</p>
|
||||
@@ -731,7 +731,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stagesWithEvalStats.filter((s: typeof stagesWithEvalStats[number]) => s.status !== 'STAGE_DRAFT' && s.totalEvals > 0).length === 0 ? (
|
||||
{roundsWithEvalStats.filter((s: typeof roundsWithEvalStats[number]) => s.status !== 'ROUND_DRAFT' && s.totalEvals > 0).length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<TrendingUp className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
@@ -740,19 +740,19 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{stagesWithEvalStats
|
||||
.filter((s: typeof stagesWithEvalStats[number]) => s.status !== 'STAGE_DRAFT' && s.totalEvals > 0)
|
||||
.map((stage: typeof stagesWithEvalStats[number]) => (
|
||||
<div key={stage.id} className="space-y-2">
|
||||
{roundsWithEvalStats
|
||||
.filter((r: typeof roundsWithEvalStats[number]) => r.status !== 'ROUND_DRAFT' && r.totalEvals > 0)
|
||||
.map((round: typeof roundsWithEvalStats[number]) => (
|
||||
<div key={round.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium truncate">{stage.name}</p>
|
||||
<p className="text-sm font-medium truncate">{round.name}</p>
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
{stage.evalPercent}%
|
||||
{round.evalPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={stage.evalPercent} className="h-2" gradient />
|
||||
<Progress value={round.evalPercent} className="h-2" gradient />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stage.submittedEvals} of {stage.totalEvals} evaluations submitted
|
||||
{round.submittedEvals} of {round.totalEvals} evaluations submitted
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -905,7 +905,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{deadline.label} — {deadline.stageName}
|
||||
{deadline.label} — {deadline.roundName}
|
||||
</p>
|
||||
<p className={`text-xs ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||
{formatDateOnly(deadline.date)} · in {days} day{days !== 1 ? 's' : ''}
|
||||
|
||||
@@ -77,7 +77,7 @@ type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | '
|
||||
|
||||
interface Assignment {
|
||||
projectId: string
|
||||
stageId: string
|
||||
roundId: string
|
||||
}
|
||||
|
||||
interface MemberRow {
|
||||
@@ -271,7 +271,7 @@ export default function MemberInvitePage() {
|
||||
} | null>(null)
|
||||
|
||||
// Pre-assignment state
|
||||
const [selectedStageId, setSelectedStageId] = useState<string>('')
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
@@ -297,27 +297,27 @@ export default function MemberInvitePage() {
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch programs with stages for pre-assignment
|
||||
// Fetch programs with rounds for pre-assignment
|
||||
const { data: programsData } = trpc.program.list.useQuery({
|
||||
status: 'ACTIVE',
|
||||
includeStages: true,
|
||||
})
|
||||
// Flatten all stages from all programs
|
||||
const stages = useMemo(() => {
|
||||
// Flatten all rounds from all programs
|
||||
const rounds = useMemo(() => {
|
||||
if (!programsData) return []
|
||||
return programsData.flatMap((program) =>
|
||||
((program.stages ?? []) as Array<{ id: string; name: string }>).map((stage: { id: string; name: string }) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
((program.stages ?? []) as Array<{ id: string; name: string }>).map((round: { id: string; name: string }) => ({
|
||||
id: round.id,
|
||||
name: round.name,
|
||||
programName: `${program.name} ${program.year}`,
|
||||
}))
|
||||
)
|
||||
}, [programsData])
|
||||
|
||||
// Fetch projects for selected stage
|
||||
// Fetch projects for selected round
|
||||
const { data: projectsData, isLoading: projectsLoading } = trpc.project.list.useQuery(
|
||||
{ stageId: selectedStageId, perPage: 200 },
|
||||
{ enabled: !!selectedStageId }
|
||||
{ roundId: selectedRoundId, perPage: 200 },
|
||||
{ enabled: !!selectedRoundId }
|
||||
)
|
||||
const projects = projectsData?.projects || []
|
||||
|
||||
@@ -365,7 +365,7 @@ export default function MemberInvitePage() {
|
||||
|
||||
// Per-row project assignment management
|
||||
const toggleProjectAssignment = (rowId: string, projectId: string) => {
|
||||
if (!selectedStageId) return
|
||||
if (!selectedRoundId) return
|
||||
setRows((prev) =>
|
||||
prev.map((r) => {
|
||||
if (r.id !== rowId) return r
|
||||
@@ -373,7 +373,7 @@ export default function MemberInvitePage() {
|
||||
if (existing) {
|
||||
return { ...r, assignments: r.assignments.filter((a) => a.projectId !== projectId) }
|
||||
} else {
|
||||
return { ...r, assignments: [...r.assignments, { projectId, stageId: selectedStageId }] }
|
||||
return { ...r, assignments: [...r.assignments, { projectId, roundId: selectedRoundId }] }
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -599,21 +599,21 @@ export default function MemberInvitePage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<Label className="text-sm font-medium">Pre-assign Projects (Optional)</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select a stage to assign projects to jury members before they onboard
|
||||
Select a round to assign projects to jury members before they onboard
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedStageId || 'none'}
|
||||
onValueChange={(v) => setSelectedStageId(v === 'none' ? '' : v)}
|
||||
value={selectedRoundId || 'none'}
|
||||
onValueChange={(v) => setSelectedRoundId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Select stage" />
|
||||
<SelectValue placeholder="Select round" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No pre-assignment</SelectItem>
|
||||
{stages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.programName} - {stage.name}
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -685,7 +685,7 @@ export default function MemberInvitePage() {
|
||||
/>
|
||||
|
||||
{/* Per-member project pre-assignment (only for jury members) */}
|
||||
{row.role === 'JURY_MEMBER' && selectedStageId && (
|
||||
{row.role === 'JURY_MEMBER' && selectedRoundId && (
|
||||
<Collapsible className="space-y-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -64,12 +64,12 @@ import {
|
||||
import { toast } from 'sonner'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
|
||||
type RecipientType = 'ALL' | 'ROLE' | 'STAGE_JURY' | 'PROGRAM_TEAM' | 'USER'
|
||||
type RecipientType = 'ALL' | 'ROLE' | 'ROUND_JURY' | 'PROGRAM_TEAM' | 'USER'
|
||||
|
||||
const RECIPIENT_TYPE_OPTIONS: { value: RecipientType; label: string }[] = [
|
||||
{ value: 'ALL', label: 'All Users' },
|
||||
{ value: 'ROLE', label: 'By Role' },
|
||||
{ value: 'STAGE_JURY', label: 'Stage Jury' },
|
||||
{ value: 'ROUND_JURY', label: 'Round Jury' },
|
||||
{ value: 'PROGRAM_TEAM', label: 'Program Team' },
|
||||
{ value: 'USER', label: 'Specific User' },
|
||||
]
|
||||
@@ -79,7 +79,7 @@ const ROLES = ['JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'PROGRAM_ADMIN'
|
||||
export default function MessagesPage() {
|
||||
const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
|
||||
const [selectedRole, setSelectedRole] = useState('')
|
||||
const [stageId, setStageId] = useState('')
|
||||
const [roundId, setStageId] = useState('')
|
||||
const [selectedProgramId, setSelectedProgramId] = useState('')
|
||||
const [selectedUserId, setSelectedUserId] = useState('')
|
||||
const [subject, setSubject] = useState('')
|
||||
@@ -173,10 +173,10 @@ export default function MessagesPage() {
|
||||
const roleLabel = selectedRole ? selectedRole.replace(/_/g, ' ') : ''
|
||||
return roleLabel ? `All ${roleLabel}s` : 'By Role (none selected)'
|
||||
}
|
||||
case 'STAGE_JURY': {
|
||||
if (!stageId) return 'Stage Jury (none selected)'
|
||||
case 'ROUND_JURY': {
|
||||
if (!roundId) return 'Stage Jury (none selected)'
|
||||
const stage = rounds?.find(
|
||||
(r) => r.id === stageId
|
||||
(r) => r.id === roundId
|
||||
)
|
||||
return stage
|
||||
? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}`
|
||||
@@ -217,7 +217,7 @@ export default function MessagesPage() {
|
||||
toast.error('Please select a role')
|
||||
return
|
||||
}
|
||||
if (recipientType === 'STAGE_JURY' && !stageId) {
|
||||
if (recipientType === 'ROUND_JURY' && !roundId) {
|
||||
toast.error('Please select a stage')
|
||||
return
|
||||
}
|
||||
@@ -237,7 +237,7 @@ export default function MessagesPage() {
|
||||
sendMutation.mutate({
|
||||
recipientType,
|
||||
recipientFilter: buildRecipientFilter(),
|
||||
stageId: stageId || undefined,
|
||||
roundId: roundId || undefined,
|
||||
subject: subject.trim(),
|
||||
body: body.trim(),
|
||||
deliveryChannels,
|
||||
@@ -332,10 +332,10 @@ export default function MessagesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipientType === 'STAGE_JURY' && (
|
||||
{recipientType === 'ROUND_JURY' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Select Stage</Label>
|
||||
<Select value={stageId} onValueChange={setStageId}>
|
||||
<Select value={roundId} onValueChange={setStageId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a stage..." />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -311,7 +311,7 @@ export default function ApplySettingsPage() {
|
||||
// Initialize local state from server data
|
||||
useEffect(() => {
|
||||
if (serverConfig && !initialized) {
|
||||
setConfig(serverConfig)
|
||||
setConfig({...DEFAULT_WIZARD_CONFIG, ...serverConfig})
|
||||
setInitialized(true)
|
||||
}
|
||||
}, [serverConfig, initialized])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { api } from '@/lib/trpc/server'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -86,22 +87,22 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Stages</CardTitle>
|
||||
<CardTitle>Rounds</CardTitle>
|
||||
<CardDescription>
|
||||
Pipeline stages for this program
|
||||
Competition rounds for this program
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href={`/admin/rounds/pipelines?programId=${id}`}>
|
||||
<Link href={`/admin/competitions?programId=${id}` as Route}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Manage Pipeline
|
||||
Manage Competitions
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(program.stages as Array<{ id: string; name: string; status: string; _count: { projects: number; assignments: number }; createdAt?: Date }>).length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No stages created yet. Set up a pipeline to get started.
|
||||
No rounds created yet. Set up a competition to get started.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
|
||||
@@ -42,14 +42,10 @@ async function ProgramsContent() {
|
||||
const programs = await prisma.program.findMany({
|
||||
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
|
||||
include: {
|
||||
pipelines: {
|
||||
competitions: {
|
||||
include: {
|
||||
tracks: {
|
||||
include: {
|
||||
stages: {
|
||||
select: { id: true, status: true },
|
||||
},
|
||||
},
|
||||
rounds: {
|
||||
select: { id: true, status: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -57,16 +53,14 @@ async function ProgramsContent() {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// Flatten stages per program for convenience
|
||||
const programsWithStageCounts = programs.map((p) => {
|
||||
const allStages = p.pipelines.flatMap((pl) =>
|
||||
pl.tracks.flatMap((t) => t.stages)
|
||||
)
|
||||
const activeStages = allStages.filter((s) => s.status === 'STAGE_ACTIVE')
|
||||
return { ...p, stageCount: allStages.length, activeStageCount: activeStages.length }
|
||||
// Flatten rounds per program for convenience
|
||||
const programsWithRoundCounts = programs.map((p) => {
|
||||
const allRounds = p.competitions.flatMap((c) => c.rounds)
|
||||
const activeRounds = allRounds.filter((r) => r.status === 'ROUND_ACTIVE')
|
||||
return { ...p, roundCount: allRounds.length, activeRoundCount: activeRounds.length }
|
||||
})
|
||||
|
||||
if (programsWithStageCounts.length === 0) {
|
||||
if (programsWithRoundCounts.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
@@ -102,14 +96,14 @@ async function ProgramsContent() {
|
||||
<TableRow>
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead>Year</TableHead>
|
||||
<TableHead>Stages</TableHead>
|
||||
<TableHead>Rounds</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{programsWithStageCounts.map((program) => (
|
||||
{programsWithRoundCounts.map((program) => (
|
||||
<TableRow key={program.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
@@ -124,10 +118,10 @@ async function ProgramsContent() {
|
||||
<TableCell>{program.year}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p>{program.stageCount} total</p>
|
||||
{program.activeStageCount > 0 && (
|
||||
<p>{program.roundCount} total</p>
|
||||
{program.activeRoundCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{program.activeStageCount} active
|
||||
{program.activeRoundCount} active
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -176,7 +170,7 @@ async function ProgramsContent() {
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className="space-y-4 md:hidden">
|
||||
{programsWithStageCounts.map((program) => (
|
||||
{programsWithRoundCounts.map((program) => (
|
||||
<Card key={program.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -193,7 +187,7 @@ async function ProgramsContent() {
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Stages</span>
|
||||
<span>
|
||||
{program.stageCount} ({program.activeStageCount} active)
|
||||
{program.roundCount} ({program.activeRoundCount} active)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
Loader2,
|
||||
Users,
|
||||
User,
|
||||
@@ -201,6 +202,10 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
AI-Suggested Mentors
|
||||
<Badge variant="outline" className="text-xs gap-1 shrink-0 ml-1">
|
||||
<Bot className="h-3 w-3" />
|
||||
AI Recommended
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Mentors matched based on expertise and project needs
|
||||
|
||||
@@ -87,18 +87,20 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
// Fetch files (flat list for backward compatibility)
|
||||
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
|
||||
|
||||
// Fetch file requirements from the pipeline's intake stage
|
||||
const { data: requirementsData } = trpc.file.getProjectRequirements.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: !!project }
|
||||
)
|
||||
// Fetch file requirements from the competition's intake round
|
||||
// Note: This procedure may need to be updated or removed depending on new system
|
||||
// const { data: requirementsData } = trpc.file.getProjectRequirements.useQuery(
|
||||
// { projectId },
|
||||
// { enabled: !!project }
|
||||
// )
|
||||
const requirementsData = null // Placeholder until procedure is updated
|
||||
|
||||
// Fetch available stages for upload selector (if project has a programId)
|
||||
// Fetch available rounds for upload selector (if project has a programId)
|
||||
const { data: programData } = trpc.program.get.useQuery(
|
||||
{ id: project?.programId || '' },
|
||||
{ enabled: !!project?.programId }
|
||||
)
|
||||
const availableStages = (programData?.stages as Array<{ id: string; name: string }>) || []
|
||||
const availableRounds = (programData?.stages as Array<{ id: string; name: string }>) || []
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
@@ -528,13 +530,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Required Documents from Pipeline Intake Stage */}
|
||||
{requirementsData && requirementsData.requirements.length > 0 && (
|
||||
{/* Required Documents from Competition Intake Round */}
|
||||
{requirementsData && (requirementsData as { requirements: Array<{ id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }> }).requirements?.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3">Required Documents</p>
|
||||
<div className="grid gap-2">
|
||||
{requirementsData.requirements.map((req, idx) => {
|
||||
{(requirementsData as { requirements: Array<{ id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }> }).requirements.map((req: { id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }, idx: number) => {
|
||||
const isFulfilled = req.fulfilled
|
||||
return (
|
||||
<div
|
||||
@@ -592,16 +594,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
{/* Additional Documents Upload */}
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3">
|
||||
{requirementsData && requirementsData.requirements.length > 0
|
||||
{requirementsData && (requirementsData as { requirements: unknown[] }).requirements?.length > 0
|
||||
? 'Additional Documents'
|
||||
: 'Upload New Files'}
|
||||
</p>
|
||||
<FileUpload
|
||||
projectId={projectId}
|
||||
availableStages={availableStages?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
|
||||
availableRounds={availableRounds?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
|
||||
onUploadComplete={() => {
|
||||
utils.file.listByProject.invalidate({ projectId })
|
||||
utils.file.getProjectRequirements.invalidate({ projectId })
|
||||
// utils.file.getProjectRequirements.invalidate({ projectId })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -757,7 +759,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
{assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && (
|
||||
<EvaluationSummaryCard
|
||||
projectId={projectId}
|
||||
stageId={assignments[0].stageId}
|
||||
roundId={assignments[0].roundId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,9 +30,9 @@ function ImportPageContent() {
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
const searchParams = useSearchParams()
|
||||
const stageIdParam = searchParams.get('stage')
|
||||
const roundIdParam = searchParams.get('stage')
|
||||
|
||||
const [selectedStageId, setSelectedStageId] = useState<string>(stageIdParam || '')
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
|
||||
|
||||
// Fetch active programs with stages
|
||||
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
|
||||
@@ -49,7 +49,7 @@ function ImportPageContent() {
|
||||
}))
|
||||
) || []
|
||||
|
||||
const selectedStage = stages.find((s: { id: string }) => s.id === selectedStageId)
|
||||
const selectedRound = stages.find((s: { id: string }) => s.id === selectedRoundId)
|
||||
|
||||
if (loadingPrograms) {
|
||||
return <ImportPageSkeleton />
|
||||
@@ -75,7 +75,7 @@ function ImportPageContent() {
|
||||
</div>
|
||||
|
||||
{/* Stage selection */}
|
||||
{!selectedStageId && (
|
||||
{!selectedRoundId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Select Stage</CardTitle>
|
||||
@@ -87,17 +87,17 @@ function ImportPageContent() {
|
||||
{stages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Active Stages</p>
|
||||
<p className="mt-2 font-medium">No Active Rounds</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a stage first before importing projects
|
||||
Create a competition with rounds before importing projects
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/rounds/new-pipeline">Create Pipeline</Link>
|
||||
<Link href="/admin/competitions">View Competitions</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select value={selectedStageId} onValueChange={setSelectedStageId}>
|
||||
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a stage" />
|
||||
</SelectTrigger>
|
||||
@@ -117,11 +117,11 @@ function ImportPageContent() {
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (selectedStageId) {
|
||||
router.push(`/admin/projects/import?stage=${selectedStageId}`)
|
||||
if (selectedRoundId) {
|
||||
router.push(`/admin/projects/import?stage=${selectedRoundId}`)
|
||||
}
|
||||
}}
|
||||
disabled={!selectedStageId}
|
||||
disabled={!selectedRoundId}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
@@ -132,14 +132,14 @@ function ImportPageContent() {
|
||||
)}
|
||||
|
||||
{/* Import form */}
|
||||
{selectedStageId && selectedStage && (
|
||||
{selectedRoundId && selectedRound && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<FileSpreadsheet className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Importing into: {selectedStage.name}</p>
|
||||
<p className="font-medium">Importing into: {selectedRound.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedStage.programName}
|
||||
{selectedRound.programName}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -147,7 +147,7 @@ function ImportPageContent() {
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
setSelectedStageId('')
|
||||
setSelectedRoundId('')
|
||||
router.push('/admin/projects/import')
|
||||
}}
|
||||
>
|
||||
@@ -172,8 +172,8 @@ function ImportPageContent() {
|
||||
</TabsList>
|
||||
<TabsContent value="csv" className="mt-4">
|
||||
<CSVImportForm
|
||||
programId={selectedStage.programId}
|
||||
stageName={selectedStage.name}
|
||||
programId={selectedRound.programId}
|
||||
stageName={selectedRound.name}
|
||||
onSuccess={() => {
|
||||
utils.project.list.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
@@ -182,8 +182,8 @@ function ImportPageContent() {
|
||||
</TabsContent>
|
||||
<TabsContent value="notion" className="mt-4">
|
||||
<NotionImportForm
|
||||
programId={selectedStage.programId}
|
||||
stageName={selectedStage.name}
|
||||
programId={selectedRound.programId}
|
||||
stageName={selectedRound.name}
|
||||
onSuccess={() => {
|
||||
utils.project.list.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
@@ -192,8 +192,8 @@ function ImportPageContent() {
|
||||
</TabsContent>
|
||||
<TabsContent value="typeform" className="mt-4">
|
||||
<TypeformImportForm
|
||||
programId={selectedStage.programId}
|
||||
stageName={selectedStage.name}
|
||||
programId={selectedRound.programId}
|
||||
stageName={selectedRound.name}
|
||||
onSuccess={() => {
|
||||
utils.project.list.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
|
||||
@@ -85,11 +85,11 @@ const ROLE_SORT_ORDER: Record<string, number> = { LEAD: 0, MEMBER: 1, ADVISOR: 2
|
||||
function NewProjectPageContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const stageIdParam = searchParams.get('stage')
|
||||
const roundIdParam = searchParams.get('stage')
|
||||
const programIdParam = searchParams.get('program')
|
||||
|
||||
const [selectedProgramId, setSelectedProgramId] = useState<string>(programIdParam || '')
|
||||
const [selectedStageId, setSelectedStageId] = useState<string>(stageIdParam || '')
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -286,7 +286,7 @@ function NewProjectPageContent() {
|
||||
<Label>Program *</Label>
|
||||
<Select value={selectedProgramId} onValueChange={(v) => {
|
||||
setSelectedProgramId(v)
|
||||
setSelectedStageId('') // Reset stage on program change
|
||||
setSelectedRoundId('') // Reset stage on program change
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a program" />
|
||||
@@ -303,7 +303,7 @@ function NewProjectPageContent() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Stage (optional)</Label>
|
||||
<Select value={selectedStageId || '__none__'} onValueChange={(v) => setSelectedStageId(v === '__none__' ? '' : v)} disabled={!selectedProgramId}>
|
||||
<Select value={selectedRoundId || '__none__'} onValueChange={(v) => setSelectedRoundId(v === '__none__' ? '' : v)} disabled={!selectedProgramId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No stage assigned" />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
ClipboardList,
|
||||
Bot,
|
||||
Eye,
|
||||
Pencil,
|
||||
FileUp,
|
||||
@@ -122,7 +123,7 @@ function parseFiltersFromParams(
|
||||
statuses: searchParams.get('status')
|
||||
? searchParams.get('status')!.split(',')
|
||||
: [],
|
||||
stageId: searchParams.get('stage') || '',
|
||||
roundId: searchParams.get('round') || '',
|
||||
competitionCategory: searchParams.get('category') || '',
|
||||
oceanIssue: searchParams.get('issue') || '',
|
||||
country: searchParams.get('country') || '',
|
||||
@@ -156,7 +157,7 @@ function filtersToParams(
|
||||
if (filters.search) params.set('q', filters.search)
|
||||
if (filters.statuses.length > 0)
|
||||
params.set('status', filters.statuses.join(','))
|
||||
if (filters.stageId) params.set('stage', filters.stageId)
|
||||
if (filters.roundId) params.set('round', filters.roundId)
|
||||
if (filters.competitionCategory)
|
||||
params.set('category', filters.competitionCategory)
|
||||
if (filters.oceanIssue) params.set('issue', filters.oceanIssue)
|
||||
@@ -181,7 +182,7 @@ export default function ProjectsPage() {
|
||||
const [filters, setFilters] = useState<ProjectFilters>({
|
||||
search: parsed.search,
|
||||
statuses: parsed.statuses,
|
||||
stageId: parsed.stageId,
|
||||
roundId: parsed.roundId,
|
||||
competitionCategory: parsed.competitionCategory,
|
||||
oceanIssue: parsed.oceanIssue,
|
||||
country: parsed.country,
|
||||
@@ -252,7 +253,7 @@ export default function ProjectsPage() {
|
||||
| 'REJECTED'
|
||||
>)
|
||||
: undefined,
|
||||
stageId: filters.stageId || undefined,
|
||||
roundId: filters.roundId || undefined,
|
||||
competitionCategory:
|
||||
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
|
||||
undefined,
|
||||
@@ -284,14 +285,14 @@ export default function ProjectsPage() {
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
|
||||
// Assign to stage dialog state
|
||||
// Assign to round dialog state
|
||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
|
||||
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
|
||||
const [assignStageId, setAssignStageId] = useState('')
|
||||
const [assignRoundId, setAssignRoundId] = useState('')
|
||||
|
||||
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
|
||||
const [taggingScope, setTaggingScope] = useState<'stage' | 'program'>('stage')
|
||||
const [selectedStageForTagging, setSelectedStageForTagging] = useState<string>('')
|
||||
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
|
||||
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
|
||||
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
|
||||
const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null)
|
||||
|
||||
@@ -351,10 +352,10 @@ export default function ProjectsPage() {
|
||||
: null
|
||||
|
||||
const handleStartTagging = () => {
|
||||
if (taggingScope === 'stage' && selectedStageForTagging) {
|
||||
// Router only accepts programId; resolve from the selected stage's parent program
|
||||
if (taggingScope === 'round' && selectedRoundForTagging) {
|
||||
// Router only accepts programId; resolve from the selected round's parent program
|
||||
const parentProgram = programs?.find((p) =>
|
||||
((p.stages ?? []) as Array<{ id: string }>)?.some((s: { id: string }) => s.id === selectedStageForTagging)
|
||||
((p.stages ?? []) as Array<{ id: string }>)?.some((s: { id: string }) => s.id === selectedRoundForTagging)
|
||||
)
|
||||
if (parentProgram) {
|
||||
startTaggingJob.mutate({ programId: parentProgram.id })
|
||||
@@ -368,19 +369,19 @@ export default function ProjectsPage() {
|
||||
if (!taggingInProgress) {
|
||||
setAiTagDialogOpen(false)
|
||||
setActiveTaggingJobId(null)
|
||||
setSelectedStageForTagging('')
|
||||
setSelectedRoundForTagging('')
|
||||
setSelectedProgramForTagging('')
|
||||
}
|
||||
}
|
||||
|
||||
// Get selected program's stages (flattened from pipelines -> tracks -> stages)
|
||||
// Get selected program's rounds (flattened from pipelines -> tracks -> stages)
|
||||
const selectedProgram = programs?.find(p => p.id === selectedProgramForTagging)
|
||||
const programStages = selectedProgram?.stages ?? []
|
||||
const programRounds = selectedProgram?.stages ?? []
|
||||
|
||||
// Calculate stats for display
|
||||
const displayProgram = taggingScope === 'program'
|
||||
? selectedProgram
|
||||
: (selectedStageForTagging ? programs?.find(p => (p.stages as Array<{ id: string }>)?.some(s => s.id === selectedStageForTagging)) : null)
|
||||
: (selectedRoundForTagging ? programs?.find(p => (p.stages as Array<{ id: string }>)?.some(s => s.id === selectedRoundForTagging)) : null)
|
||||
|
||||
// Calculate progress percentage
|
||||
const taggingProgressPercent = jobStatus && jobStatus.totalProjects > 0
|
||||
@@ -394,7 +395,7 @@ export default function ProjectsPage() {
|
||||
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
|
||||
const [bulkNotificationsConfirmed, setBulkNotificationsConfirmed] = useState(false)
|
||||
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
|
||||
const [bulkAssignStageId, setBulkAssignStageId] = useState('')
|
||||
const [bulkAssignRoundId, setBulkAssignRoundId] = useState('')
|
||||
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
|
||||
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
|
||||
|
||||
@@ -415,7 +416,7 @@ export default function ProjectsPage() {
|
||||
| 'REJECTED'
|
||||
>)
|
||||
: undefined,
|
||||
stageId: filters.stageId || undefined,
|
||||
roundId: filters.roundId || undefined,
|
||||
competitionCategory:
|
||||
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
|
||||
undefined,
|
||||
@@ -475,12 +476,12 @@ export default function ProjectsPage() {
|
||||
}
|
||||
)
|
||||
|
||||
const bulkAssignToStage = trpc.projectPool.assignToStage.useMutation({
|
||||
const bulkAssignToRound = trpc.projectPool.assignToRound.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to stage`)
|
||||
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to round`)
|
||||
setSelectedIds(new Set())
|
||||
setAllMatchingSelected(false)
|
||||
setBulkAssignStageId('')
|
||||
setBulkAssignRoundId('')
|
||||
setBulkAssignDialogOpen(false)
|
||||
utils.project.list.invalidate()
|
||||
},
|
||||
@@ -576,13 +577,13 @@ export default function ProjectsPage() {
|
||||
? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected
|
||||
: false
|
||||
|
||||
const assignToStage = trpc.projectPool.assignToStage.useMutation({
|
||||
const assignToRound = trpc.projectPool.assignToRound.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Project assigned to stage')
|
||||
toast.success('Project assigned to round')
|
||||
utils.project.list.invalidate()
|
||||
setAssignDialogOpen(false)
|
||||
setProjectToAssign(null)
|
||||
setAssignStageId('')
|
||||
setAssignRoundId('')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to assign project')
|
||||
@@ -618,7 +619,7 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
|
||||
<Tags className="mr-2 h-4 w-4" />
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
AI Tags
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
@@ -668,7 +669,7 @@ export default function ProjectsPage() {
|
||||
filters={filters}
|
||||
filterOptions={filterOptions ? {
|
||||
...filterOptions,
|
||||
stages: programs?.flatMap(p =>
|
||||
rounds: programs?.flatMap(p =>
|
||||
(p.stages as Array<{ id: string; name: string }>)?.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
@@ -799,7 +800,7 @@ export default function ProjectsPage() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filters.search ||
|
||||
filters.statuses.length > 0 ||
|
||||
filters.stageId ||
|
||||
filters.roundId ||
|
||||
filters.competitionCategory ||
|
||||
filters.oceanIssue ||
|
||||
filters.country
|
||||
@@ -992,7 +993,7 @@ export default function ProjectsPage() {
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
Assign to Stage
|
||||
Assign to Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
@@ -1132,7 +1133,7 @@ export default function ProjectsPage() {
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
Assign to Stage
|
||||
Assign to Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
@@ -1258,10 +1259,10 @@ export default function ProjectsPage() {
|
||||
onClick={() => setBulkAssignDialogOpen(true)}
|
||||
>
|
||||
<ArrowRightCircle className="mr-1.5 h-4 w-4" />
|
||||
Assign to Stage
|
||||
Assign to Round
|
||||
</Button>
|
||||
{/* Change Status (only when filtered by stage) */}
|
||||
{filters.stageId && (
|
||||
{/* Change Status (only when filtered by round) */}
|
||||
{filters.roundId && (
|
||||
<>
|
||||
<Select value={bulkStatus} onValueChange={setBulkStatus}>
|
||||
<SelectTrigger className="w-[160px] h-9 text-sm">
|
||||
@@ -1442,22 +1443,22 @@ export default function ProjectsPage() {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Assign to Stage Dialog */}
|
||||
{/* Assign to Round Dialog */}
|
||||
<Dialog open={assignDialogOpen} onOpenChange={(open) => {
|
||||
setAssignDialogOpen(open)
|
||||
if (!open) { setProjectToAssign(null); setAssignStageId('') }
|
||||
if (!open) { setProjectToAssign(null); setAssignRoundId('') }
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign to Stage</DialogTitle>
|
||||
<DialogTitle>Assign to Round</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign "{projectToAssign?.title}" to a stage.
|
||||
Assign "{projectToAssign?.title}" to a round.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Select Stage</Label>
|
||||
<Select value={assignStageId} onValueChange={setAssignStageId}>
|
||||
<Label>Select Round</Label>
|
||||
<Select value={assignRoundId} onValueChange={setAssignRoundId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a round..." />
|
||||
</SelectTrigger>
|
||||
@@ -1479,40 +1480,40 @@ export default function ProjectsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (projectToAssign && assignStageId) {
|
||||
assignToStage.mutate({
|
||||
if (projectToAssign && assignRoundId) {
|
||||
assignToRound.mutate({
|
||||
projectIds: [projectToAssign.id],
|
||||
stageId: assignStageId,
|
||||
roundId: assignRoundId,
|
||||
})
|
||||
}
|
||||
}}
|
||||
disabled={!assignStageId || assignToStage.isPending}
|
||||
disabled={!assignRoundId || assignToRound.isPending}
|
||||
>
|
||||
{assignToStage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{assignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Assign
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk Assign to Stage Dialog */}
|
||||
{/* Bulk Assign to Round Dialog */}
|
||||
<Dialog open={bulkAssignDialogOpen} onOpenChange={(open) => {
|
||||
setBulkAssignDialogOpen(open)
|
||||
if (!open) setBulkAssignStageId('')
|
||||
if (!open) setBulkAssignRoundId('')
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign to Stage</DialogTitle>
|
||||
<DialogTitle>Assign to Round</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a stage. Projects will have their status set to "Assigned".
|
||||
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a round. Projects will have their status set to "Assigned".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Select Stage</Label>
|
||||
<Select value={bulkAssignStageId} onValueChange={setBulkAssignStageId}>
|
||||
<Label>Select Round</Label>
|
||||
<Select value={bulkAssignRoundId} onValueChange={setBulkAssignRoundId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a stage..." />
|
||||
<SelectValue placeholder="Choose a round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.flatMap((p) =>
|
||||
@@ -1532,16 +1533,16 @@ export default function ProjectsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (bulkAssignStageId && selectedIds.size > 0) {
|
||||
bulkAssignToStage.mutate({
|
||||
if (bulkAssignRoundId && selectedIds.size > 0) {
|
||||
bulkAssignToRound.mutate({
|
||||
projectIds: Array.from(selectedIds),
|
||||
stageId: bulkAssignStageId,
|
||||
roundId: bulkAssignRoundId,
|
||||
})
|
||||
}
|
||||
}}
|
||||
disabled={!bulkAssignStageId || bulkAssignToStage.isPending}
|
||||
disabled={!bulkAssignRoundId || bulkAssignToRound.isPending}
|
||||
>
|
||||
{bulkAssignToStage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{bulkAssignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Assign {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1718,19 +1719,19 @@ export default function ProjectsPage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTaggingScope('stage')}
|
||||
onClick={() => setTaggingScope('round')}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors ${
|
||||
taggingScope === 'stage'
|
||||
taggingScope === 'round'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className={`h-6 w-6 ${taggingScope === 'stage' ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<span className={`text-sm font-medium ${taggingScope === 'stage' ? 'text-primary' : ''}`}>
|
||||
Single Stage
|
||||
<FolderOpen className={`h-6 w-6 ${taggingScope === 'round' ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<span className={`text-sm font-medium ${taggingScope === 'round' ? 'text-primary' : ''}`}>
|
||||
Single Round
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground text-center">
|
||||
Tag projects in one specific stage
|
||||
Tag projects in one specific round
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -1747,7 +1748,7 @@ export default function ProjectsPage() {
|
||||
Entire Edition
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground text-center">
|
||||
Tag all projects across all stages
|
||||
Tag all projects across all rounds
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1755,15 +1756,15 @@ export default function ProjectsPage() {
|
||||
|
||||
{/* Selection */}
|
||||
<div className="space-y-2">
|
||||
{taggingScope === 'stage' ? (
|
||||
{taggingScope === 'round' ? (
|
||||
<>
|
||||
<Label htmlFor="stage-select">Select Stage</Label>
|
||||
<Label htmlFor="round-select">Select Round</Label>
|
||||
<Select
|
||||
value={selectedStageForTagging}
|
||||
onValueChange={setSelectedStageForTagging}
|
||||
value={selectedRoundForTagging}
|
||||
onValueChange={setSelectedRoundForTagging}
|
||||
>
|
||||
<SelectTrigger id="stage-select">
|
||||
<SelectValue placeholder="Choose a stage..." />
|
||||
<SelectTrigger id="round-select">
|
||||
<SelectValue placeholder="Choose a round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.flatMap(p =>
|
||||
@@ -1828,7 +1829,7 @@ export default function ProjectsPage() {
|
||||
onClick={handleStartTagging}
|
||||
disabled={
|
||||
taggingInProgress ||
|
||||
(taggingScope === 'stage' && !selectedStageForTagging) ||
|
||||
(taggingScope === 'round' && !selectedRoundForTagging) ||
|
||||
(taggingScope === 'program' && !selectedProgramForTagging)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -58,17 +58,17 @@ export default function ProjectPoolPage() {
|
||||
const stages = (selectedProgramData?.stages || []) as Array<{ id: string; name: string }>
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const assignMutation = trpc.projectPool.assignToStage.useMutation({
|
||||
const assignMutation = trpc.projectPool.assignToRound.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.project.list.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to stage`)
|
||||
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
|
||||
setSelectedProjects([])
|
||||
setAssignDialogOpen(false)
|
||||
setTargetStageId('')
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Failed to assign projects')
|
||||
},
|
||||
})
|
||||
@@ -77,14 +77,14 @@ export default function ProjectPoolPage() {
|
||||
if (selectedProjects.length === 0 || !targetStageId) return
|
||||
assignMutation.mutate({
|
||||
projectIds: selectedProjects,
|
||||
stageId: targetStageId,
|
||||
roundId: targetStageId,
|
||||
})
|
||||
}
|
||||
|
||||
const handleQuickAssign = (projectId: string, stageId: string) => {
|
||||
const handleQuickAssign = (projectId: string, roundId: string) => {
|
||||
assignMutation.mutate({
|
||||
projectIds: [projectId],
|
||||
stageId,
|
||||
roundId,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ export default function ProjectPoolPage() {
|
||||
<Skeleton className="h-9 w-[200px]" />
|
||||
) : (
|
||||
<Select
|
||||
onValueChange={(stageId) => handleQuickAssign(project.id, stageId)}
|
||||
onValueChange={(roundId) => handleQuickAssign(project.id, roundId)}
|
||||
disabled={assignMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
|
||||
@@ -63,7 +63,7 @@ const ISSUE_LABELS: Record<string, string> = {
|
||||
export interface ProjectFilters {
|
||||
search: string
|
||||
statuses: string[]
|
||||
stageId: string
|
||||
roundId: string
|
||||
competitionCategory: string
|
||||
oceanIssue: string
|
||||
country: string
|
||||
@@ -76,7 +76,7 @@ export interface FilterOptions {
|
||||
countries: string[]
|
||||
categories: Array<{ value: string; count: number }>
|
||||
issues: Array<{ value: string; count: number }>
|
||||
stages?: Array<{ id: string; name: string; programName: string; programYear: number }>
|
||||
rounds?: Array<{ id: string; name: string; programName: string; programYear: number }>
|
||||
}
|
||||
|
||||
interface ProjectFiltersBarProps {
|
||||
@@ -94,7 +94,7 @@ export function ProjectFiltersBar({
|
||||
|
||||
const activeFilterCount = [
|
||||
filters.statuses.length > 0,
|
||||
filters.stageId !== '',
|
||||
filters.roundId !== '',
|
||||
filters.competitionCategory !== '',
|
||||
filters.oceanIssue !== '',
|
||||
filters.country !== '',
|
||||
@@ -114,7 +114,7 @@ export function ProjectFiltersBar({
|
||||
onChange({
|
||||
search: filters.search,
|
||||
statuses: [],
|
||||
stageId: '',
|
||||
roundId: '',
|
||||
competitionCategory: '',
|
||||
oceanIssue: '',
|
||||
country: '',
|
||||
@@ -175,19 +175,19 @@ export function ProjectFiltersBar({
|
||||
{/* Select filters grid */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Stage / Edition</Label>
|
||||
<Label className="text-sm">Round / Edition</Label>
|
||||
<Select
|
||||
value={filters.stageId || '_all'}
|
||||
value={filters.roundId || '_all'}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...filters, stageId: v === '_all' ? '' : v })
|
||||
onChange({ ...filters, roundId: v === '_all' ? '' : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All stages" />
|
||||
<SelectValue placeholder="All rounds" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All stages</SelectItem>
|
||||
{filterOptions?.stages?.map((s) => (
|
||||
<SelectItem value="_all">All rounds</SelectItem>
|
||||
{filterOptions?.rounds?.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name} ({s.programYear} {s.programName})
|
||||
</SelectItem>
|
||||
|
||||
@@ -77,7 +77,7 @@ function ReportsOverview() {
|
||||
}
|
||||
|
||||
const scopeInput = parseSelection(selectedValue)
|
||||
const hasScope = !!scopeInput.stageId || !!scopeInput.programId
|
||||
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
|
||||
|
||||
const { data: projectRankings, isLoading: projectsLoading } =
|
||||
trpc.analytics.getProjectRankings.useQuery(
|
||||
@@ -107,7 +107,7 @@ function ReportsOverview() {
|
||||
|
||||
const totalPrograms = dashStats?.programCount ?? programs?.length ?? 0
|
||||
const totalProjects = dashStats?.projectCount ?? 0
|
||||
const activeRounds = dashStats?.activeStageCount ?? rounds.filter((r: { status: string }) => r.status === 'STAGE_ACTIVE').length
|
||||
const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r: { status: string }) => r.status === 'ROUND_ACTIVE').length
|
||||
const jurorCount = dashStats?.jurorCount ?? 0
|
||||
const submittedEvaluations = dashStats?.submittedEvaluations ?? 0
|
||||
const totalEvaluations = dashStats?.totalEvaluations ?? 0
|
||||
@@ -400,11 +400,11 @@ function ReportsOverview() {
|
||||
)
|
||||
}
|
||||
|
||||
// Parse selection value: "all:programId" for edition-wide, or stageId
|
||||
function parseSelection(value: string | null): { stageId?: string; programId?: string } {
|
||||
// Parse selection value: "all:programId" for edition-wide, or roundId
|
||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||
if (!value) return {}
|
||||
if (value.startsWith('all:')) return { programId: value.slice(4) }
|
||||
return { stageId: value }
|
||||
return { roundId: value }
|
||||
}
|
||||
|
||||
function StageAnalytics() {
|
||||
@@ -423,7 +423,7 @@ function StageAnalytics() {
|
||||
}
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: scoreDistribution, isLoading: scoreLoading } =
|
||||
trpc.analytics.getScoreDistribution.useQuery(
|
||||
@@ -464,11 +464,11 @@ function StageAnalytics() {
|
||||
const selectedRound = rounds.find((r) => r.id === selectedValue)
|
||||
const geoInput = queryInput.programId
|
||||
? { programId: queryInput.programId }
|
||||
: { programId: selectedRound?.programId || '', stageId: queryInput.stageId }
|
||||
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
|
||||
const { data: geoData, isLoading: geoLoading } =
|
||||
trpc.analytics.getGeographicDistribution.useQuery(
|
||||
geoInput,
|
||||
{ enabled: hasSelection && !!(geoInput.programId || geoInput.stageId) }
|
||||
{ enabled: hasSelection && !!(geoInput.programId || geoInput.roundId) }
|
||||
)
|
||||
|
||||
if (roundsLoading) {
|
||||
@@ -613,19 +613,19 @@ function CrossStageTab() {
|
||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
const [selectedStageIds, setSelectedStageIds] = useState<string[]>([])
|
||||
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
|
||||
|
||||
const { data: comparison, isLoading: comparisonLoading } =
|
||||
trpc.analytics.getCrossStageComparison.useQuery(
|
||||
{ stageIds: selectedStageIds },
|
||||
{ enabled: selectedStageIds.length >= 2 }
|
||||
trpc.analytics.getCrossRoundComparison.useQuery(
|
||||
{ roundIds: selectedRoundIds },
|
||||
{ enabled: selectedRoundIds.length >= 2 }
|
||||
)
|
||||
|
||||
const toggleStage = (stageId: string) => {
|
||||
setSelectedStageIds((prev) =>
|
||||
prev.includes(stageId)
|
||||
? prev.filter((id) => id !== stageId)
|
||||
: [...prev, stageId]
|
||||
const toggleRound = (roundId: string) => {
|
||||
setSelectedRoundIds((prev) =>
|
||||
prev.includes(roundId)
|
||||
? prev.filter((id) => id !== roundId)
|
||||
: [...prev, roundId]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -646,20 +646,20 @@ function CrossStageTab() {
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stages.map((stage) => {
|
||||
const isSelected = selectedStageIds.includes(stage.id)
|
||||
const isSelected = selectedRoundIds.includes(stage.id)
|
||||
return (
|
||||
<Badge
|
||||
key={stage.id}
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
className="cursor-pointer text-sm py-1.5 px-3"
|
||||
onClick={() => toggleStage(stage.id)}
|
||||
onClick={() => toggleRound(stage.id)}
|
||||
>
|
||||
{stage.programName} - {stage.name}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{selectedStageIds.length < 2 && (
|
||||
{selectedRoundIds.length < 2 && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
Select at least 2 stages to enable comparison
|
||||
</p>
|
||||
@@ -668,7 +668,7 @@ function CrossStageTab() {
|
||||
</Card>
|
||||
|
||||
{/* Comparison charts */}
|
||||
{comparisonLoading && selectedStageIds.length >= 2 && (
|
||||
{comparisonLoading && selectedRoundIds.length >= 2 && (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-[350px]" />
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
@@ -680,8 +680,8 @@ function CrossStageTab() {
|
||||
|
||||
{comparison && (
|
||||
<CrossStageComparisonChart data={comparison as Array<{
|
||||
stageId: string
|
||||
stageName: string
|
||||
roundId: string
|
||||
roundName: string
|
||||
projectCount: number
|
||||
evaluationCount: number
|
||||
completionRate: number
|
||||
@@ -707,7 +707,7 @@ function JurorConsistencyTab() {
|
||||
}
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: consistency, isLoading: consistencyLoading } =
|
||||
trpc.analytics.getJurorConsistency.useQuery(
|
||||
@@ -779,7 +779,7 @@ function DiversityTab() {
|
||||
}
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: diversity, isLoading: diversityLoading } =
|
||||
trpc.analytics.getDiversityMetrics.useQuery(
|
||||
@@ -882,7 +882,7 @@ export default function ReportsPage() {
|
||||
<TabsTrigger value="pipeline" className="gap-2" asChild>
|
||||
<Link href={"/admin/reports/stages" as Route}>
|
||||
<Layers className="h-4 w-4" />
|
||||
By Pipeline
|
||||
By Round
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -901,7 +901,7 @@ export default function ReportsPage() {
|
||||
</Select>
|
||||
{pdfStageId && (
|
||||
<ExportPdfButton
|
||||
stageId={pdfStageId}
|
||||
roundId={pdfStageId}
|
||||
roundName={selectedPdfStage?.name}
|
||||
programName={selectedPdfStage?.programName}
|
||||
/>
|
||||
|
||||
@@ -1,671 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
Layers,
|
||||
Users,
|
||||
CheckCircle2,
|
||||
Target,
|
||||
Trophy,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const stateColors: Record<string, string> = {
|
||||
PENDING: 'bg-gray-100 text-gray-700',
|
||||
IN_PROGRESS: 'bg-blue-100 text-blue-700',
|
||||
PASSED: 'bg-emerald-100 text-emerald-700',
|
||||
REJECTED: 'bg-red-100 text-red-700',
|
||||
COMPLETED: 'bg-emerald-100 text-emerald-700',
|
||||
ELIMINATED: 'bg-red-100 text-red-700',
|
||||
WAITING: 'bg-amber-100 text-amber-700',
|
||||
}
|
||||
|
||||
const awardStatusColors: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-700',
|
||||
NOMINATIONS_OPEN: 'bg-blue-100 text-blue-700',
|
||||
VOTING_OPEN: 'bg-amber-100 text-amber-700',
|
||||
CLOSED: 'bg-emerald-100 text-emerald-700',
|
||||
ARCHIVED: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
// ─── Stages Tab Content ────────────────────────────────────────────────────
|
||||
|
||||
function StagesTabContent({
|
||||
activePipelineId,
|
||||
}: {
|
||||
activePipelineId: string
|
||||
}) {
|
||||
const [selectedStageId, setSelectedStageId] = useState<string>('')
|
||||
|
||||
const { data: overview, isLoading: overviewLoading } =
|
||||
trpc.analytics.getStageCompletionOverview.useQuery(
|
||||
{ pipelineId: activePipelineId },
|
||||
{ enabled: !!activePipelineId }
|
||||
)
|
||||
|
||||
const { data: scoreDistribution, isLoading: scoresLoading } =
|
||||
trpc.analytics.getStageScoreDistribution.useQuery(
|
||||
{ stageId: selectedStageId },
|
||||
{ enabled: !!selectedStageId }
|
||||
)
|
||||
|
||||
if (overviewLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!overview) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<BarChart3 className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No pipeline data</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Select a pipeline to view analytics.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="py-5 text-center">
|
||||
<Layers className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold">{overview.summary.totalStages}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Stages</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-5 text-center">
|
||||
<Target className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold">{overview.summary.totalProjects}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Projects</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-5 text-center">
|
||||
<Users className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold">{overview.summary.totalAssignments}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Assignments</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-5 text-center">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold">{overview.summary.totalCompleted}</p>
|
||||
<p className="text-xs text-muted-foreground">Evaluations Completed</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Stage overview table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Stage Completion Overview</CardTitle>
|
||||
<CardDescription>
|
||||
Click a stage to see detailed score distribution
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Stage</TableHead>
|
||||
<TableHead>Track</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="text-center">Projects</TableHead>
|
||||
<TableHead className="text-center">Assignments</TableHead>
|
||||
<TableHead className="text-center">Completed</TableHead>
|
||||
<TableHead className="text-center">Jurors</TableHead>
|
||||
<TableHead>Progress</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{overview.stages.map((stage) => (
|
||||
<TableRow
|
||||
key={stage.stageId}
|
||||
className={cn(
|
||||
'cursor-pointer transition-colors',
|
||||
selectedStageId === stage.stageId && 'bg-brand-blue/5 dark:bg-brand-teal/5'
|
||||
)}
|
||||
onClick={() => setSelectedStageId(stage.stageId)}
|
||||
>
|
||||
<TableCell className="font-medium">{stage.stageName}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">{stage.trackName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">{stage.totalProjects}</TableCell>
|
||||
<TableCell className="text-center tabular-nums">{stage.totalAssignments}</TableCell>
|
||||
<TableCell className="text-center tabular-nums">{stage.completedEvaluations}</TableCell>
|
||||
<TableCell className="text-center tabular-nums">{stage.jurorCount}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<Progress value={stage.completionRate} className="h-2 flex-1" />
|
||||
<span className="text-xs tabular-nums font-medium w-8 text-right">
|
||||
{stage.completionRate}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* State breakdown per stage */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Project State Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{overview.stages.map((stage) => (
|
||||
<div key={stage.stageId} className="space-y-2">
|
||||
<p className="text-sm font-medium">{stage.stageName}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stage.stateBreakdown.map((sb) => (
|
||||
<Badge
|
||||
key={sb.state}
|
||||
variant="outline"
|
||||
className={cn('text-xs', stateColors[sb.state])}
|
||||
>
|
||||
{sb.state}: {sb.count}
|
||||
</Badge>
|
||||
))}
|
||||
{stage.stateBreakdown.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">No projects</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Score distribution for selected stage */}
|
||||
{selectedStageId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Score Distribution
|
||||
{overview.stages.find((s) => s.stageId === selectedStageId) && (
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
— {overview.stages.find((s) => s.stageId === selectedStageId)?.stageName}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{scoresLoading ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : scoreDistribution ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{scoreDistribution.totalEvaluations}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Evaluations</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{scoreDistribution.averageGlobalScore.toFixed(1)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Avg Score</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{scoreDistribution.criterionDistributions.length}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Criteria</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score histogram (text-based) */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Global Score Distribution</p>
|
||||
{scoreDistribution.globalDistribution.map((bucket) => {
|
||||
const maxCount = Math.max(
|
||||
...scoreDistribution.globalDistribution.map((b) => b.count),
|
||||
1
|
||||
)
|
||||
const width = (bucket.count / maxCount) * 100
|
||||
return (
|
||||
<div key={bucket.score} className="flex items-center gap-2">
|
||||
<span className="text-xs tabular-nums w-4 text-right">{bucket.score}</span>
|
||||
<div className="flex-1 h-5 bg-muted/30 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand-blue/60 dark:bg-brand-teal/60 rounded transition-all"
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums w-6 text-right">{bucket.count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No score data available for this stage.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Awards Tab Content ────────────────────────────────────────────────────
|
||||
|
||||
function AwardsTabContent({
|
||||
activePipelineId,
|
||||
}: {
|
||||
activePipelineId: string
|
||||
}) {
|
||||
const [selectedAwardStageId, setSelectedAwardStageId] = useState<string>('')
|
||||
|
||||
const { data: awards, isLoading: awardsLoading } =
|
||||
trpc.analytics.getAwardSummary.useQuery(
|
||||
{ pipelineId: activePipelineId },
|
||||
{ enabled: !!activePipelineId }
|
||||
)
|
||||
|
||||
const { data: voteDistribution, isLoading: votesLoading } =
|
||||
trpc.analytics.getAwardVoteDistribution.useQuery(
|
||||
{ stageId: selectedAwardStageId },
|
||||
{ enabled: !!selectedAwardStageId }
|
||||
)
|
||||
|
||||
if (awardsLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!awards || awards.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No award tracks</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This pipeline has no award tracks configured.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Awards summary table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5" />
|
||||
Award Tracks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Click an award's stage to see vote/score distribution
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Award</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Scoring</TableHead>
|
||||
<TableHead>Routing</TableHead>
|
||||
<TableHead className="text-center">Projects</TableHead>
|
||||
<TableHead className="text-center">Completion</TableHead>
|
||||
<TableHead>Winner</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{awards.map((award) => (
|
||||
<TableRow key={award.trackId}>
|
||||
<TableCell className="font-medium">{award.awardName}</TableCell>
|
||||
<TableCell>
|
||||
{award.awardStatus ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-xs', awardStatusColors[award.awardStatus])}
|
||||
>
|
||||
{award.awardStatus.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{award.scoringMode ? (
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{award.scoringMode.toLowerCase().replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{award.routingMode ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{award.routingMode}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">{award.projectCount}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 min-w-[100px]">
|
||||
<Progress value={award.completionRate} className="h-2 flex-1" />
|
||||
<span className="text-xs tabular-nums font-medium w-8 text-right">
|
||||
{award.completionRate}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{award.winner ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Trophy className="h-3.5 w-3.5 text-amber-500 shrink-0" />
|
||||
<span className="text-sm font-medium truncate max-w-[150px]">
|
||||
{award.winner.title}
|
||||
</span>
|
||||
{award.winner.overridden && (
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0">
|
||||
overridden
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Not finalized</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Award stages clickable list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Award Stage Details</CardTitle>
|
||||
<CardDescription>
|
||||
Select a stage to view vote and score distribution
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{awards.map((award) =>
|
||||
award.stages.map((stage) => (
|
||||
<button
|
||||
key={stage.id}
|
||||
onClick={() => setSelectedAwardStageId(stage.id)}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg border p-3 transition-colors',
|
||||
selectedAwardStageId === stage.id
|
||||
? 'border-brand-blue bg-brand-blue/5 dark:border-brand-teal dark:bg-brand-teal/5'
|
||||
: 'hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{award.awardName} — {stage.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Type: {stage.stageType.toLowerCase().replace(/_/g, ' ')} | Status: {stage.status}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Vote/score distribution for selected award stage */}
|
||||
{selectedAwardStageId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Vote / Score Distribution
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{votesLoading ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : voteDistribution && voteDistribution.projects.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{voteDistribution.totalEvaluations}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Evaluations</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{voteDistribution.totalVotes}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Award Votes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead className="text-center">Evals</TableHead>
|
||||
<TableHead className="text-center">Votes</TableHead>
|
||||
<TableHead className="text-center">Avg Score</TableHead>
|
||||
<TableHead className="text-center">Min</TableHead>
|
||||
<TableHead className="text-center">Max</TableHead>
|
||||
<TableHead className="text-center">Avg Rank</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{voteDistribution.projects.map((project) => (
|
||||
<TableRow key={project.projectId}>
|
||||
<TableCell className="font-medium truncate max-w-[200px]">
|
||||
{project.title}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground truncate max-w-[120px]">
|
||||
{project.teamName || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
{project.evaluationCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
{project.voteCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums font-medium">
|
||||
{project.avgScore !== null ? project.avgScore.toFixed(1) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
{project.minScore !== null ? project.minScore.toFixed(1) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
{project.maxScore !== null ? project.maxScore.toFixed(1) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
{project.avgRank !== null ? project.avgRank.toFixed(1) : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No vote/score data available for this stage.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Page ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function StageAnalyticsReportsPage() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id ?? ''
|
||||
|
||||
const [selectedPipelineId, setSelectedPipelineId] = useState<string>('')
|
||||
|
||||
// Fetch pipelines
|
||||
const { data: pipelines, isLoading: pipelinesLoading } =
|
||||
trpc.pipeline.list.useQuery(
|
||||
{ programId },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
// Auto-select first pipeline
|
||||
const activePipelineId = selectedPipelineId || (pipelines?.[0]?.id ?? '')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={"/admin/reports" as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Layers className="h-6 w-6" />
|
||||
Pipeline Analytics
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Stage-level reporting for the pipeline system
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline selector */}
|
||||
{pipelines && pipelines.length > 0 && (
|
||||
<Select
|
||||
value={activePipelineId}
|
||||
onValueChange={(v) => {
|
||||
setSelectedPipelineId(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue placeholder="Select pipeline" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pipelines.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pipelinesLoading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : !activePipelineId ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<BarChart3 className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No pipeline data</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Select a pipeline to view analytics.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Tabs defaultValue="stages" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="stages" className="gap-2">
|
||||
<Layers className="h-4 w-4" />
|
||||
Stages
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="awards" className="gap-2">
|
||||
<Trophy className="h-4 w-4" />
|
||||
Awards
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="stages" className="space-y-6">
|
||||
<StagesTabContent activePipelineId={activePipelineId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="awards" className="space-y-6">
|
||||
<AwardsTabContent activePipelineId={activePipelineId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
|
||||
import type { StepConfig } from '@/components/ui/sidebar-stepper'
|
||||
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
|
||||
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
|
||||
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
|
||||
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
||||
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
|
||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
||||
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
|
||||
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import { defaultWizardState, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
|
||||
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
||||
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
export default function NewPipelinePage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = searchParams.get('programId') || currentEdition?.id || ''
|
||||
|
||||
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const initialStateRef = useRef(JSON.stringify(state))
|
||||
|
||||
// Update programId in state when edition context loads
|
||||
useEffect(() => {
|
||||
if (programId && !state.programId) {
|
||||
setState((prev) => ({ ...prev, programId }))
|
||||
}
|
||||
}, [programId, state.programId])
|
||||
|
||||
// Dirty tracking — warn on navigate away
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (JSON.stringify(state) !== initialStateRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [state])
|
||||
|
||||
const updateState = useCallback((updates: Partial<WizardState>) => {
|
||||
setState((prev) => ({ ...prev, ...updates }))
|
||||
}, [])
|
||||
|
||||
// Get stage configs from the main track
|
||||
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
|
||||
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
|
||||
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
|
||||
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
|
||||
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
|
||||
|
||||
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
|
||||
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
|
||||
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
|
||||
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
|
||||
|
||||
const updateStageConfig = useCallback(
|
||||
(stageType: string, configJson: Record<string, unknown>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) => {
|
||||
if (track.kind !== 'MAIN') return track
|
||||
return {
|
||||
...track,
|
||||
stages: track.stages.map((stage) =>
|
||||
stage.stageType === stageType ? { ...stage, configJson } : stage
|
||||
),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updateMainTrackStages = useCallback(
|
||||
(stages: WizardState['tracks'][0]['stages']) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) =>
|
||||
track.kind === 'MAIN' ? { ...track, stages } : track
|
||||
),
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Validation
|
||||
const basicsValid = validateBasics(state).valid
|
||||
const tracksValid = validateTracks(state.tracks).valid
|
||||
const allValid = validateAll(state).valid
|
||||
|
||||
// Mutations
|
||||
const createMutation = trpc.pipeline.createStructure.useMutation({
|
||||
onSuccess: (data) => {
|
||||
initialStateRef.current = JSON.stringify(state) // prevent dirty warning
|
||||
toast.success('Pipeline created successfully')
|
||||
router.push(`/admin/rounds/pipeline/${data.pipeline.id}` as Route)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const publishMutation = trpc.pipeline.publish.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Pipeline published successfully')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = async (publish: boolean) => {
|
||||
const validation = validateAll(state)
|
||||
if (!validation.valid) {
|
||||
toast.error('Please fix validation errors before saving')
|
||||
// Navigate to first section with errors
|
||||
if (!validation.sections.basics.valid) setCurrentStep(0)
|
||||
else if (!validation.sections.tracks.valid) setCurrentStep(2)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await createMutation.mutateAsync({
|
||||
programId: state.programId,
|
||||
name: state.name,
|
||||
slug: state.slug,
|
||||
settingsJson: {
|
||||
...state.settingsJson,
|
||||
notificationConfig: state.notificationConfig,
|
||||
overridePolicy: state.overridePolicy,
|
||||
},
|
||||
tracks: state.tracks.map((t) => ({
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
kind: t.kind,
|
||||
sortOrder: t.sortOrder,
|
||||
routingModeDefault: t.routingModeDefault,
|
||||
decisionMode: t.decisionMode,
|
||||
stages: t.stages.map((s) => ({
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
stageType: s.stageType,
|
||||
sortOrder: s.sortOrder,
|
||||
configJson: s.configJson,
|
||||
})),
|
||||
awardConfig: t.awardConfig,
|
||||
})),
|
||||
autoTransitions: true,
|
||||
})
|
||||
|
||||
if (publish && result.pipeline.id) {
|
||||
await publishMutation.mutateAsync({ id: result.pipeline.id })
|
||||
}
|
||||
}
|
||||
|
||||
const isSaving = createMutation.isPending && !publishMutation.isPending
|
||||
const isSubmitting = publishMutation.isPending
|
||||
|
||||
if (!programId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/rounds/pipelines">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Create Pipeline</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please select an edition first to create a pipeline.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step configuration
|
||||
const steps: StepConfig[] = [
|
||||
{
|
||||
title: 'Basics',
|
||||
description: 'Pipeline name and program',
|
||||
isValid: basicsValid,
|
||||
},
|
||||
{
|
||||
title: 'Intake',
|
||||
description: 'Submission window & files',
|
||||
isValid: !!intakeStage,
|
||||
},
|
||||
{
|
||||
title: 'Main Track Stages',
|
||||
description: 'Configure pipeline stages',
|
||||
isValid: tracksValid,
|
||||
},
|
||||
{
|
||||
title: 'Screening',
|
||||
description: 'Gate rules and AI screening',
|
||||
isValid: !!filterStage,
|
||||
},
|
||||
{
|
||||
title: 'Evaluation',
|
||||
description: 'Jury assignment strategy',
|
||||
isValid: !!evalStage,
|
||||
},
|
||||
{
|
||||
title: 'Awards',
|
||||
description: 'Special award tracks',
|
||||
isValid: true, // Awards are optional
|
||||
},
|
||||
{
|
||||
title: 'Live Finals',
|
||||
description: 'Voting and reveal settings',
|
||||
isValid: !!liveStage,
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
description: 'Event notifications',
|
||||
isValid: true, // Always valid
|
||||
},
|
||||
{
|
||||
title: 'Review & Create',
|
||||
description: 'Validation summary',
|
||||
isValid: allValid,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/rounds/pipelines">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Create Pipeline</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure the full pipeline structure for project evaluation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Stepper */}
|
||||
<SidebarStepper
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
onStepChange={setCurrentStep}
|
||||
onSave={() => handleSave(false)}
|
||||
onSubmit={() => handleSave(true)}
|
||||
isSaving={isSaving}
|
||||
isSubmitting={isSubmitting}
|
||||
saveLabel="Save Draft"
|
||||
submitLabel="Save & Publish"
|
||||
canSubmit={allValid}
|
||||
>
|
||||
{/* Step 0: Basics */}
|
||||
<div>
|
||||
<BasicsSection state={state} onChange={updateState} />
|
||||
</div>
|
||||
|
||||
{/* Step 1: Intake */}
|
||||
<div>
|
||||
<IntakeSection
|
||||
config={intakeConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Main Track Stages */}
|
||||
<div>
|
||||
<MainTrackSection
|
||||
stages={mainTrack?.stages ?? []}
|
||||
onChange={updateMainTrackStages}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Screening */}
|
||||
<div>
|
||||
<FilteringSection
|
||||
config={filterConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Evaluation */}
|
||||
<div>
|
||||
<AssignmentSection
|
||||
config={evalConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 5: Awards */}
|
||||
<div>
|
||||
<AwardsSection
|
||||
tracks={state.tracks}
|
||||
onChange={(tracks) => updateState({ tracks })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 6: Live Finals */}
|
||||
<div>
|
||||
<LiveFinalsSection
|
||||
config={liveConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 7: Notifications */}
|
||||
<div>
|
||||
<NotificationsSection
|
||||
config={state.notificationConfig}
|
||||
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
||||
overridePolicy={state.overridePolicy}
|
||||
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 8: Review & Create */}
|
||||
<div>
|
||||
<ReviewSection state={state} />
|
||||
</div>
|
||||
</SidebarStepper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
type AdvancedPipelinePageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function AdvancedPipelinePage({
|
||||
params,
|
||||
}: AdvancedPipelinePageProps) {
|
||||
const { id } = await params
|
||||
redirect(`/admin/rounds/pipeline/${id}` as never)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
type EditPipelinePageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function EditPipelinePage({ params }: EditPipelinePageProps) {
|
||||
const { id } = await params
|
||||
// Editing now happens inline on the detail page
|
||||
redirect(`/admin/rounds/pipeline/${id}` as never)
|
||||
}
|
||||
@@ -1,675 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
ArrowLeft,
|
||||
MoreHorizontal,
|
||||
Rocket,
|
||||
Archive,
|
||||
Layers,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Save,
|
||||
Wand2,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { InlineEditableText } from '@/components/ui/inline-editable-text'
|
||||
import { PipelineFlowchart } from '@/components/admin/pipeline/pipeline-flowchart'
|
||||
import { StageDetailSheet } from '@/components/admin/pipeline/stage-detail-sheet'
|
||||
import { usePipelineInlineEdit } from '@/hooks/use-pipeline-inline-edit'
|
||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
||||
import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor'
|
||||
import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
|
||||
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
|
||||
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-700',
|
||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||
CLOSED: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
export default function PipelineDetailPage() {
|
||||
const params = useParams()
|
||||
const pipelineId = params.id as string
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
|
||||
const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [structureTracks, setStructureTracks] = useState<WizardTrackConfig[]>([])
|
||||
const [notificationConfig, setNotificationConfig] = useState<Record<string, boolean>>({})
|
||||
const [overridePolicy, setOverridePolicy] = useState<Record<string, unknown>>({
|
||||
allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
|
||||
})
|
||||
const [structureDirty, setStructureDirty] = useState(false)
|
||||
const [settingsDirty, setSettingsDirty] = useState(false)
|
||||
|
||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
||||
id: pipelineId,
|
||||
})
|
||||
|
||||
const { isUpdating, updatePipeline, updateStageConfig } =
|
||||
usePipelineInlineEdit(pipelineId)
|
||||
|
||||
const publishMutation = trpc.pipeline.publish.useMutation({
|
||||
onSuccess: () => toast.success('Pipeline published'),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateMutation = trpc.pipeline.update.useMutation({
|
||||
onSuccess: () => toast.success('Pipeline updated'),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.pipeline.getDraft.invalidate({ id: pipelineId })
|
||||
toast.success('Pipeline structure updated')
|
||||
setStructureDirty(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const materializeRequirementsMutation =
|
||||
trpc.file.materializeRequirementsFromConfig.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
if (result.skipped && result.reason === 'already_materialized') {
|
||||
toast.message('Requirements already materialized')
|
||||
return
|
||||
}
|
||||
if (result.skipped && result.reason === 'no_config_requirements') {
|
||||
toast.message('No legacy config requirements found')
|
||||
return
|
||||
}
|
||||
await utils.file.listRequirements.invalidate()
|
||||
toast.success(`Materialized ${result.created} requirement(s)`)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Auto-select first track on load
|
||||
useEffect(() => {
|
||||
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
|
||||
const firstTrack = pipeline.tracks.sort((a, b) => a.sortOrder - b.sortOrder)[0]
|
||||
setSelectedTrackId(firstTrack.id)
|
||||
}
|
||||
}, [pipeline, selectedTrackId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pipeline) return
|
||||
|
||||
const nextTracks = pipeline.tracks
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((track) =>
|
||||
toWizardTrackConfig({
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
slug: track.slug,
|
||||
kind: track.kind,
|
||||
sortOrder: track.sortOrder,
|
||||
routingMode: track.routingMode,
|
||||
decisionMode: track.decisionMode,
|
||||
stages: track.stages.map((stage) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
slug: stage.slug,
|
||||
stageType: stage.stageType,
|
||||
sortOrder: stage.sortOrder,
|
||||
configJson: stage.configJson,
|
||||
})),
|
||||
specialAward: track.specialAward
|
||||
? {
|
||||
name: track.specialAward.name,
|
||||
description: track.specialAward.description,
|
||||
scoringMode: track.specialAward.scoringMode,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
)
|
||||
setStructureTracks(nextTracks)
|
||||
|
||||
const settings = (pipeline.settingsJson as Record<string, unknown> | null) ?? {}
|
||||
setNotificationConfig(
|
||||
((settings.notificationConfig as Record<string, boolean> | undefined) ??
|
||||
defaultNotificationConfig()) as Record<string, boolean>
|
||||
)
|
||||
setOverridePolicy(
|
||||
((settings.overridePolicy as Record<string, unknown> | undefined) ?? {
|
||||
allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
setStructureDirty(false)
|
||||
setSettingsDirty(false)
|
||||
}, [pipeline])
|
||||
|
||||
const trackOptionsForEditors = useMemo(
|
||||
() =>
|
||||
(pipeline?.tracks ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((track) => ({
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
stages: track.stages
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((stage) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
sortOrder: stage.sortOrder,
|
||||
})),
|
||||
})),
|
||||
[pipeline]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<div>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!pipeline) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/rounds/pipelines">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Pipeline Not Found</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The requested pipeline does not exist
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedTrack = pipeline.tracks.find((t) => t.id === selectedTrackId)
|
||||
const selectedStage = selectedTrack?.stages.find(
|
||||
(s) => s.id === selectedStageId
|
||||
)
|
||||
const mainTrackDraft = structureTracks.find((track) => track.kind === 'MAIN')
|
||||
const hasAwardTracks = pipeline.tracks.some((t) => t.kind === 'AWARD')
|
||||
const hasMultipleTracks = pipeline.tracks.length > 1
|
||||
|
||||
const handleTrackChange = (trackId: string) => {
|
||||
setSelectedTrackId(trackId)
|
||||
setSelectedStageId(null)
|
||||
}
|
||||
|
||||
const handleStageSelect = (stageId: string) => {
|
||||
setSelectedStageId(stageId)
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
const handleStatusChange = async (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
|
||||
await updateMutation.mutateAsync({
|
||||
id: pipelineId,
|
||||
status: newStatus,
|
||||
})
|
||||
}
|
||||
|
||||
const updateMainTrackStages = (stages: WizardTrackConfig['stages']) => {
|
||||
setStructureTracks((prev) =>
|
||||
prev.map((track) =>
|
||||
track.kind === 'MAIN'
|
||||
? {
|
||||
...track,
|
||||
stages,
|
||||
}
|
||||
: track
|
||||
)
|
||||
)
|
||||
setStructureDirty(true)
|
||||
}
|
||||
|
||||
const handleSaveStructure = async () => {
|
||||
await updateStructureMutation.mutateAsync({
|
||||
id: pipelineId,
|
||||
tracks: structureTracks.map((track) => ({
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
slug: track.slug,
|
||||
kind: track.kind,
|
||||
sortOrder: track.sortOrder,
|
||||
routingModeDefault: track.routingModeDefault,
|
||||
decisionMode: track.decisionMode,
|
||||
stages: track.stages.map((stage) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
slug: stage.slug,
|
||||
stageType: stage.stageType,
|
||||
sortOrder: stage.sortOrder,
|
||||
configJson: stage.configJson,
|
||||
})),
|
||||
awardConfig: track.awardConfig,
|
||||
})),
|
||||
autoTransitions: false,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
const currentSettings = (pipeline.settingsJson as Record<string, unknown> | null) ?? {}
|
||||
await updatePipeline({
|
||||
settingsJson: {
|
||||
...currentSettings,
|
||||
notificationConfig,
|
||||
overridePolicy,
|
||||
},
|
||||
})
|
||||
setSettingsDirty(false)
|
||||
}
|
||||
|
||||
// Prepare flowchart data for the selected track
|
||||
const flowchartTracks = selectedTrack ? [selectedTrack] : []
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Link href="/admin/rounds/pipelines" className="mt-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<InlineEditableText
|
||||
value={pipeline.name}
|
||||
onSave={(newName) => updatePipeline({ name: newName })}
|
||||
variant="h1"
|
||||
placeholder="Untitled Pipeline"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors shrink-0',
|
||||
statusColors[pipeline.status] ?? '',
|
||||
'hover:opacity-80'
|
||||
)}
|
||||
>
|
||||
{pipeline.status}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange('DRAFT')}
|
||||
disabled={pipeline.status === 'DRAFT' || updateMutation.isPending}
|
||||
>
|
||||
Draft
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange('ACTIVE')}
|
||||
disabled={pipeline.status === 'ACTIVE' || updateMutation.isPending}
|
||||
>
|
||||
Active
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange('CLOSED')}
|
||||
disabled={pipeline.status === 'CLOSED' || updateMutation.isPending}
|
||||
>
|
||||
Closed
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange('ARCHIVED')}
|
||||
disabled={pipeline.status === 'ARCHIVED' || updateMutation.isPending}
|
||||
>
|
||||
Archived
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<span className="text-muted-foreground">slug:</span>
|
||||
<InlineEditableText
|
||||
value={pipeline.slug}
|
||||
onSave={(newSlug) => updatePipeline({ slug: newSlug })}
|
||||
variant="mono"
|
||||
placeholder="pipeline-slug"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}/wizard` as Route}>
|
||||
<Wand2 className="h-4 w-4 mr-2" />
|
||||
Edit in Wizard
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{pipeline.status === 'DRAFT' && (
|
||||
<DropdownMenuItem
|
||||
disabled={publishMutation.isPending}
|
||||
onClick={() => publishMutation.mutate({ id: pipelineId })}
|
||||
>
|
||||
{publishMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Rocket className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Publish
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{pipeline.status === 'ACTIVE' && (
|
||||
<DropdownMenuItem
|
||||
disabled={updateMutation.isPending}
|
||||
onClick={() => handleStatusChange('CLOSED')}
|
||||
>
|
||||
Close Pipeline
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={updateMutation.isPending}
|
||||
onClick={() => handleStatusChange('ARCHIVED')}
|
||||
>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline Summary */}
|
||||
<div className="grid gap-3 grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Tracks</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{pipeline.tracks.length}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pipeline.tracks.filter((t) => t.kind === 'MAIN').length} main,{' '}
|
||||
{pipeline.tracks.filter((t) => t.kind === 'AWARD').length} award
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">Stages</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{pipeline.tracks.reduce((sum, t) => sum + t.stages.length, 0)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">across all tracks</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm font-medium">Transitions</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{pipeline.tracks.reduce(
|
||||
(sum, t) =>
|
||||
sum +
|
||||
t.stages.reduce(
|
||||
(s, stage) => s + stage.transitionsFrom.length,
|
||||
0
|
||||
),
|
||||
0
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">stage connections</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Track Switcher (only if multiple tracks) */}
|
||||
{hasMultipleTracks && (
|
||||
<div className="flex items-center gap-2 flex-wrap overflow-x-auto pb-1">
|
||||
{pipeline.tracks
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((track) => (
|
||||
<button
|
||||
key={track.id}
|
||||
onClick={() => handleTrackChange(track.id)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||||
selectedTrackId === track.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<span>{track.name}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[9px] h-4 px-1',
|
||||
selectedTrackId === track.id
|
||||
? 'border-primary-foreground/20 text-primary-foreground/80'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
{track.kind}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline Flowchart */}
|
||||
{flowchartTracks.length > 0 ? (
|
||||
<div>
|
||||
<PipelineFlowchart
|
||||
tracks={flowchartTracks}
|
||||
selectedStageId={selectedStageId}
|
||||
onStageSelect={handleStageSelect}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Click a stage to edit its configuration
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No tracks configured for this pipeline
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stage Detail Sheet */}
|
||||
<StageDetailSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
stage={
|
||||
selectedStage
|
||||
? {
|
||||
id: selectedStage.id,
|
||||
name: selectedStage.name,
|
||||
stageType: selectedStage.stageType,
|
||||
configJson: selectedStage.configJson as Record<string, unknown> | null,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onSaveConfig={updateStageConfig}
|
||||
isSaving={isUpdating}
|
||||
pipelineId={pipelineId}
|
||||
materializeRequirements={(stageId) =>
|
||||
materializeRequirementsMutation.mutate({ stageId })
|
||||
}
|
||||
isMaterializing={materializeRequirementsMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Stage Management */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Stage Management</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Add, remove, reorder, or change stage types. Click a stage in the flowchart to edit its settings.
|
||||
</p>
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">Pipeline Structure</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSaveStructure}
|
||||
disabled={!structureDirty || updateStructureMutation.isPending}
|
||||
>
|
||||
{updateStructureMutation.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Save Structure
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mainTrackDraft ? (
|
||||
<MainTrackSection
|
||||
stages={mainTrackDraft.stages}
|
||||
onChange={updateMainTrackStages}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No main track configured.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<AwardsSection
|
||||
tracks={structureTracks}
|
||||
onChange={(tracks) => {
|
||||
setStructureTracks(tracks)
|
||||
setStructureDirty(true)
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Award Governance (only if award tracks exist) */}
|
||||
{hasAwardTracks && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Award Governance</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure special awards, voting, and scoring for award tracks.
|
||||
</p>
|
||||
<AwardGovernanceEditor
|
||||
pipelineId={pipelineId}
|
||||
tracks={pipeline.tracks
|
||||
.filter((track) => track.kind === 'AWARD')
|
||||
.map((track) => ({
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
decisionMode: track.decisionMode,
|
||||
specialAward: track.specialAward
|
||||
? {
|
||||
id: track.specialAward.id,
|
||||
name: track.specialAward.name,
|
||||
description: track.specialAward.description,
|
||||
criteriaText: track.specialAward.criteriaText,
|
||||
useAiEligibility: track.specialAward.useAiEligibility,
|
||||
scoringMode: track.specialAward.scoringMode,
|
||||
maxRankedPicks: track.specialAward.maxRankedPicks,
|
||||
votingStartAt: track.specialAward.votingStartAt,
|
||||
votingEndAt: track.specialAward.votingEndAt,
|
||||
status: track.specialAward.status,
|
||||
}
|
||||
: null,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Settings</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">Notifications and Overrides</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSaveSettings}
|
||||
disabled={!settingsDirty || isUpdating}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
<NotificationsSection
|
||||
config={notificationConfig}
|
||||
onChange={(next) => {
|
||||
setNotificationConfig(next)
|
||||
setSettingsDirty(true)
|
||||
}}
|
||||
overridePolicy={overridePolicy}
|
||||
onOverridePolicyChange={(next) => {
|
||||
setOverridePolicy(next)
|
||||
setSettingsDirty(true)
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
|
||||
import type { StepConfig } from '@/components/ui/sidebar-stepper'
|
||||
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
|
||||
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
|
||||
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
|
||||
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
||||
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
|
||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
||||
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
|
||||
|
||||
import { defaultNotificationConfig, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
|
||||
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
|
||||
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
||||
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
export default function EditPipelineWizardPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const pipelineId = params.id as string
|
||||
|
||||
const [state, setState] = useState<WizardState | null>(null)
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const initialStateRef = useRef<string>('')
|
||||
|
||||
// Load existing pipeline data
|
||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery(
|
||||
{ id: pipelineId },
|
||||
{ enabled: !!pipelineId }
|
||||
)
|
||||
|
||||
// Initialize state when pipeline data loads
|
||||
useEffect(() => {
|
||||
if (pipeline && !state) {
|
||||
const settings = (pipeline.settingsJson as Record<string, unknown> | null) ?? {}
|
||||
const initialState: WizardState = {
|
||||
name: pipeline.name,
|
||||
slug: pipeline.slug,
|
||||
programId: pipeline.programId,
|
||||
settingsJson: settings,
|
||||
tracks: pipeline.tracks
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map(track => toWizardTrackConfig({
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
slug: track.slug,
|
||||
kind: track.kind as 'MAIN' | 'AWARD' | 'SHOWCASE',
|
||||
sortOrder: track.sortOrder,
|
||||
routingMode: track.routingMode as 'SHARED' | 'EXCLUSIVE' | null,
|
||||
decisionMode: track.decisionMode as 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION' | null,
|
||||
stages: track.stages.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
stageType: s.stageType as 'INTAKE' | 'FILTER' | 'EVALUATION' | 'SELECTION' | 'LIVE_FINAL' | 'RESULTS',
|
||||
sortOrder: s.sortOrder,
|
||||
configJson: s.configJson,
|
||||
})),
|
||||
specialAward: track.specialAward ? {
|
||||
name: track.specialAward.name,
|
||||
description: track.specialAward.description,
|
||||
scoringMode: track.specialAward.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED',
|
||||
} : null,
|
||||
})),
|
||||
notificationConfig: (settings.notificationConfig as Record<string, boolean>) ?? defaultNotificationConfig(),
|
||||
overridePolicy: (settings.overridePolicy as Record<string, unknown>) ?? { allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
||||
}
|
||||
setState(initialState)
|
||||
initialStateRef.current = JSON.stringify(initialState)
|
||||
}
|
||||
}, [pipeline, state])
|
||||
|
||||
// Dirty tracking — warn on navigate away
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (state && JSON.stringify(state) !== initialStateRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [state])
|
||||
|
||||
const updateState = useCallback((updates: Partial<WizardState>) => {
|
||||
setState((prev) => prev ? { ...prev, ...updates } : prev)
|
||||
}, [])
|
||||
|
||||
// Mutations
|
||||
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const updateSettingsMutation = trpc.pipeline.update.useMutation({
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const publishMutation = trpc.pipeline.publish.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Pipeline published successfully')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = async (publish: boolean) => {
|
||||
if (!state) return
|
||||
|
||||
const validation = validateAll(state)
|
||||
if (!validation.valid) {
|
||||
toast.error('Please fix validation errors before saving')
|
||||
if (!validation.sections.basics.valid) setCurrentStep(0)
|
||||
else if (!validation.sections.tracks.valid) setCurrentStep(2)
|
||||
return
|
||||
}
|
||||
|
||||
await updateStructureMutation.mutateAsync({
|
||||
id: pipelineId,
|
||||
name: state.name,
|
||||
slug: state.slug,
|
||||
settingsJson: {
|
||||
...state.settingsJson,
|
||||
notificationConfig: state.notificationConfig,
|
||||
overridePolicy: state.overridePolicy,
|
||||
},
|
||||
tracks: state.tracks.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
kind: t.kind,
|
||||
sortOrder: t.sortOrder,
|
||||
routingModeDefault: t.routingModeDefault,
|
||||
decisionMode: t.decisionMode,
|
||||
stages: t.stages.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
stageType: s.stageType,
|
||||
sortOrder: s.sortOrder,
|
||||
configJson: s.configJson,
|
||||
})),
|
||||
awardConfig: t.awardConfig,
|
||||
})),
|
||||
autoTransitions: true,
|
||||
})
|
||||
|
||||
await updateSettingsMutation.mutateAsync({
|
||||
id: pipelineId,
|
||||
settingsJson: {
|
||||
...state.settingsJson,
|
||||
notificationConfig: state.notificationConfig,
|
||||
overridePolicy: state.overridePolicy,
|
||||
},
|
||||
})
|
||||
|
||||
if (publish) {
|
||||
await publishMutation.mutateAsync({ id: pipelineId })
|
||||
}
|
||||
|
||||
initialStateRef.current = JSON.stringify(state)
|
||||
toast.success(publish ? 'Pipeline saved and published' : 'Pipeline changes saved')
|
||||
router.push(`/admin/rounds/pipeline/${pipelineId}` as Route)
|
||||
}
|
||||
|
||||
const isSaving = updateStructureMutation.isPending && !publishMutation.isPending
|
||||
const isSubmitting = publishMutation.isPending
|
||||
|
||||
// Loading state
|
||||
if (isLoading || !state) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Edit Pipeline (Wizard)</h1>
|
||||
<p className="text-sm text-muted-foreground">Loading pipeline data...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Get stage configs from the main track
|
||||
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
|
||||
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
|
||||
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
|
||||
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
|
||||
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
|
||||
|
||||
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
|
||||
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
|
||||
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
|
||||
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
|
||||
|
||||
const updateStageConfig = (stageType: string, configJson: Record<string, unknown>) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) => {
|
||||
if (track.kind !== 'MAIN') return track
|
||||
return {
|
||||
...track,
|
||||
stages: track.stages.map((stage) =>
|
||||
stage.stageType === stageType ? { ...stage, configJson } : stage
|
||||
),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateMainTrackStages = (stages: WizardState['tracks'][0]['stages']) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) =>
|
||||
track.kind === 'MAIN' ? { ...track, stages } : track
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Validation
|
||||
const basicsValid = validateBasics(state).valid
|
||||
const tracksValid = validateTracks(state.tracks).valid
|
||||
const allValid = validateAll(state).valid
|
||||
|
||||
// Step configuration
|
||||
const steps: StepConfig[] = [
|
||||
{
|
||||
title: 'Basics',
|
||||
description: 'Pipeline name and program',
|
||||
isValid: basicsValid,
|
||||
},
|
||||
{
|
||||
title: 'Intake',
|
||||
description: 'Submission window & files',
|
||||
isValid: !!intakeStage,
|
||||
},
|
||||
{
|
||||
title: 'Main Track Stages',
|
||||
description: 'Configure pipeline stages',
|
||||
isValid: tracksValid,
|
||||
},
|
||||
{
|
||||
title: 'Screening',
|
||||
description: 'Gate rules and AI screening',
|
||||
isValid: !!filterStage,
|
||||
},
|
||||
{
|
||||
title: 'Evaluation',
|
||||
description: 'Jury assignment strategy',
|
||||
isValid: !!evalStage,
|
||||
},
|
||||
{
|
||||
title: 'Awards',
|
||||
description: 'Special award tracks',
|
||||
isValid: true,
|
||||
},
|
||||
{
|
||||
title: 'Live Finals',
|
||||
description: 'Voting and reveal settings',
|
||||
isValid: !!liveStage,
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
description: 'Event notifications',
|
||||
isValid: true,
|
||||
},
|
||||
{
|
||||
title: 'Review & Save',
|
||||
description: 'Validation summary',
|
||||
isValid: allValid,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Edit Pipeline (Wizard)</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Modify the pipeline structure for project evaluation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Stepper */}
|
||||
<SidebarStepper
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
onStepChange={setCurrentStep}
|
||||
onSave={() => handleSave(false)}
|
||||
onSubmit={() => handleSave(true)}
|
||||
isSaving={isSaving}
|
||||
isSubmitting={isSubmitting}
|
||||
saveLabel="Save Changes"
|
||||
submitLabel="Save & Publish"
|
||||
canSubmit={allValid}
|
||||
>
|
||||
{/* Step 0: Basics */}
|
||||
<div>
|
||||
<BasicsSection state={state} onChange={updateState} />
|
||||
</div>
|
||||
|
||||
{/* Step 1: Intake */}
|
||||
<div>
|
||||
<IntakeSection
|
||||
config={intakeConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Main Track Stages */}
|
||||
<div>
|
||||
<MainTrackSection
|
||||
stages={mainTrack?.stages ?? []}
|
||||
onChange={updateMainTrackStages}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Screening */}
|
||||
<div>
|
||||
<FilteringSection
|
||||
config={filterConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Evaluation */}
|
||||
<div>
|
||||
<AssignmentSection
|
||||
config={evalConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 5: Awards */}
|
||||
<div>
|
||||
<AwardsSection
|
||||
tracks={state.tracks}
|
||||
onChange={(tracks) => updateState({ tracks })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 6: Live Finals */}
|
||||
<div>
|
||||
<LiveFinalsSection
|
||||
config={liveConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 7: Notifications */}
|
||||
<div>
|
||||
<NotificationsSection
|
||||
config={state.notificationConfig}
|
||||
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
||||
overridePolicy={state.overridePolicy}
|
||||
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 8: Review & Save */}
|
||||
<div>
|
||||
<ReviewSection state={state} />
|
||||
</div>
|
||||
</SidebarStepper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
180
src/app/(applicant)/applicant/competitions/[windowId]/page.tsx
Normal file
180
src/app/(applicant)/applicant/competitions/[windowId]/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { FileUploadSlot } from '@/components/applicant/file-upload-slot'
|
||||
import { ArrowLeft, Lock, Clock, Calendar, AlertCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function ApplicantSubmissionWindowPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const windowId = params.windowId as string
|
||||
|
||||
const [uploadedFiles, setUploadedFiles] = useState<Record<string, File>>({})
|
||||
|
||||
const { data: window, isLoading } = trpc.round.getById.useQuery(
|
||||
{ id: windowId },
|
||||
{ enabled: !!windowId }
|
||||
)
|
||||
|
||||
const { data: deadlineStatus } = trpc.round.checkDeadline.useQuery(
|
||||
{ windowId },
|
||||
{
|
||||
enabled: !!windowId,
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
}
|
||||
)
|
||||
|
||||
const handleUpload = (requirementId: string, file: File) => {
|
||||
setUploadedFiles(prev => ({ ...prev, [requirementId]: file }))
|
||||
toast.success(`File "${file.name}" selected for upload`)
|
||||
// In a real implementation, this would trigger file upload
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!window) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/applicant/competitions' as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Submission window not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isLocked = deadlineStatus?.status === 'LOCKED' || deadlineStatus?.status === 'CLOSED'
|
||||
const deadline = window.windowCloseAt
|
||||
? new Date(window.windowCloseAt)
|
||||
: null
|
||||
const timeRemaining = deadline ? deadline.getTime() - Date.now() : null
|
||||
const daysRemaining = timeRemaining ? Math.floor(timeRemaining / (1000 * 60 * 60 * 24)) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/applicant/competitions' as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{window.name}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Upload required documents for this submission window
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deadline card */}
|
||||
{deadline && (
|
||||
<Card className={isLocked ? 'border-red-200 bg-red-50/50' : 'border-l-4 border-l-amber-500'}>
|
||||
<CardContent className="flex items-start gap-3 p-4">
|
||||
{isLocked ? (
|
||||
<>
|
||||
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm text-red-900">Submission Window Closed</p>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
This submission window closed on {deadline.toLocaleDateString()}. No further
|
||||
uploads are allowed.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">Deadline Countdown</p>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-2xl font-bold tabular-nums text-amber-600">
|
||||
{daysRemaining !== null ? daysRemaining : '—'}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
day{daysRemaining !== 1 ? 's' : ''} remaining
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Due: {deadline.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* File requirements */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>File Requirements</CardTitle>
|
||||
<CardDescription>
|
||||
Upload the required files below. {isLocked && 'Viewing only - window is closed.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* File requirements would be fetched separately in a real implementation */}
|
||||
{false ? (
|
||||
[].map((req: any) => (
|
||||
<FileUploadSlot
|
||||
key={req.id}
|
||||
requirement={{
|
||||
id: req.id,
|
||||
label: req.label,
|
||||
description: req.description,
|
||||
mimeTypes: req.mimeTypes || [],
|
||||
maxSizeMb: req.maxSizeMb,
|
||||
required: req.required || false,
|
||||
}}
|
||||
isLocked={isLocked}
|
||||
onUpload={(file) => handleUpload(req.id, file)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Calendar className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No file requirements defined for this window
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!isLocked && (
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline">Save Draft</Button>
|
||||
<Button className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
Submit All Files
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
src/app/(applicant)/applicant/competitions/page.tsx
Normal file
124
src/app/(applicant)/applicant/competitions/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
|
||||
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function ApplicantCompetitionsPage() {
|
||||
const { data: session } = useSession()
|
||||
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
||||
enabled: !!session,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const competitionId = myProject?.project?.programId
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Competition Timeline</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Track your progress through competition rounds
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/applicant' as Route} aria-label="Back to applicant dashboard">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!competitionId ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Active Competition</h2>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
You don't have an active project in any competition yet. Submit your application
|
||||
when a competition opens.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<ApplicantCompetitionTimeline competitionId={competitionId} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button variant="outline" className="w-full justify-start" asChild>
|
||||
<Link href={'/applicant/documents' as Route}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
View Documents
|
||||
</Link>
|
||||
</Button>
|
||||
{myProject?.openRounds && myProject.openRounds.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground px-3 py-2 bg-muted/50 rounded-md">
|
||||
{myProject.openRounds.length} submission window
|
||||
{myProject.openRounds.length !== 1 ? 's' : ''} currently open
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timeline Info</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Current Status:</span>
|
||||
<span className="font-medium">
|
||||
{myProject?.currentStatus || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{myProject?.project && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Project:</span>
|
||||
<span className="font-medium truncate ml-2" title={myProject.project.title}>
|
||||
{myProject.project.title}
|
||||
</span>
|
||||
</div>
|
||||
{myProject.project.submittedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Submitted:</span>
|
||||
<span className="font-medium">
|
||||
{new Date(myProject.project.submittedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export default function ApplicantDocumentsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const { project, openStages } = data
|
||||
const { project, openRounds } = data
|
||||
const isDraft = !project.submittedAt
|
||||
|
||||
return (
|
||||
@@ -98,23 +98,23 @@ export default function ApplicantDocumentsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Per-stage upload sections */}
|
||||
{openStages.length > 0 && (
|
||||
{/* Per-round upload sections */}
|
||||
{openRounds.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{openStages.map((stage) => {
|
||||
{openRounds.map((round: { id: string; name: string; windowCloseAt?: string | Date | null }) => {
|
||||
const now = new Date()
|
||||
const hasDeadline = !!stage.windowCloseAt
|
||||
const deadlinePassed = hasDeadline && now > new Date(stage.windowCloseAt!)
|
||||
const hasDeadline = !!round.windowCloseAt
|
||||
const deadlinePassed = hasDeadline && now > new Date(round.windowCloseAt!)
|
||||
const isLate = deadlinePassed
|
||||
|
||||
return (
|
||||
<Card key={stage.id}>
|
||||
<Card key={round.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{stage.name}</CardTitle>
|
||||
<CardTitle className="text-lg">{round.name}</CardTitle>
|
||||
<CardDescription>
|
||||
Upload documents for this stage
|
||||
Upload documents for this round
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -127,7 +127,7 @@ export default function ApplicantDocumentsPage() {
|
||||
{hasDeadline && !deadlinePassed && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Due {new Date(stage.windowCloseAt!).toLocaleDateString()}
|
||||
Due {new Date(round.windowCloseAt!).toLocaleDateString()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -136,7 +136,7 @@ export default function ApplicantDocumentsPage() {
|
||||
<CardContent>
|
||||
<RequirementUploadList
|
||||
projectId={project.id}
|
||||
stageId={stage.id}
|
||||
roundId={round.id}
|
||||
disabled={false}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -163,7 +163,7 @@ export default function ApplicantDocumentsPage() {
|
||||
<div className="space-y-2">
|
||||
{project.files.map((file) => {
|
||||
const Icon = fileTypeIcons[file.fileType] || File
|
||||
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
|
||||
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -197,13 +197,13 @@ export default function ApplicantDocumentsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* No open stages message */}
|
||||
{openStages.length === 0 && project.files.length === 0 && (
|
||||
{/* No open rounds message */}
|
||||
{openRounds.length === 0 && project.files.length === 0 && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-6 text-center">
|
||||
<Clock className="h-10 w-10 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-muted-foreground">
|
||||
No stages are currently open for document submissions.
|
||||
No rounds are currently open for document submissions.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function ApplicantDashboardPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const { project, timeline, currentStatus, openStages } = data
|
||||
const { project, timeline, currentStatus, openRounds } = data
|
||||
const isDraft = !project.submittedAt
|
||||
const programYear = project.program?.year
|
||||
const programName = project.program?.name
|
||||
@@ -221,7 +221,7 @@ export default function ApplicantDashboardPage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Documents</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{openStages.length > 0 ? `${openStages.length} stage(s) open` : 'View uploads'}
|
||||
{openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -347,10 +347,10 @@ export default function ApplicantDashboardPage() {
|
||||
<span className="text-muted-foreground">Last Updated</span>
|
||||
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{openStages.length > 0 && openStages[0].windowCloseAt && (
|
||||
{openRounds.length > 0 && openRounds[0].windowCloseAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Deadline</span>
|
||||
<span>{new Date(openStages[0].windowCloseAt).toLocaleDateString()}</span>
|
||||
<span>{new Date(openRounds[0].windowCloseAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
FileText,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
|
||||
|
||||
export default function StageDocumentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
|
||||
// Get applicant's project via dashboard endpoint
|
||||
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
|
||||
const project = dashboard?.project
|
||||
const projectId = project?.id ?? ''
|
||||
|
||||
const { data: requirements, isLoading: reqLoading } =
|
||||
trpc.stage.getRequirements.useQuery(
|
||||
{ stageId, projectId },
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
const isWindowOpen = requirements?.windowStatus?.isOpen ?? false
|
||||
const isLate = requirements?.windowStatus?.isLate ?? false
|
||||
const closeAt = requirements?.windowStatus?.closesAt
|
||||
? new Date(requirements.windowStatus.closesAt)
|
||||
: null
|
||||
|
||||
if (reqLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={"/applicant/pipeline" as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Back to Pipeline</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Documents</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Upload required documents for this stage
|
||||
</p>
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={requirements?.deadlineInfo?.windowOpenAt}
|
||||
windowCloseAt={requirements?.deadlineInfo?.windowCloseAt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Deadline info */}
|
||||
{closeAt && isWindowOpen && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">Submission deadline</span>
|
||||
</div>
|
||||
<CountdownTimer deadline={closeAt} label="Closes in" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Late submission warning */}
|
||||
{isLate && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
The submission window has passed. Late submissions may be accepted at the discretion of the administrators.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Window closed */}
|
||||
{!isWindowOpen && !isLate && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">
|
||||
The document submission window for this stage is closed.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* File requirements */}
|
||||
{requirements?.fileRequirements && requirements.fileRequirements.length > 0 ? (
|
||||
<RequirementUploadList
|
||||
projectId={projectId}
|
||||
stageId={stageId}
|
||||
disabled={!isWindowOpen && !isLate}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No document requirements</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
There are no specific document requirements for this stage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Uploaded files summary */}
|
||||
{requirements?.uploadedFiles && requirements.uploadedFiles.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Uploaded Files</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{requirements.uploadedFiles.map((file: { id: string; fileName: string; size: number; createdAt: string | Date }) => (
|
||||
<div key={file.id} className="flex items-center gap-3 text-sm">
|
||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{file.fileName}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{(file.size / (1024 * 1024)).toFixed(1)}MB
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
const stateLabels: Record<string, string> = {
|
||||
PENDING: 'Pending',
|
||||
IN_PROGRESS: 'In Progress',
|
||||
PASSED: 'Passed',
|
||||
REJECTED: 'Not Selected',
|
||||
COMPLETED: 'Completed',
|
||||
WAITING: 'Waiting',
|
||||
}
|
||||
|
||||
const stateColors: Record<string, string> = {
|
||||
PASSED: 'text-emerald-600 bg-emerald-50 border-emerald-200 dark:text-emerald-400 dark:bg-emerald-950/30 dark:border-emerald-900',
|
||||
COMPLETED: 'text-emerald-600 bg-emerald-50 border-emerald-200 dark:text-emerald-400 dark:bg-emerald-950/30 dark:border-emerald-900',
|
||||
REJECTED: 'text-destructive bg-destructive/5 border-destructive/30',
|
||||
IN_PROGRESS: 'text-blue-600 bg-blue-50 border-blue-200 dark:text-blue-400 dark:bg-blue-950/30 dark:border-blue-900',
|
||||
PENDING: 'text-muted-foreground bg-muted border-muted',
|
||||
WAITING: 'text-amber-600 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/30 dark:border-amber-900',
|
||||
}
|
||||
|
||||
export default function StageStatusPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
|
||||
// Get applicant's project via dashboard endpoint
|
||||
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
|
||||
const project = dashboard?.project
|
||||
const projectId = project?.id ?? ''
|
||||
const programId = project?.program?.id ?? ''
|
||||
|
||||
const { data: pipelineView } =
|
||||
trpc.pipeline.getApplicantView.useQuery(
|
||||
{ programId, projectId },
|
||||
{ enabled: !!programId && !!projectId }
|
||||
)
|
||||
|
||||
const { data: timeline, isLoading } =
|
||||
trpc.stage.getApplicantTimeline.useQuery(
|
||||
{ projectId, pipelineId: pipelineView?.pipelineId ?? '' },
|
||||
{ enabled: !!projectId && !!pipelineView?.pipelineId }
|
||||
)
|
||||
|
||||
// Find the specific stage
|
||||
const stageData = timeline?.find((item) => item.stageId === stageId)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={"/applicant/pipeline" as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Back to Pipeline</span>
|
||||
</div>
|
||||
|
||||
{stageData ? (
|
||||
<>
|
||||
{/* Stage state card */}
|
||||
<Card className={`border ${stateColors[stageData.state] ?? ''}`}>
|
||||
<CardContent className="py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{stageData.state === 'PASSED' || stageData.state === 'COMPLETED' ? (
|
||||
<CheckCircle2 className="h-12 w-12 text-emerald-600" />
|
||||
) : stageData.state === 'REJECTED' ? (
|
||||
<XCircle className="h-12 w-12 text-destructive" />
|
||||
) : (
|
||||
<Clock className="h-12 w-12 text-blue-600" />
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{stageData.stageName}</h2>
|
||||
<Badge className="mt-2 text-sm">
|
||||
{stateLabels[stageData.state] ?? stageData.state}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Decision details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Stage Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">Stage Type</p>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{stageData.stageType.toLowerCase().replace(/_/g, ' ')}
|
||||
</p>
|
||||
</div>
|
||||
{stageData.enteredAt && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">Entered</p>
|
||||
<p className="text-sm font-medium">
|
||||
{new Date(stageData.enteredAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{stageData.exitedAt && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">Exited</p>
|
||||
<p className="text-sm font-medium">
|
||||
{new Date(stageData.exitedAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Next steps */}
|
||||
{(stageData.state === 'IN_PROGRESS' || stageData.state === 'PENDING') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
Next Steps
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{stageData.stageType === 'INTAKE' && (
|
||||
<>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
Make sure all required documents are uploaded before the deadline.
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
You will be notified once reviewers complete their evaluation.
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
{stageData.stageType === 'EVALUATION' && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
Your project is being reviewed by jury members. Results will be shared once evaluation is complete.
|
||||
</li>
|
||||
)}
|
||||
{stageData.stageType === 'LIVE_FINAL' && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
Prepare for the live presentation. Check your email for schedule and logistics details.
|
||||
</li>
|
||||
)}
|
||||
{!['INTAKE', 'EVALUATION', 'LIVE_FINAL'].includes(stageData.stageType) && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
Your project is progressing through this stage. Updates will appear here.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Full timeline */}
|
||||
{timeline && timeline.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Full Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-0">
|
||||
{timeline.map((item, index) => (
|
||||
<div key={item.stageId} className="relative flex gap-4">
|
||||
{index < timeline.length - 1 && (
|
||||
<div className={`absolute left-[11px] top-[24px] h-full w-0.5 ${
|
||||
item.state === 'PASSED' || item.state === 'COMPLETED'
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-muted'
|
||||
}`} />
|
||||
)}
|
||||
<div className="relative z-10 flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<div className={`h-3 w-3 rounded-full ${
|
||||
item.stageId === stageId
|
||||
? 'ring-2 ring-brand-blue ring-offset-2 bg-brand-blue'
|
||||
: item.state === 'PASSED' || item.state === 'COMPLETED'
|
||||
? 'bg-emerald-500'
|
||||
: item.state === 'REJECTED'
|
||||
? 'bg-destructive'
|
||||
: item.isCurrent
|
||||
? 'bg-blue-500'
|
||||
: 'bg-muted-foreground/30'
|
||||
}`} />
|
||||
</div>
|
||||
<div className="flex-1 pb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-sm font-medium ${
|
||||
item.stageId === stageId ? 'text-brand-blue dark:text-brand-teal' : ''
|
||||
}`}>
|
||||
{item.stageName}
|
||||
</p>
|
||||
<Badge
|
||||
variant={
|
||||
item.state === 'PASSED' || item.state === 'COMPLETED'
|
||||
? 'success'
|
||||
: item.state === 'REJECTED'
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{stateLabels[item.state] ?? item.state}
|
||||
</Badge>
|
||||
</div>
|
||||
{item.enteredAt && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{new Date(item.enteredAt).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Stage not found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Your project has not entered this stage yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Upload,
|
||||
Users,
|
||||
MessageSquare,
|
||||
ArrowRight,
|
||||
FileText,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { StageTimeline } from '@/components/shared/stage-timeline'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const stateLabels: Record<string, string> = {
|
||||
PENDING: 'Pending',
|
||||
IN_PROGRESS: 'In Progress',
|
||||
PASSED: 'Passed',
|
||||
REJECTED: 'Not Selected',
|
||||
COMPLETED: 'Completed',
|
||||
WAITING: 'Waiting',
|
||||
}
|
||||
|
||||
const stateVariants: Record<string, 'success' | 'destructive' | 'warning' | 'secondary' | 'info'> = {
|
||||
PENDING: 'secondary',
|
||||
IN_PROGRESS: 'info',
|
||||
PASSED: 'success',
|
||||
REJECTED: 'destructive',
|
||||
COMPLETED: 'success',
|
||||
WAITING: 'warning',
|
||||
}
|
||||
|
||||
export default function ApplicantPipelinePage() {
|
||||
// Get applicant's project via dashboard endpoint
|
||||
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
|
||||
|
||||
const project = dashboard?.project
|
||||
const projectId = project?.id ?? ''
|
||||
const programId = project?.program?.id ?? ''
|
||||
|
||||
const { data: pipelineView, isLoading: pipelineLoading } =
|
||||
trpc.pipeline.getApplicantView.useQuery(
|
||||
{ programId, projectId },
|
||||
{ enabled: !!programId && !!projectId }
|
||||
)
|
||||
|
||||
const { data: timeline, isLoading: timelineLoading } =
|
||||
trpc.stage.getApplicantTimeline.useQuery(
|
||||
{ projectId, pipelineId: pipelineView?.pipelineId ?? '' },
|
||||
{ enabled: !!projectId && !!pipelineView?.pipelineId }
|
||||
)
|
||||
|
||||
const isLoading = pipelineLoading || timelineLoading
|
||||
|
||||
if (!project && !isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Layers className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No project found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You don't have a project in the current edition yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Build timeline items for StageTimeline
|
||||
const timelineItems = timeline?.map((item) => ({
|
||||
id: item.stageId,
|
||||
name: item.stageName,
|
||||
stageType: item.stageType,
|
||||
isCurrent: item.isCurrent,
|
||||
state: item.state,
|
||||
enteredAt: item.enteredAt,
|
||||
})) ?? []
|
||||
|
||||
// Find current stage
|
||||
const currentStage = timeline?.find((item) => item.isCurrent)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
|
||||
</div>
|
||||
|
||||
{/* Project title + status */}
|
||||
<Card>
|
||||
<CardContent className="py-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{project?.title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{(project as { teamName?: string } | undefined)?.teamName}</p>
|
||||
</div>
|
||||
{currentStage && (
|
||||
<Badge variant={stateVariants[currentStage.state] ?? 'secondary'}>
|
||||
{stateLabels[currentStage.state] ?? currentStage.state}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stage Timeline visualization */}
|
||||
{timelineItems.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Pipeline Progress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StageTimeline stages={timelineItems} orientation="horizontal" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current stage details */}
|
||||
{currentStage && (
|
||||
<Card className="border-brand-blue/30 dark:border-brand-teal/30">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Current Stage</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">{currentStage.stageName}</h3>
|
||||
<p className="text-sm text-muted-foreground capitalize">
|
||||
{currentStage.stageType.toLowerCase().replace(/_/g, ' ')}
|
||||
</p>
|
||||
</div>
|
||||
{currentStage.enteredAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Entered {new Date(currentStage.enteredAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Decision history */}
|
||||
{timeline && timeline.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Stage History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{timeline.map((item) => (
|
||||
<div
|
||||
key={item.stageId}
|
||||
className="flex items-center justify-between py-2 border-b last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
'h-2 w-2 rounded-full',
|
||||
item.state === 'PASSED' || item.state === 'COMPLETED'
|
||||
? 'bg-emerald-500'
|
||||
: item.state === 'REJECTED'
|
||||
? 'bg-destructive'
|
||||
: item.isCurrent
|
||||
? 'bg-blue-500'
|
||||
: 'bg-muted-foreground'
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{item.stageName}</p>
|
||||
{item.enteredAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(item.enteredAt).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={stateVariants[item.state] ?? 'secondary'} className="text-xs">
|
||||
{stateLabels[item.state] ?? item.state}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{currentStage && (
|
||||
<Link
|
||||
href={`/applicant/pipeline/${currentStage.stageId}/documents` as Route}
|
||||
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-lg bg-blue-50 p-2 dark:bg-blue-950/40">
|
||||
<Upload className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">Upload Documents</p>
|
||||
<p className="text-xs text-muted-foreground">Submit required files</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={"/applicant/team" as Route}
|
||||
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-lg bg-teal-50 p-2 dark:bg-teal-950/40">
|
||||
<Users className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">View Team</p>
|
||||
<p className="text-xs text-muted-foreground">Team members</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href={"/applicant/mentor" as Route}
|
||||
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-amber-500/30 hover:bg-amber-50/50 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-lg bg-amber-50 p-2 dark:bg-amber-950/40">
|
||||
<MessageSquare className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">Contact Mentor</p>
|
||||
<p className="text-xs text-muted-foreground">Send a message</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
src/app/(applicant)/error.tsx
Normal file
72
src/app/(applicant)/error.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, LayoutDashboard } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function ApplicantError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Applicant section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('applicant')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to your dashboard.'}
|
||||
</p>
|
||||
{!isChunk && (error.message || error.digest) && (
|
||||
<p className="text-xs text-muted-foreground bg-muted rounded px-3 py-2 font-mono break-all">
|
||||
{error.message || `Error ID: ${error.digest}`}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-center gap-2">
|
||||
{isChunk ? (
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/applicant">
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { ExpertiseSelect } from '@/components/shared/expertise-select'
|
||||
@@ -40,10 +41,21 @@ import {
|
||||
Camera,
|
||||
Globe,
|
||||
FileText,
|
||||
Scale,
|
||||
} from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'preferences' | 'complete'
|
||||
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'jury' | 'preferences' | 'complete'
|
||||
|
||||
type JuryPref = {
|
||||
juryGroupMemberId: string
|
||||
juryGroupName: string
|
||||
currentCap: number
|
||||
allowCapAdjustment: boolean
|
||||
allowRatioAdjustment: boolean
|
||||
selfServiceCap: number | null
|
||||
selfServiceRatio: number | null
|
||||
}
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter()
|
||||
@@ -62,6 +74,7 @@ export default function OnboardingPage() {
|
||||
const [notificationPreference, setNotificationPreference] = useState<
|
||||
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
|
||||
>('EMAIL')
|
||||
const [juryPrefs, setJuryPrefs] = useState<Map<string, { cap?: number; ratio?: number }>>(new Map())
|
||||
|
||||
// Fetch current user data only after session is hydrated
|
||||
const { data: userData, isLoading: userLoading, refetch: refetchUser } = trpc.user.me.useQuery(
|
||||
@@ -105,6 +118,14 @@ export default function OnboardingPage() {
|
||||
}
|
||||
}, [userData, initialized])
|
||||
|
||||
// Fetch jury onboarding context
|
||||
const { data: onboardingCtx } = trpc.user.getOnboardingContext.useQuery(
|
||||
undefined,
|
||||
{ enabled: isAuthenticated }
|
||||
)
|
||||
const juryMemberships: JuryPref[] = onboardingCtx?.memberships ?? []
|
||||
const hasJuryStep = onboardingCtx?.hasSelfServiceOptions ?? false
|
||||
|
||||
// Fetch feature flags only after session is hydrated
|
||||
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery(
|
||||
undefined,
|
||||
@@ -117,14 +138,15 @@ export default function OnboardingPage() {
|
||||
onSuccess: () => utils.user.me.invalidate(),
|
||||
})
|
||||
|
||||
// Dynamic steps based on WhatsApp availability
|
||||
// Dynamic steps based on WhatsApp availability and jury self-service
|
||||
const steps: Step[] = useMemo(() => {
|
||||
if (whatsappEnabled) {
|
||||
return ['name', 'photo', 'country', 'bio', 'phone', 'tags', 'preferences', 'complete']
|
||||
}
|
||||
// Skip phone step if WhatsApp is disabled
|
||||
return ['name', 'photo', 'country', 'bio', 'tags', 'preferences', 'complete']
|
||||
}, [whatsappEnabled])
|
||||
const base: Step[] = ['name', 'photo', 'country', 'bio']
|
||||
if (whatsappEnabled) base.push('phone')
|
||||
base.push('tags')
|
||||
if (hasJuryStep) base.push('jury')
|
||||
base.push('preferences', 'complete')
|
||||
return base
|
||||
}, [whatsappEnabled, hasJuryStep])
|
||||
|
||||
const currentIndex = steps.indexOf(step)
|
||||
const totalVisibleSteps = steps.length - 1 // Exclude 'complete' from count
|
||||
@@ -149,6 +171,23 @@ export default function OnboardingPage() {
|
||||
|
||||
const handleComplete = async () => {
|
||||
try {
|
||||
// Build jury preferences from state
|
||||
const juryPreferences = juryMemberships
|
||||
.map((m) => {
|
||||
const pref = juryPrefs.get(m.juryGroupMemberId)
|
||||
if (!pref) return null
|
||||
return {
|
||||
juryGroupMemberId: m.juryGroupMemberId,
|
||||
selfServiceCap: pref.cap,
|
||||
selfServiceRatio: pref.ratio,
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
juryGroupMemberId: string
|
||||
selfServiceCap?: number
|
||||
selfServiceRatio?: number
|
||||
}>
|
||||
|
||||
await completeOnboarding.mutateAsync({
|
||||
name,
|
||||
country: country || undefined,
|
||||
@@ -156,6 +195,7 @@ export default function OnboardingPage() {
|
||||
phoneNumber: phoneNumber || undefined,
|
||||
expertiseTags,
|
||||
notificationPreference,
|
||||
juryPreferences: juryPreferences.length > 0 ? juryPreferences : undefined,
|
||||
})
|
||||
setStep('complete')
|
||||
toast.success('Welcome to MOPC!')
|
||||
@@ -227,6 +267,7 @@ export default function OnboardingPage() {
|
||||
bio: 'About',
|
||||
phone: 'Phone',
|
||||
tags: 'Expertise',
|
||||
jury: 'Jury',
|
||||
preferences: 'Settings',
|
||||
}
|
||||
return (
|
||||
@@ -473,7 +514,95 @@ export default function OnboardingPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 7: Preferences */}
|
||||
{/* Jury Preferences Step (conditional) */}
|
||||
{step === 'jury' && (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Scale className="h-5 w-5 text-primary" />
|
||||
Jury Preferences
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Customize your assignment preferences for each jury panel you belong to.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{juryMemberships.map((m) => {
|
||||
const pref = juryPrefs.get(m.juryGroupMemberId) ?? {}
|
||||
const capValue = pref.cap ?? m.selfServiceCap ?? m.currentCap
|
||||
const ratioValue = pref.ratio ?? m.selfServiceRatio ?? 0.5
|
||||
|
||||
return (
|
||||
<div key={m.juryGroupMemberId} className="rounded-lg border p-4 space-y-4">
|
||||
<h4 className="font-medium text-sm">{m.juryGroupName}</h4>
|
||||
|
||||
{m.allowCapAdjustment && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Maximum assignments: {capValue}
|
||||
</Label>
|
||||
<Slider
|
||||
value={[capValue]}
|
||||
onValueChange={([v]) =>
|
||||
setJuryPrefs((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(m.juryGroupMemberId, { ...pref, cap: v })
|
||||
return next
|
||||
})
|
||||
}
|
||||
min={1}
|
||||
max={m.currentCap}
|
||||
step={1}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Admin default: {m.currentCap}. You may reduce this to match your availability.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{m.allowRatioAdjustment && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Startup vs Business Concept ratio: {Math.round(ratioValue * 100)}% / {Math.round((1 - ratioValue) * 100)}%
|
||||
</Label>
|
||||
<Slider
|
||||
value={[ratioValue * 100]}
|
||||
onValueChange={([v]) =>
|
||||
setJuryPrefs((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(m.juryGroupMemberId, { ...pref, ratio: v / 100 })
|
||||
return next
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>More Business Concepts</span>
|
||||
<span>More Startups</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={goBack} className="flex-1">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={goNext} className="flex-1">
|
||||
Continue
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Notification Preferences */}
|
||||
{step === 'preferences' && (
|
||||
<>
|
||||
<CardHeader>
|
||||
|
||||
153
src/app/(jury)/jury/competitions/[roundId]/live/page.tsx
Normal file
153
src/app/(jury)/jury/competitions/[roundId]/live/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { use, useState } from 'react';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { LiveVotingForm } from '@/components/jury/live-voting-form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function JuryLivePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
|
||||
const params = use(paramsPromise);
|
||||
const utils = trpc.useUtils();
|
||||
const [notes, setNotes] = useState('');
|
||||
const [priorDataOpen, setPriorDataOpen] = useState(false);
|
||||
|
||||
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId });
|
||||
|
||||
// Placeholder for prior data - this would need to be implemented in evaluation router
|
||||
const priorData = null as { averageScore?: number; evaluationCount?: number; strengths?: string; weaknesses?: string } | null;
|
||||
|
||||
const submitVoteMutation = trpc.liveVoting.vote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Vote submitted successfully');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
const handleVoteSubmit = (vote: { score: number }) => {
|
||||
if (!cursor?.activeProject?.id) return;
|
||||
|
||||
submitVoteMutation.mutate({
|
||||
sessionId: params.roundId,
|
||||
projectId: cursor.activeProject.id,
|
||||
score: vote.score
|
||||
});
|
||||
};
|
||||
|
||||
if (!cursor?.activeProject) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Waiting for ceremony to begin...</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
The admin will control which project is displayed
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Project Display */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-2xl">{cursor.activeProject.title}</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Live project presentation
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{cursor.activeProject.description && (
|
||||
<p className="text-muted-foreground">{cursor.activeProject.description}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Prior Jury Data (Collapsible) */}
|
||||
{priorData && (
|
||||
<Collapsible open={priorDataOpen} onOpenChange={setPriorDataOpen}>
|
||||
<Card>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer hover:bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Prior Evaluation Data</CardTitle>
|
||||
{priorDataOpen ? (
|
||||
<ChevronUp className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Average Score</p>
|
||||
<p className="mt-1 text-2xl font-bold">
|
||||
{priorData.averageScore?.toFixed(1) || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
|
||||
<p className="mt-1 text-2xl font-bold">{priorData.evaluationCount || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
{priorData.strengths && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Key Strengths</p>
|
||||
<p className="mt-1 text-sm">{priorData.strengths}</p>
|
||||
</div>
|
||||
)}
|
||||
{priorData.weaknesses && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Areas for Improvement</p>
|
||||
<p className="mt-1 text-sm">{priorData.weaknesses}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Notes Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Notes</CardTitle>
|
||||
<CardDescription>Optional notes for this project</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add your observations and comments..."
|
||||
rows={4}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Voting Form */}
|
||||
<LiveVotingForm
|
||||
projectId={cursor.activeProject.id}
|
||||
onVoteSubmit={handleVoteSubmit}
|
||||
disabled={submitVoteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src/app/(jury)/jury/competitions/[roundId]/page.tsx
Normal file
118
src/app/(jury)/jury/competitions/[roundId]/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, CheckCircle2, Clock, Circle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function JuryRoundDetailPage() {
|
||||
const params = useParams()
|
||||
const roundId = params.roundId as string
|
||||
|
||||
const { data: assignments, isLoading } = trpc.roundAssignment.getMyAssignments.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
const { data: round } = trpc.round.getById.useQuery(
|
||||
{ id: roundId },
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-64" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/jury/competitions' as Route} aria-label="Back to competitions list">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
{round?.name || 'Round Details'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Your assigned projects for this round
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Assigned Projects</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!assignments || assignments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Circle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No assignments yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You will see your assigned projects here once they are assigned
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{assignments.map((assignment) => {
|
||||
const isCompleted = assignment.evaluation?.status === 'SUBMITTED'
|
||||
const isDraft = assignment.evaluation?.status === 'DRAFT'
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={assignment.id}
|
||||
href={`/jury/competitions/${roundId}/projects/${assignment.projectId}` as Route}
|
||||
className="flex items-center justify-between p-4 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{assignment.project.title}</p>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{assignment.project.competitionCategory && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{assignment.project.competitionCategory}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-4">
|
||||
{isCompleted ? (
|
||||
<Badge variant="default" className="bg-emerald-50 text-emerald-700 border-emerald-200">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Completed
|
||||
</Badge>
|
||||
) : isDraft ? (
|
||||
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-amber-200">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
Draft
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">
|
||||
<Circle className="mr-1 h-3 w-3" />
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { ArrowLeft, Save, Send, AlertCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function JuryEvaluatePage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const roundId = params.roundId as string
|
||||
const projectId = params.projectId as string
|
||||
|
||||
const [showCOIDialog, setShowCOIDialog] = useState(true)
|
||||
const [coiAccepted, setCoiAccepted] = useState(false)
|
||||
const [globalScore, setGlobalScore] = useState('')
|
||||
const [feedbackGeneral, setFeedbackGeneral] = useState('')
|
||||
const [feedbackStrengths, setFeedbackStrengths] = useState('')
|
||||
const [feedbackWeaknesses, setFeedbackWeaknesses] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: project } = trpc.project.get.useQuery(
|
||||
{ id: projectId },
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
const submitMutation = trpc.evaluation.submit.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.roundAssignment.getMyAssignments.invalidate()
|
||||
toast.success('Evaluation submitted successfully')
|
||||
router.push(`/jury/competitions/${roundId}` as Route)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
const score = parseInt(globalScore)
|
||||
if (isNaN(score) || score < 1 || score > 10) {
|
||||
toast.error('Please enter a valid score between 1 and 10')
|
||||
return
|
||||
}
|
||||
|
||||
if (!feedbackGeneral.trim() || feedbackGeneral.length < 10) {
|
||||
toast.error('Please provide general feedback (minimum 10 characters)')
|
||||
return
|
||||
}
|
||||
|
||||
// In a real implementation, we would first get or create the evaluation ID
|
||||
// For now, this is a placeholder that shows the structure
|
||||
toast.error('Evaluation submission requires an existing evaluation ID. This feature needs backend integration.')
|
||||
|
||||
/* Real implementation would be:
|
||||
submitMutation.mutate({
|
||||
id: evaluationId, // From assignment.evaluation.id
|
||||
criterionScoresJson: {}, // Criterion scores
|
||||
globalScore: score,
|
||||
binaryDecision: true,
|
||||
feedbackText: feedbackGeneral,
|
||||
})
|
||||
*/
|
||||
}
|
||||
|
||||
if (!coiAccepted && showCOIDialog) {
|
||||
return (
|
||||
<Dialog open={showCOIDialog} onOpenChange={setShowCOIDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Conflict of Interest Declaration</DialogTitle>
|
||||
<DialogDescription className="space-y-3 pt-2">
|
||||
<p>
|
||||
Before evaluating this project, you must confirm that you have no conflict of
|
||||
interest.
|
||||
</p>
|
||||
<p>
|
||||
A conflict of interest exists if you have a personal, professional, or financial
|
||||
relationship with the project team that could influence your judgment.
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-start gap-3 py-4">
|
||||
<Checkbox
|
||||
id="coi"
|
||||
checked={coiAccepted}
|
||||
onCheckedChange={(checked) => setCoiAccepted(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="coi" className="text-sm leading-relaxed cursor-pointer">
|
||||
I confirm that I have no conflict of interest with this project and can provide an
|
||||
unbiased evaluation.
|
||||
</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/jury/competitions/${roundId}` as Route)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowCOIDialog(false)}
|
||||
disabled={!coiAccepted}
|
||||
className="bg-brand-blue hover:bg-brand-blue-light"
|
||||
>
|
||||
Continue to Evaluation
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
Evaluate Project
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{project?.title || 'Loading...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<CardContent className="flex items-start gap-3 p-4">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">Important Reminder</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Your evaluation will be used to assess this project. Please provide thoughtful and
|
||||
constructive feedback to help the team improve.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Evaluation Form</CardTitle>
|
||||
<CardDescription>
|
||||
Provide your assessment of the project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="globalScore">
|
||||
Overall Score <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="globalScore"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={globalScore}
|
||||
onChange={(e) => setGlobalScore(e.target.value)}
|
||||
placeholder="Enter score (1-10)"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide a score from 1 to 10 based on your overall assessment
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feedbackGeneral">
|
||||
General Feedback <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="feedbackGeneral"
|
||||
value={feedbackGeneral}
|
||||
onChange={(e) => setFeedbackGeneral(e.target.value)}
|
||||
placeholder="Provide your overall feedback on the project..."
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feedbackStrengths">Strengths</Label>
|
||||
<Textarea
|
||||
id="feedbackStrengths"
|
||||
value={feedbackStrengths}
|
||||
onChange={(e) => setFeedbackStrengths(e.target.value)}
|
||||
placeholder="What are the key strengths of this project?"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feedbackWeaknesses">Areas for Improvement</Label>
|
||||
<Textarea
|
||||
id="feedbackWeaknesses"
|
||||
value={feedbackWeaknesses}
|
||||
onChange={(e) => setFeedbackWeaknesses(e.target.value)}
|
||||
placeholder="What areas could be improved?"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/jury/competitions/${roundId}/projects/${projectId}` as Route)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={submitMutation.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitMutation.isPending}
|
||||
className="bg-brand-blue hover:bg-brand-blue-light"
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{submitMutation.isPending ? 'Submitting...' : 'Submit Evaluation'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, 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 { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
||||
import { ArrowLeft, FileText, Users, MapPin, Target } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function JuryProjectDetailPage() {
|
||||
const params = useParams()
|
||||
const roundId = params.roundId as string
|
||||
const projectId = params.projectId as string
|
||||
|
||||
const { data: project, isLoading } = trpc.project.get.useQuery(
|
||||
{ id: projectId },
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-48" />
|
||||
<Skeleton className="h-64" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/competitions/${roundId}` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Project not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/competitions/${roundId}` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-2xl">{project.title}</CardTitle>
|
||||
{project.teamName && (
|
||||
<p className="text-muted-foreground mt-1">{project.teamName}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/competitions/${roundId}/projects/${projectId}/evaluate` as Route}>
|
||||
<Target className="mr-2 h-4 w-4" />
|
||||
Evaluate Project
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Project metadata */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{project.country}
|
||||
</Badge>
|
||||
)}
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline">{project.competitionCategory}</Badge>
|
||||
)}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
project.tags.slice(0, 3).map((tag: string) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{project.description && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Description</h3>
|
||||
<p className="text-muted-foreground whitespace-pre-wrap">
|
||||
{project.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team members */}
|
||||
{project.teamMembers && project.teamMembers.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3 flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Team Members ({project.teamMembers.length})
|
||||
</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{project.teamMembers.map((member: any) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-brand-blue/10 text-brand-blue font-semibold text-sm">
|
||||
{member.user.name?.charAt(0).toUpperCase() || '?'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">
|
||||
{member.user.name || member.user.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.role === 'LEAD' ? 'Team Lead' : member.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Documents */}
|
||||
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function JuryDeliberationPage({ params: paramsPromise }: { params: Promise<{ sessionId: string }> }) {
|
||||
const params = use(paramsPromise);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery({
|
||||
sessionId: params.sessionId
|
||||
});
|
||||
|
||||
const submitVoteMutation = trpc.deliberation.submitVote.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.deliberation.getSession.invalidate();
|
||||
toast.success('Vote submitted successfully');
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmitVote = (votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>) => {
|
||||
votes.forEach((vote) => {
|
||||
submitVoteMutation.mutate({
|
||||
sessionId: params.sessionId,
|
||||
juryMemberId: session?.currentUser?.id || '',
|
||||
projectId: vote.projectId,
|
||||
rank: vote.rank,
|
||||
isWinnerPick: vote.isWinnerPick
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Loading session...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Session not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasVoted = session.currentUser?.hasVoted;
|
||||
|
||||
if (session.status !== 'DELIB_VOTING') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Deliberation Session</CardTitle>
|
||||
<CardDescription>
|
||||
{session.round?.name} - {session.category}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
{session.status === 'DELIB_OPEN'
|
||||
? 'Voting has not started yet. Please wait for the admin to open voting.'
|
||||
: session.status === 'DELIB_TALLYING'
|
||||
? 'Voting is closed. Results are being tallied.'
|
||||
: 'This session is locked.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasVoted) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Deliberation Session</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{session.round?.name} - {session.category}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge>{session.status}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" />
|
||||
<p className="font-medium">Vote Submitted</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Thank you for your participation in this deliberation
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Deliberation Session</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{session.round?.name} - {session.category}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge>{session.status}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
{session.mode === 'SINGLE_WINNER_VOTE'
|
||||
? 'Select your top choice for this category.'
|
||||
: 'Rank all projects from best to least preferred.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DeliberationRankingForm
|
||||
projects={session.projects || []}
|
||||
mode={session.mode}
|
||||
onSubmit={handleSubmitVote}
|
||||
disabled={submitVoteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/app/(jury)/jury/competitions/page.tsx
Normal file
116
src/app/(jury)/jury/competitions/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
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 { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, ArrowRight, ClipboardList, Target } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function JuryCompetitionsPage() {
|
||||
const { data: competitions, isLoading } = trpc.competition.getMyCompetitions.useQuery()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-40" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
My Competitions
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
View competitions and rounds you're assigned to
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/jury' as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!competitions || competitions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-4">
|
||||
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">No Competitions</h2>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
You don't have any active competition assignments yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{competitions.map((competition) => {
|
||||
const activeRounds = competition.rounds?.filter(r => r.status !== 'ROUND_ARCHIVED') || []
|
||||
const totalRounds = competition.rounds?.length || 0
|
||||
|
||||
return (
|
||||
<Card key={competition.id} className="flex flex-col transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{competition.name}</CardTitle>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{totalRounds} round{totalRounds !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col space-y-4">
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="space-y-2">
|
||||
{activeRounds.length > 0 ? (
|
||||
activeRounds.slice(0, 2).map((round) => (
|
||||
<Link
|
||||
key={round.id}
|
||||
href={`/jury/competitions/${round.id}` as Route}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Target className="h-4 w-4 text-brand-teal shrink-0" />
|
||||
<span className="text-sm font-medium truncate">{round.name}</span>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-brand-blue transition-colors shrink-0" />
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">
|
||||
No active rounds
|
||||
</p>
|
||||
)}
|
||||
{activeRounds.length > 2 && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
+{activeRounds.length - 2} more round{activeRounds.length - 2 !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -60,23 +60,19 @@ async function JuryDashboardContent() {
|
||||
country: true,
|
||||
},
|
||||
},
|
||||
stage: {
|
||||
round: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
track: {
|
||||
competition: {
|
||||
select: {
|
||||
pipeline: {
|
||||
program: {
|
||||
select: {
|
||||
program: {
|
||||
select: {
|
||||
name: true,
|
||||
year: true,
|
||||
},
|
||||
},
|
||||
name: true,
|
||||
year: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -96,7 +92,7 @@ async function JuryDashboardContent() {
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ stage: { windowCloseAt: 'asc' } },
|
||||
{ round: { windowCloseAt: 'asc' } },
|
||||
{ createdAt: 'asc' },
|
||||
],
|
||||
}),
|
||||
@@ -106,7 +102,7 @@ async function JuryDashboardContent() {
|
||||
extendedUntil: { gte: new Date() },
|
||||
},
|
||||
select: {
|
||||
stageId: true,
|
||||
roundId: true,
|
||||
extendedUntil: true,
|
||||
},
|
||||
}),
|
||||
@@ -126,49 +122,49 @@ async function JuryDashboardContent() {
|
||||
const completionRate =
|
||||
totalAssignments > 0 ? (completedAssignments / totalAssignments) * 100 : 0
|
||||
|
||||
// Group assignments by stage
|
||||
const assignmentsByStage = assignments.reduce(
|
||||
// Group assignments by round
|
||||
const assignmentsByRound = assignments.reduce(
|
||||
(acc, assignment) => {
|
||||
const stageId = assignment.stage.id
|
||||
if (!acc[stageId]) {
|
||||
acc[stageId] = {
|
||||
stage: assignment.stage,
|
||||
const roundId = assignment.round.id
|
||||
if (!acc[roundId]) {
|
||||
acc[roundId] = {
|
||||
round: assignment.round,
|
||||
assignments: [],
|
||||
}
|
||||
}
|
||||
acc[stageId].assignments.push(assignment)
|
||||
acc[roundId].assignments.push(assignment)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, { stage: (typeof assignments)[0]['stage']; assignments: typeof assignments }>
|
||||
{} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }>
|
||||
)
|
||||
|
||||
const graceByStage = new Map<string, Date>()
|
||||
const graceByRound = new Map<string, Date>()
|
||||
for (const gp of gracePeriods) {
|
||||
const existing = graceByStage.get(gp.stageId)
|
||||
const existing = graceByRound.get(gp.roundId)
|
||||
if (!existing || gp.extendedUntil > existing) {
|
||||
graceByStage.set(gp.stageId, gp.extendedUntil)
|
||||
graceByRound.set(gp.roundId, gp.extendedUntil)
|
||||
}
|
||||
}
|
||||
|
||||
// Active stages (voting window open)
|
||||
// Active rounds (voting window open)
|
||||
const now = new Date()
|
||||
const activeStages = Object.values(assignmentsByStage).filter(
|
||||
({ stage }) =>
|
||||
stage.status === 'STAGE_ACTIVE' &&
|
||||
stage.windowOpenAt &&
|
||||
stage.windowCloseAt &&
|
||||
new Date(stage.windowOpenAt) <= now &&
|
||||
new Date(stage.windowCloseAt) >= now
|
||||
const activeRounds = Object.values(assignmentsByRound).filter(
|
||||
({ round }) =>
|
||||
round.status === 'ROUND_ACTIVE' &&
|
||||
round.windowOpenAt &&
|
||||
round.windowCloseAt &&
|
||||
new Date(round.windowOpenAt) <= now &&
|
||||
new Date(round.windowCloseAt) >= now
|
||||
)
|
||||
|
||||
// Find next unevaluated assignment in an active stage
|
||||
// Find next unevaluated assignment in an active round
|
||||
const nextUnevaluated = assignments.find((a) => {
|
||||
const isActive =
|
||||
a.stage.status === 'STAGE_ACTIVE' &&
|
||||
a.stage.windowOpenAt &&
|
||||
a.stage.windowCloseAt &&
|
||||
new Date(a.stage.windowOpenAt) <= now &&
|
||||
new Date(a.stage.windowCloseAt) >= now
|
||||
a.round.status === 'ROUND_ACTIVE' &&
|
||||
a.round.windowOpenAt &&
|
||||
a.round.windowCloseAt &&
|
||||
new Date(a.round.windowOpenAt) <= now &&
|
||||
new Date(a.round.windowCloseAt) >= now
|
||||
const isIncomplete = !a.evaluation || a.evaluation.status === 'NOT_STARTED' || a.evaluation.status === 'DRAFT'
|
||||
return isActive && isIncomplete
|
||||
})
|
||||
@@ -176,14 +172,14 @@ async function JuryDashboardContent() {
|
||||
// Recent assignments for the quick list (latest 5)
|
||||
const recentAssignments = assignments.slice(0, 6)
|
||||
|
||||
// Get active stage remaining count
|
||||
// Get active round remaining count
|
||||
const activeRemaining = assignments.filter((a) => {
|
||||
const isActive =
|
||||
a.stage.status === 'STAGE_ACTIVE' &&
|
||||
a.stage.windowOpenAt &&
|
||||
a.stage.windowCloseAt &&
|
||||
new Date(a.stage.windowOpenAt) <= now &&
|
||||
new Date(a.stage.windowCloseAt) >= now
|
||||
a.round.status === 'ROUND_ACTIVE' &&
|
||||
a.round.windowOpenAt &&
|
||||
a.round.windowCloseAt &&
|
||||
new Date(a.round.windowOpenAt) <= now &&
|
||||
new Date(a.round.windowCloseAt) >= now
|
||||
const isIncomplete = !a.evaluation || a.evaluation.status !== 'SUBMITTED'
|
||||
return isActive && isIncomplete
|
||||
}).length
|
||||
@@ -241,7 +237,7 @@ async function JuryDashboardContent() {
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 max-w-md mx-auto">
|
||||
<Link
|
||||
href="/jury/stages"
|
||||
href="/jury/competitions"
|
||||
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
||||
>
|
||||
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40">
|
||||
@@ -253,7 +249,7 @@ async function JuryDashboardContent() {
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/jury/stages"
|
||||
href="/jury/competitions"
|
||||
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40">
|
||||
@@ -293,7 +289,7 @@ async function JuryDashboardContent() {
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild size="lg" className="bg-brand-blue hover:bg-brand-blue-light shadow-md">
|
||||
<Link href={`/jury/stages/${nextUnevaluated.stage.id}/projects/${nextUnevaluated.project.id}/evaluate`}>
|
||||
<Link href={`/jury/competitions/${nextUnevaluated.round.id}/projects/${nextUnevaluated.project.id}/evaluate`}>
|
||||
{nextUnevaluated.evaluation?.status === 'DRAFT' ? 'Continue Evaluation' : 'Start Evaluation'}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
@@ -363,7 +359,7 @@ async function JuryDashboardContent() {
|
||||
<CardTitle className="text-lg">My Assignments</CardTitle>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild className="text-brand-teal hover:text-brand-blue">
|
||||
<Link href="/jury/stages">
|
||||
<Link href="/jury/competitions">
|
||||
View all
|
||||
<ArrowRight className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
@@ -378,11 +374,11 @@ async function JuryDashboardContent() {
|
||||
const isCompleted = evaluation?.status === 'SUBMITTED'
|
||||
const isDraft = evaluation?.status === 'DRAFT'
|
||||
const isVotingOpen =
|
||||
assignment.stage.status === 'STAGE_ACTIVE' &&
|
||||
assignment.stage.windowOpenAt &&
|
||||
assignment.stage.windowCloseAt &&
|
||||
new Date(assignment.stage.windowOpenAt) <= now &&
|
||||
new Date(assignment.stage.windowCloseAt) >= now
|
||||
assignment.round.status === 'ROUND_ACTIVE' &&
|
||||
assignment.round.windowOpenAt &&
|
||||
assignment.round.windowCloseAt &&
|
||||
new Date(assignment.round.windowOpenAt) <= now &&
|
||||
new Date(assignment.round.windowCloseAt) >= now
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -394,7 +390,7 @@ async function JuryDashboardContent() {
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}`}
|
||||
href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`}
|
||||
className="flex-1 min-w-0 group"
|
||||
>
|
||||
<p className="text-sm font-medium truncate group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
|
||||
@@ -405,7 +401,7 @@ async function JuryDashboardContent() {
|
||||
{assignment.project.teamName}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 dark:bg-brand-teal/10 dark:text-brand-teal/80 border-0">
|
||||
{assignment.stage.name}
|
||||
{assignment.round.name}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -425,19 +421,19 @@ async function JuryDashboardContent() {
|
||||
)}
|
||||
{isCompleted ? (
|
||||
<Button variant="ghost" size="sm" asChild className="h-7 px-2">
|
||||
<Link href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}/evaluation`}>
|
||||
<Link href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}/evaluate`}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
) : isVotingOpen ? (
|
||||
<Button size="sm" asChild className="h-7 px-3 bg-brand-blue hover:bg-brand-blue-light shadow-sm">
|
||||
<Link href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}/evaluate`}>
|
||||
<Link href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}/evaluate`}>
|
||||
{isDraft ? 'Continue' : 'Evaluate'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" asChild className="h-7 px-2">
|
||||
<Link href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}`}>
|
||||
<Link href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -478,7 +474,7 @@ async function JuryDashboardContent() {
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Link
|
||||
href="/jury/stages"
|
||||
href="/jury/competitions"
|
||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
||||
>
|
||||
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40 dark:group-hover:bg-blue-950/60">
|
||||
@@ -490,7 +486,7 @@ async function JuryDashboardContent() {
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/jury/stages"
|
||||
href="/jury/competitions"
|
||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60">
|
||||
@@ -509,8 +505,8 @@ async function JuryDashboardContent() {
|
||||
|
||||
{/* Right column */}
|
||||
<div className="lg:col-span-5 space-y-4">
|
||||
{/* Active Stages */}
|
||||
{activeStages.length > 0 && (
|
||||
{/* Active Rounds */}
|
||||
{activeRounds.length > 0 && (
|
||||
<AnimatedCard index={8}>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
@@ -528,21 +524,21 @@ async function JuryDashboardContent() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{activeStages.map(({ stage, assignments: stageAssignments }) => {
|
||||
const stageCompleted = stageAssignments.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
{activeRounds.map(({ round, assignments: roundAssignments }: { round: (typeof assignments)[0]['round']; assignments: typeof assignments }) => {
|
||||
const roundCompleted = roundAssignments.filter(
|
||||
(a: typeof assignments[0]) => a.evaluation?.status === 'SUBMITTED'
|
||||
).length
|
||||
const stageTotal = stageAssignments.length
|
||||
const stageProgress =
|
||||
stageTotal > 0 ? (stageCompleted / stageTotal) * 100 : 0
|
||||
const isAlmostDone = stageProgress >= 80
|
||||
const deadline = graceByStage.get(stage.id) ?? (stage.windowCloseAt ? new Date(stage.windowCloseAt) : null)
|
||||
const roundTotal = roundAssignments.length
|
||||
const roundProgress =
|
||||
roundTotal > 0 ? (roundCompleted / roundTotal) * 100 : 0
|
||||
const isAlmostDone = roundProgress >= 80
|
||||
const deadline = graceByRound.get(round.id) ?? (round.windowCloseAt ? new Date(round.windowCloseAt) : null)
|
||||
const isUrgent = deadline && (deadline.getTime() - now.getTime()) < 24 * 60 * 60 * 1000
|
||||
const program = stage.track.pipeline.program
|
||||
const program = round.competition.program
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stage.id}
|
||||
key={round.id}
|
||||
className={cn(
|
||||
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isUrgent
|
||||
@@ -552,7 +548,7 @@ async function JuryDashboardContent() {
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{stage.name}</h3>
|
||||
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{round.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{program.name} · {program.year}
|
||||
</p>
|
||||
@@ -568,13 +564,13 @@ async function JuryDashboardContent() {
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{stageCompleted}/{stageTotal}
|
||||
{roundCompleted}/{roundTotal}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-2.5 w-full overflow-hidden rounded-full bg-muted/60">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
|
||||
style={{ width: `${stageProgress}%` }}
|
||||
style={{ width: `${roundProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -585,16 +581,16 @@ async function JuryDashboardContent() {
|
||||
deadline={deadline}
|
||||
label="Deadline:"
|
||||
/>
|
||||
{stage.windowCloseAt && (
|
||||
{round.windowCloseAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({formatDateOnly(stage.windowCloseAt)})
|
||||
({formatDateOnly(round.windowCloseAt)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button asChild size="sm" className="w-full bg-brand-blue hover:bg-brand-blue-light shadow-sm">
|
||||
<Link href={`/jury/stages/${stage.id}/assignments`}>
|
||||
<Link href={`/jury/competitions/${round.id}`}>
|
||||
View Assignments
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
@@ -608,7 +604,7 @@ async function JuryDashboardContent() {
|
||||
)}
|
||||
|
||||
{/* No active stages */}
|
||||
{activeStages.length === 0 && (
|
||||
{activeRounds.length === 0 && (
|
||||
<AnimatedCard index={8}>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-6 text-center">
|
||||
@@ -624,8 +620,8 @@ async function JuryDashboardContent() {
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Completion Summary by Stage */}
|
||||
{Object.keys(assignmentsByStage).length > 0 && (
|
||||
{/* Completion Summary by Round */}
|
||||
{Object.keys(assignmentsByRound).length > 0 && (
|
||||
<AnimatedCard index={9}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
@@ -637,14 +633,14 @@ async function JuryDashboardContent() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{Object.values(assignmentsByStage).map(({ stage, assignments: stageAssignments }) => {
|
||||
const done = stageAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length
|
||||
const total = stageAssignments.length
|
||||
{Object.values(assignmentsByRound).map(({ round, assignments: roundAssignments }: { round: (typeof assignments)[0]['round']; assignments: typeof assignments }) => {
|
||||
const done = roundAssignments.filter((a: typeof assignments[0]) => a.evaluation?.status === 'SUBMITTED').length
|
||||
const total = roundAssignments.length
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0
|
||||
return (
|
||||
<div key={stage.id} className="space-y-2">
|
||||
<div key={round.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium truncate">{stage.name}</span>
|
||||
<span className="font-medium truncate">{round.name}</span>
|
||||
<div className="flex items-baseline gap-1 shrink-0 ml-2">
|
||||
<span className="font-bold tabular-nums text-brand-blue dark:text-brand-teal">{pct}%</span>
|
||||
<span className="text-xs text-muted-foreground">({done}/{total})</span>
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ArrowLeft,
|
||||
FileEdit,
|
||||
Eye,
|
||||
ShieldAlert,
|
||||
AlertCircle,
|
||||
ClipboardList,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Type for assignment with included relations from stageAssignment.myAssignments
|
||||
type AssignmentWithRelations = {
|
||||
id: string
|
||||
projectId: string
|
||||
stageId: string
|
||||
isCompleted: boolean
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
country: string | null
|
||||
tags: string[]
|
||||
description: string | null
|
||||
}
|
||||
evaluation?: {
|
||||
id: string
|
||||
status: string
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
submittedAt: Date | null
|
||||
} | null
|
||||
conflictOfInterest?: {
|
||||
id: string
|
||||
hasConflict: boolean
|
||||
conflictType: string | null
|
||||
reviewAction: string | null
|
||||
} | null
|
||||
stage?: {
|
||||
id: string
|
||||
name: string
|
||||
track: {
|
||||
name: string
|
||||
pipeline: { id: string; name: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAssignmentStatus(assignment: {
|
||||
evaluation?: { status: string } | null
|
||||
conflictOfInterest?: { id: string } | null
|
||||
}) {
|
||||
if (assignment.conflictOfInterest) return 'COI'
|
||||
if (!assignment.evaluation) return 'NOT_STARTED'
|
||||
return assignment.evaluation.status
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case 'SUBMITTED':
|
||||
return (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Submitted
|
||||
</Badge>
|
||||
)
|
||||
case 'DRAFT':
|
||||
return (
|
||||
<Badge variant="warning" className="text-xs">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
In Progress
|
||||
</Badge>
|
||||
)
|
||||
case 'COI':
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<ShieldAlert className="mr-1 h-3 w-3" />
|
||||
COI Declared
|
||||
</Badge>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Not Started
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default function StageAssignmentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
|
||||
const { data: stageInfo, isLoading: stageLoading } =
|
||||
trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
const { data: rawAssignments, isLoading: assignmentsLoading } =
|
||||
trpc.stageAssignment.myAssignments.useQuery({ stageId })
|
||||
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
|
||||
|
||||
const { data: windowStatus } =
|
||||
trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
const isLoading = stageLoading || assignmentsLoading
|
||||
|
||||
const totalAssignments = assignments?.length ?? 0
|
||||
const completedCount = assignments?.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
).length ?? 0
|
||||
const coiCount = assignments?.filter((a) => a.conflictOfInterest).length ?? 0
|
||||
const pendingCount = totalAssignments - completedCount - coiCount
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-20" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={"/jury/stages" as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{stageInfo?.name ?? 'Stage Assignments'}
|
||||
</h1>
|
||||
{stageInfo && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{stageInfo.track.name} · {stageInfo.track.pipeline.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold">{totalAssignments}</p>
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-emerald-600">{completedCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Completed</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-amber-600">{pendingCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Pending</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-red-600">{coiCount}</p>
|
||||
<p className="text-xs text-muted-foreground">COI Declared</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Assignments table */}
|
||||
{assignments && assignments.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignments.map((assignment) => {
|
||||
const status = getAssignmentStatus(assignment)
|
||||
return (
|
||||
<TableRow key={assignment.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/jury/stages/${stageId}/projects/${assignment.project.id}` as Route}
|
||||
className="hover:text-brand-blue dark:hover:text-brand-teal transition-colors"
|
||||
>
|
||||
{assignment.project.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{assignment.project.teamName}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{assignment.project.country ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{status === 'SUBMITTED' ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluation` as Route}>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
) : status === 'COI' ? (
|
||||
<Button variant="ghost" size="sm" disabled>
|
||||
<ShieldAlert className="mr-1 h-3 w-3" />
|
||||
COI
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
asChild
|
||||
disabled={!isWindowOpen}
|
||||
className={cn(
|
||||
'bg-brand-blue hover:bg-brand-blue-light',
|
||||
!isWindowOpen && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluate` as Route}>
|
||||
<FileEdit className="mr-1 h-3 w-3" />
|
||||
{status === 'DRAFT' ? 'Continue' : 'Evaluate'}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list */}
|
||||
<div className="md:hidden divide-y">
|
||||
{assignments.map((assignment) => {
|
||||
const status = getAssignmentStatus(assignment)
|
||||
return (
|
||||
<div key={assignment.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`/jury/stages/${stageId}/projects/${assignment.project.id}` as Route}
|
||||
className="font-medium text-sm hover:text-brand-blue transition-colors"
|
||||
>
|
||||
{assignment.project.title}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{assignment.project.teamName}
|
||||
{assignment.project.country && ` · ${assignment.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
{status === 'SUBMITTED' ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluation` as Route}>
|
||||
View Evaluation
|
||||
</Link>
|
||||
</Button>
|
||||
) : status !== 'COI' && isWindowOpen ? (
|
||||
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluate` as Route}>
|
||||
{status === 'DRAFT' ? 'Continue' : 'Evaluate'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ClipboardList className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No assignments in this stage</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Assignments will appear here once an administrator assigns projects to you.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Window closed notice */}
|
||||
{!isWindowOpen && totalAssignments > 0 && completedCount < totalAssignments && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
{windowStatus?.reason ?? 'The evaluation window for this stage is currently closed.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
ArrowLeft,
|
||||
GitCompare,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Type for assignment with included relations from stageAssignment.myAssignments
|
||||
type AssignmentWithRelations = {
|
||||
id: string
|
||||
projectId: string
|
||||
stageId: string
|
||||
isCompleted: boolean
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
country: string | null
|
||||
tags: string[]
|
||||
description: string | null
|
||||
}
|
||||
evaluation?: {
|
||||
id: string
|
||||
status: string
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
submittedAt: Date | null
|
||||
} | null
|
||||
conflictOfInterest?: {
|
||||
id: string
|
||||
hasConflict: boolean
|
||||
conflictType: string | null
|
||||
reviewAction: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export default function StageComparePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: rawAssignments, isLoading: assignmentsLoading } =
|
||||
trpc.stageAssignment.myAssignments.useQuery({ stageId })
|
||||
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: evaluations } =
|
||||
trpc.evaluation.listStageEvaluations.useQuery({ stageId })
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
|
||||
const criteria = stageForm?.criteriaJson?.filter(
|
||||
(c: { type?: string }) => c.type !== 'section_header'
|
||||
) ?? []
|
||||
|
||||
// Map evaluations by project ID
|
||||
const evalByProject = useMemo(() => {
|
||||
const map = new Map<string, (typeof evaluations extends (infer T)[] | undefined ? T : never)>()
|
||||
evaluations?.forEach((e) => {
|
||||
if (e.assignment?.projectId) {
|
||||
map.set(e.assignment.projectId, e)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, [evaluations])
|
||||
|
||||
const toggleProject = (projectId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId)
|
||||
} else if (next.size < 4) {
|
||||
next.add(projectId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectedAssignments = assignments?.filter((a) =>
|
||||
selectedIds.has(a.project.id)
|
||||
) ?? []
|
||||
|
||||
if (assignmentsLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const submittedAssignments = assignments?.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
) ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<GitCompare className="h-6 w-6" />
|
||||
Compare Projects
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Select 2-4 evaluated projects to compare side-by-side
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{submittedAssignments.length < 2 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Not enough evaluations</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You need at least 2 submitted evaluations to compare projects.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Project selector */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Select Projects ({selectedIds.size}/4)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{submittedAssignments.map((assignment) => {
|
||||
const isSelected = selectedIds.has(assignment.project.id)
|
||||
const eval_ = evalByProject.get(assignment.project.id)
|
||||
return (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
|
||||
isSelected
|
||||
? 'border-brand-blue bg-brand-blue/5 dark:border-brand-teal dark:bg-brand-teal/5'
|
||||
: 'hover:bg-muted/50',
|
||||
selectedIds.size >= 4 && !isSelected && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onClick={() => toggleProject(assignment.project.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={selectedIds.size >= 4 && !isSelected}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{assignment.project.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignment.project.teamName}
|
||||
</p>
|
||||
</div>
|
||||
{eval_?.globalScore != null && (
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
<Star className="mr-1 h-3 w-3 text-amber-500" />
|
||||
{eval_.globalScore.toFixed(1)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comparison table */}
|
||||
{selectedAssignments.length >= 2 && (
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[140px]">Criterion</TableHead>
|
||||
{selectedAssignments.map((a) => (
|
||||
<TableHead key={a.id} className="text-center min-w-[120px]">
|
||||
<div className="truncate max-w-[120px]" title={a.project.title}>
|
||||
{a.project.title}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* Criterion rows */}
|
||||
{criteria.map((criterion: { id: string; label: string; type?: string; scale?: string | number }) => (
|
||||
<TableRow key={criterion.id}>
|
||||
<TableCell className="font-medium text-sm">
|
||||
{criterion.label}
|
||||
</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
const scores = eval_?.criterionScoresJson as Record<string, number | string | boolean> | null
|
||||
const score = scores?.[criterion.id]
|
||||
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
{criterion.type === 'boolean' ? (
|
||||
score ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600 mx-auto" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
) : criterion.type === 'text' ? (
|
||||
<span className="text-xs truncate max-w-[100px] block">
|
||||
{String(score ?? '—')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="tabular-nums font-semibold">
|
||||
{typeof score === 'number' ? score.toFixed(1) : '—'}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{/* Global score row */}
|
||||
<TableRow className="bg-muted/50 font-semibold">
|
||||
<TableCell>Global Score</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
<span className="tabular-nums text-lg">
|
||||
{eval_?.globalScore?.toFixed(1) ?? '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
|
||||
{/* Decision row */}
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Decision</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
{eval_?.binaryDecision != null ? (
|
||||
<Badge variant={eval_.binaryDecision ? 'success' : 'destructive'}>
|
||||
{eval_.binaryDecision ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Pause,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function StageJuryLivePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [hasVoted, setHasVoted] = useState(false)
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
// Get live cursor for this stage
|
||||
const { data: cursorData } = trpc.live.getCursor.useQuery(
|
||||
{ stageId },
|
||||
{ enabled: !!stageId }
|
||||
)
|
||||
|
||||
const sessionId = cursorData?.sessionId ?? null
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
activeProject,
|
||||
isPaused,
|
||||
error: sseError,
|
||||
reconnect,
|
||||
} = useStageliveSse(sessionId)
|
||||
|
||||
// Reset vote state when active project changes
|
||||
const activeProjectId = activeProject?.id
|
||||
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
|
||||
|
||||
if (activeProjectId && activeProjectId !== lastVotedProjectId && hasVoted) {
|
||||
setHasVoted(false)
|
||||
setSelectedScore(null)
|
||||
}
|
||||
|
||||
const castVoteMutation = trpc.live.castStageVote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Vote submitted!')
|
||||
setHasVoted(true)
|
||||
setSelectedScore(null)
|
||||
setLastVotedProjectId(activeProjectId ?? null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleVote = () => {
|
||||
if (!sessionId || !activeProject || selectedScore === null) return
|
||||
castVoteMutation.mutate({
|
||||
sessionId,
|
||||
projectId: activeProject.id,
|
||||
score: selectedScore,
|
||||
})
|
||||
}
|
||||
|
||||
if (!cursorData && !stageInfo) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No live session active</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A live presentation session has not been started for this stage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header with connection status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Live Voting</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Connected
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
)}
|
||||
{!isConnected && (
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sseError && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{sseError}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Paused overlay */}
|
||||
{isPaused ? (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Pause className="h-12 w-12 text-amber-600 mb-3" />
|
||||
<p className="text-lg font-semibold">Session Paused</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The session administrator has paused voting. Please wait...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : activeProject ? (
|
||||
<>
|
||||
{/* Active project card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
|
||||
{activeProject.teamName && (
|
||||
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent>
|
||||
<p className="text-sm">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Voting controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500" />
|
||||
Cast Your Vote
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasVoted ? (
|
||||
<div className="flex flex-col items-center py-6 text-center">
|
||||
<CheckCircle2 className="h-12 w-12 text-emerald-600 mb-3" />
|
||||
<p className="font-semibold">Vote submitted!</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Waiting for the next project...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-5 gap-2 sm:grid-cols-10">
|
||||
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-12 text-lg font-bold tabular-nums',
|
||||
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light'
|
||||
)}
|
||||
onClick={() => setSelectedScore(score)}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-brand-blue hover:bg-brand-blue-light"
|
||||
size="lg"
|
||||
disabled={selectedScore === null || castVoteMutation.isPending}
|
||||
onClick={handleVote}
|
||||
>
|
||||
{castVoteMutation.isPending ? 'Submitting...' : 'Submit Vote'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Star className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Waiting for next project...</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The session administrator will advance to the next project.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, AlertCircle } from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
|
||||
|
||||
export default function StageEvaluatePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
// Fetch assignment details
|
||||
const { data: assignment, isLoading: assignmentLoading } =
|
||||
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
|
||||
|
||||
// Fetch stage info for breadcrumb
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
// Fetch or create evaluation draft
|
||||
const startEval = trpc.evaluation.startStage.useMutation()
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
// State for the evaluation returned by the mutation
|
||||
const [evaluation, setEvaluation] = useState<{
|
||||
id: string
|
||||
status: string
|
||||
criterionScoresJson?: unknown
|
||||
globalScore?: number | null
|
||||
binaryDecision?: boolean | null
|
||||
feedbackText?: string | null
|
||||
} | null>(null)
|
||||
|
||||
// Start evaluation on first load if we have the assignment
|
||||
useEffect(() => {
|
||||
if (assignment && !evaluation && !startEval.isPending && (windowStatus?.isOpen ?? false)) {
|
||||
startEval.mutate(
|
||||
{ assignmentId: assignment.id, stageId },
|
||||
{ onSuccess: (data) => setEvaluation(data) }
|
||||
)
|
||||
}
|
||||
}, [assignment?.id, windowStatus?.isOpen])
|
||||
|
||||
const isLoading = assignmentLoading || startEval.isPending
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</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 mb-3" />
|
||||
<p className="font-medium text-destructive">Assignment not found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You don't have an assignment for this project in this stage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
const criteria = stageForm?.criteriaJson ?? []
|
||||
|
||||
// Get COI status from assignment
|
||||
const coiStatus = {
|
||||
hasConflict: !!assignment.conflictOfInterest,
|
||||
declared: !!assignment.conflictOfInterest,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project title + stage window */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{assignment.project.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{assignment.project.teamName}
|
||||
{assignment.project.country && ` · ${assignment.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grace period notice */}
|
||||
{windowStatus?.hasGracePeriod && windowStatus?.graceExpiresAt && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
You are in a grace period. Please submit your evaluation before{' '}
|
||||
{new Date(windowStatus.graceExpiresAt).toLocaleString()}.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Window closed notice */}
|
||||
{!isWindowOpen && !windowStatus?.hasGracePeriod && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">
|
||||
{windowStatus?.reason ?? 'The evaluation window for this stage is closed.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Project files */}
|
||||
<CollapsibleFilesSection
|
||||
projectId={projectId}
|
||||
fileCount={assignment.project.files?.length ?? 0}
|
||||
stageId={stageId}
|
||||
/>
|
||||
|
||||
{/* Evaluation form */}
|
||||
{isWindowOpen || windowStatus?.hasGracePeriod ? (
|
||||
<EvaluationFormWithCOI
|
||||
assignmentId={assignment.id}
|
||||
evaluationId={evaluation?.id ?? null}
|
||||
projectTitle={assignment.project.title}
|
||||
criteria={criteria as Array<{ id: string; label: string; description?: string; type?: 'numeric' | 'text' | 'boolean' | 'section_header'; scale?: number; weight?: number; required?: boolean }>}
|
||||
initialData={
|
||||
evaluation
|
||||
? {
|
||||
criterionScoresJson:
|
||||
evaluation.criterionScoresJson as Record<string, number | string | boolean> | null,
|
||||
globalScore: evaluation.globalScore ?? null,
|
||||
binaryDecision: evaluation.binaryDecision ?? null,
|
||||
feedbackText: evaluation.feedbackText ?? null,
|
||||
status: evaluation.status,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
isVotingOpen={isWindowOpen || !!windowStatus?.hasGracePeriod}
|
||||
deadline={
|
||||
windowStatus?.graceExpiresAt
|
||||
? new Date(windowStatus.graceExpiresAt)
|
||||
: stageInfo?.windowCloseAt
|
||||
? new Date(stageInfo.windowCloseAt)
|
||||
: null
|
||||
}
|
||||
coiStatus={coiStatus}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Star,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
|
||||
export default function ViewStageEvaluationPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
const { data: evaluations, isLoading } =
|
||||
trpc.evaluation.listStageEvaluations.useQuery({ stageId, projectId })
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
|
||||
const evaluation = evaluations?.[0] // Most recent evaluation
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!evaluation) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No evaluation found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You haven't submitted an evaluation for this project yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const criterionScores = evaluation.criterionScoresJson as Record<string, number | string | boolean> | null
|
||||
const criteria = (stageForm?.criteriaJson as Array<{ id: string; label: string; type?: string; scale?: number }>) ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{evaluation.assignment?.project?.title ?? 'Evaluation'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Submitted evaluation — read only
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="success" className="self-start">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Submitted
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Submission info */}
|
||||
{evaluation.submittedAt && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-2 py-3">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Submitted on{' '}
|
||||
{new Date(evaluation.submittedAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Criterion scores */}
|
||||
{criteria.length > 0 && criterionScores && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Criterion Scores</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{criteria.map((criterion) => {
|
||||
const score = criterionScores[criterion.id]
|
||||
if (criterion.type === 'section_header') {
|
||||
return (
|
||||
<div key={criterion.id} className="pt-2">
|
||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
|
||||
{criterion.label}
|
||||
</h3>
|
||||
<Separator className="mt-2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={criterion.id} className="flex items-center justify-between py-2">
|
||||
<span className="text-sm font-medium">{criterion.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{criterion.type === 'boolean' ? (
|
||||
<Badge variant={score ? 'success' : 'secondary'}>
|
||||
{score ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
) : criterion.type === 'text' ? (
|
||||
<span className="text-sm text-muted-foreground max-w-[200px] truncate">
|
||||
{String(score ?? '—')}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 text-amber-500" />
|
||||
<span className="font-semibold tabular-nums">
|
||||
{typeof score === 'number' ? score.toFixed(1) : '—'}
|
||||
</span>
|
||||
{criterion.scale && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {criterion.scale}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Global score + Decision */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500" />
|
||||
Global Score
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-4xl font-bold tabular-nums">
|
||||
{evaluation.globalScore?.toFixed(1) ?? '—'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{evaluation.binaryDecision !== null && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Decision</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge
|
||||
variant={evaluation.binaryDecision ? 'success' : 'destructive'}
|
||||
className="text-lg px-4 py-2"
|
||||
>
|
||||
{evaluation.binaryDecision ? 'Recommend' : 'Do Not Recommend'}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{evaluation.feedbackText && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Feedback
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p className="whitespace-pre-wrap">{evaluation.feedbackText}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileEdit,
|
||||
Eye,
|
||||
Users,
|
||||
MapPin,
|
||||
Tag,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
|
||||
function EvalStatusCard({
|
||||
status,
|
||||
stageId,
|
||||
projectId,
|
||||
isWindowOpen,
|
||||
}: {
|
||||
status: string
|
||||
stageId: string
|
||||
projectId: string
|
||||
isWindowOpen: boolean
|
||||
}) {
|
||||
const isSubmitted = status === 'SUBMITTED'
|
||||
const isDraft = status === 'DRAFT'
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Evaluation Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={
|
||||
isSubmitted ? 'success' : isDraft ? 'warning' : 'secondary'
|
||||
}
|
||||
>
|
||||
{isSubmitted ? 'Submitted' : isDraft ? 'In Progress' : 'Not Started'}
|
||||
</Badge>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{isSubmitted ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluation` as Route}>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
View Evaluation
|
||||
</Link>
|
||||
</Button>
|
||||
) : isWindowOpen ? (
|
||||
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluate` as Route}>
|
||||
<FileEdit className="mr-1 h-3 w-3" />
|
||||
{isDraft ? 'Continue Evaluation' : 'Start Evaluation'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Window Closed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StageProjectDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
const { data: assignment, isLoading: assignmentLoading } =
|
||||
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
|
||||
if (assignmentLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</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 mb-3" />
|
||||
<p className="font-medium text-destructive">Assignment not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const project = assignment.project
|
||||
const evalStatus = assignment.evaluation?.status ?? 'NOT_STARTED'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{project.title}</h1>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
|
||||
{project.teamName && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{project.teamName}
|
||||
</span>
|
||||
)}
|
||||
{project.country && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{project.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project description */}
|
||||
{project.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Description</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
{project.tags.map((tag: string) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Evaluation status */}
|
||||
<EvalStatusCard
|
||||
status={evalStatus}
|
||||
stageId={stageId}
|
||||
projectId={projectId}
|
||||
isWindowOpen={isWindowOpen}
|
||||
/>
|
||||
|
||||
{/* Project files */}
|
||||
<CollapsibleFilesSection
|
||||
projectId={projectId}
|
||||
fileCount={project.files?.length ?? 0}
|
||||
stageId={stageId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Target,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function JuryStagesDashboard() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id ?? ''
|
||||
|
||||
const { data: stages, isLoading: stagesLoading } =
|
||||
trpc.stageAssignment.myStages.useQuery(
|
||||
{ programId },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
const totalAssignments = stages?.reduce((sum, s) => sum + s.stats.total, 0) ?? 0
|
||||
const totalCompleted = stages?.reduce((sum, s) => sum + s.stats.completed, 0) ?? 0
|
||||
const totalInProgress = stages?.reduce((sum, s) => sum + s.stats.inProgress, 0) ?? 0
|
||||
const totalPending = totalAssignments - totalCompleted - totalInProgress
|
||||
const completionRate = totalAssignments > 0
|
||||
? Math.round((totalCompleted / totalAssignments) * 100)
|
||||
: 0
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Total Assignments',
|
||||
value: totalAssignments,
|
||||
icon: ClipboardList,
|
||||
accentColor: 'border-l-blue-500',
|
||||
iconBg: 'bg-blue-50 dark:bg-blue-950/40',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
},
|
||||
{
|
||||
label: 'Completed',
|
||||
value: totalCompleted,
|
||||
icon: CheckCircle2,
|
||||
accentColor: 'border-l-emerald-500',
|
||||
iconBg: 'bg-emerald-50 dark:bg-emerald-950/40',
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
},
|
||||
{
|
||||
label: 'In Progress',
|
||||
value: totalInProgress,
|
||||
icon: Clock,
|
||||
accentColor: 'border-l-amber-500',
|
||||
iconBg: 'bg-amber-50 dark:bg-amber-950/40',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
},
|
||||
{
|
||||
label: 'Pending',
|
||||
value: totalPending,
|
||||
icon: Target,
|
||||
accentColor: 'border-l-slate-400',
|
||||
iconBg: 'bg-slate-50 dark:bg-slate-800/50',
|
||||
iconColor: 'text-slate-500 dark:text-slate-400',
|
||||
},
|
||||
]
|
||||
|
||||
if (stagesLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Card key={i} className="border-l-4 border-l-muted">
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<Skeleton className="h-11 w-11 rounded-xl" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-12" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="py-5">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stages || stages.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3">
|
||||
<Layers className="h-8 w-8 text-brand-teal/60" />
|
||||
</div>
|
||||
<p className="text-lg font-semibold">No stage assignments yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
|
||||
Your stage-based assignments will appear here once an administrator assigns projects to you.
|
||||
</p>
|
||||
<Button variant="outline" asChild className="mt-4">
|
||||
<Link href="/jury">
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{stats.map((stat) => (
|
||||
<Card
|
||||
key={stat.label}
|
||||
className={cn('border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', stat.accentColor)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<div className={cn('rounded-xl p-3', stat.iconBg)}>
|
||||
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p>
|
||||
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Card className="border-l-4 border-l-brand-teal">
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<div className="rounded-xl p-3 bg-brand-blue/10 dark:bg-brand-blue/20">
|
||||
<BarChart3 className="h-5 w-5 text-brand-blue dark:text-brand-teal" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight text-brand-blue dark:text-brand-teal">
|
||||
{completionRate}%
|
||||
</p>
|
||||
<Progress value={completionRate} className="h-1.5 mt-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Stage cards */}
|
||||
<div className="space-y-3">
|
||||
{stages.map((stage) => {
|
||||
const stageProgress = stage.stats.total > 0
|
||||
? Math.round((stage.stats.completed / stage.stats.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Card key={stage.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="py-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-lg truncate">{stage.name}</h3>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stage.windowOpenAt}
|
||||
windowCloseAt={stage.windowCloseAt}
|
||||
status={stage.status}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{stage.track.name} · {stage.track.pipeline.name}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{stage.stats.completed}/{stage.stats.total}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={stageProgress} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:flex-col sm:items-end">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{stage.stats.completed > 0 && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{stage.stats.completed} done
|
||||
</Badge>
|
||||
)}
|
||||
{stage.stats.inProgress > 0 && (
|
||||
<Badge variant="warning" className="text-xs">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
{stage.stats.inProgress} in progress
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stage.id}/assignments` as Route}>
|
||||
View Assignments
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
src/app/(mentor)/mentor/workspace/[projectId]/page.tsx
Normal file
141
src/app/(mentor)/mentor/workspace/[projectId]/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
|
||||
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
|
||||
import { ArrowLeft, MessageSquare, FileText, Upload } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function MentorWorkspaceDetailPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
|
||||
// Get mentor assignment for this project
|
||||
const { data: assignments } = trpc.mentor.getMyProjects.useQuery()
|
||||
const assignment = assignments?.find(a => a.projectId === projectId)
|
||||
|
||||
const { data: project, isLoading } = trpc.project.get.useQuery(
|
||||
{ id: projectId },
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/mentor/workspace' as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Project not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/mentor/workspace' as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{project.title}</h1>
|
||||
{project.status && (
|
||||
<Badge variant="secondary">{project.status}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{project.teamName && (
|
||||
<p className="text-muted-foreground mt-1">{project.teamName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="chat" className="w-full">
|
||||
<TabsList className="w-full sm:w-auto grid grid-cols-3 sm:inline-grid">
|
||||
<TabsTrigger value="chat" className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Chat</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="files" className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Files</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="promotion" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Promotion</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="chat" className="mt-6">
|
||||
{assignment ? (
|
||||
<WorkspaceChat mentorAssignmentId={assignment.id} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<MessageSquare className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Loading workspace...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workspace Files</CardTitle>
|
||||
<CardDescription>
|
||||
Files shared in the mentor workspace
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center py-8">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
File listing feature coming soon
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="promotion" className="mt-6">
|
||||
{assignment ? (
|
||||
<FilePromotionPanel mentorAssignmentId={assignment.id} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<Upload className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Loading workspace...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
128
src/app/(mentor)/mentor/workspace/page.tsx
Normal file
128
src/app/(mentor)/mentor/workspace/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, ArrowRight, Users, Briefcase } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
SUBMITTED: 'secondary',
|
||||
ELIGIBLE: 'default',
|
||||
SEMIFINALIST: 'default',
|
||||
FINALIST: 'default',
|
||||
WINNER: 'default',
|
||||
REJECTED: 'destructive',
|
||||
}
|
||||
|
||||
export default function MentorWorkspacePage() {
|
||||
const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-40" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const projects = assignments || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Mentor Workspace</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Collaborate with your assigned mentee projects
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/mentor' as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-4">
|
||||
<Briefcase className="h-8 w-8 text-brand-teal/60" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">No Projects Assigned</h2>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
You don't have any mentee projects assigned yet. Check back later.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((assignment) => {
|
||||
const project = assignment.project
|
||||
const teamSize = project.teamMembers?.length || 0
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={assignment.id}
|
||||
className="flex flex-col transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-lg truncate" title={project.title}>
|
||||
{project.title}
|
||||
</CardTitle>
|
||||
{project.teamName && (
|
||||
<p className="text-sm text-muted-foreground mt-1 truncate">
|
||||
{project.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{project.status && (
|
||||
<Badge variant={statusColors[project.status] || 'secondary'} className="shrink-0 ml-2">
|
||||
{project.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col space-y-4">
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center justify-between text-sm pt-3 border-t">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{teamSize} member{teamSize !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/mentor/workspace/${project.id}` as Route}>
|
||||
Open
|
||||
<ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -54,11 +54,11 @@ import {
|
||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
// Parse selection value: "all:programId" for edition-wide, or stageId
|
||||
function parseSelection(value: string | null): { stageId?: string; programId?: string } {
|
||||
// Parse selection value: "all:programId" for edition-wide, or roundId
|
||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||
if (!value) return {}
|
||||
if (value.startsWith('all:')) return { programId: value.slice(4) }
|
||||
return { stageId: value }
|
||||
return { roundId: value }
|
||||
}
|
||||
|
||||
function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
@@ -72,7 +72,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
) || []
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: overviewStats, isLoading: statsLoading } =
|
||||
trpc.analytics.getOverviewStats.useQuery(
|
||||
@@ -101,7 +101,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
}
|
||||
|
||||
const totalProjects = stages.reduce((acc, s) => acc + (s._count?.projects || 0), 0)
|
||||
const activeStages = stages.filter((s) => s.status === 'STAGE_ACTIVE').length
|
||||
const activeStages = stages.filter((s) => s.status === 'ROUND_ACTIVE').length
|
||||
const totalPrograms = programs?.length || 0
|
||||
|
||||
return (
|
||||
@@ -341,7 +341,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
|
||||
function AnalyticsTab({ selectedValue }: { selectedValue: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: scoreDistribution, isLoading: scoreLoading } =
|
||||
trpc.analytics.getScoreDistribution.useQuery(
|
||||
@@ -462,19 +462,19 @@ function CrossStageTab() {
|
||||
((p.stages || []) as Array<{ id: string; name: string }>).map(s => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
const [selectedStageIds, setSelectedStageIds] = useState<string[]>([])
|
||||
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
|
||||
|
||||
const { data: comparison, isLoading: comparisonLoading } =
|
||||
trpc.analytics.getCrossStageComparison.useQuery(
|
||||
{ stageIds: selectedStageIds },
|
||||
{ enabled: selectedStageIds.length >= 2 }
|
||||
trpc.analytics.getCrossRoundComparison.useQuery(
|
||||
{ roundIds: selectedRoundIds },
|
||||
{ enabled: selectedRoundIds.length >= 2 }
|
||||
)
|
||||
|
||||
const toggleStage = (stageId: string) => {
|
||||
setSelectedStageIds((prev) =>
|
||||
prev.includes(stageId)
|
||||
? prev.filter((id) => id !== stageId)
|
||||
: [...prev, stageId]
|
||||
const toggleRound = (roundId: string) => {
|
||||
setSelectedRoundIds((prev) =>
|
||||
prev.includes(roundId)
|
||||
? prev.filter((id) => id !== roundId)
|
||||
: [...prev, roundId]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -492,15 +492,15 @@ function CrossStageTab() {
|
||||
{stages.map((stage) => (
|
||||
<Badge
|
||||
key={stage.id}
|
||||
variant={selectedStageIds.includes(stage.id) ? 'default' : 'outline'}
|
||||
variant={selectedRoundIds.includes(stage.id) ? 'default' : 'outline'}
|
||||
className="cursor-pointer text-sm py-1.5 px-3"
|
||||
onClick={() => toggleStage(stage.id)}
|
||||
onClick={() => toggleRound(stage.id)}
|
||||
>
|
||||
{stage.programName} - {stage.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{selectedStageIds.length < 2 && (
|
||||
{selectedRoundIds.length < 2 && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
Select at least 2 stages to enable comparison
|
||||
</p>
|
||||
@@ -508,11 +508,11 @@ function CrossStageTab() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{comparisonLoading && selectedStageIds.length >= 2 && <Skeleton className="h-[350px]" />}
|
||||
{comparisonLoading && selectedRoundIds.length >= 2 && <Skeleton className="h-[350px]" />}
|
||||
|
||||
{comparison && (
|
||||
<CrossStageComparisonChart data={comparison as Array<{
|
||||
stageId: string; stageName: string; projectCount: number; evaluationCount: number
|
||||
roundId: string; roundName: string; projectCount: number; evaluationCount: number
|
||||
completionRate: number; averageScore: number | null
|
||||
scoreDistribution: { score: number; count: number }[]
|
||||
}>} />
|
||||
@@ -523,7 +523,7 @@ function CrossStageTab() {
|
||||
|
||||
function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: consistency, isLoading } =
|
||||
trpc.analytics.getJurorConsistency.useQuery(
|
||||
@@ -551,7 +551,7 @@ function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
|
||||
|
||||
function DiversityTab({ selectedValue }: { selectedValue: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: diversity, isLoading } =
|
||||
trpc.analytics.getDiversityMetrics.useQuery(
|
||||
@@ -595,7 +595,7 @@ export default function ObserverReportsPage() {
|
||||
}
|
||||
|
||||
const hasSelection = !!selectedValue
|
||||
const selectedStage = stages.find((s) => s.id === selectedValue)
|
||||
const selectedRound = stages.find((s) => s.id === selectedValue)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -662,9 +662,9 @@ export default function ObserverReportsPage() {
|
||||
</TabsList>
|
||||
{selectedValue && !selectedValue.startsWith('all:') && (
|
||||
<ExportPdfButton
|
||||
stageId={selectedValue}
|
||||
roundName={selectedStage?.name}
|
||||
programName={selectedStage?.programName}
|
||||
roundId={selectedValue}
|
||||
roundName={selectedRound?.name}
|
||||
programName={selectedRound?.programName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,16 +46,16 @@ export default function StageApplyPage() {
|
||||
return (
|
||||
<ApplyWizardDynamic
|
||||
mode="stage"
|
||||
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
|
||||
config={{...DEFAULT_WIZARD_CONFIG, ...config.wizardConfig}}
|
||||
programName={config.program.name}
|
||||
programYear={config.program.year}
|
||||
stageId={config.stage.id}
|
||||
roundId={config.stage.id}
|
||||
isOpen={config.stage.isOpen}
|
||||
submissionDeadline={config.stage.submissionEndDate}
|
||||
submissionDeadline={config.stage.submissionDeadline}
|
||||
onSubmit={async (data) => {
|
||||
await submitMutation.mutateAsync({
|
||||
mode: 'stage',
|
||||
stageId: config.stage.id,
|
||||
roundId: config.stage.id,
|
||||
data: data as any,
|
||||
})
|
||||
}}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function EditionApplyPage() {
|
||||
return (
|
||||
<ApplyWizardDynamic
|
||||
mode="edition"
|
||||
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
|
||||
config={{...DEFAULT_WIZARD_CONFIG, ...config.wizardConfig}}
|
||||
programName={config.program.name}
|
||||
programYear={config.program.year}
|
||||
programId={config.program.id}
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useCallback } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Pause,
|
||||
Trophy,
|
||||
Star,
|
||||
RefreshCw,
|
||||
Waves,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
||||
export default function StageScoreboardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}) {
|
||||
const { sessionId } = use(params)
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
activeProject,
|
||||
isPaused,
|
||||
error: sseError,
|
||||
reconnect,
|
||||
} = useStageliveSse(sessionId)
|
||||
|
||||
// Fetch audience context for stage info and cohort data
|
||||
const { data: context } = trpc.live.getAudienceContext.useQuery(
|
||||
{ sessionId },
|
||||
{ refetchInterval: 5000 }
|
||||
)
|
||||
|
||||
const stageInfo = context?.stageInfo
|
||||
|
||||
// Fetch scores by querying cohort projects + their votes
|
||||
// We use getAudienceContext.openCohorts to get project IDs, then aggregate
|
||||
const openCohorts = context?.openCohorts ?? []
|
||||
const allProjectIds = openCohorts.flatMap(
|
||||
(c: { projectIds?: string[] }) => c.projectIds ?? []
|
||||
)
|
||||
const uniqueProjectIds = [...new Set(allProjectIds)]
|
||||
|
||||
// For live scores, we poll the audience context and compute from the cursor data
|
||||
// The getAudienceContext returns projects with vote data when available
|
||||
const projectScores = (context as Record<string, unknown>)?.projectScores as
|
||||
| Array<{
|
||||
projectId: string
|
||||
title: string
|
||||
teamName?: string | null
|
||||
averageScore: number
|
||||
voteCount: number
|
||||
}>
|
||||
| undefined
|
||||
|
||||
// Sort projects by average score descending
|
||||
const sortedProjects = [...(projectScores ?? [])].sort(
|
||||
(a, b) => b.averageScore - a.averageScore
|
||||
)
|
||||
const maxScore = Math.max(...sortedProjects.map((p) => p.averageScore), 1)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/10 to-background">
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Waves className="h-10 w-10 text-brand-blue" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-brand-blue dark:text-brand-teal">
|
||||
MOPC Live Scores
|
||||
</h1>
|
||||
{stageInfo && (
|
||||
<p className="text-sm text-muted-foreground">{stageInfo.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isConnected ? (
|
||||
<Badge variant="success">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Live
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="destructive">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Reconnect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paused state */}
|
||||
{isPaused && (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<CardContent className="flex items-center justify-center gap-3 py-6">
|
||||
<Pause className="h-8 w-8 text-amber-600" />
|
||||
<p className="text-lg font-semibold text-amber-700 dark:text-amber-300">
|
||||
Session Paused
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current project highlight */}
|
||||
{activeProject && !isPaused && (
|
||||
<Card className="overflow-hidden border-2 border-brand-blue/30 dark:border-brand-teal/30">
|
||||
<div className="h-1.5 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="flex items-center justify-center gap-2 text-brand-teal text-xs uppercase tracking-wide mb-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Now Presenting
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
|
||||
{activeProject.teamName && (
|
||||
<p className="text-muted-foreground">{activeProject.teamName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent className="text-center">
|
||||
<p className="text-sm">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Leaderboard / Rankings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-amber-500" />
|
||||
Rankings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedProjects.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-muted-foreground">
|
||||
{uniqueProjectIds.length === 0
|
||||
? 'Waiting for presentations to begin...'
|
||||
: 'No scores yet. Votes will appear here in real-time.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sortedProjects.map((project, index) => {
|
||||
const isCurrent = project.projectId === activeProject?.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.projectId}
|
||||
className={`rounded-lg p-4 transition-all duration-300 ${
|
||||
isCurrent
|
||||
? 'bg-brand-blue/5 border border-brand-blue/30 dark:bg-brand-teal/5 dark:border-brand-teal/30'
|
||||
: 'bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Rank */}
|
||||
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
{index === 0 ? (
|
||||
<Trophy className="h-4 w-4 text-amber-500" />
|
||||
) : index === 1 ? (
|
||||
<span className="font-bold text-gray-400">2</span>
|
||||
) : index === 2 ? (
|
||||
<span className="font-bold text-amber-600">3</span>
|
||||
) : (
|
||||
<span className="font-bold text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{project.title}</p>
|
||||
{project.teamName && (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{project.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-xl font-bold tabular-nums">
|
||||
{project.averageScore?.toFixed(1) || '--'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.voteCount} vote{project.voteCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3">
|
||||
<Progress
|
||||
value={
|
||||
project.averageScore
|
||||
? (project.averageScore / maxScore) * 100
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Waiting state */}
|
||||
{!activeProject && !isPaused && sortedProjects.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Trophy className="h-16 w-16 text-amber-500/30 mb-4" />
|
||||
<p className="text-xl font-semibold">Waiting for presentations</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Scores will appear here as projects are presented.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* SSE error */}
|
||||
{sseError && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3 text-sm text-destructive">
|
||||
{sseError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Monaco Ocean Protection Challenge · Live Scoreboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -337,7 +337,7 @@ export function SubmissionDetailClient() {
|
||||
<div className="space-y-2">
|
||||
{project.files.map((file) => {
|
||||
const Icon = fileTypeIcons[file.fileType] || File
|
||||
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
|
||||
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -194,7 +194,7 @@ function AudienceVotingContent({ sessionId }: { sessionId: string }) {
|
||||
<CardTitle>Audience Voting</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
|
||||
{data.session.round?.competition.program.name} - {data.session.round?.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -257,7 +257,7 @@ function AudienceVotingContent({ sessionId }: { sessionId: string }) {
|
||||
<CardTitle>Audience Voting</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
|
||||
{data.session.round?.competition.program.name} - {data.session.round?.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
88
src/app/(public)/vote/competition/[roundId]/page.tsx
Normal file
88
src/app/(public)/vote/competition/[roundId]/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { AudienceVoteCard } from '@/components/public/audience-vote-card';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function AudienceVotePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
|
||||
const params = use(paramsPromise);
|
||||
const utils = trpc.useUtils();
|
||||
const [hasVoted, setHasVoted] = useState(false);
|
||||
|
||||
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId });
|
||||
|
||||
const submitVoteMutation = trpc.liveVoting.castAudienceVote.useMutation({
|
||||
onSuccess: () => {
|
||||
setHasVoted(true);
|
||||
// Store in localStorage to prevent duplicate votes
|
||||
if (cursor?.activeProject?.id) {
|
||||
localStorage.setItem(`voted-${params.roundId}-${cursor.activeProject.id}`, 'true');
|
||||
}
|
||||
toast.success('Vote submitted! Thank you for participating.');
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Check localStorage on mount
|
||||
useEffect(() => {
|
||||
if (cursor?.activeProject?.id) {
|
||||
const voted = localStorage.getItem(`voted-${params.roundId}-${cursor.activeProject.id}`);
|
||||
if (voted === 'true') {
|
||||
setHasVoted(true);
|
||||
}
|
||||
}
|
||||
}, [cursor?.activeProject?.id, params.roundId]);
|
||||
|
||||
const handleVote = () => {
|
||||
if (!cursor?.activeProject?.id) return;
|
||||
|
||||
submitVoteMutation.mutate({
|
||||
projectId: cursor.activeProject.id,
|
||||
sessionId: params.roundId,
|
||||
score: 1,
|
||||
token: `audience-${Date.now()}`
|
||||
});
|
||||
};
|
||||
|
||||
if (!cursor?.activeProject) {
|
||||
return (
|
||||
<div className="container mx-auto flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-center text-lg text-muted-foreground">
|
||||
No project is currently being presented
|
||||
</p>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Please wait for the ceremony to begin
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-4xl font-bold text-[#053d57]">Monaco Ocean Protection Challenge</h1>
|
||||
<p className="mt-2 text-lg text-muted-foreground">Live Audience Voting</p>
|
||||
</div>
|
||||
|
||||
<AudienceVoteCard
|
||||
project={cursor.activeProject}
|
||||
onVote={handleVote}
|
||||
hasVoted={hasVoted}
|
||||
/>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
Live voting in progress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Pause,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Waves,
|
||||
} from 'lucide-react'
|
||||
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function StageAudienceVotePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}) {
|
||||
const { sessionId } = use(params)
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [hasVoted, setHasVoted] = useState(false)
|
||||
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
activeProject,
|
||||
isPaused,
|
||||
error: sseError,
|
||||
reconnect,
|
||||
} = useStageliveSse(sessionId)
|
||||
|
||||
const castVoteMutation = trpc.live.castStageVote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Your vote has been recorded!')
|
||||
setHasVoted(true)
|
||||
setLastVotedProjectId(activeProject?.id ?? null)
|
||||
setSelectedScore(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Reset vote state when project changes
|
||||
if (activeProject?.id && activeProject.id !== lastVotedProjectId) {
|
||||
if (hasVoted) {
|
||||
setHasVoted(false)
|
||||
setSelectedScore(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVote = () => {
|
||||
if (!activeProject || selectedScore === null) return
|
||||
castVoteMutation.mutate({
|
||||
sessionId,
|
||||
projectId: activeProject.id,
|
||||
score: selectedScore,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
|
||||
<div className="mx-auto max-w-lg px-4 py-8 space-y-6">
|
||||
{/* MOPC branding header */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Waves className="h-8 w-8 text-brand-blue" />
|
||||
<h1 className="text-2xl font-bold text-brand-blue dark:text-brand-teal">
|
||||
MOPC Live Vote
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isConnected ? (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Live
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sseError && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{sseError}</p>
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Paused state */}
|
||||
{isPaused ? (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Pause className="h-16 w-16 text-amber-600 mb-4" />
|
||||
<p className="text-xl font-semibold">Voting Paused</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Please wait for the next project...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : activeProject ? (
|
||||
<>
|
||||
{/* Active project card */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
|
||||
{activeProject.teamName && (
|
||||
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent>
|
||||
<p className="text-sm text-center">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Voting controls */}
|
||||
<Card>
|
||||
<CardContent className="py-6 space-y-6">
|
||||
{hasVoted ? (
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<CheckCircle2 className="h-16 w-16 text-emerald-600 mb-4" />
|
||||
<p className="text-xl font-semibold">Thank you!</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Your vote has been recorded. Waiting for the next project...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-center text-sm font-medium text-muted-foreground">
|
||||
Rate this project from 1 to 10
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-14 text-xl font-bold tabular-nums',
|
||||
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light scale-110'
|
||||
)}
|
||||
onClick={() => setSelectedScore(score)}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-14 text-lg bg-brand-blue hover:bg-brand-blue-light"
|
||||
disabled={selectedScore === null || castVoteMutation.isPending}
|
||||
onClick={handleVote}
|
||||
>
|
||||
{castVoteMutation.isPending ? (
|
||||
'Submitting...'
|
||||
) : selectedScore !== null ? (
|
||||
<>
|
||||
<Star className="mr-2 h-5 w-5" />
|
||||
Vote {selectedScore}/10
|
||||
</>
|
||||
) : (
|
||||
'Select a score to vote'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Star className="h-16 w-16 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-xl font-semibold">Waiting...</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
The next project will appear here shortly.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Monaco Ocean Protection Challenge
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const { sessionId } = await params
|
||||
|
||||
// Validate session exists
|
||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
||||
where: { sessionId },
|
||||
})
|
||||
|
||||
if (!cursor) {
|
||||
return new Response('Session not found', { status: 404 })
|
||||
}
|
||||
|
||||
// Manually fetch related data since LiveProgressCursor doesn't have these relations
|
||||
let activeProject = null
|
||||
if (cursor.activeProjectId) {
|
||||
activeProject = await prisma.project.findUnique({
|
||||
where: { id: cursor.activeProjectId },
|
||||
select: { id: true, title: true, teamName: true, description: true },
|
||||
})
|
||||
}
|
||||
|
||||
const stageInfo = await prisma.stage.findUnique({
|
||||
where: { id: cursor.stageId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Send initial state
|
||||
type CohortWithProjects = Awaited<ReturnType<typeof prisma.cohort.findMany<{
|
||||
where: { stageId: string }
|
||||
include: { projects: { select: { projectId: true } } }
|
||||
}>>>
|
||||
|
||||
const cohortPromise: Promise<CohortWithProjects> = prisma.cohort
|
||||
.findMany({
|
||||
where: { stageId: cursor.stageId },
|
||||
include: {
|
||||
projects: {
|
||||
select: { projectId: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((cohorts) => {
|
||||
const initData = {
|
||||
activeProject,
|
||||
isPaused: cursor.isPaused,
|
||||
stageInfo,
|
||||
openCohorts: cohorts.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isOpen: c.isOpen,
|
||||
projectIds: c.projects.map((p) => p.projectId),
|
||||
})),
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: init\ndata: ${JSON.stringify(initData)}\n\n`)
|
||||
)
|
||||
|
||||
return cohorts
|
||||
})
|
||||
.catch((): CohortWithProjects => {
|
||||
// Ignore errors on init
|
||||
return []
|
||||
})
|
||||
|
||||
cohortPromise.then((initialCohorts: CohortWithProjects) => {
|
||||
// Poll for updates
|
||||
let lastActiveProjectId = cursor.activeProjectId
|
||||
let lastIsPaused = cursor.isPaused
|
||||
let lastCohortState = JSON.stringify(
|
||||
(initialCohorts ?? []).map((c: { id: string; isOpen: boolean; windowOpenAt: Date | null; windowCloseAt: Date | null }) => ({
|
||||
id: c.id,
|
||||
isOpen: c.isOpen,
|
||||
windowOpenAt: c.windowOpenAt?.toISOString() ?? null,
|
||||
windowCloseAt: c.windowCloseAt?.toISOString() ?? null,
|
||||
}))
|
||||
)
|
||||
|
||||
intervalId = setInterval(async () => {
|
||||
try {
|
||||
const updated = await prisma.liveProgressCursor.findUnique({
|
||||
where: { sessionId },
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: session.ended\ndata: ${JSON.stringify({ reason: 'Session removed' })}\n\n`
|
||||
)
|
||||
)
|
||||
controller.close()
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for cursor changes
|
||||
if (
|
||||
updated.activeProjectId !== lastActiveProjectId ||
|
||||
updated.isPaused !== lastIsPaused
|
||||
) {
|
||||
// Fetch updated active project if changed
|
||||
let updatedActiveProject = null
|
||||
if (updated.activeProjectId) {
|
||||
updatedActiveProject = await prisma.project.findUnique({
|
||||
where: { id: updated.activeProjectId },
|
||||
select: { id: true, title: true, teamName: true, description: true },
|
||||
})
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: cursor.updated\ndata: ${JSON.stringify({
|
||||
activeProject: updatedActiveProject,
|
||||
isPaused: updated.isPaused,
|
||||
})}\n\n`
|
||||
)
|
||||
)
|
||||
|
||||
// Check pause/resume transitions
|
||||
if (updated.isPaused && !lastIsPaused) {
|
||||
controller.enqueue(encoder.encode(`event: session.paused\ndata: {}\n\n`))
|
||||
} else if (!updated.isPaused && lastIsPaused) {
|
||||
controller.enqueue(encoder.encode(`event: session.resumed\ndata: {}\n\n`))
|
||||
}
|
||||
|
||||
lastActiveProjectId = updated.activeProjectId
|
||||
lastIsPaused = updated.isPaused
|
||||
}
|
||||
|
||||
// Poll cohort changes
|
||||
const currentCohorts = await prisma.cohort.findMany({
|
||||
where: { stageId: cursor.stageId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isOpen: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
projects: { select: { projectId: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const currentCohortState = JSON.stringify(
|
||||
currentCohorts.map((c) => ({
|
||||
id: c.id,
|
||||
isOpen: c.isOpen,
|
||||
windowOpenAt: c.windowOpenAt?.toISOString() ?? null,
|
||||
windowCloseAt: c.windowCloseAt?.toISOString() ?? null,
|
||||
}))
|
||||
)
|
||||
|
||||
if (currentCohortState !== lastCohortState) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: cohort.window.changed\ndata: ${JSON.stringify({
|
||||
openCohorts: currentCohorts.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isOpen: c.isOpen,
|
||||
projectIds: c.projects.map((p) => p.projectId),
|
||||
})),
|
||||
})}\n\n`
|
||||
)
|
||||
)
|
||||
lastCohortState = currentCohortState
|
||||
}
|
||||
|
||||
// Send heartbeat to keep connection alive
|
||||
controller.enqueue(encoder.encode(`: heartbeat\n\n`))
|
||||
} catch {
|
||||
// Connection may be closed, ignore errors
|
||||
}
|
||||
}, POLL_INTERVAL_MS)
|
||||
})
|
||||
},
|
||||
cancel() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Check if client disconnected
|
||||
request.signal.addEventListener('abort', () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user