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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user