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',
|
||||
},
|
||||
})
|
||||
}
|
||||
181
src/components/admin/assignment/assignment-preview-sheet.tsx
Normal file
181
src/components/admin/assignment/assignment-preview-sheet.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AlertTriangle, Bot, CheckCircle2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface AssignmentPreviewSheetProps {
|
||||
roundId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AssignmentPreviewSheet({
|
||||
roundId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AssignmentPreviewSheetProps) {
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const {
|
||||
data: preview,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = trpc.roundAssignment.preview.useQuery(
|
||||
{ roundId, honorIntents: true, requiredReviews: 3 },
|
||||
{ enabled: open }
|
||||
)
|
||||
|
||||
const { mutate: execute, isPending: isExecuting } = trpc.roundAssignment.execute.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Created ${result.created} assignments`)
|
||||
utils.roundAssignment.coverageReport.invalidate({ roundId })
|
||||
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
refetch()
|
||||
}
|
||||
}, [open, refetch])
|
||||
|
||||
const handleExecute = () => {
|
||||
if (!preview?.assignments || preview.assignments.length === 0) {
|
||||
toast.error('No assignments to execute')
|
||||
return
|
||||
}
|
||||
|
||||
execute({
|
||||
roundId,
|
||||
assignments: preview.assignments.map((a: any) => ({
|
||||
userId: a.userId,
|
||||
projectId: a.projectId,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Assignment Preview</SheetTitle>
|
||||
<SheetDescription className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs gap-1 shrink-0">
|
||||
<Bot className="h-3 w-3" />
|
||||
AI Suggested
|
||||
</Badge>
|
||||
Review the proposed assignments before executing. All assignments are admin-approved on execute.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-200px)] mt-6">
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : preview ? (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
{preview.stats.assignmentsGenerated || 0} Assignments Proposed
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{preview.stats.totalJurors || 0} jurors will receive assignments
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{preview.warnings && preview.warnings.length > 0 && (
|
||||
<Card className="border-amber-500">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||
Warnings ({preview.warnings.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{preview.warnings.map((warning: string, idx: number) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<span className="text-amber-600">•</span>
|
||||
<span>{warning}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{preview.assignments && preview.assignments.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Assignment Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total assignments:</span>
|
||||
<span className="font-medium">{preview.assignments.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Unique projects:</span>
|
||||
<span className="font-medium">
|
||||
{new Set(preview.assignments.map((a: any) => a.projectId)).size}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Unique jurors:</span>
|
||||
<span className="font-medium">
|
||||
{new Set(preview.assignments.map((a: any) => a.userId)).size}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No preview data available</p>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="mt-6">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExecute}
|
||||
disabled={isExecuting || !preview?.assignments || preview.assignments.length === 0}
|
||||
>
|
||||
{isExecuting ? 'Executing...' : 'Execute Assignments'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
98
src/components/admin/assignment/coverage-report.tsx
Normal file
98
src/components/admin/assignment/coverage-report.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AlertCircle, CheckCircle2, Users } from 'lucide-react'
|
||||
|
||||
interface CoverageReportProps {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function CoverageReport({ roundId }: CoverageReportProps) {
|
||||
const { data: coverage, isLoading } = trpc.roundAssignment.coverageReport.useQuery({
|
||||
roundId,
|
||||
requiredReviews: 3,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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-32" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!coverage) {
|
||||
return <p className="text-muted-foreground">No coverage data available</p>
|
||||
}
|
||||
|
||||
const totalAssigned = coverage.fullyAssigned || 0
|
||||
const totalProjects = coverage.totalProjects || 0
|
||||
const avgPerJuror = coverage.avgReviewsPerProject?.toFixed(1) || '0'
|
||||
const unassignedCount = coverage.unassigned || 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Assigned</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalAssigned}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totalProjects > 0
|
||||
? `${((totalAssigned / totalProjects) * 100).toFixed(1)}% coverage`
|
||||
: 'No projects'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Per Juror</CardTitle>
|
||||
<Users className="h-4 w-4 text-blue-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{avgPerJuror}</div>
|
||||
<p className="text-xs text-muted-foreground">Assignments per juror</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Unassigned</CardTitle>
|
||||
<AlertCircle className="h-4 w-4 text-amber-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{unassignedCount}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Projects below 3 reviews
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{coverage.unassigned > 0 && (
|
||||
<Card className="border-amber-500">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Coverage Warnings</CardTitle>
|
||||
<CardDescription>Issues detected in assignment coverage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{coverage.unassigned} projects have insufficient coverage</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
src/components/admin/competition/competition-timeline.tsx
Normal file
156
src/components/admin/competition/competition-timeline.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { format } from 'date-fns'
|
||||
import { CheckCircle2, Circle, Clock } from 'lucide-react'
|
||||
|
||||
const roundTypeColors: Record<string, string> = {
|
||||
INTAKE: 'bg-gray-100 text-gray-700 border-gray-300',
|
||||
FILTERING: 'bg-amber-100 text-amber-700 border-amber-300',
|
||||
EVALUATION: 'bg-blue-100 text-blue-700 border-blue-300',
|
||||
SUBMISSION: 'bg-purple-100 text-purple-700 border-purple-300',
|
||||
MENTORING: 'bg-teal-100 text-teal-700 border-teal-300',
|
||||
LIVE_FINAL: 'bg-red-100 text-red-700 border-red-300',
|
||||
DELIBERATION: 'bg-indigo-100 text-indigo-700 border-indigo-300',
|
||||
}
|
||||
|
||||
const roundStatusConfig: Record<string, { icon: typeof Circle; color: string }> = {
|
||||
ROUND_DRAFT: { icon: Circle, color: 'text-gray-400' },
|
||||
ROUND_ACTIVE: { icon: Clock, color: 'text-emerald-500' },
|
||||
ROUND_CLOSED: { icon: CheckCircle2, color: 'text-blue-500' },
|
||||
ROUND_ARCHIVED: { icon: CheckCircle2, color: 'text-gray-400' },
|
||||
}
|
||||
|
||||
type RoundSummary = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
roundType: string
|
||||
status: string
|
||||
sortOrder: number
|
||||
windowOpenAt: Date | string | null
|
||||
windowCloseAt: Date | string | null
|
||||
}
|
||||
|
||||
export function CompetitionTimeline({
|
||||
competitionId,
|
||||
rounds,
|
||||
}: {
|
||||
competitionId: string
|
||||
rounds: RoundSummary[]
|
||||
}) {
|
||||
if (rounds.length === 0) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No rounds configured yet. Add rounds to see the competition timeline.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Round Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Desktop: horizontal timeline */}
|
||||
<div className="hidden md:block overflow-x-auto pb-2">
|
||||
<div className="flex items-start gap-0 min-w-max">
|
||||
{rounds.map((round, index) => {
|
||||
const statusCfg = roundStatusConfig[round.status] ?? roundStatusConfig.ROUND_DRAFT
|
||||
const StatusIcon = statusCfg.icon
|
||||
const isLast = index === rounds.length - 1
|
||||
|
||||
return (
|
||||
<div key={round.id} className="flex items-start">
|
||||
<Link
|
||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
||||
className="group flex flex-col items-center text-center w-32 shrink-0"
|
||||
>
|
||||
<div className="relative">
|
||||
<StatusIcon className={cn('h-6 w-6', statusCfg.color)} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-medium group-hover:text-primary transition-colors line-clamp-2">
|
||||
{round.name}
|
||||
</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'mt-1 text-[9px]',
|
||||
roundTypeColors[round.roundType] ?? ''
|
||||
)}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
{round.windowOpenAt && (
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
{format(new Date(round.windowOpenAt), 'MMM d')}
|
||||
{round.windowCloseAt && (
|
||||
<> - {format(new Date(round.windowCloseAt), 'MMM d')}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
{!isLast && (
|
||||
<div className="mt-3 h-px w-8 bg-border shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: vertical timeline */}
|
||||
<div className="md:hidden space-y-0">
|
||||
{rounds.map((round, index) => {
|
||||
const statusCfg = roundStatusConfig[round.status] ?? roundStatusConfig.ROUND_DRAFT
|
||||
const StatusIcon = statusCfg.icon
|
||||
const isLast = index === rounds.length - 1
|
||||
|
||||
return (
|
||||
<div key={round.id}>
|
||||
<Link
|
||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
||||
className="flex items-start gap-3 py-2 hover:bg-muted/50 rounded-md px-2 -mx-2 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<StatusIcon className={cn('h-5 w-5', statusCfg.color)} />
|
||||
{!isLast && <div className="w-px flex-1 bg-border mt-1 min-h-[16px]" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">{round.name}</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[9px] shrink-0',
|
||||
roundTypeColors[round.roundType] ?? ''
|
||||
)}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{round.windowOpenAt && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{format(new Date(round.windowOpenAt), 'MMM d, yyyy')}
|
||||
{round.windowCloseAt && (
|
||||
<> - {format(new Date(round.windowCloseAt), 'MMM d, yyyy')}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
248
src/components/admin/competition/round-config-form.tsx
Normal file
248
src/components/admin/competition/round-config-form.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
type RoundConfigFormProps = {
|
||||
roundType: string
|
||||
config: Record<string, unknown>
|
||||
onChange: (config: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
export function RoundConfigForm({ roundType, config, onChange }: RoundConfigFormProps) {
|
||||
const updateConfig = (key: string, value: unknown) => {
|
||||
onChange({ ...config, [key]: value })
|
||||
}
|
||||
|
||||
if (roundType === 'INTAKE') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Intake Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="allowDrafts">Allow Drafts</Label>
|
||||
<Switch
|
||||
id="allowDrafts"
|
||||
checked={(config.allowDrafts as boolean) ?? true}
|
||||
onCheckedChange={(checked) => updateConfig('allowDrafts', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="draftExpiryDays">Draft Expiry (days)</Label>
|
||||
<Input
|
||||
id="draftExpiryDays"
|
||||
type="number"
|
||||
min={1}
|
||||
value={(config.draftExpiryDays as number) ?? 30}
|
||||
onChange={(e) => updateConfig('draftExpiryDays', parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxFileSizeMB">Max File Size (MB)</Label>
|
||||
<Input
|
||||
id="maxFileSizeMB"
|
||||
type="number"
|
||||
min={1}
|
||||
value={(config.maxFileSizeMB as number) ?? 50}
|
||||
onChange={(e) => updateConfig('maxFileSizeMB', parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="publicFormEnabled">Public Form Enabled</Label>
|
||||
<Switch
|
||||
id="publicFormEnabled"
|
||||
checked={(config.publicFormEnabled as boolean) ?? false}
|
||||
onCheckedChange={(checked) => updateConfig('publicFormEnabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (roundType === 'FILTERING') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Filtering Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="aiScreeningEnabled">AI Screening</Label>
|
||||
<Switch
|
||||
id="aiScreeningEnabled"
|
||||
checked={(config.aiScreeningEnabled as boolean) ?? true}
|
||||
onCheckedChange={(checked) => updateConfig('aiScreeningEnabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="duplicateDetectionEnabled">Duplicate Detection</Label>
|
||||
<Switch
|
||||
id="duplicateDetectionEnabled"
|
||||
checked={(config.duplicateDetectionEnabled as boolean) ?? true}
|
||||
onCheckedChange={(checked) => updateConfig('duplicateDetectionEnabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="manualReviewEnabled">Manual Review</Label>
|
||||
<Switch
|
||||
id="manualReviewEnabled"
|
||||
checked={(config.manualReviewEnabled as boolean) ?? true}
|
||||
onCheckedChange={(checked) => updateConfig('manualReviewEnabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batchSize">Batch Size</Label>
|
||||
<Input
|
||||
id="batchSize"
|
||||
type="number"
|
||||
min={1}
|
||||
value={(config.batchSize as number) ?? 20}
|
||||
onChange={(e) => updateConfig('batchSize', parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (roundType === 'EVALUATION') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Evaluation Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requiredReviews">Required Reviews per Project</Label>
|
||||
<Input
|
||||
id="requiredReviews"
|
||||
type="number"
|
||||
min={1}
|
||||
value={(config.requiredReviewsPerProject as number) ?? 3}
|
||||
onChange={(e) => updateConfig('requiredReviewsPerProject', parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoringMode">Scoring Mode</Label>
|
||||
<Select
|
||||
value={(config.scoringMode as string) ?? 'criteria'}
|
||||
onValueChange={(value) => updateConfig('scoringMode', value)}
|
||||
>
|
||||
<SelectTrigger id="scoringMode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="criteria">Criteria-based</SelectItem>
|
||||
<SelectItem value="global">Global score</SelectItem>
|
||||
<SelectItem value="binary">Binary (pass/fail)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="requireFeedback">Require Feedback</Label>
|
||||
<Switch
|
||||
id="requireFeedback"
|
||||
checked={(config.requireFeedback as boolean) ?? true}
|
||||
onCheckedChange={(checked) => updateConfig('requireFeedback', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="coiRequired">COI Declaration Required</Label>
|
||||
<Switch
|
||||
id="coiRequired"
|
||||
checked={(config.coiRequired as boolean) ?? true}
|
||||
onCheckedChange={(checked) => updateConfig('coiRequired', checked)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (roundType === 'LIVE_FINAL') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Live Final Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="juryVotingEnabled">Jury Voting</Label>
|
||||
<Switch
|
||||
id="juryVotingEnabled"
|
||||
checked={(config.juryVotingEnabled as boolean) ?? true}
|
||||
onCheckedChange={(checked) => updateConfig('juryVotingEnabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="audienceVotingEnabled">Audience Voting</Label>
|
||||
<Switch
|
||||
id="audienceVotingEnabled"
|
||||
checked={(config.audienceVotingEnabled as boolean) ?? false}
|
||||
onCheckedChange={(checked) => updateConfig('audienceVotingEnabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(config.audienceVotingEnabled as boolean) && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="audienceVoteWeight">Audience Vote Weight (0-1)</Label>
|
||||
<Input
|
||||
id="audienceVoteWeight"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={(config.audienceVoteWeight as number) ?? 0}
|
||||
onChange={(e) => updateConfig('audienceVoteWeight', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="presentationDuration">Presentation Duration (min)</Label>
|
||||
<Input
|
||||
id="presentationDuration"
|
||||
type="number"
|
||||
min={1}
|
||||
value={(config.presentationDurationMinutes as number) ?? 15}
|
||||
onChange={(e) => updateConfig('presentationDurationMinutes', parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Default view for other types
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{roundType} Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configuration UI for {roundType} rounds is not yet implemented.
|
||||
</p>
|
||||
<pre className="mt-4 p-3 bg-muted rounded text-xs overflow-auto">
|
||||
{JSON.stringify(config, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
159
src/components/admin/competition/sections/basics-section.tsx
Normal file
159
src/components/admin/competition/sections/basics-section.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
type WizardState = {
|
||||
programId: string
|
||||
name: string
|
||||
slug: string
|
||||
categoryMode: string
|
||||
startupFinalistCount: number
|
||||
conceptFinalistCount: number
|
||||
notifyOnRoundAdvance: boolean
|
||||
notifyOnDeadlineApproach: boolean
|
||||
deadlineReminderDays: number[]
|
||||
}
|
||||
|
||||
type BasicsSectionProps = {
|
||||
state: WizardState
|
||||
onChange: (updates: Partial<WizardState>) => void
|
||||
}
|
||||
|
||||
export function BasicsSection({ state, onChange }: BasicsSectionProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Competition Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Competition Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g., 2026 Ocean Innovation Challenge"
|
||||
value={state.name}
|
||||
onChange={(e) => onChange({ name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">Slug *</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
placeholder="e.g., 2026-ocean-innovation"
|
||||
value={state.slug}
|
||||
onChange={(e) => onChange({ slug: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL-safe identifier (lowercase, hyphens only)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryMode">Category Mode</Label>
|
||||
<Select value={state.categoryMode} onValueChange={(value) => onChange({ categoryMode: value })}>
|
||||
<SelectTrigger id="categoryMode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SHARED">Shared (all categories together)</SelectItem>
|
||||
<SelectItem value="SEPARATE">Separate (categories evaluated independently)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startupFinalists">Startup Finalists</Label>
|
||||
<Input
|
||||
id="startupFinalists"
|
||||
type="number"
|
||||
min={1}
|
||||
value={state.startupFinalistCount}
|
||||
onChange={(e) => onChange({ startupFinalistCount: parseInt(e.target.value, 10) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conceptFinalists">Concept Finalists</Label>
|
||||
<Input
|
||||
id="conceptFinalists"
|
||||
type="number"
|
||||
min={1}
|
||||
value={state.conceptFinalistCount}
|
||||
onChange={(e) => onChange({ conceptFinalistCount: parseInt(e.target.value, 10) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Notifications</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notifyRoundAdvance">Round Advancement Notifications</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Notify participants when they advance to the next round
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifyRoundAdvance"
|
||||
checked={state.notifyOnRoundAdvance}
|
||||
onCheckedChange={(checked) => onChange({ notifyOnRoundAdvance: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notifyDeadline">Deadline Reminders</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send reminders as deadlines approach
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifyDeadline"
|
||||
checked={state.notifyOnDeadlineApproach}
|
||||
onCheckedChange={(checked) => onChange({ notifyOnDeadlineApproach: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.notifyOnDeadlineApproach && (
|
||||
<div className="space-y-2">
|
||||
<Label>Reminder Days</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state.deadlineReminderDays.map((days, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-16"
|
||||
value={days}
|
||||
onChange={(e) => {
|
||||
const newDays = [...state.deadlineReminderDays]
|
||||
newDays[index] = parseInt(e.target.value, 10)
|
||||
onChange({ deadlineReminderDays: newDays })
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">days</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Days before deadline to send reminders
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
type WizardJuryGroup = {
|
||||
tempId: string
|
||||
name: string
|
||||
slug: string
|
||||
defaultMaxAssignments: number
|
||||
defaultCapMode: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
type JuryGroupsSectionProps = {
|
||||
juryGroups: WizardJuryGroup[]
|
||||
onChange: (groups: WizardJuryGroup[]) => void
|
||||
}
|
||||
|
||||
export function JuryGroupsSection({ juryGroups, onChange }: JuryGroupsSectionProps) {
|
||||
const handleAddGroup = () => {
|
||||
const newGroup: WizardJuryGroup = {
|
||||
tempId: crypto.randomUUID(),
|
||||
name: '',
|
||||
slug: '',
|
||||
defaultMaxAssignments: 5,
|
||||
defaultCapMode: 'SOFT',
|
||||
sortOrder: juryGroups.length,
|
||||
}
|
||||
onChange([...juryGroups, newGroup])
|
||||
}
|
||||
|
||||
const handleRemoveGroup = (tempId: string) => {
|
||||
const updated = juryGroups.filter((g) => g.tempId !== tempId)
|
||||
const reordered = updated.map((g, index) => ({ ...g, sortOrder: index }))
|
||||
onChange(reordered)
|
||||
}
|
||||
|
||||
const handleUpdateGroup = (tempId: string, updates: Partial<WizardJuryGroup>) => {
|
||||
const updated = juryGroups.map((g) =>
|
||||
g.tempId === tempId ? { ...g, ...updates } : g
|
||||
)
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Jury Groups</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create jury groups for evaluation rounds (optional)
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{juryGroups.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No jury groups yet. Add groups to assign evaluators to rounds.
|
||||
</div>
|
||||
) : (
|
||||
juryGroups.map((group, index) => (
|
||||
<div key={group.tempId} className="flex items-start gap-2 border rounded-lg p-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="flex-1 min-w-0 space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Group Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., Technical Jury"
|
||||
value={group.name}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value
|
||||
const slug = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||
handleUpdateGroup(group.tempId, { name, slug })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Slug</Label>
|
||||
<Input
|
||||
placeholder="e.g., technical-jury"
|
||||
value={group.slug}
|
||||
onChange={(e) => handleUpdateGroup(group.tempId, { slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Max Assignments per Juror</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={group.defaultMaxAssignments}
|
||||
onChange={(e) =>
|
||||
handleUpdateGroup(group.tempId, {
|
||||
defaultMaxAssignments: parseInt(e.target.value, 10),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Cap Mode</Label>
|
||||
<Select
|
||||
value={group.defaultCapMode}
|
||||
onValueChange={(value) => handleUpdateGroup(group.tempId, { defaultCapMode: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HARD">Hard (strict limit)</SelectItem>
|
||||
<SelectItem value="SOFT">Soft (flexible)</SelectItem>
|
||||
<SelectItem value="NONE">None (unlimited)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive shrink-0"
|
||||
onClick={() => handleRemoveGroup(group.tempId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={handleAddGroup}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Jury Group
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
213
src/components/admin/competition/sections/review-section.tsx
Normal file
213
src/components/admin/competition/sections/review-section.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
type ReviewSectionProps = {
|
||||
state: WizardState
|
||||
}
|
||||
|
||||
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 function ReviewSection({ state }: ReviewSectionProps) {
|
||||
const warnings: string[] = []
|
||||
|
||||
if (!state.name) warnings.push('Competition name is required')
|
||||
if (!state.slug) warnings.push('Competition slug is required')
|
||||
if (state.rounds.length === 0) warnings.push('At least one round is required')
|
||||
if (state.rounds.some((r) => !r.name)) warnings.push('All rounds must have a name')
|
||||
if (state.rounds.some((r) => !r.slug)) warnings.push('All rounds must have a slug')
|
||||
if (state.juryGroups.some((g) => !g.name)) warnings.push('All jury groups must have a name')
|
||||
if (state.juryGroups.some((g) => !g.slug)) warnings.push('All jury groups must have a slug')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Validation Status */}
|
||||
{warnings.length > 0 ? (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
Please fix the following issues:
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1 text-sm text-destructive/90">
|
||||
{warnings.map((warning, index) => (
|
||||
<li key={index} className="ml-4 list-disc">
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="border-emerald-500/50 bg-emerald-500/5">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
|
||||
<p className="text-sm font-medium text-emerald-700">
|
||||
Ready to create competition
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Competition Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Competition Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Name</p>
|
||||
<p className="text-sm">{state.name || <em className="text-muted-foreground">Not set</em>}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Slug</p>
|
||||
<p className="text-sm font-mono">{state.slug || <em className="text-muted-foreground">Not set</em>}</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Category Mode</p>
|
||||
<p className="text-sm">{state.categoryMode}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Finalists</p>
|
||||
<p className="text-sm">
|
||||
{state.startupFinalistCount} Startup / {state.conceptFinalistCount} Concept
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5">Notifications</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{state.notifyOnRoundAdvance && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Round Advance
|
||||
</Badge>
|
||||
)}
|
||||
{state.notifyOnDeadlineApproach && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Deadline Reminders ({state.deadlineReminderDays.join(', ')} days)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rounds Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Rounds ({state.rounds.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{state.rounds.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No rounds configured</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{state.rounds.map((round, index) => (
|
||||
<div key={round.tempId} className="flex items-center gap-3 py-2 border-b last:border-0">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{round.name || <em>Unnamed</em>}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{round.slug || <em>no-slug</em>}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Jury Groups Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Jury Groups ({state.juryGroups.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{state.juryGroups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No jury groups configured</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{state.juryGroups.map((group, index) => (
|
||||
<div key={group.tempId} className="flex items-center gap-3 py-2 border-b last:border-0">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{group.name || <em>Unnamed</em>}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{group.slug || <em>no-slug</em>}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
<span>Max: {group.defaultMaxAssignments}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{group.defaultCapMode}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
195
src/components/admin/competition/sections/rounds-section.tsx
Normal file
195
src/components/admin/competition/sections/rounds-section.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
|
||||
type WizardRound = {
|
||||
tempId: string
|
||||
name: string
|
||||
slug: string
|
||||
roundType: string
|
||||
sortOrder: number
|
||||
configJson: Record<string, unknown>
|
||||
}
|
||||
|
||||
type RoundsSectionProps = {
|
||||
rounds: WizardRound[]
|
||||
onChange: (rounds: WizardRound[]) => void
|
||||
}
|
||||
|
||||
const roundTypes = [
|
||||
{ value: 'INTAKE', label: 'Intake', color: 'bg-gray-100 text-gray-700' },
|
||||
{ value: 'FILTERING', label: 'Filtering', color: 'bg-amber-100 text-amber-700' },
|
||||
{ value: 'EVALUATION', label: 'Evaluation', color: 'bg-blue-100 text-blue-700' },
|
||||
{ value: 'SUBMISSION', label: 'Submission', color: 'bg-purple-100 text-purple-700' },
|
||||
{ value: 'MENTORING', label: 'Mentoring', color: 'bg-teal-100 text-teal-700' },
|
||||
{ value: 'LIVE_FINAL', label: 'Live Final', color: 'bg-red-100 text-red-700' },
|
||||
{ value: 'DELIBERATION', label: 'Deliberation', color: 'bg-indigo-100 text-indigo-700' },
|
||||
]
|
||||
|
||||
export function RoundsSection({ rounds, onChange }: RoundsSectionProps) {
|
||||
const handleAddRound = () => {
|
||||
const newRound: WizardRound = {
|
||||
tempId: crypto.randomUUID(),
|
||||
name: '',
|
||||
slug: '',
|
||||
roundType: 'EVALUATION',
|
||||
sortOrder: rounds.length,
|
||||
configJson: {},
|
||||
}
|
||||
onChange([...rounds, newRound])
|
||||
}
|
||||
|
||||
const handleRemoveRound = (tempId: string) => {
|
||||
const updated = rounds.filter((r) => r.tempId !== tempId)
|
||||
// Reorder
|
||||
const reordered = updated.map((r, index) => ({ ...r, sortOrder: index }))
|
||||
onChange(reordered)
|
||||
}
|
||||
|
||||
const handleUpdateRound = (tempId: string, updates: Partial<WizardRound>) => {
|
||||
const updated = rounds.map((r) =>
|
||||
r.tempId === tempId ? { ...r, ...updates } : r
|
||||
)
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (index === 0) return
|
||||
const updated = [...rounds]
|
||||
;[updated[index - 1], updated[index]] = [updated[index], updated[index - 1]]
|
||||
const reordered = updated.map((r, i) => ({ ...r, sortOrder: i }))
|
||||
onChange(reordered)
|
||||
}
|
||||
|
||||
const handleMoveDown = (index: number) => {
|
||||
if (index === rounds.length - 1) return
|
||||
const updated = [...rounds]
|
||||
;[updated[index], updated[index + 1]] = [updated[index + 1], updated[index]]
|
||||
const reordered = updated.map((r, i) => ({ ...r, sortOrder: i }))
|
||||
onChange(reordered)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Competition Rounds</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Define the stages of your competition workflow
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{rounds.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No rounds yet. Add your first round to get started.
|
||||
</div>
|
||||
) : (
|
||||
rounds.map((round, index) => {
|
||||
const typeConfig = roundTypes.find((t) => t.value === round.roundType)
|
||||
return (
|
||||
<div key={round.tempId} className="flex items-start gap-2 border rounded-lg p-3">
|
||||
<div className="flex flex-col gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === rounds.length - 1}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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="flex-1 min-w-0 space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Round Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., First Evaluation"
|
||||
value={round.name}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value
|
||||
const slug = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||
handleUpdateRound(round.tempId, { name, slug })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Slug</Label>
|
||||
<Input
|
||||
placeholder="e.g., first-evaluation"
|
||||
value={round.slug}
|
||||
onChange={(e) => handleUpdateRound(round.tempId, { slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">Round Type</Label>
|
||||
<Select
|
||||
value={round.roundType}
|
||||
onValueChange={(value) => handleUpdateRound(round.tempId, { roundType: value })}
|
||||
>
|
||||
<SelectTrigger className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roundTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{typeConfig && (
|
||||
<Badge variant="secondary" className={typeConfig.color}>
|
||||
{typeConfig.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive shrink-0"
|
||||
onClick={() => handleRemoveRound(round.tempId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={handleAddRound}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Round
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
src/components/admin/deliberation/admin-override-dialog.tsx
Normal file
144
src/components/admin/deliberation/admin-override-dialog.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface AdminOverrideDialogProps {
|
||||
sessionId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectIds: string[];
|
||||
}
|
||||
|
||||
export function AdminOverrideDialog({
|
||||
sessionId,
|
||||
open,
|
||||
onOpenChange,
|
||||
projectIds
|
||||
}: AdminOverrideDialogProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const [rankings, setRankings] = useState<Record<string, number>>({});
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const { data: session } = trpc.deliberation.getSession.useQuery(
|
||||
{ sessionId },
|
||||
{ enabled: open }
|
||||
);
|
||||
|
||||
const adminDecideMutation = trpc.deliberation.adminDecide.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.deliberation.getSession.invalidate();
|
||||
utils.deliberation.aggregate.invalidate();
|
||||
toast.success('Admin override applied successfully');
|
||||
onOpenChange(false);
|
||||
setRankings({});
|
||||
setReason('');
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!reason.trim()) {
|
||||
toast.error('Reason is required for admin override');
|
||||
return;
|
||||
}
|
||||
|
||||
const rankingsArray = Object.entries(rankings).map(([projectId, rank]) => ({
|
||||
projectId,
|
||||
rank
|
||||
}));
|
||||
|
||||
if (rankingsArray.length === 0) {
|
||||
toast.error('Please assign at least one rank');
|
||||
return;
|
||||
}
|
||||
|
||||
adminDecideMutation.mutate({
|
||||
sessionId,
|
||||
rankings: rankingsArray,
|
||||
reason: reason.trim()
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Admin Override</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manually set the final rankings for this deliberation session. This action will be
|
||||
audited.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Label>Project Rankings</Label>
|
||||
<div className="space-y-2">
|
||||
{projectIds.map((projectId) => {
|
||||
const project = session?.projects?.find((p: any) => p.id === projectId);
|
||||
return (
|
||||
<div key={projectId} className="flex items-center gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Rank"
|
||||
value={rankings[projectId] || ''}
|
||||
onChange={(e) =>
|
||||
setRankings({
|
||||
...rankings,
|
||||
[projectId]: parseInt(e.target.value) || 0
|
||||
})
|
||||
}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="flex-1 text-sm">{project?.title || projectId}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reason">Reason (Required) *</Label>
|
||||
<Textarea
|
||||
id="reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Explain why this admin override is necessary..."
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={adminDecideMutation.isPending || !reason.trim()}
|
||||
>
|
||||
{adminDecideMutation.isPending ? 'Applying...' : 'Apply Override'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
179
src/components/admin/deliberation/results-panel.tsx
Normal file
179
src/components/admin/deliberation/results-panel.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from '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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AdminOverrideDialog } from './admin-override-dialog';
|
||||
|
||||
interface ResultsPanelProps {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export function ResultsPanel({ sessionId }: ResultsPanelProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const [overrideDialogOpen, setOverrideDialogOpen] = useState(false);
|
||||
|
||||
const { data: session } = trpc.deliberation.getSession.useQuery({ sessionId });
|
||||
const { data: aggregatedResults } = trpc.deliberation.aggregate.useQuery({ sessionId });
|
||||
|
||||
const initRunoffMutation = trpc.deliberation.initRunoff.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.deliberation.getSession.invalidate();
|
||||
utils.deliberation.aggregate.invalidate();
|
||||
toast.success('Runoff voting initiated');
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
const finalizeMutation = trpc.deliberation.finalize.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.deliberation.getSession.invalidate();
|
||||
toast.success('Results finalized successfully');
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
if (!aggregatedResults) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<p className="text-muted-foreground">No voting results yet</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Detect ties: check if two or more top-ranked candidates share the same totalScore
|
||||
const hasTie = (() => {
|
||||
const rankings = aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }> | undefined;
|
||||
if (!rankings || rankings.length < 2) return false;
|
||||
// Group projects by totalScore
|
||||
const scoreGroups = new Map<number, string[]>();
|
||||
for (const r of rankings) {
|
||||
const score = r.totalScore ?? 0;
|
||||
const group = scoreGroups.get(score) || [];
|
||||
group.push(r.projectId);
|
||||
scoreGroups.set(score, group);
|
||||
}
|
||||
// A tie exists if the highest score is shared by 2+ projects
|
||||
const topScore = Math.max(...scoreGroups.keys());
|
||||
const topGroup = scoreGroups.get(topScore);
|
||||
return (topGroup?.length ?? 0) >= 2;
|
||||
})();
|
||||
const tiedProjectIds = hasTie
|
||||
? (aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }>)
|
||||
.filter((r) => r.totalScore === (aggregatedResults.rankings as Array<{ totalScore?: number }>)[0]?.totalScore)
|
||||
.map((r) => r.projectId)
|
||||
: [];
|
||||
const canFinalize = session?.status === 'DELIB_TALLYING' && !hasTie;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Results Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Voting Results</CardTitle>
|
||||
<CardDescription>
|
||||
Aggregated voting results
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{aggregatedResults.rankings?.map((result: any, index: number) => (
|
||||
<div
|
||||
key={result.projectId}
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-bold">
|
||||
#{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{result.projectTitle}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.votes} votes • {result.averageRank?.toFixed(2)} avg rank
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-lg">
|
||||
{result.totalScore?.toFixed(1) || 0}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tie Warning */}
|
||||
{hasTie && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Tie Detected</AlertTitle>
|
||||
<AlertDescription className="space-y-3">
|
||||
<p>
|
||||
Multiple projects have the same score. You must resolve this before finalizing
|
||||
results.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => initRunoffMutation.mutate({ sessionId, tiedProjectIds })}
|
||||
disabled={initRunoffMutation.isPending}
|
||||
>
|
||||
Initiate Runoff Vote
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOverrideDialogOpen(true)}
|
||||
>
|
||||
Admin Override
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Finalize Button */}
|
||||
{canFinalize && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<div>
|
||||
<p className="font-medium">Ready to Finalize</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All votes counted, no ties detected
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => finalizeMutation.mutate({ sessionId })}
|
||||
disabled={finalizeMutation.isPending}
|
||||
>
|
||||
{finalizeMutation.isPending ? 'Finalizing...' : 'Finalize Results'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Admin Override Dialog */}
|
||||
<AdminOverrideDialog
|
||||
sessionId={sessionId}
|
||||
open={overrideDialogOpen}
|
||||
onOpenChange={setOverrideDialogOpen}
|
||||
projectIds={aggregatedResults.rankings?.map((r: any) => r.projectId) || []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Bot,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
@@ -38,7 +39,7 @@ import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface EvaluationSummaryCardProps {
|
||||
projectId: string
|
||||
stageId: string
|
||||
roundId: string
|
||||
}
|
||||
|
||||
interface ScoringPatterns {
|
||||
@@ -71,7 +72,7 @@ const sentimentColors: Record<string, { badge: 'default' | 'secondary' | 'destru
|
||||
|
||||
export function EvaluationSummaryCard({
|
||||
projectId,
|
||||
stageId,
|
||||
roundId,
|
||||
}: EvaluationSummaryCardProps) {
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
@@ -79,7 +80,7 @@ export function EvaluationSummaryCard({
|
||||
data: summary,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = trpc.evaluation.getSummary.useQuery({ projectId, stageId })
|
||||
} = trpc.evaluation.getSummary.useQuery({ projectId, roundId })
|
||||
|
||||
const generateMutation = trpc.evaluation.generateSummary.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -95,7 +96,7 @@ export function EvaluationSummaryCard({
|
||||
|
||||
const handleGenerate = () => {
|
||||
setIsGenerating(true)
|
||||
generateMutation.mutate({ projectId, stageId })
|
||||
generateMutation.mutate({ projectId, roundId })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -157,6 +158,10 @@ export function EvaluationSummaryCard({
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
AI Evaluation Summary
|
||||
<Badge variant="outline" className="text-xs gap-1 shrink-0 ml-1">
|
||||
<Bot className="h-3 w-3" />
|
||||
AI Generated
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2 mt-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
|
||||
@@ -63,7 +63,7 @@ function getMimeLabel(mime: string): string {
|
||||
}
|
||||
|
||||
interface FileRequirementsEditorProps {
|
||||
stageId: string;
|
||||
roundId: string;
|
||||
}
|
||||
|
||||
interface RequirementFormData {
|
||||
@@ -83,35 +83,35 @@ const emptyForm: RequirementFormData = {
|
||||
};
|
||||
|
||||
export function FileRequirementsEditor({
|
||||
stageId,
|
||||
roundId,
|
||||
}: FileRequirementsEditorProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: requirements = [], isLoading } =
|
||||
trpc.file.listRequirements.useQuery({ stageId });
|
||||
trpc.file.listRequirements.useQuery({ roundId });
|
||||
const createMutation = trpc.file.createRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ stageId });
|
||||
utils.file.listRequirements.invalidate({ roundId });
|
||||
toast.success("Requirement created");
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
const updateMutation = trpc.file.updateRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ stageId });
|
||||
utils.file.listRequirements.invalidate({ roundId });
|
||||
toast.success("Requirement updated");
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
const deleteMutation = trpc.file.deleteRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ stageId });
|
||||
utils.file.listRequirements.invalidate({ roundId });
|
||||
toast.success("Requirement deleted");
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
const reorderMutation = trpc.file.reorderRequirements.useMutation({
|
||||
onSuccess: () => utils.file.listRequirements.invalidate({ stageId }),
|
||||
onSuccess: () => utils.file.listRequirements.invalidate({ roundId }),
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
@@ -156,7 +156,7 @@ export function FileRequirementsEditor({
|
||||
});
|
||||
} else {
|
||||
await createMutation.mutateAsync({
|
||||
stageId,
|
||||
roundId,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
acceptedMimeTypes: form.acceptedMimeTypes,
|
||||
@@ -182,7 +182,7 @@ export function FileRequirementsEditor({
|
||||
newOrder[index],
|
||||
];
|
||||
await reorderMutation.mutateAsync({
|
||||
stageId,
|
||||
roundId,
|
||||
orderedIds: newOrder.map((r) => r.id),
|
||||
});
|
||||
};
|
||||
|
||||
168
src/components/admin/jury/add-member-dialog.tsx
Normal file
168
src/components/admin/jury/add-member-dialog.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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'
|
||||
|
||||
interface AddMemberDialogProps {
|
||||
juryGroupId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('')
|
||||
const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
|
||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: userResponse, isLoading: isSearching } = trpc.user.list.useQuery(
|
||||
{ search: searchQuery, perPage: 20 },
|
||||
{ enabled: searchQuery.length > 0 }
|
||||
)
|
||||
|
||||
const users = userResponse?.users || []
|
||||
|
||||
const { mutate: addMember, isPending } = trpc.juryGroup.addMember.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.juryGroup.getById.invalidate({ id: juryGroupId })
|
||||
toast.success('Member added successfully')
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
setSearchQuery('')
|
||||
setSelectedUserId('')
|
||||
setRole('MEMBER')
|
||||
setMaxAssignments('')
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!selectedUserId) {
|
||||
toast.error('Please select a user')
|
||||
return
|
||||
}
|
||||
|
||||
addMember({
|
||||
juryGroupId,
|
||||
userId: selectedUserId,
|
||||
role,
|
||||
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Member to Jury Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search for a user and assign them to this jury group
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search">Search User</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Search by name or email..."
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{isSearching && (
|
||||
<p className="text-sm text-muted-foreground">Searching...</p>
|
||||
)}
|
||||
{users && users.length > 0 && (
|
||||
<div className="max-h-40 overflow-y-auto border rounded-md">
|
||||
{users.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-accent ${
|
||||
selectedUserId === user.id ? 'bg-accent' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedUserId(user.id)
|
||||
setSearchQuery(user.email)
|
||||
}}
|
||||
>
|
||||
<div className="font-medium">{user.name || 'Unnamed User'}</div>
|
||||
<div className="text-muted-foreground text-xs">{user.email}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select value={role} onValueChange={(val) => setRole(val as any)}>
|
||||
<SelectTrigger id="role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MEMBER">Member</SelectItem>
|
||||
<SelectItem value="CHAIR">Chair</SelectItem>
|
||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label>
|
||||
<Input
|
||||
id="maxAssignments"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Leave empty to use group default"
|
||||
value={maxAssignments}
|
||||
onChange={(e) => setMaxAssignments(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !selectedUserId}>
|
||||
{isPending ? 'Adding...' : 'Add Member'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
156
src/components/admin/jury/jury-members-table.tsx
Normal file
156
src/components/admin/jury/jury-members-table.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Trash2, UserPlus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { AddMemberDialog } from './add-member-dialog'
|
||||
|
||||
interface JuryMember {
|
||||
id: string
|
||||
userId: string
|
||||
role: string
|
||||
user: {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string
|
||||
}
|
||||
maxAssignmentsOverride: number | null
|
||||
preferredStartupRatio: number | null
|
||||
}
|
||||
|
||||
interface JuryMembersTableProps {
|
||||
juryGroupId: string
|
||||
members: JuryMember[]
|
||||
}
|
||||
|
||||
export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps) {
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { mutate: removeMember, isPending: isRemoving } = trpc.juryGroup.removeMember.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.juryGroup.getById.invalidate({ id: juryGroupId })
|
||||
toast.success('Member removed successfully')
|
||||
setRemovingMemberId(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
setRemovingMemberId(null)
|
||||
},
|
||||
})
|
||||
|
||||
const handleRemove = (memberId: string) => {
|
||||
removeMember({ id: memberId })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setAddDialogOpen(true)}>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Add Member
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Role</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No members yet. Add members to get started.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell className="font-medium">
|
||||
{member.user.name || 'Unnamed User'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{member.user.email}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<Badge variant={member.role === 'CHAIR' ? 'default' : 'secondary'}>
|
||||
{member.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
{member.maxAssignmentsOverride ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRemovingMemberId(member.id)}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<AddMemberDialog
|
||||
juryGroupId={juryGroupId}
|
||||
open={addDialogOpen}
|
||||
onOpenChange={setAddDialogOpen}
|
||||
/>
|
||||
|
||||
<AlertDialog open={!!removingMemberId} onOpenChange={() => setRemovingMemberId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Member</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove this member from the jury group? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => removingMemberId && handleRemove(removingMemberId)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
194
src/components/admin/live/live-control-panel.tsx
Normal file
194
src/components/admin/live/live-control-panel.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from '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 { ChevronLeft, ChevronRight, Play, Square, Timer } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface LiveControlPanelProps {
|
||||
roundId: string;
|
||||
competitionId: string;
|
||||
}
|
||||
|
||||
export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const [timerSeconds, setTimerSeconds] = useState(300); // 5 minutes default
|
||||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
||||
|
||||
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId });
|
||||
// TODO: Add getScores to live router
|
||||
const scores: any[] = [];
|
||||
|
||||
// TODO: Implement cursor mutation
|
||||
const moveCursorMutation = {
|
||||
mutate: () => {},
|
||||
isPending: false
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTimerRunning) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTimerSeconds((prev) => {
|
||||
if (prev <= 1) {
|
||||
setIsTimerRunning(false);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isTimerRunning]);
|
||||
|
||||
const handlePrevious = () => {
|
||||
// TODO: Implement previous navigation
|
||||
toast.info('Previous navigation not yet implemented');
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
// TODO: Implement next navigation
|
||||
toast.info('Next navigation not yet implemented');
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Project Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Current Project</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handlePrevious}
|
||||
disabled={moveCursorMutation.isPending}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleNext}
|
||||
disabled={moveCursorMutation.isPending}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{cursor?.activeProject ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold">{cursor.activeProject.title}</h3>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Total projects: {cursor.totalProjects}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No project selected</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Timer Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Timer className="h-5 w-5" />
|
||||
Timer
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-bold tabular-nums">{formatTime(timerSeconds)}</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
{!isTimerRunning ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => setIsTimerRunning(true)}
|
||||
disabled={timerSeconds === 0}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Timer
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="flex-1" onClick={() => setIsTimerRunning(false)} variant="destructive">
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
Stop Timer
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTimerSeconds(300);
|
||||
setIsTimerRunning(false);
|
||||
}}
|
||||
>
|
||||
Reset (5:00)
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Voting Controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Voting Controls</CardTitle>
|
||||
<CardDescription>Manage jury and audience voting</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button className="w-full" variant="default">
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Open Jury Voting
|
||||
</Button>
|
||||
<Button className="w-full" variant="outline">
|
||||
Close Voting
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Scores Display */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Live Scores</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{scores && scores.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{scores.map((score: any, index: number) => (
|
||||
<div
|
||||
key={score.projectId}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
#{index + 1} {score.projectTitle}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{score.votes} votes</p>
|
||||
</div>
|
||||
<Badge variant="outline">{score.totalScore.toFixed(1)}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground">No scores yet</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/components/admin/live/project-navigator-grid.tsx
Normal file
55
src/components/admin/live/project-navigator-grid.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface ProjectNavigatorGridProps {
|
||||
projects: Project[];
|
||||
currentProjectId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ProjectNavigatorGrid({
|
||||
projects,
|
||||
currentProjectId,
|
||||
onSelect
|
||||
}: ProjectNavigatorGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
{projects.map((project, index) => (
|
||||
<Card
|
||||
key={project.id}
|
||||
className={cn(
|
||||
'cursor-pointer transition-all hover:shadow-md',
|
||||
currentProjectId === project.id && 'ring-2 ring-primary'
|
||||
)}
|
||||
onClick={() => onSelect(project.id)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-bold text-muted-foreground">#{index + 1}</span>
|
||||
{currentProjectId === project.id && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Current
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm font-medium">{project.title}</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.category}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,15 +18,15 @@ import {
|
||||
} from '@/lib/pdf-generator'
|
||||
|
||||
interface PdfReportProps {
|
||||
stageId: string
|
||||
roundId: string
|
||||
sections: string[]
|
||||
}
|
||||
|
||||
export function PdfReportGenerator({ stageId, sections }: PdfReportProps) {
|
||||
export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const { refetch } = trpc.export.getReportData.useQuery(
|
||||
{ stageId, sections },
|
||||
{ roundId, sections },
|
||||
{ enabled: false }
|
||||
)
|
||||
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, 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 { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Save, Loader2 } from 'lucide-react'
|
||||
|
||||
type TrackAwardLite = {
|
||||
id: string
|
||||
name: string
|
||||
decisionMode: 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION' | null
|
||||
specialAward: {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
criteriaText: string | null
|
||||
useAiEligibility: boolean
|
||||
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||
maxRankedPicks: number | null
|
||||
votingStartAt: Date | null
|
||||
votingEndAt: Date | null
|
||||
status: string
|
||||
} | null
|
||||
}
|
||||
|
||||
type AwardGovernanceEditorProps = {
|
||||
pipelineId: string
|
||||
tracks: TrackAwardLite[]
|
||||
}
|
||||
|
||||
type AwardDraft = {
|
||||
trackId: string
|
||||
awardId: string
|
||||
awardName: string
|
||||
description: string
|
||||
criteriaText: string
|
||||
useAiEligibility: boolean
|
||||
decisionMode: 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'
|
||||
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||
maxRankedPicks: string
|
||||
votingStartAt: string
|
||||
votingEndAt: string
|
||||
}
|
||||
|
||||
function toDateTimeInputValue(value: Date | null | undefined): string {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
|
||||
return local.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function toDateOrUndefined(value: string): Date | undefined {
|
||||
if (!value) return undefined
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed
|
||||
}
|
||||
|
||||
export function AwardGovernanceEditor({
|
||||
pipelineId,
|
||||
tracks,
|
||||
}: AwardGovernanceEditorProps) {
|
||||
const utils = trpc.useUtils()
|
||||
const [drafts, setDrafts] = useState<Record<string, AwardDraft>>({})
|
||||
|
||||
const awardTracks = useMemo(
|
||||
() => tracks.filter((track) => !!track.specialAward),
|
||||
[tracks]
|
||||
)
|
||||
|
||||
const updateAward = trpc.specialAward.update.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const configureGovernance = trpc.award.configureGovernance.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const nextDrafts: Record<string, AwardDraft> = {}
|
||||
for (const track of awardTracks) {
|
||||
const award = track.specialAward
|
||||
if (!award) continue
|
||||
|
||||
nextDrafts[track.id] = {
|
||||
trackId: track.id,
|
||||
awardId: award.id,
|
||||
awardName: award.name,
|
||||
description: award.description ?? '',
|
||||
criteriaText: award.criteriaText ?? '',
|
||||
useAiEligibility: award.useAiEligibility,
|
||||
decisionMode: track.decisionMode ?? 'JURY_VOTE',
|
||||
scoringMode: award.scoringMode,
|
||||
maxRankedPicks: award.maxRankedPicks?.toString() ?? '',
|
||||
votingStartAt: toDateTimeInputValue(award.votingStartAt),
|
||||
votingEndAt: toDateTimeInputValue(award.votingEndAt),
|
||||
}
|
||||
}
|
||||
setDrafts(nextDrafts)
|
||||
}, [awardTracks])
|
||||
|
||||
const isSaving = updateAward.isPending || configureGovernance.isPending
|
||||
|
||||
const handleSave = async (trackId: string) => {
|
||||
const draft = drafts[trackId]
|
||||
if (!draft) return
|
||||
|
||||
const votingStartAt = toDateOrUndefined(draft.votingStartAt)
|
||||
const votingEndAt = toDateOrUndefined(draft.votingEndAt)
|
||||
if (votingStartAt && votingEndAt && votingEndAt <= votingStartAt) {
|
||||
toast.error('Voting end must be after voting start')
|
||||
return
|
||||
}
|
||||
|
||||
const maxRankedPicks = draft.maxRankedPicks
|
||||
? parseInt(draft.maxRankedPicks, 10)
|
||||
: undefined
|
||||
|
||||
await updateAward.mutateAsync({
|
||||
id: draft.awardId,
|
||||
name: draft.awardName.trim(),
|
||||
description: draft.description.trim() || undefined,
|
||||
criteriaText: draft.criteriaText.trim() || undefined,
|
||||
useAiEligibility: draft.useAiEligibility,
|
||||
scoringMode: draft.scoringMode,
|
||||
maxRankedPicks,
|
||||
votingStartAt,
|
||||
votingEndAt,
|
||||
})
|
||||
|
||||
await configureGovernance.mutateAsync({
|
||||
trackId: draft.trackId,
|
||||
decisionMode: draft.decisionMode,
|
||||
scoringMode: draft.scoringMode,
|
||||
maxRankedPicks,
|
||||
votingStartAt,
|
||||
votingEndAt,
|
||||
})
|
||||
|
||||
await utils.pipeline.getDraft.invalidate({ id: pipelineId })
|
||||
toast.success('Award governance updated')
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Award Governance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{awardTracks.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No award tracks in this pipeline.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{awardTracks.map((track) => {
|
||||
const draft = drafts[track.id]
|
||||
if (!draft) return null
|
||||
|
||||
return (
|
||||
<div key={track.id} className="rounded-md border p-3 space-y-3">
|
||||
<p className="text-sm font-medium">{track.name}</p>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Award Name</Label>
|
||||
<Input
|
||||
value={draft.awardName}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[track.id]: { ...draft, awardName: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Decision Mode</Label>
|
||||
<Select
|
||||
value={draft.decisionMode}
|
||||
onValueChange={(value) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[track.id]: {
|
||||
...draft,
|
||||
decisionMode: value as AwardDraft['decisionMode'],
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="JURY_VOTE">Jury Vote</SelectItem>
|
||||
<SelectItem value="AWARD_MASTER_DECISION">
|
||||
Award Master Decision
|
||||
</SelectItem>
|
||||
<SelectItem value="ADMIN_DECISION">Admin Decision</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Scoring Mode</Label>
|
||||
<Select
|
||||
value={draft.scoringMode}
|
||||
onValueChange={(value) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[track.id]: {
|
||||
...draft,
|
||||
scoringMode: value as AwardDraft['scoringMode'],
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<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="space-y-1">
|
||||
<Label className="text-xs">Max Ranked Picks</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={draft.maxRankedPicks}
|
||||
disabled={draft.scoringMode !== 'RANKED'}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[track.id]: {
|
||||
...draft,
|
||||
maxRankedPicks: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={draft.useAiEligibility}
|
||||
onCheckedChange={(checked) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[track.id]: {
|
||||
...draft,
|
||||
useAiEligibility: checked,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Label className="text-xs">AI Eligibility</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Voting Start</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={draft.votingStartAt}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[track.id]: { ...draft, votingStartAt: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Voting End</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={draft.votingEndAt}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[track.id]: { ...draft, votingEndAt: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={draft.description}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[track.id]: { ...draft, description: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Eligibility Criteria</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={draft.criteriaText}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[track.id]: { ...draft, criteriaText: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => handleSave(track.id)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<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 Award Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, 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 {
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Loader2,
|
||||
Play,
|
||||
} from 'lucide-react'
|
||||
|
||||
type FilteringRulesEditorProps = {
|
||||
stageId: string
|
||||
}
|
||||
|
||||
type RuleDraft = {
|
||||
id: string
|
||||
name: string
|
||||
ruleType: 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING'
|
||||
priority: number
|
||||
configText: string
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_BY_TYPE: Record<
|
||||
RuleDraft['ruleType'],
|
||||
Record<string, unknown>
|
||||
> = {
|
||||
FIELD_BASED: {
|
||||
conditions: [
|
||||
{
|
||||
field: 'competitionCategory',
|
||||
operator: 'equals',
|
||||
value: 'STARTUP',
|
||||
},
|
||||
],
|
||||
logic: 'AND',
|
||||
action: 'PASS',
|
||||
},
|
||||
DOCUMENT_CHECK: {
|
||||
requiredFileTypes: ['application/pdf'],
|
||||
minFileCount: 1,
|
||||
action: 'REJECT',
|
||||
},
|
||||
AI_SCREENING: {
|
||||
criteriaText:
|
||||
'Project must clearly demonstrate ocean impact and practical feasibility.',
|
||||
action: 'FLAG',
|
||||
},
|
||||
}
|
||||
|
||||
export function FilteringRulesEditor({ stageId }: FilteringRulesEditorProps) {
|
||||
const utils = trpc.useUtils()
|
||||
const [drafts, setDrafts] = useState<Record<string, RuleDraft>>({})
|
||||
|
||||
const { data: rules = [], isLoading } = trpc.filtering.getRules.useQuery({
|
||||
stageId,
|
||||
})
|
||||
|
||||
const createRule = trpc.filtering.createRule.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.filtering.getRules.invalidate({ stageId })
|
||||
toast.success('Filtering rule created')
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const updateRule = trpc.filtering.updateRule.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.filtering.getRules.invalidate({ stageId })
|
||||
toast.success('Filtering rule updated')
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const deleteRule = trpc.filtering.deleteRule.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.filtering.getRules.invalidate({ stageId })
|
||||
toast.success('Filtering rule deleted')
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const reorderRules = trpc.filtering.reorderRules.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.filtering.getRules.invalidate({ stageId })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const executeRules = trpc.filtering.executeRules.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(
|
||||
`Filtering executed: ${data.passed} passed, ${data.filteredOut} filtered, ${data.flagged} flagged`
|
||||
)
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const orderedRules = useMemo(
|
||||
() => [...rules].sort((a, b) => a.priority - b.priority),
|
||||
[rules]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const nextDrafts: Record<string, RuleDraft> = {}
|
||||
for (const rule of orderedRules) {
|
||||
nextDrafts[rule.id] = {
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
ruleType: rule.ruleType,
|
||||
priority: rule.priority,
|
||||
configText: JSON.stringify(rule.configJson ?? {}, null, 2),
|
||||
}
|
||||
}
|
||||
setDrafts(nextDrafts)
|
||||
}, [orderedRules])
|
||||
|
||||
const handleCreateRule = async () => {
|
||||
const priority = orderedRules.length
|
||||
await createRule.mutateAsync({
|
||||
stageId,
|
||||
name: `Rule ${priority + 1}`,
|
||||
ruleType: 'FIELD_BASED',
|
||||
priority,
|
||||
configJson: DEFAULT_CONFIG_BY_TYPE.FIELD_BASED,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaveRule = async (ruleId: string) => {
|
||||
const draft = drafts[ruleId]
|
||||
if (!draft) return
|
||||
|
||||
let parsedConfig: Record<string, unknown>
|
||||
try {
|
||||
parsedConfig = JSON.parse(draft.configText) as Record<string, unknown>
|
||||
} catch {
|
||||
toast.error('Rule config must be valid JSON')
|
||||
return
|
||||
}
|
||||
|
||||
await updateRule.mutateAsync({
|
||||
id: ruleId,
|
||||
name: draft.name.trim(),
|
||||
ruleType: draft.ruleType,
|
||||
priority: draft.priority,
|
||||
configJson: parsedConfig,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMoveRule = async (index: number, direction: 'up' | 'down') => {
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1
|
||||
if (targetIndex < 0 || targetIndex >= orderedRules.length) return
|
||||
|
||||
const reordered = [...orderedRules]
|
||||
const temp = reordered[index]
|
||||
reordered[index] = reordered[targetIndex]
|
||||
reordered[targetIndex] = temp
|
||||
|
||||
await reorderRules.mutateAsync({
|
||||
rules: reordered.map((rule, idx) => ({
|
||||
id: rule.id,
|
||||
priority: idx,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Filtering Rules</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
Loading rules...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-sm">Filtering Rules</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => executeRules.mutate({ stageId })}
|
||||
disabled={executeRules.isPending}
|
||||
>
|
||||
{executeRules.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Run
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleCreateRule}
|
||||
disabled={createRule.isPending}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{orderedRules.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No filtering rules configured yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{orderedRules.map((rule, index) => {
|
||||
const draft = drafts[rule.id]
|
||||
if (!draft) return null
|
||||
|
||||
return (
|
||||
<div key={rule.id} className="rounded-md border p-3 space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-12">
|
||||
<div className="sm:col-span-5 space-y-1">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[rule.id]: {
|
||||
...draft,
|
||||
name: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-4 space-y-1">
|
||||
<Label className="text-xs">Rule Type</Label>
|
||||
<Select
|
||||
value={draft.ruleType}
|
||||
onValueChange={(value) => {
|
||||
const ruleType = value as RuleDraft['ruleType']
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[rule.id]: {
|
||||
...draft,
|
||||
ruleType,
|
||||
configText: JSON.stringify(
|
||||
DEFAULT_CONFIG_BY_TYPE[ruleType],
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="FIELD_BASED">Field Based</SelectItem>
|
||||
<SelectItem value="DOCUMENT_CHECK">Document Check</SelectItem>
|
||||
<SelectItem value="AI_SCREENING">AI Screening</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="sm:col-span-3 space-y-1">
|
||||
<Label className="text-xs">Priority</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.priority}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[rule.id]: {
|
||||
...draft,
|
||||
priority: parseInt(e.target.value, 10) || 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Rule Config (JSON)</Label>
|
||||
<Textarea
|
||||
className="font-mono text-xs min-h-28"
|
||||
value={draft.configText}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[rule.id]: {
|
||||
...draft,
|
||||
configText: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleMoveRule(index, 'up')}
|
||||
disabled={index === 0 || reorderRules.isPending}
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleMoveRule(index, 'down')}
|
||||
disabled={
|
||||
index === orderedRules.length - 1 || reorderRules.isPending
|
||||
}
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleSaveRule(rule.id)}
|
||||
disabled={updateRule.isPending}
|
||||
>
|
||||
{updateRule.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
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => deleteRule.mutate({ id: rule.id })}
|
||||
disabled={deleteRule.isPending}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import { motion } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
type StageNode = {
|
||||
id: string
|
||||
name: string
|
||||
stageType: string
|
||||
sortOrder: number
|
||||
_count?: { projectStageStates: number }
|
||||
}
|
||||
|
||||
type FlowchartTrack = {
|
||||
id: string
|
||||
name: string
|
||||
kind: string
|
||||
sortOrder: number
|
||||
stages: StageNode[]
|
||||
}
|
||||
|
||||
type PipelineFlowchartProps = {
|
||||
tracks: FlowchartTrack[]
|
||||
selectedStageId?: string | null
|
||||
onStageSelect?: (stageId: string) => void
|
||||
className?: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const stageTypeColors: Record<string, { bg: string; border: string; text: string; glow: string }> = {
|
||||
INTAKE: { bg: '#eff6ff', border: '#93c5fd', text: '#1d4ed8', glow: '#3b82f6' },
|
||||
FILTER: { bg: '#fffbeb', border: '#fcd34d', text: '#b45309', glow: '#f59e0b' },
|
||||
EVALUATION: { bg: '#faf5ff', border: '#c084fc', text: '#7e22ce', glow: '#a855f7' },
|
||||
SELECTION: { bg: '#fff1f2', border: '#fda4af', text: '#be123c', glow: '#f43f5e' },
|
||||
LIVE_FINAL: { bg: '#ecfdf5', border: '#6ee7b7', text: '#047857', glow: '#10b981' },
|
||||
RESULTS: { bg: '#ecfeff', border: '#67e8f9', text: '#0e7490', glow: '#06b6d4' },
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 140
|
||||
const NODE_HEIGHT = 70
|
||||
const NODE_GAP = 32
|
||||
const ARROW_SIZE = 6
|
||||
const TRACK_LABEL_HEIGHT = 28
|
||||
const TRACK_GAP = 20
|
||||
|
||||
export function PipelineFlowchart({
|
||||
tracks,
|
||||
selectedStageId,
|
||||
onStageSelect,
|
||||
className,
|
||||
compact = false,
|
||||
}: PipelineFlowchartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [hoveredStageId, setHoveredStageId] = useState<string | null>(null)
|
||||
|
||||
const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
||||
// Calculate dimensions
|
||||
const nodeW = compact ? 100 : NODE_WIDTH
|
||||
const nodeH = compact ? 50 : NODE_HEIGHT
|
||||
const gap = compact ? 20 : NODE_GAP
|
||||
|
||||
const maxStages = Math.max(...sortedTracks.map((t) => t.stages.length), 1)
|
||||
const totalWidth = maxStages * nodeW + (maxStages - 1) * gap + 40
|
||||
const totalHeight =
|
||||
sortedTracks.length * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) - TRACK_GAP + 20
|
||||
|
||||
const getNodePosition = useCallback(
|
||||
(trackIndex: number, stageIndex: number) => {
|
||||
const x = 20 + stageIndex * (nodeW + gap)
|
||||
const y = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) + TRACK_LABEL_HEIGHT
|
||||
return { x, y }
|
||||
},
|
||||
[nodeW, nodeH, gap]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('relative rounded-lg border bg-card', className)}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
width={totalWidth}
|
||||
height={totalHeight}
|
||||
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
|
||||
className="min-w-full"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth={ARROW_SIZE}
|
||||
markerHeight={ARROW_SIZE}
|
||||
refX={ARROW_SIZE}
|
||||
refY={ARROW_SIZE / 2}
|
||||
orient="auto"
|
||||
>
|
||||
<path
|
||||
d={`M 0 0 L ${ARROW_SIZE} ${ARROW_SIZE / 2} L 0 ${ARROW_SIZE} Z`}
|
||||
fill="#94a3b8"
|
||||
/>
|
||||
</marker>
|
||||
{/* Glow filter for selected node */}
|
||||
<filter id="selectedGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feFlood floodColor="#3b82f6" floodOpacity="0.3" result="color" />
|
||||
<feComposite in="color" in2="blur" operator="in" result="glow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="glow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{sortedTracks.map((track, trackIndex) => {
|
||||
const sortedStages = [...track.stages].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder
|
||||
)
|
||||
const trackY = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP)
|
||||
|
||||
return (
|
||||
<g key={track.id}>
|
||||
{/* Track label */}
|
||||
<text
|
||||
x={20}
|
||||
y={trackY + 14}
|
||||
className="fill-muted-foreground text-[11px] font-medium"
|
||||
style={{ fontFamily: 'inherit' }}
|
||||
>
|
||||
{track.name}
|
||||
{track.kind !== 'MAIN' && ` (${track.kind})`}
|
||||
</text>
|
||||
|
||||
{/* Arrows between stages */}
|
||||
{sortedStages.map((stage, stageIndex) => {
|
||||
if (stageIndex === 0) return null
|
||||
const from = getNodePosition(trackIndex, stageIndex - 1)
|
||||
const to = getNodePosition(trackIndex, stageIndex)
|
||||
const arrowY = from.y + nodeH / 2
|
||||
return (
|
||||
<line
|
||||
key={`arrow-${stage.id}`}
|
||||
x1={from.x + nodeW}
|
||||
y1={arrowY}
|
||||
x2={to.x - 2}
|
||||
y2={arrowY}
|
||||
stroke="#94a3b8"
|
||||
strokeWidth={1.5}
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Stage nodes */}
|
||||
{sortedStages.map((stage, stageIndex) => {
|
||||
const pos = getNodePosition(trackIndex, stageIndex)
|
||||
const isSelected = selectedStageId === stage.id
|
||||
const isHovered = hoveredStageId === stage.id
|
||||
const colors = stageTypeColors[stage.stageType] ?? {
|
||||
bg: '#f8fafc',
|
||||
border: '#cbd5e1',
|
||||
text: '#475569',
|
||||
glow: '#64748b',
|
||||
}
|
||||
const projectCount = stage._count?.projectStageStates ?? 0
|
||||
|
||||
return (
|
||||
<g
|
||||
key={stage.id}
|
||||
onClick={() => onStageSelect?.(stage.id)}
|
||||
onMouseEnter={() => setHoveredStageId(stage.id)}
|
||||
onMouseLeave={() => setHoveredStageId(null)}
|
||||
className={cn(onStageSelect && 'cursor-pointer')}
|
||||
filter={isSelected ? 'url(#selectedGlow)' : undefined}
|
||||
>
|
||||
{/* Selection ring */}
|
||||
{isSelected && (
|
||||
<motion.rect
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
x={pos.x - 3}
|
||||
y={pos.y - 3}
|
||||
width={nodeW + 6}
|
||||
height={nodeH + 6}
|
||||
rx={10}
|
||||
fill="none"
|
||||
stroke={colors.glow}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Node background */}
|
||||
<rect
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
width={nodeW}
|
||||
height={nodeH}
|
||||
rx={8}
|
||||
fill={colors.bg}
|
||||
stroke={isSelected ? colors.glow : colors.border}
|
||||
strokeWidth={isSelected ? 2 : 1}
|
||||
style={{
|
||||
transition: 'stroke 0.15s, stroke-width 0.15s',
|
||||
transform: isHovered && !isSelected ? 'scale(1.02)' : undefined,
|
||||
transformOrigin: `${pos.x + nodeW / 2}px ${pos.y + nodeH / 2}px`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Stage name */}
|
||||
<text
|
||||
x={pos.x + nodeW / 2}
|
||||
y={pos.y + (compact ? 20 : 24)}
|
||||
textAnchor="middle"
|
||||
fill={colors.text}
|
||||
className={cn(compact ? 'text-[10px]' : 'text-xs', 'font-medium')}
|
||||
style={{ fontFamily: 'inherit' }}
|
||||
>
|
||||
{stage.name.length > (compact ? 12 : 16)
|
||||
? stage.name.slice(0, compact ? 10 : 14) + '...'
|
||||
: stage.name}
|
||||
</text>
|
||||
|
||||
{/* Type badge */}
|
||||
<text
|
||||
x={pos.x + nodeW / 2}
|
||||
y={pos.y + (compact ? 34 : 40)}
|
||||
textAnchor="middle"
|
||||
fill={colors.text}
|
||||
className="text-[9px]"
|
||||
style={{ fontFamily: 'inherit', opacity: 0.7 }}
|
||||
>
|
||||
{stage.stageType.replace('_', ' ')}
|
||||
</text>
|
||||
|
||||
{/* Project count */}
|
||||
{!compact && projectCount > 0 && (
|
||||
<>
|
||||
<rect
|
||||
x={pos.x + nodeW / 2 - 14}
|
||||
y={pos.y + nodeH - 18}
|
||||
width={28}
|
||||
height={14}
|
||||
rx={7}
|
||||
fill={colors.border}
|
||||
opacity={0.3}
|
||||
/>
|
||||
<text
|
||||
x={pos.x + nodeW / 2}
|
||||
y={pos.y + nodeH - 8}
|
||||
textAnchor="middle"
|
||||
fill={colors.text}
|
||||
className="text-[9px] font-medium"
|
||||
style={{ fontFamily: 'inherit' }}
|
||||
>
|
||||
{projectCount}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
{/* Scroll hint gradient for mobile */}
|
||||
{totalWidth > 400 && (
|
||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-card to-transparent pointer-events-none sm:hidden" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
type StageNode = {
|
||||
id?: string
|
||||
name: string
|
||||
stageType: string
|
||||
sortOrder: number
|
||||
_count?: { projectStageStates: number }
|
||||
}
|
||||
|
||||
type TrackLane = {
|
||||
id?: string
|
||||
name: string
|
||||
kind: string
|
||||
sortOrder: number
|
||||
stages: StageNode[]
|
||||
}
|
||||
|
||||
type PipelineVisualizationProps = {
|
||||
tracks: TrackLane[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const stageColors: Record<string, string> = {
|
||||
INTAKE: 'bg-blue-50 border-blue-300 text-blue-700',
|
||||
FILTER: 'bg-amber-50 border-amber-300 text-amber-700',
|
||||
EVALUATION: 'bg-purple-50 border-purple-300 text-purple-700',
|
||||
SELECTION: 'bg-rose-50 border-rose-300 text-rose-700',
|
||||
LIVE_FINAL: 'bg-emerald-50 border-emerald-300 text-emerald-700',
|
||||
RESULTS: 'bg-cyan-50 border-cyan-300 text-cyan-700',
|
||||
}
|
||||
|
||||
const trackKindBadge: Record<string, string> = {
|
||||
MAIN: 'bg-blue-100 text-blue-700',
|
||||
AWARD: 'bg-amber-100 text-amber-700',
|
||||
SHOWCASE: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
export function PipelineVisualization({
|
||||
tracks,
|
||||
className,
|
||||
}: PipelineVisualizationProps) {
|
||||
const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{sortedTracks.map((track) => {
|
||||
const sortedStages = [...track.stages].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder
|
||||
)
|
||||
|
||||
return (
|
||||
<Card key={track.id ?? track.name} className="p-4">
|
||||
{/* Track header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm font-semibold">{track.name}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px] h-5',
|
||||
trackKindBadge[track.kind] ?? ''
|
||||
)}
|
||||
>
|
||||
{track.kind}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Stage flow */}
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-1">
|
||||
{sortedStages.map((stage, index) => (
|
||||
<div key={stage.id ?? index} className="flex items-center gap-1 shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center rounded-lg border px-3 py-2 min-w-[100px]',
|
||||
stageColors[stage.stageType] ?? 'bg-gray-50 border-gray-300'
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-medium text-center leading-tight">
|
||||
{stage.name}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-70 mt-0.5">
|
||||
{stage.stageType.replace('_', ' ')}
|
||||
</span>
|
||||
{stage._count?.projectStageStates !== undefined &&
|
||||
stage._count.projectStageStates > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] h-4 px-1 mt-1"
|
||||
>
|
||||
{stage._count.projectStageStates}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{index < sortedStages.length - 1 && (
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sortedStages.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
No stages
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{tracks.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No tracks to visualize
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { Plus, X, Sparkles, AlertCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// ─── Field & Operator Definitions ────────────────────────────────────────────
|
||||
|
||||
const FIELD_OPTIONS = [
|
||||
{ value: 'competitionCategory', label: 'Competition Category', tooltip: 'Values: STARTUP, BUSINESS_CONCEPT' },
|
||||
{ value: 'oceanIssue', label: 'Ocean Issue', tooltip: 'The ocean issue the project addresses' },
|
||||
{ value: 'country', label: 'Country', tooltip: 'Country of origin' },
|
||||
{ value: 'geographicZone', label: 'Geographic Zone', tooltip: 'Geographic zone of the project' },
|
||||
{ value: 'wantsMentorship', label: 'Wants Mentorship', tooltip: 'Boolean: true or false' },
|
||||
{ value: 'tags', label: 'Tags', tooltip: 'Project tags (comma-separated for "in" operator)' },
|
||||
] as const
|
||||
|
||||
const OPERATOR_OPTIONS = [
|
||||
{ value: 'eq', label: 'equals' },
|
||||
{ value: 'neq', label: 'does not equal' },
|
||||
{ value: 'in', label: 'is one of' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
{ value: 'gt', label: 'greater than' },
|
||||
{ value: 'lt', label: 'less than' },
|
||||
] as const
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type SimpleCondition = {
|
||||
field: string
|
||||
operator: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
type CompoundPredicate = {
|
||||
logic: 'and' | 'or'
|
||||
conditions: SimpleCondition[]
|
||||
}
|
||||
|
||||
type PredicateBuilderProps = {
|
||||
value: Record<string, unknown>
|
||||
onChange: (predicate: Record<string, unknown>) => void
|
||||
pipelineId?: string
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function isSimpleCondition(obj: Record<string, unknown>): obj is SimpleCondition {
|
||||
return typeof obj.field === 'string' && typeof obj.operator === 'string' && 'value' in obj
|
||||
}
|
||||
|
||||
function isCompoundPredicate(obj: Record<string, unknown>): obj is CompoundPredicate {
|
||||
return 'logic' in obj && Array.isArray((obj as CompoundPredicate).conditions)
|
||||
}
|
||||
|
||||
function detectInitialMode(value: Record<string, unknown>): 'simple' | 'ai' | 'advanced' {
|
||||
if (isCompoundPredicate(value)) return 'simple'
|
||||
if (isSimpleCondition(value)) return 'simple'
|
||||
// Empty object or unknown shape
|
||||
if (Object.keys(value).length === 0) return 'simple'
|
||||
return 'advanced'
|
||||
}
|
||||
|
||||
function valueToConditions(value: Record<string, unknown>): SimpleCondition[] {
|
||||
if (isCompoundPredicate(value)) {
|
||||
return value.conditions.map((c) => ({
|
||||
field: c.field || 'competitionCategory',
|
||||
operator: c.operator || 'eq',
|
||||
value: c.value ?? '',
|
||||
}))
|
||||
}
|
||||
if (isSimpleCondition(value)) {
|
||||
return [{ field: value.field, operator: value.operator, value: value.value }]
|
||||
}
|
||||
return [{ field: 'competitionCategory', operator: 'eq', value: '' }]
|
||||
}
|
||||
|
||||
function valueToLogic(value: Record<string, unknown>): 'and' | 'or' {
|
||||
if (isCompoundPredicate(value)) return value.logic
|
||||
return 'and'
|
||||
}
|
||||
|
||||
function conditionsToPredicate(
|
||||
conditions: SimpleCondition[],
|
||||
logic: 'and' | 'or'
|
||||
): Record<string, unknown> {
|
||||
if (conditions.length === 1) {
|
||||
return conditions[0] as unknown as Record<string, unknown>
|
||||
}
|
||||
return { logic, conditions }
|
||||
}
|
||||
|
||||
function displayValue(val: unknown): string {
|
||||
if (Array.isArray(val)) return val.join(', ')
|
||||
if (typeof val === 'boolean') return val ? 'true' : 'false'
|
||||
return String(val ?? '')
|
||||
}
|
||||
|
||||
function parseInputValue(text: string, field: string): unknown {
|
||||
if (field === 'wantsMentorship') {
|
||||
return text.toLowerCase() === 'true'
|
||||
}
|
||||
if (text.includes(',')) {
|
||||
return text.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// ─── Simple Mode ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SimpleMode({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Record<string, unknown>
|
||||
onChange: (predicate: Record<string, unknown>) => void
|
||||
}) {
|
||||
const [conditions, setConditions] = useState<SimpleCondition[]>(() => valueToConditions(value))
|
||||
const [logic, setLogic] = useState<'and' | 'or'>(() => valueToLogic(value))
|
||||
|
||||
const emitChange = useCallback(
|
||||
(nextConditions: SimpleCondition[], nextLogic: 'and' | 'or') => {
|
||||
onChange(conditionsToPredicate(nextConditions, nextLogic))
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const updateCondition = (index: number, field: keyof SimpleCondition, val: unknown) => {
|
||||
const next = conditions.map((c, i) => (i === index ? { ...c, [field]: val } : c))
|
||||
setConditions(next)
|
||||
emitChange(next, logic)
|
||||
}
|
||||
|
||||
const addCondition = () => {
|
||||
const next = [...conditions, { field: 'competitionCategory', operator: 'eq', value: '' }]
|
||||
setConditions(next)
|
||||
emitChange(next, logic)
|
||||
}
|
||||
|
||||
const removeCondition = (index: number) => {
|
||||
if (conditions.length <= 1) return
|
||||
const next = conditions.filter((_, i) => i !== index)
|
||||
setConditions(next)
|
||||
emitChange(next, logic)
|
||||
}
|
||||
|
||||
const toggleLogic = () => {
|
||||
const nextLogic = logic === 'and' ? 'or' : 'and'
|
||||
setLogic(nextLogic)
|
||||
emitChange(conditions, nextLogic)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="space-y-2">
|
||||
{conditions.map((condition, index) => (
|
||||
<div key={index}>
|
||||
{index > 0 && (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-5 px-2 text-[10px] font-medium"
|
||||
onClick={toggleLogic}
|
||||
>
|
||||
{logic.toUpperCase()}
|
||||
</Button>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="w-[160px] shrink-0">
|
||||
<Select
|
||||
value={condition.field}
|
||||
onValueChange={(v) => updateCondition(index, 'field', v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{FIELD_OPTIONS.find((f) => f.value === condition.field)?.tooltip || 'Select a field'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-[130px] shrink-0">
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(v) => updateCondition(index, 'operator', v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATOR_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
className="h-8 text-xs flex-1 min-w-[100px]"
|
||||
value={displayValue(condition.value)}
|
||||
onChange={(e) =>
|
||||
updateCondition(index, 'value', parseInputValue(e.target.value, condition.field))
|
||||
}
|
||||
placeholder={condition.field === 'wantsMentorship' ? 'true / false' : 'e.g. STARTUP'}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeCondition(index)}
|
||||
disabled={conditions.length <= 1}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={addCondition}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add condition
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── AI Mode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function AIMode({
|
||||
value,
|
||||
onChange,
|
||||
pipelineId,
|
||||
onSwitchToSimple,
|
||||
}: {
|
||||
value: Record<string, unknown>
|
||||
onChange: (predicate: Record<string, unknown>) => void
|
||||
pipelineId?: string
|
||||
onSwitchToSimple: () => void
|
||||
}) {
|
||||
const [text, setText] = useState('')
|
||||
const [result, setResult] = useState<{
|
||||
predicateJson: Record<string, unknown>
|
||||
explanation: string
|
||||
} | null>(null)
|
||||
|
||||
const handleGenerate = () => {
|
||||
toast.error('AI rule parsing is not currently available')
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
if (result) {
|
||||
onChange(result.predicateJson)
|
||||
toast.success('Rule applied')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
className="text-xs min-h-16"
|
||||
placeholder='Describe your rule in plain English, e.g. "Route startup projects from France to the Fast Track"'
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={!text.trim() || !pipelineId}
|
||||
>
|
||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||
Generate Rule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!pipelineId && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
Save the pipeline first to enable AI rule generation.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="rounded-md border bg-muted/50 p-3 space-y-2">
|
||||
<div className="text-xs font-medium">Generated Rule</div>
|
||||
<p className="text-xs text-muted-foreground">{result.explanation}</p>
|
||||
<pre className="text-[10px] font-mono bg-background rounded p-2 overflow-x-auto">
|
||||
{JSON.stringify(result.predicateJson, null, 2)}
|
||||
</pre>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" size="sm" className="h-7 text-xs" onClick={handleApply}>
|
||||
Apply Rule
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
onChange(result.predicateJson)
|
||||
onSwitchToSimple()
|
||||
}}
|
||||
>
|
||||
Edit in Simple mode
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(value).length > 0 && !result && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Current predicate: <code className="font-mono">{JSON.stringify(value)}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Advanced Mode ───────────────────────────────────────────────────────────
|
||||
|
||||
function AdvancedMode({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Record<string, unknown>
|
||||
onChange: (predicate: Record<string, unknown>) => void
|
||||
}) {
|
||||
const [jsonText, setJsonText] = useState(() => JSON.stringify(value, null, 2))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
setJsonText(text)
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>
|
||||
setError(null)
|
||||
onChange(parsed)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Invalid JSON')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
className="font-mono text-xs min-h-28"
|
||||
value={jsonText}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder='{ "field": "competitionCategory", "operator": "eq", "value": "STARTUP" }'
|
||||
/>
|
||||
{error && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-destructive">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Use <code className="font-mono">{'{ field, operator, value }'}</code> for simple conditions
|
||||
or <code className="font-mono">{'{ logic: "and"|"or", conditions: [...] }'}</code> for compound rules.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function PredicateBuilder({ value, onChange, pipelineId }: PredicateBuilderProps) {
|
||||
const [activeTab, setActiveTab] = useState<string>(() => detectInitialMode(value))
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="simple" className="text-xs px-3 h-6">
|
||||
Simple
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ai" className="text-xs px-3 h-6">
|
||||
<Sparkles className="mr-1 h-3 w-3" />
|
||||
AI
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced" className="text-xs px-3 h-6">
|
||||
Advanced
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="simple">
|
||||
<SimpleMode value={value} onChange={onChange} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ai">
|
||||
<AIMode
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
pipelineId={pipelineId}
|
||||
onSwitchToSimple={() => setActiveTab('simple')}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced">
|
||||
<AdvancedMode value={value} onChange={onChange} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import type { EvaluationConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
const ASSIGNMENT_CATEGORIES = [
|
||||
{ key: 'STARTUP', label: 'Startups' },
|
||||
{ key: 'BUSINESS_CONCEPT', label: 'Business Concepts' },
|
||||
] as const
|
||||
|
||||
type AssignmentSectionProps = {
|
||||
config: EvaluationConfig
|
||||
onChange: (config: EvaluationConfig) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export function AssignmentSection({ config, onChange, isActive }: AssignmentSectionProps) {
|
||||
const updateConfig = (updates: Partial<EvaluationConfig>) => {
|
||||
onChange({ ...config, ...updates })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Required Reviews per Project</Label>
|
||||
<InfoTooltip content="Number of independent jury evaluations needed per project before it can be decided." />
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={config.requiredReviews ?? 3}
|
||||
disabled={isActive}
|
||||
onChange={(e) =>
|
||||
updateConfig({ requiredReviews: parseInt(e.target.value) || 3 })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Minimum number of jury evaluations per project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Max Load per Juror</Label>
|
||||
<InfoTooltip content="Maximum number of projects a single juror can be assigned in this stage." />
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={config.maxLoadPerJuror ?? 20}
|
||||
disabled={isActive}
|
||||
onChange={(e) =>
|
||||
updateConfig({ maxLoadPerJuror: parseInt(e.target.value) || 20 })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum projects assigned to one juror
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Min Load per Juror</Label>
|
||||
<InfoTooltip content="Minimum target assignments per juror. The system prioritizes jurors below this threshold." />
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={50}
|
||||
value={config.minLoadPerJuror ?? 5}
|
||||
disabled={isActive}
|
||||
onChange={(e) =>
|
||||
updateConfig({ minLoadPerJuror: parseInt(e.target.value) || 5 })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Target minimum projects per juror
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(config.minLoadPerJuror ?? 0) > (config.maxLoadPerJuror ?? 20) && (
|
||||
<p className="text-sm text-destructive">
|
||||
Min load per juror cannot exceed max load per juror.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Availability Weighting</Label>
|
||||
<InfoTooltip content="When enabled, jurors who are available during the voting window are prioritized in assignment." />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Factor in juror availability when assigning projects
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.availabilityWeighting ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ availabilityWeighting: checked })
|
||||
}
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Overflow Policy</Label>
|
||||
<InfoTooltip content="'Queue' holds excess projects, 'Expand Pool' invites more jurors, 'Reduce Reviews' lowers the required review count." />
|
||||
</div>
|
||||
<Select
|
||||
value={config.overflowPolicy ?? 'queue'}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
overflowPolicy: value as EvaluationConfig['overflowPolicy'],
|
||||
})
|
||||
}
|
||||
disabled={isActive}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="queue">
|
||||
Queue — Hold unassigned projects for manual assignment
|
||||
</SelectItem>
|
||||
<SelectItem value="expand_pool">
|
||||
Expand Pool — Invite additional jurors automatically
|
||||
</SelectItem>
|
||||
<SelectItem value="reduce_reviews">
|
||||
Reduce Reviews — Lower required reviews to fit available jurors
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Category Balance */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Balance assignments by category</Label>
|
||||
<InfoTooltip content="Ensure each juror receives a balanced mix of project categories within their assignment limits." />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set per-category min/max assignment targets per juror
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.categoryQuotasEnabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({
|
||||
categoryQuotasEnabled: checked,
|
||||
categoryQuotas: checked
|
||||
? config.categoryQuotas ?? {
|
||||
STARTUP: { min: 0, max: 10 },
|
||||
BUSINESS_CONCEPT: { min: 0, max: 10 },
|
||||
}
|
||||
: config.categoryQuotas,
|
||||
})
|
||||
}
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.categoryQuotasEnabled && (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
{ASSIGNMENT_CATEGORIES.map((cat) => {
|
||||
const catQuota = (config.categoryQuotas ?? {})[cat.key] ?? { min: 0, max: 10 }
|
||||
return (
|
||||
<div key={cat.key} className="space-y-2">
|
||||
<Label className="text-sm font-medium">{cat.label}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Min per juror</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={50}
|
||||
value={catQuota.min}
|
||||
disabled={isActive}
|
||||
onChange={(e) =>
|
||||
updateConfig({
|
||||
categoryQuotas: {
|
||||
...config.categoryQuotas,
|
||||
[cat.key]: {
|
||||
...catQuota,
|
||||
min: parseInt(e.target.value, 10) || 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Max per juror</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={catQuota.max}
|
||||
disabled={isActive}
|
||||
onChange={(e) =>
|
||||
updateConfig({
|
||||
categoryQuotas: {
|
||||
...config.categoryQuotas,
|
||||
[cat.key]: {
|
||||
...catQuota,
|
||||
max: parseInt(e.target.value, 10) || 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{catQuota.min > catQuota.max && (
|
||||
<p className="text-xs text-destructive">
|
||||
Min cannot exceed max for {cat.label.toLowerCase()}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Plus, Trash2, Trophy } from 'lucide-react'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { defaultAwardTrack } from '@/lib/pipeline-defaults'
|
||||
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
|
||||
import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client'
|
||||
|
||||
type AwardsSectionProps = {
|
||||
tracks: WizardTrackConfig[]
|
||||
onChange: (tracks: WizardTrackConfig[]) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps) {
|
||||
const awardTracks = tracks.filter((t) => t.kind === 'AWARD')
|
||||
const nonAwardTracks = tracks.filter((t) => t.kind !== 'AWARD')
|
||||
|
||||
const addAward = () => {
|
||||
const newTrack = defaultAwardTrack(awardTracks.length)
|
||||
newTrack.sortOrder = tracks.length
|
||||
onChange([...tracks, newTrack])
|
||||
}
|
||||
|
||||
const updateAward = (index: number, updates: Partial<WizardTrackConfig>) => {
|
||||
const updated = [...tracks]
|
||||
const awardIndex = tracks.findIndex(
|
||||
(t) => t.kind === 'AWARD' && awardTracks.indexOf(t) === index
|
||||
)
|
||||
if (awardIndex >= 0) {
|
||||
updated[awardIndex] = { ...updated[awardIndex], ...updates }
|
||||
onChange(updated)
|
||||
}
|
||||
}
|
||||
|
||||
const removeAward = (index: number) => {
|
||||
const toRemove = awardTracks[index]
|
||||
onChange(tracks.filter((t) => t !== toRemove))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure special award tracks that run alongside the main competition.
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addAward} disabled={isActive}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add Award Track
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{awardTracks.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
<Trophy className="h-8 w-8 mx-auto mb-2 text-muted-foreground/50" />
|
||||
No award tracks configured. Awards are optional.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{awardTracks.map((track, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Trophy className="h-4 w-4 text-amber-500" />
|
||||
Award Track {index + 1}
|
||||
</CardTitle>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
disabled={isActive}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Award Track?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove the "{track.name}" award track and all
|
||||
its stages. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => removeAward(index)}>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Award Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., Innovation Award"
|
||||
value={track.awardConfig?.name ?? track.name}
|
||||
disabled={isActive}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value
|
||||
updateAward(index, {
|
||||
name,
|
||||
slug: slugify(name),
|
||||
awardConfig: {
|
||||
...track.awardConfig,
|
||||
name,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs">Routing Mode</Label>
|
||||
<InfoTooltip content="Shared: projects compete in the main track and this award simultaneously. Exclusive: projects are routed exclusively to this track." />
|
||||
</div>
|
||||
<Select
|
||||
value={track.routingModeDefault ?? 'SHARED'}
|
||||
onValueChange={(value) =>
|
||||
updateAward(index, {
|
||||
routingModeDefault: value as RoutingMode,
|
||||
})
|
||||
}
|
||||
disabled={isActive}
|
||||
>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SHARED">
|
||||
Shared — Projects compete in main + this award
|
||||
</SelectItem>
|
||||
<SelectItem value="EXCLUSIVE">
|
||||
Exclusive — Projects enter only this track
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs">Decision Mode</Label>
|
||||
<InfoTooltip content="How the winner is determined for this award track." />
|
||||
</div>
|
||||
<Select
|
||||
value={track.decisionMode ?? 'JURY_VOTE'}
|
||||
onValueChange={(value) =>
|
||||
updateAward(index, { decisionMode: value as DecisionMode })
|
||||
}
|
||||
disabled={isActive}
|
||||
>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="JURY_VOTE">Jury Vote</SelectItem>
|
||||
<SelectItem value="AWARD_MASTER_DECISION">
|
||||
Award Master Decision
|
||||
</SelectItem>
|
||||
<SelectItem value="ADMIN_DECISION">Admin Decision</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs">Scoring Mode</Label>
|
||||
<InfoTooltip content="The method used to aggregate scores for this award." />
|
||||
</div>
|
||||
<Select
|
||||
value={track.awardConfig?.scoringMode ?? 'PICK_WINNER'}
|
||||
onValueChange={(value) =>
|
||||
updateAward(index, {
|
||||
awardConfig: {
|
||||
...track.awardConfig!,
|
||||
scoringMode: value as AwardScoringMode,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={isActive}
|
||||
>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
|
||||
<SelectItem value="RANKED">Ranked</SelectItem>
|
||||
<SelectItem value="SCORED">Scored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Description (optional)</Label>
|
||||
<Textarea
|
||||
placeholder="Brief description of this award..."
|
||||
value={track.awardConfig?.description ?? ''}
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
onChange={(e) =>
|
||||
updateAward(index, {
|
||||
awardConfig: {
|
||||
...track.awardConfig!,
|
||||
description: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { WizardState } from '@/types/pipeline-wizard'
|
||||
|
||||
type BasicsSectionProps = {
|
||||
state: WizardState
|
||||
onChange: (updates: Partial<WizardState>) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export function BasicsSection({ state, onChange, isActive }: BasicsSectionProps) {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({})
|
||||
|
||||
// Auto-generate slug from name
|
||||
useEffect(() => {
|
||||
if (state.name && !state.slug) {
|
||||
onChange({ slug: slugify(state.name) })
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pipeline-name">Pipeline Name</Label>
|
||||
<Input
|
||||
id="pipeline-name"
|
||||
placeholder="e.g., MOPC 2026"
|
||||
value={state.name}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value
|
||||
onChange({ name, slug: slugify(name) })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="pipeline-slug">Slug</Label>
|
||||
<InfoTooltip content="URL-friendly identifier. Cannot be changed after the pipeline is activated." />
|
||||
</div>
|
||||
<Input
|
||||
id="pipeline-slug"
|
||||
placeholder="e.g., mopc-2026"
|
||||
value={state.slug}
|
||||
onChange={(e) => onChange({ slug: e.target.value })}
|
||||
pattern="^[a-z0-9-]+$"
|
||||
disabled={isActive}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isActive
|
||||
? 'Slug cannot be changed on active pipelines'
|
||||
: 'Lowercase letters, numbers, and hyphens only'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="pipeline-program">Program</Label>
|
||||
<InfoTooltip content="The program edition this pipeline belongs to. Each program can have multiple pipelines." />
|
||||
</div>
|
||||
<Select
|
||||
value={state.programId}
|
||||
onValueChange={(value) => onChange({ programId: value })}
|
||||
>
|
||||
<SelectTrigger id="pipeline-program">
|
||||
<SelectValue placeholder={isLoading ? 'Loading...' : 'Select a program'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name} ({p.year})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,479 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Plus, Trash2, ChevronDown, Info, Brain, Shield } from 'lucide-react'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import type { FilterConfig, FilterRuleConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
// ─── Known Fields for Eligibility Rules ──────────────────────────────────────
|
||||
|
||||
type KnownField = {
|
||||
value: string
|
||||
label: string
|
||||
operators: string[]
|
||||
valueType: 'select' | 'text' | 'number' | 'boolean'
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const KNOWN_FIELDS: KnownField[] = [
|
||||
{ value: 'competitionCategory', label: 'Category', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. STARTUP' },
|
||||
{ value: 'oceanIssue', label: 'Ocean Issue', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. Pollution' },
|
||||
{ value: 'country', label: 'Country', operators: ['is', 'is_not', 'is_one_of'], valueType: 'text', placeholder: 'e.g. France' },
|
||||
{ value: 'geographicZone', label: 'Region', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. Mediterranean' },
|
||||
{ value: 'foundedAt', label: 'Founded Year', operators: ['after', 'before'], valueType: 'number', placeholder: 'e.g. 2020' },
|
||||
{ value: 'description', label: 'Has Description', operators: ['exists', 'min_length'], valueType: 'number', placeholder: 'Min chars' },
|
||||
{ value: 'files', label: 'File Count', operators: ['greaterThan', 'lessThan'], valueType: 'number', placeholder: 'e.g. 1' },
|
||||
{ value: 'wantsMentorship', label: 'Wants Mentorship', operators: ['equals'], valueType: 'boolean' },
|
||||
]
|
||||
|
||||
const OPERATOR_LABELS: Record<string, string> = {
|
||||
is: 'is',
|
||||
is_not: 'is not',
|
||||
is_one_of: 'is one of',
|
||||
after: 'after',
|
||||
before: 'before',
|
||||
exists: 'exists',
|
||||
min_length: 'min length',
|
||||
greaterThan: 'greater than',
|
||||
lessThan: 'less than',
|
||||
equals: 'equals',
|
||||
}
|
||||
|
||||
// ─── Human-readable preview for a rule ───────────────────────────────────────
|
||||
|
||||
function getRulePreview(rule: FilterRuleConfig): string {
|
||||
const field = KNOWN_FIELDS.find((f) => f.value === rule.field)
|
||||
const fieldLabel = field?.label ?? rule.field
|
||||
const opLabel = OPERATOR_LABELS[rule.operator] ?? rule.operator
|
||||
|
||||
if (rule.operator === 'exists') {
|
||||
return `Projects where ${fieldLabel} exists will pass`
|
||||
}
|
||||
|
||||
const valueStr = typeof rule.value === 'boolean'
|
||||
? (rule.value ? 'Yes' : 'No')
|
||||
: String(rule.value)
|
||||
|
||||
return `Projects where ${fieldLabel} ${opLabel} ${valueStr} will pass`
|
||||
}
|
||||
|
||||
// ─── AI Screening: Fields the AI Sees ────────────────────────────────────────
|
||||
|
||||
const AI_VISIBLE_FIELDS = [
|
||||
'Project title',
|
||||
'Description',
|
||||
'Competition category',
|
||||
'Ocean issue',
|
||||
'Country & region',
|
||||
'Tags',
|
||||
'Founded year',
|
||||
'Team size',
|
||||
'File count',
|
||||
]
|
||||
|
||||
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type FilteringSectionProps = {
|
||||
config: FilterConfig
|
||||
onChange: (config: FilterConfig) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function FilteringSection({ config, onChange, isActive }: FilteringSectionProps) {
|
||||
const [rulesOpen, setRulesOpen] = useState(false)
|
||||
const [aiFieldsOpen, setAiFieldsOpen] = useState(false)
|
||||
|
||||
const updateConfig = (updates: Partial<FilterConfig>) => {
|
||||
onChange({ ...config, ...updates })
|
||||
}
|
||||
|
||||
const rules = config.rules ?? []
|
||||
const aiCriteriaText = config.aiCriteriaText ?? ''
|
||||
const thresholds = config.aiConfidenceThresholds ?? { high: 0.85, medium: 0.6, low: 0.4 }
|
||||
|
||||
const updateRule = (index: number, updates: Partial<FilterRuleConfig>) => {
|
||||
const updated = [...rules]
|
||||
updated[index] = { ...updated[index], ...updates }
|
||||
onChange({ ...config, rules: updated })
|
||||
}
|
||||
|
||||
const addRule = () => {
|
||||
onChange({
|
||||
...config,
|
||||
rules: [
|
||||
...rules,
|
||||
{ field: '', operator: 'is', value: '', weight: 1 },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
onChange({ ...config, rules: rules.filter((_, i) => i !== index) })
|
||||
}
|
||||
|
||||
const getFieldConfig = (fieldValue: string): KnownField | undefined => {
|
||||
return KNOWN_FIELDS.find((f) => f.value === fieldValue)
|
||||
}
|
||||
|
||||
const highPct = Math.round(thresholds.high * 100)
|
||||
const medPct = Math.round(thresholds.medium * 100)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── AI Screening (Primary) ────────────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Brain className="h-4 w-4 text-primary" />
|
||||
<Label>AI Screening</Label>
|
||||
<InfoTooltip content="Uses AI to evaluate projects against your criteria in natural language. Results are suggestions, not final decisions." />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Use AI to evaluate projects against your screening criteria
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.aiRubricEnabled}
|
||||
onCheckedChange={(checked) => updateConfig({ aiRubricEnabled: checked })}
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.aiRubricEnabled && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
||||
{/* Criteria Textarea (THE KEY MISSING PIECE) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Screening Criteria</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Describe what makes a project eligible or ineligible in natural language.
|
||||
The AI will evaluate each project against these criteria.
|
||||
</p>
|
||||
<Textarea
|
||||
value={aiCriteriaText}
|
||||
onChange={(e) => updateConfig({ aiCriteriaText: e.target.value })}
|
||||
placeholder="e.g., Projects must demonstrate a clear ocean conservation impact. Reject projects that are purely commercial with no environmental benefit. Flag projects with vague descriptions for manual review."
|
||||
rows={5}
|
||||
className="resize-y"
|
||||
disabled={isActive}
|
||||
/>
|
||||
{aiCriteriaText.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{aiCriteriaText.length} characters
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* "What the AI sees" Info Card */}
|
||||
<Collapsible open={aiFieldsOpen} onOpenChange={setAiFieldsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
<span>What the AI sees</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 ml-auto transition-transform ${aiFieldsOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<Card className="mt-2 bg-muted/50 border-muted">
|
||||
<CardContent className="pt-3 pb-3 px-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
All data is anonymized before being sent to the AI. Only these fields are included:
|
||||
</p>
|
||||
<ul className="grid grid-cols-2 sm:grid-cols-3 gap-1">
|
||||
{AI_VISIBLE_FIELDS.map((field) => (
|
||||
<li key={field} className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
{field}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground/70 mt-2 italic">
|
||||
No personal identifiers (names, emails, etc.) are sent to the AI.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Confidence Thresholds */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Confidence Thresholds</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Control how the AI's confidence score maps to outcomes.
|
||||
</p>
|
||||
|
||||
{/* Visual range preview */}
|
||||
<div className="flex items-center gap-1 text-[10px] font-medium">
|
||||
<div className="flex-1 bg-emerald-100 dark:bg-emerald-950 border border-emerald-300 dark:border-emerald-800 rounded-l px-2 py-1 text-center text-emerald-700 dark:text-emerald-400">
|
||||
Auto-approve above {highPct}%
|
||||
</div>
|
||||
<div className="flex-1 bg-amber-100 dark:bg-amber-950 border border-amber-300 dark:border-amber-800 px-2 py-1 text-center text-amber-700 dark:text-amber-400">
|
||||
Review {medPct}%{'\u2013'}{highPct}%
|
||||
</div>
|
||||
<div className="flex-1 bg-red-100 dark:bg-red-950 border border-red-300 dark:border-red-800 rounded-r px-2 py-1 text-center text-red-700 dark:text-red-400">
|
||||
Auto-reject below {medPct}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* High threshold slider */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500 shrink-0" />
|
||||
<Label className="text-xs">Auto-approve threshold</Label>
|
||||
</div>
|
||||
<span className="text-xs font-mono font-medium">{highPct}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[highPct]}
|
||||
onValueChange={([v]) =>
|
||||
updateConfig({
|
||||
aiConfidenceThresholds: {
|
||||
...thresholds,
|
||||
high: v / 100,
|
||||
},
|
||||
})
|
||||
}
|
||||
min={50}
|
||||
max={100}
|
||||
step={5}
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Medium threshold slider */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-amber-500 shrink-0" />
|
||||
<Label className="text-xs">Manual review threshold</Label>
|
||||
</div>
|
||||
<span className="text-xs font-mono font-medium">{medPct}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[medPct]}
|
||||
onValueChange={([v]) =>
|
||||
updateConfig({
|
||||
aiConfidenceThresholds: {
|
||||
...thresholds,
|
||||
medium: v / 100,
|
||||
},
|
||||
})
|
||||
}
|
||||
min={20}
|
||||
max={80}
|
||||
step={5}
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Manual Review Queue ────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Manual Review Queue</Label>
|
||||
<InfoTooltip content="When enabled, projects that don't meet auto-processing thresholds are queued for admin review instead of being auto-rejected." />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Projects below medium confidence go to manual review
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.manualQueueEnabled}
|
||||
onCheckedChange={(checked) => updateConfig({ manualQueueEnabled: checked })}
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Eligibility Rules (Secondary, Collapsible) ─────────────────── */}
|
||||
<Collapsible open={rulesOpen} onOpenChange={setRulesOpen}>
|
||||
<div className="flex items-center justify-between">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
<Label className="cursor-pointer">Eligibility Rules</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({rules.length} rule{rules.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${rulesOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
{rulesOpen && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add Rule
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
|
||||
Deterministic rules that projects must pass. Applied before AI screening.
|
||||
</p>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-3 mt-3">
|
||||
{rules.map((rule, index) => {
|
||||
const fieldConfig = getFieldConfig(rule.field)
|
||||
const availableOperators = fieldConfig?.operators ?? Object.keys(OPERATOR_LABELS)
|
||||
|
||||
return (
|
||||
<Card key={index}>
|
||||
<CardContent className="pt-3 pb-3 px-4 space-y-2">
|
||||
{/* Rule inputs */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 grid gap-2 sm:grid-cols-3">
|
||||
{/* Field dropdown */}
|
||||
<Select
|
||||
value={rule.field}
|
||||
onValueChange={(value) => {
|
||||
const newFieldConfig = getFieldConfig(value)
|
||||
const firstOp = newFieldConfig?.operators[0] ?? 'is'
|
||||
updateRule(index, {
|
||||
field: value,
|
||||
operator: firstOp,
|
||||
value: newFieldConfig?.valueType === 'boolean' ? true : '',
|
||||
})
|
||||
}}
|
||||
disabled={isActive}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select field..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KNOWN_FIELDS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Operator dropdown (filtered by field) */}
|
||||
<Select
|
||||
value={rule.operator}
|
||||
onValueChange={(value) => updateRule(index, { operator: value })}
|
||||
disabled={isActive || !rule.field}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableOperators.map((op) => (
|
||||
<SelectItem key={op} value={op}>
|
||||
{OPERATOR_LABELS[op] ?? op}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Value input (adapted by field type) */}
|
||||
{rule.operator === 'exists' ? (
|
||||
<div className="h-8 flex items-center text-xs text-muted-foreground italic">
|
||||
(no value needed)
|
||||
</div>
|
||||
) : fieldConfig?.valueType === 'boolean' ? (
|
||||
<Select
|
||||
value={String(rule.value)}
|
||||
onValueChange={(v) => updateRule(index, { value: v === 'true' })}
|
||||
disabled={isActive}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Yes</SelectItem>
|
||||
<SelectItem value="false">No</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : fieldConfig?.valueType === 'number' ? (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={fieldConfig.placeholder ?? 'Value'}
|
||||
value={String(rule.value)}
|
||||
className="h-8 text-sm"
|
||||
disabled={isActive}
|
||||
onChange={(e) => updateRule(index, { value: e.target.value ? Number(e.target.value) : '' })}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={fieldConfig?.placeholder ?? 'Value'}
|
||||
value={String(rule.value)}
|
||||
className="h-8 text-sm"
|
||||
disabled={isActive}
|
||||
onChange={(e) => updateRule(index, { value: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive mt-0.5"
|
||||
onClick={() => removeRule(index)}
|
||||
disabled={isActive}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Human-readable preview */}
|
||||
{rule.field && rule.operator && (
|
||||
<p className="text-xs text-muted-foreground italic pl-1">
|
||||
{getRulePreview(rule)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{rules.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-3">
|
||||
No eligibility rules configured. All projects will pass through to AI screening (if enabled).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!rulesOpen ? null : rules.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Plus, Trash2, FileText } from 'lucide-react'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import type { IntakeConfig, FileRequirementConfig } from '@/types/pipeline-wizard'
|
||||
import {
|
||||
FILE_TYPE_CATEGORIES,
|
||||
getActiveCategoriesFromMimeTypes,
|
||||
categoriesToMimeTypes,
|
||||
} from '@/lib/file-type-categories'
|
||||
|
||||
type FileTypePickerProps = {
|
||||
value: string[]
|
||||
onChange: (mimeTypes: string[]) => void
|
||||
}
|
||||
|
||||
function FileTypePicker({ value, onChange }: FileTypePickerProps) {
|
||||
const activeCategories = getActiveCategoriesFromMimeTypes(value)
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
const isActive = activeCategories.includes(categoryId)
|
||||
const newCategories = isActive
|
||||
? activeCategories.filter((id) => id !== categoryId)
|
||||
: [...activeCategories, categoryId]
|
||||
onChange(categoriesToMimeTypes(newCategories))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Accepted Types</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{FILE_TYPE_CATEGORIES.map((cat) => {
|
||||
const isActive = activeCategories.includes(cat.id)
|
||||
return (
|
||||
<Button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
variant={isActive ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => toggleCategory(cat.id)}
|
||||
>
|
||||
{cat.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{activeCategories.length === 0 ? (
|
||||
<Badge variant="secondary" className="text-[10px]">All types</Badge>
|
||||
) : (
|
||||
activeCategories.map((catId) => {
|
||||
const cat = FILE_TYPE_CATEGORIES.find((c) => c.id === catId)
|
||||
return cat ? (
|
||||
<Badge key={catId} variant="secondary" className="text-[10px]">
|
||||
{cat.label}
|
||||
</Badge>
|
||||
) : null
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type IntakeSectionProps = {
|
||||
config: IntakeConfig
|
||||
onChange: (config: IntakeConfig) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps) {
|
||||
const updateConfig = (updates: Partial<IntakeConfig>) => {
|
||||
onChange({ ...config, ...updates })
|
||||
}
|
||||
|
||||
const fileRequirements = config.fileRequirements ?? []
|
||||
|
||||
const updateFileReq = (index: number, updates: Partial<FileRequirementConfig>) => {
|
||||
const updated = [...fileRequirements]
|
||||
updated[index] = { ...updated[index], ...updates }
|
||||
onChange({ ...config, fileRequirements: updated })
|
||||
}
|
||||
|
||||
const addFileReq = () => {
|
||||
onChange({
|
||||
...config,
|
||||
fileRequirements: [
|
||||
...fileRequirements,
|
||||
{
|
||||
name: '',
|
||||
description: '',
|
||||
acceptedMimeTypes: ['application/pdf'],
|
||||
maxSizeMB: 50,
|
||||
isRequired: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const removeFileReq = (index: number) => {
|
||||
const updated = fileRequirements.filter((_, i) => i !== index)
|
||||
onChange({ ...config, fileRequirements: updated })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isActive && (
|
||||
<p className="text-sm text-amber-600 bg-amber-50 rounded-md px-3 py-2">
|
||||
Some settings are locked because this pipeline is active.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Submission Window */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Submission Window</Label>
|
||||
<InfoTooltip content="When enabled, projects can only be submitted within the configured date range." />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable timed submission windows for project intake
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.submissionWindowEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ submissionWindowEnabled: checked })
|
||||
}
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Late Policy */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Late Submission Policy</Label>
|
||||
<InfoTooltip content="Controls how submissions after the deadline are handled. 'Reject' blocks them, 'Flag' accepts but marks as late, 'Accept' treats them normally." />
|
||||
</div>
|
||||
<Select
|
||||
value={config.lateSubmissionPolicy ?? 'flag'}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
lateSubmissionPolicy: value as IntakeConfig['lateSubmissionPolicy'],
|
||||
})
|
||||
}
|
||||
disabled={isActive}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="reject">Reject late submissions</SelectItem>
|
||||
<SelectItem value="flag">Accept but flag as late</SelectItem>
|
||||
<SelectItem value="accept">Accept normally</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(config.lateSubmissionPolicy ?? 'flag') === 'flag' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Grace Period (hours)</Label>
|
||||
<InfoTooltip content="Extra time after the deadline during which late submissions are still accepted but flagged." />
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={168}
|
||||
value={config.lateGraceHours ?? 24}
|
||||
onChange={(e) =>
|
||||
updateConfig({ lateGraceHours: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Requirements */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>File Requirements</Label>
|
||||
<InfoTooltip content="Define what files applicants must upload. Each requirement can specify accepted formats and size limits." />
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addFileReq} disabled={isActive}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add Requirement
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fileRequirements.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No file requirements configured. Projects can be submitted without files.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{fileRequirements.map((req, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="h-4 w-4 text-muted-foreground mt-2 shrink-0" />
|
||||
<div className="flex-1 grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">File Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., Executive Summary"
|
||||
value={req.name}
|
||||
onChange={(e) => updateFileReq(index, { name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Max Size (MB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={req.maxSizeMB ?? ''}
|
||||
onChange={(e) =>
|
||||
updateFileReq(index, {
|
||||
maxSizeMB: parseInt(e.target.value) || undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Input
|
||||
placeholder="Brief description of this requirement"
|
||||
value={req.description ?? ''}
|
||||
onChange={(e) =>
|
||||
updateFileReq(index, { description: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={req.isRequired}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFileReq(index, { isRequired: checked })
|
||||
}
|
||||
/>
|
||||
<Label className="text-xs">Required</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<FileTypePicker
|
||||
value={req.acceptedMimeTypes ?? []}
|
||||
onChange={(mimeTypes) =>
|
||||
updateFileReq(index, { acceptedMimeTypes: mimeTypes })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeFileReq(index)}
|
||||
disabled={isActive}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type LiveFinalsSectionProps = {
|
||||
config: LiveFinalConfig
|
||||
onChange: (config: LiveFinalConfig) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSectionProps) {
|
||||
const updateConfig = (updates: Partial<LiveFinalConfig>) => {
|
||||
onChange({ ...config, ...updates })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Jury Voting</Label>
|
||||
<InfoTooltip content="Enable jury members to cast votes during the live ceremony." />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow jury members to vote during the live finals event
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.juryVotingEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ juryVotingEnabled: checked })
|
||||
}
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Audience Voting</Label>
|
||||
<InfoTooltip content="Allow audience members to participate in voting alongside the jury." />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow audience members to vote on projects
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.audienceVotingEnabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ audienceVotingEnabled: checked })
|
||||
}
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(config.audienceVotingEnabled ?? false) && (
|
||||
<div className="pl-4 border-l-2 border-muted space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs">Audience Vote Weight</Label>
|
||||
<InfoTooltip content="Percentage weight of audience votes vs jury votes in the final score (e.g., 30 means 30% audience, 70% jury)." />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[(config.audienceVoteWeight ?? 0) * 100]}
|
||||
onValueChange={([v]) =>
|
||||
updateConfig({ audienceVoteWeight: v / 100 })
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs font-mono w-10 text-right">
|
||||
{Math.round((config.audienceVoteWeight ?? 0) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Percentage weight of audience votes in the final score
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Cohort Setup Mode</Label>
|
||||
<InfoTooltip content="Auto: system assigns projects to presentation groups. Manual: admin defines cohorts." />
|
||||
</div>
|
||||
<Select
|
||||
value={config.cohortSetupMode ?? 'manual'}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
cohortSetupMode: value as LiveFinalConfig['cohortSetupMode'],
|
||||
})
|
||||
}
|
||||
disabled={isActive}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual">
|
||||
Manual — Admin creates cohorts and assigns projects
|
||||
</SelectItem>
|
||||
<SelectItem value="auto">
|
||||
Auto — System creates cohorts from pipeline results
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Result Reveal Policy</Label>
|
||||
<InfoTooltip content="Immediate: show results as votes come in. Delayed: reveal after all votes. Ceremony: reveal during a dedicated announcement." />
|
||||
</div>
|
||||
<Select
|
||||
value={config.revealPolicy ?? 'ceremony'}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
revealPolicy: value as LiveFinalConfig['revealPolicy'],
|
||||
})
|
||||
}
|
||||
disabled={isActive}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="immediate">
|
||||
Immediate — Results shown after each vote
|
||||
</SelectItem>
|
||||
<SelectItem value="delayed">
|
||||
Delayed — Results hidden until admin reveals
|
||||
</SelectItem>
|
||||
<SelectItem value="ceremony">
|
||||
Ceremony — Results revealed in dramatic sequence
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import type { WizardStageConfig } from '@/types/pipeline-wizard'
|
||||
import type { StageType } from '@prisma/client'
|
||||
|
||||
type MainTrackSectionProps = {
|
||||
stages: WizardStageConfig[]
|
||||
onChange: (stages: WizardStageConfig[]) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const STAGE_TYPE_OPTIONS: { value: StageType; label: string; color: string }[] = [
|
||||
{ value: 'INTAKE', label: 'Intake', color: 'bg-blue-100 text-blue-700' },
|
||||
{ value: 'FILTER', label: 'Filter', color: 'bg-amber-100 text-amber-700' },
|
||||
{ value: 'EVALUATION', label: 'Evaluation', color: 'bg-purple-100 text-purple-700' },
|
||||
{ value: 'SELECTION', label: 'Selection', color: 'bg-emerald-100 text-emerald-700' },
|
||||
{ value: 'LIVE_FINAL', label: 'Live Final', color: 'bg-rose-100 text-rose-700' },
|
||||
{ value: 'RESULTS', label: 'Results', color: 'bg-gray-100 text-gray-700' },
|
||||
]
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export function MainTrackSection({ stages, onChange, isActive }: MainTrackSectionProps) {
|
||||
const updateStage = useCallback(
|
||||
(index: number, updates: Partial<WizardStageConfig>) => {
|
||||
const updated = [...stages]
|
||||
updated[index] = { ...updated[index], ...updates }
|
||||
onChange(updated)
|
||||
},
|
||||
[stages, onChange]
|
||||
)
|
||||
|
||||
const addStage = () => {
|
||||
const maxOrder = Math.max(...stages.map((s) => s.sortOrder), -1)
|
||||
onChange([
|
||||
...stages,
|
||||
{
|
||||
name: '',
|
||||
slug: '',
|
||||
stageType: 'EVALUATION',
|
||||
sortOrder: maxOrder + 1,
|
||||
configJson: {},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const removeStage = (index: number) => {
|
||||
if (stages.length <= 2) return // Minimum 2 stages
|
||||
const updated = stages.filter((_, i) => i !== index)
|
||||
// Re-number sortOrder
|
||||
onChange(updated.map((s, i) => ({ ...s, sortOrder: i })))
|
||||
}
|
||||
|
||||
const moveStage = (index: number, direction: 'up' | 'down') => {
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1
|
||||
if (newIndex < 0 || newIndex >= stages.length) return
|
||||
const updated = [...stages]
|
||||
const temp = updated[index]
|
||||
updated[index] = updated[newIndex]
|
||||
updated[newIndex] = temp
|
||||
onChange(updated.map((s, i) => ({ ...s, sortOrder: i })))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Define the stages projects flow through in the main competition track.
|
||||
Drag to reorder. Minimum 2 stages required.
|
||||
</p>
|
||||
<InfoTooltip
|
||||
content="INTAKE: Collect project submissions. FILTER: Automated screening. EVALUATION: Jury review and scoring. SELECTION: Choose finalists. LIVE_FINAL: Live ceremony voting. RESULTS: Publish outcomes."
|
||||
side="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addStage} disabled={isActive}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add Stage
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<p className="text-sm text-amber-600 bg-amber-50 rounded-md px-3 py-2">
|
||||
Stage structure is locked because this pipeline is active. Use the Advanced editor for config changes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{stages.map((stage, index) => {
|
||||
const typeInfo = STAGE_TYPE_OPTIONS.find((t) => t.value === stage.stageType)
|
||||
const hasDuplicateSlug = stage.slug && stages.some((s, i) => i !== index && s.slug === stage.slug)
|
||||
return (
|
||||
<Card key={index}>
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Reorder */}
|
||||
<div className="flex flex-col shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
disabled={isActive || index === 0}
|
||||
onClick={() => moveStage(index, 'up')}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground mx-auto" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
disabled={isActive || index === stages.length - 1}
|
||||
onClick={() => moveStage(index, 'down')}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Order number */}
|
||||
<span className="text-xs text-muted-foreground font-mono w-5 text-center shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
{/* Stage name */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Input
|
||||
placeholder="Stage name"
|
||||
value={stage.name}
|
||||
className={cn('h-8 text-sm', hasDuplicateSlug && 'border-destructive')}
|
||||
disabled={isActive}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value
|
||||
updateStage(index, { name, slug: slugify(name) })
|
||||
}}
|
||||
/>
|
||||
{hasDuplicateSlug && (
|
||||
<p className="text-[10px] text-destructive mt-0.5">Duplicate name</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage type */}
|
||||
<div className="w-36 shrink-0">
|
||||
<Select
|
||||
value={stage.stageType}
|
||||
onValueChange={(value) =>
|
||||
updateStage(index, { stageType: value as StageType })
|
||||
}
|
||||
disabled={isActive}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STAGE_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Type badge */}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('shrink-0 text-[10px]', typeInfo?.color)}
|
||||
>
|
||||
{typeInfo?.label}
|
||||
</Badge>
|
||||
|
||||
{/* Remove */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
disabled={isActive || stages.length <= 2}
|
||||
onClick={() => removeStage(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{stages.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
No stages configured. Click "Add Stage" to begin.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Bell } from 'lucide-react'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
|
||||
type NotificationsSectionProps = {
|
||||
config: Record<string, boolean>
|
||||
onChange: (config: Record<string, boolean>) => void
|
||||
overridePolicy: Record<string, unknown>
|
||||
onOverridePolicyChange: (policy: Record<string, unknown>) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const NOTIFICATION_EVENTS = [
|
||||
{
|
||||
key: 'stage.transitioned',
|
||||
label: 'Stage Transitioned',
|
||||
description: 'When a stage changes status (draft → active → closed)',
|
||||
},
|
||||
{
|
||||
key: 'filtering.completed',
|
||||
label: 'Filtering Completed',
|
||||
description: 'When batch filtering finishes processing',
|
||||
},
|
||||
{
|
||||
key: 'assignment.generated',
|
||||
label: 'Assignments Generated',
|
||||
description: 'When jury assignments are created or updated',
|
||||
},
|
||||
{
|
||||
key: 'live.cursor.updated',
|
||||
label: 'Live Cursor Updated',
|
||||
description: 'When the live presentation moves to next project',
|
||||
},
|
||||
{
|
||||
key: 'cohort.window.changed',
|
||||
label: 'Cohort Window Changed',
|
||||
description: 'When a cohort voting window opens or closes',
|
||||
},
|
||||
{
|
||||
key: 'decision.overridden',
|
||||
label: 'Decision Overridden',
|
||||
description: 'When an admin overrides an automated decision',
|
||||
},
|
||||
{
|
||||
key: 'award.winner.finalized',
|
||||
label: 'Award Winner Finalized',
|
||||
description: 'When a special award winner is selected',
|
||||
},
|
||||
]
|
||||
|
||||
export function NotificationsSection({
|
||||
config,
|
||||
onChange,
|
||||
overridePolicy,
|
||||
onOverridePolicyChange,
|
||||
isActive,
|
||||
}: NotificationsSectionProps) {
|
||||
const toggleEvent = (key: string, enabled: boolean) => {
|
||||
onChange({ ...config, [key]: enabled })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose which pipeline events trigger notifications. All events are enabled by default.
|
||||
</p>
|
||||
<InfoTooltip content="Configure email notifications for pipeline events. Each event type can be individually enabled or disabled." />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{NOTIFICATION_EVENTS.map((event) => (
|
||||
<Card key={event.key}>
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Bell className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<Label className="text-sm font-medium">{event.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{event.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config[event.key] !== false}
|
||||
onCheckedChange={(checked) => toggleEvent(event.key, checked)}
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Override Governance */}
|
||||
<div className="space-y-3 pt-2 border-t">
|
||||
<Label>Override Governance</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Who can override automated decisions in this pipeline?
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={
|
||||
Array.isArray(overridePolicy.allowedRoles) &&
|
||||
overridePolicy.allowedRoles.includes('SUPER_ADMIN')
|
||||
}
|
||||
disabled
|
||||
/>
|
||||
<Label className="text-sm">Super Admins (always enabled)</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={
|
||||
Array.isArray(overridePolicy.allowedRoles) &&
|
||||
overridePolicy.allowedRoles.includes('PROGRAM_ADMIN')
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
const roles = Array.isArray(overridePolicy.allowedRoles)
|
||||
? [...overridePolicy.allowedRoles]
|
||||
: ['SUPER_ADMIN']
|
||||
if (checked && !roles.includes('PROGRAM_ADMIN')) {
|
||||
roles.push('PROGRAM_ADMIN')
|
||||
} else if (!checked) {
|
||||
const idx = roles.indexOf('PROGRAM_ADMIN')
|
||||
if (idx >= 0) roles.splice(idx, 1)
|
||||
}
|
||||
onOverridePolicyChange({ ...overridePolicy, allowedRoles: roles })
|
||||
}}
|
||||
/>
|
||||
<Label className="text-sm">Program Admins</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={
|
||||
Array.isArray(overridePolicy.allowedRoles) &&
|
||||
overridePolicy.allowedRoles.includes('AWARD_MASTER')
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
const roles = Array.isArray(overridePolicy.allowedRoles)
|
||||
? [...overridePolicy.allowedRoles]
|
||||
: ['SUPER_ADMIN']
|
||||
if (checked && !roles.includes('AWARD_MASTER')) {
|
||||
roles.push('AWARD_MASTER')
|
||||
} else if (!checked) {
|
||||
const idx = roles.indexOf('AWARD_MASTER')
|
||||
if (idx >= 0) roles.splice(idx, 1)
|
||||
}
|
||||
onOverridePolicyChange({ ...overridePolicy, allowedRoles: roles })
|
||||
}}
|
||||
/>
|
||||
<Label className="text-sm">Award Masters</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ResultsConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type ResultsSectionProps = {
|
||||
config: ResultsConfig
|
||||
onChange: (config: ResultsConfig) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export function ResultsSection({
|
||||
config,
|
||||
onChange,
|
||||
isActive,
|
||||
}: ResultsSectionProps) {
|
||||
const updateConfig = (updates: Partial<ResultsConfig>) => {
|
||||
onChange({ ...config, ...updates })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Publication Mode</Label>
|
||||
<InfoTooltip content="Manual publish requires explicit admin action. Auto publish triggers on stage close." />
|
||||
</div>
|
||||
<Select
|
||||
value={config.publicationMode ?? 'manual'}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
publicationMode: value as ResultsConfig['publicationMode'],
|
||||
})
|
||||
}
|
||||
disabled={isActive}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
<SelectItem value="auto_on_close">Auto on Close</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Show Detailed Scores</Label>
|
||||
<InfoTooltip content="Expose detailed score breakdowns in published results." />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Controls score transparency in the results experience
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.showDetailedScores ?? false}
|
||||
onCheckedChange={(checked) => updateConfig({ showDetailedScores: checked })}
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Show Rankings</Label>
|
||||
<InfoTooltip content="Display ordered rankings in final results." />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disable to show winners only without full ranking table
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.showRankings ?? true}
|
||||
onCheckedChange={(checked) => updateConfig({ showRankings: checked })}
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight, ShieldCheck } from 'lucide-react'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { validateAll } from '@/lib/pipeline-validation'
|
||||
import { normalizeStageConfig } from '@/lib/stage-config-schema'
|
||||
import type { WizardState, ValidationResult, WizardStageConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type ReviewSectionProps = {
|
||||
state: WizardState
|
||||
}
|
||||
|
||||
function ValidationStatusIcon({ result }: { result: ValidationResult }) {
|
||||
if (result.valid && result.warnings.length === 0) {
|
||||
return <CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
}
|
||||
if (result.valid && result.warnings.length > 0) {
|
||||
return <AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
}
|
||||
return <AlertCircle className="h-4 w-4 text-destructive" />
|
||||
}
|
||||
|
||||
function ValidationSection({
|
||||
label,
|
||||
result,
|
||||
}: {
|
||||
label: string
|
||||
result: ValidationResult
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<ValidationStatusIcon result={result} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{result.errors.map((err, i) => (
|
||||
<p key={i} className="text-xs text-destructive mt-0.5">
|
||||
{err}
|
||||
</p>
|
||||
))}
|
||||
{result.warnings.map((warn, i) => (
|
||||
<p key={i} className="text-xs text-amber-600 mt-0.5">
|
||||
{warn}
|
||||
</p>
|
||||
))}
|
||||
{result.valid && result.errors.length === 0 && result.warnings.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Looks good</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function stagePolicySummary(stage: WizardStageConfig): string {
|
||||
const config = normalizeStageConfig(
|
||||
stage.stageType,
|
||||
stage.configJson as Record<string, unknown>
|
||||
)
|
||||
|
||||
switch (stage.stageType) {
|
||||
case 'INTAKE':
|
||||
return `${String(config.lateSubmissionPolicy)} late policy, ${Array.isArray(config.fileRequirements) ? config.fileRequirements.length : 0} file reqs`
|
||||
case 'FILTER':
|
||||
return `${Array.isArray(config.rules) ? config.rules.length : 0} rules, AI ${config.aiRubricEnabled ? 'on' : 'off'}`
|
||||
case 'EVALUATION':
|
||||
return `${String(config.requiredReviews)} reviews, load ${String(config.minLoadPerJuror)}-${String(config.maxLoadPerJuror)}`
|
||||
case 'SELECTION':
|
||||
return `ranking ${String(config.rankingMethod)}, tie ${String(config.tieBreaker)}`
|
||||
case 'LIVE_FINAL':
|
||||
return `jury ${config.juryVotingEnabled ? 'on' : 'off'}, audience ${config.audienceVotingEnabled ? 'on' : 'off'}`
|
||||
case 'RESULTS':
|
||||
return `publication ${String(config.publicationMode)}, rankings ${config.showRankings ? 'shown' : 'hidden'}`
|
||||
default:
|
||||
return 'Configured'
|
||||
}
|
||||
}
|
||||
|
||||
export function ReviewSection({ state }: ReviewSectionProps) {
|
||||
const validation = validateAll(state)
|
||||
|
||||
const totalTracks = state.tracks.length
|
||||
const totalStages = state.tracks.reduce((sum, t) => sum + t.stages.length, 0)
|
||||
const totalTransitions = state.tracks.reduce(
|
||||
(sum, t) => sum + Math.max(0, t.stages.length - 1),
|
||||
0
|
||||
)
|
||||
const enabledNotifications = Object.values(state.notificationConfig).filter(Boolean).length
|
||||
|
||||
const blockers = [
|
||||
...validation.sections.basics.errors,
|
||||
...validation.sections.tracks.errors,
|
||||
...validation.sections.notifications.errors,
|
||||
]
|
||||
const warnings = [
|
||||
...validation.sections.basics.warnings,
|
||||
...validation.sections.tracks.warnings,
|
||||
...validation.sections.notifications.warnings,
|
||||
]
|
||||
|
||||
const hasMainTrack = state.tracks.some((track) => track.kind === 'MAIN')
|
||||
const hasStages = totalStages > 0
|
||||
const hasNotificationDefaults = enabledNotifications > 0
|
||||
const publishReady = validation.valid && hasMainTrack && hasStages
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4',
|
||||
publishReady
|
||||
? 'border-emerald-200 bg-emerald-50'
|
||||
: 'border-destructive/30 bg-destructive/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{publishReady ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 mt-0.5" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<p className={cn('font-medium', publishReady ? 'text-emerald-800' : 'text-destructive')}>
|
||||
{publishReady
|
||||
? 'Pipeline is ready for publish'
|
||||
: 'Pipeline has publish blockers'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Draft save can proceed with warnings. Publish should only proceed with zero blockers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CardTitle className="text-sm">Readiness Checks</CardTitle>
|
||||
<InfoTooltip content="Critical blockers prevent publish. Warnings indicate recommended fixes." />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="rounded-md border p-2 text-center">
|
||||
<p className="text-xl font-semibold">{blockers.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Blockers</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-2 text-center">
|
||||
<p className="text-xl font-semibold">{warnings.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Warnings</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-2 text-center">
|
||||
<p className="text-xl font-semibold">{totalTracks}</p>
|
||||
<p className="text-xs text-muted-foreground">Tracks</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-2 text-center">
|
||||
<p className="text-xl font-semibold">{totalStages}</p>
|
||||
<p className="text-xs text-muted-foreground">Stages</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{blockers.length > 0 && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
||||
<p className="text-xs font-medium text-destructive mb-1">Publish Blockers</p>
|
||||
{blockers.map((blocker, i) => (
|
||||
<p key={i} className="text-xs text-destructive">
|
||||
{blocker}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3">
|
||||
<p className="text-xs font-medium text-amber-700 mb-1">Warnings</p>
|
||||
{warnings.map((warn, i) => (
|
||||
<p key={i} className="text-xs text-amber-700">
|
||||
{warn}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CardTitle className="text-sm">Validation Detail</CardTitle>
|
||||
<InfoTooltip content="Automated checks per setup section." />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
<ValidationSection label="Basics" result={validation.sections.basics} />
|
||||
<ValidationSection label="Tracks & Stages" result={validation.sections.tracks} />
|
||||
<ValidationSection label="Notifications" result={validation.sections.notifications} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CardTitle className="text-sm">Structure and Policy Matrix</CardTitle>
|
||||
<InfoTooltip content="Stage-by-stage policy preview used for final sanity check before creation." />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{totalTracks}</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
||||
<Layers className="h-3 w-3" />
|
||||
Tracks
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{totalStages}</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
Stages
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{totalTransitions}</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
Transitions
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{enabledNotifications}</p>
|
||||
<p className="text-xs text-muted-foreground">Notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{state.tracks.map((track, i) => (
|
||||
<div key={i} className="rounded-md border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px]',
|
||||
track.kind === 'MAIN'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: track.kind === 'AWARD'
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
)}
|
||||
>
|
||||
{track.kind}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">{track.name || '(unnamed track)'}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{track.stages.length} stages</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{track.stages.map((stage, stageIndex) => (
|
||||
<div
|
||||
key={stageIndex}
|
||||
className="flex items-center justify-between text-xs border-b last:border-0 py-1.5"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{stageIndex + 1}. {stage.name || '(unnamed stage)'} ({stage.stageType})
|
||||
</span>
|
||||
<span className="text-muted-foreground">{stagePolicySummary(stage)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Publish Guardrails
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between rounded-md border p-2">
|
||||
<span>Main track present</span>
|
||||
<Badge variant={hasMainTrack ? 'default' : 'destructive'}>
|
||||
{hasMainTrack ? 'Pass' : 'Fail'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border p-2">
|
||||
<span>At least one stage configured</span>
|
||||
<Badge variant={hasStages ? 'default' : 'destructive'}>
|
||||
{hasStages ? 'Pass' : 'Fail'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border p-2">
|
||||
<span>Validation blockers cleared</span>
|
||||
<Badge variant={blockers.length === 0 ? 'default' : 'destructive'}>
|
||||
{blockers.length === 0 ? 'Pass' : 'Fail'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border p-2">
|
||||
<span>Notification policy configured</span>
|
||||
<Badge variant={hasNotificationDefaults ? 'default' : 'secondary'}>
|
||||
{hasNotificationDefaults ? 'Configured' : 'Optional'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user