Admin dashboard & round management UX overhaul
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m43s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m43s
- Extract round detail monolith (2900→600 lines) into 13 standalone components - Add shared round/status config (round-config.ts) replacing 4 local copies - Delete 12 legacy competition-scoped pages, merge project pool into projects page - Add round-type-specific dashboard stat panels (submission, mentoring, live final, deliberation, summary) - Add contextual header quick actions based on active round type - Improve pipeline visualization: progress bars, checkmarks, chevron connectors, overflow fix - Add config tab completion dots (green/amber/red) and inline validation warnings - Enhance juries page with round assignments, member avatars, and cap mode badges - Add context-aware project list (recent submissions vs active evaluations) - Move competition settings into Manage Editions page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,206 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { ArrowLeft, Loader2, PlayCircle, Zap } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
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 aiAssignmentMutation = trpc.roundAssignment.aiPreview.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('AI assignments ready!', {
|
||||
action: { label: 'Review', onClick: () => setPreviewSheetOpen(true) },
|
||||
duration: 10000,
|
||||
})
|
||||
},
|
||||
onError: (err) => toast.error(`AI generation failed: ${err.message}`),
|
||||
})
|
||||
|
||||
const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({
|
||||
id: competitionId,
|
||||
})
|
||||
|
||||
const { data: selectedRound } = trpc.round.getById.useQuery(
|
||||
{ id: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
)
|
||||
|
||||
const requiredReviews = (selectedRound?.configJson as Record<string, unknown>)?.requiredReviewsPerProject as number || 3
|
||||
|
||||
const { data: unassignedQueue, isLoading: isLoadingQueue } =
|
||||
trpc.roundAssignment.unassignedQueue.useQuery(
|
||||
{ roundId: selectedRoundId, requiredReviews },
|
||||
{ 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 gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })
|
||||
}}
|
||||
disabled={aiAssignmentMutation.isPending}
|
||||
>
|
||||
{aiAssignmentMutation.isPending ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Generating...</>
|
||||
) : (
|
||||
<><Zap className="mr-2 h-4 w-4" />{aiAssignmentMutation.data ? 'Regenerate' : 'Generate with AI'}</>
|
||||
)}
|
||||
</Button>
|
||||
{aiAssignmentMutation.data && (
|
||||
<Button variant="outline" onClick={() => setPreviewSheetOpen(true)}>
|
||||
Review 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} requiredReviews={requiredReviews} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="unassigned" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Unassigned Projects</CardTitle>
|
||||
<CardDescription>
|
||||
Projects with fewer than {requiredReviews} 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} / {requiredReviews} 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}
|
||||
requiredReviews={requiredReviews}
|
||||
aiResult={aiAssignmentMutation.data ?? null}
|
||||
isAIGenerating={aiAssignmentMutation.isPending}
|
||||
onGenerateAI={() => aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })}
|
||||
onResetAI={() => aiAssignmentMutation.reset()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
'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: '',
|
||||
criteriaText: '',
|
||||
useAiEligibility: false,
|
||||
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED',
|
||||
maxRankedPicks: '3',
|
||||
});
|
||||
|
||||
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,
|
||||
competitionId: params.competitionId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
criteriaText: formData.criteriaText.trim() || undefined,
|
||||
scoringMode: formData.scoringMode,
|
||||
useAiEligibility: formData.useAiEligibility,
|
||||
maxRankedPicks: formData.scoringMode === 'RANKED' ? parseInt(formData.maxRankedPicks) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
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="criteriaText">Eligibility Criteria</Label>
|
||||
<Textarea
|
||||
id="criteriaText"
|
||||
value={formData.criteriaText}
|
||||
onChange={(e) => setFormData({ ...formData, criteriaText: e.target.value })}
|
||||
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This text will be used by AI to determine which projects are eligible for this award.
|
||||
</p>
|
||||
</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="grid gap-4 sm:grid-cols-2">
|
||||
<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 — Each juror picks 1</SelectItem>
|
||||
<SelectItem value="RANKED">Ranked — Each juror ranks top N</SelectItem>
|
||||
<SelectItem value="SCORED">Scored — Use evaluation form</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formData.scoringMode === 'RANKED' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||
<Input
|
||||
id="maxPicks"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={formData.maxRankedPicks}
|
||||
onChange={(e) => setFormData({ ...formData, maxRankedPicks: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { use, useMemo } 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';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
DELIB_OPEN: 'Open',
|
||||
VOTING: 'Voting',
|
||||
TALLYING: 'Tallying',
|
||||
RUNOFF: 'Runoff',
|
||||
DELIB_LOCKED: 'Locked',
|
||||
};
|
||||
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
DELIB_OPEN: 'outline',
|
||||
VOTING: 'default',
|
||||
TALLYING: 'secondary',
|
||||
RUNOFF: 'secondary',
|
||||
DELIB_LOCKED: 'secondary',
|
||||
};
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
STARTUP: 'Startup',
|
||||
BUSINESS_CONCEPT: 'Business Concept',
|
||||
};
|
||||
const TIE_BREAK_LABELS: Record<string, string> = {
|
||||
TIE_RUNOFF: 'Runoff Vote',
|
||||
TIE_ADMIN_DECIDES: 'Admin Decides',
|
||||
SCORE_FALLBACK: 'Score Fallback',
|
||||
};
|
||||
|
||||
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 },
|
||||
{ refetchInterval: 10_000 }
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Derive which participants have voted from the votes array
|
||||
const voterUserIds = useMemo(() => {
|
||||
if (!session?.votes) return new Set<string>();
|
||||
return new Set(session.votes.map((v: any) => v.juryMember?.user?.id).filter(Boolean));
|
||||
}, [session?.votes]);
|
||||
|
||||
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 variant={STATUS_VARIANTS[session.status] ?? 'outline'}>{STATUS_LABELS[session.status] ?? session.status}</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{session.round?.name} - {CATEGORY_LABELS[session.category] ?? 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">{TIE_BREAK_LABELS[session.tieBreakMethod] ?? 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?.user?.name ?? 'Unknown'}</p>
|
||||
<p className="text-sm text-muted-foreground">{participant.user?.user?.email}</p>
|
||||
</div>
|
||||
<Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'outline'}>
|
||||
{voterUserIds.has(participant.user?.user?.id) ? '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 !== '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?.user?.name ?? 'Unknown'}</span>
|
||||
<Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'secondary'}>
|
||||
{voterUserIds.has(participant.user?.user?.id) ? 'Submitted' : 'Not Voted'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="results" className="space-y-4">
|
||||
<ResultsPanel sessionId={params.sessionId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
'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 [selectedJuryGroupId, setSelectedJuryGroupId] = useState('');
|
||||
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 || [];
|
||||
|
||||
// Jury groups & members for participant selection
|
||||
const { data: juryGroups = [] } = trpc.juryGroup.list.useQuery(
|
||||
{ competitionId: params.competitionId },
|
||||
{ enabled: !!params.competitionId }
|
||||
);
|
||||
|
||||
const { data: selectedJuryGroup } = trpc.juryGroup.getById.useQuery(
|
||||
{ id: selectedJuryGroupId },
|
||||
{ enabled: !!selectedJuryGroupId }
|
||||
);
|
||||
const juryMembers = selectedJuryGroup?.members ?? [];
|
||||
|
||||
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;
|
||||
}
|
||||
if (formData.participantUserIds.length === 0) {
|
||||
toast.error('Please select at least one participant');
|
||||
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',
|
||||
VOTING: 'default',
|
||||
TALLYING: 'secondary',
|
||||
RUNOFF: 'secondary',
|
||||
DELIB_LOCKED: 'secondary',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
DELIB_OPEN: 'Open',
|
||||
VOTING: 'Voting',
|
||||
TALLYING: 'Tallying',
|
||||
RUNOFF: 'Runoff',
|
||||
DELIB_LOCKED: 'Locked',
|
||||
};
|
||||
return <Badge variant={variants[status] || 'outline'}>{labels[status] || 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 === 'BUSINESS_CONCEPT' ? 'Business Concept' : session.category === 'STARTUP' ? 'Startup' : 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 === 'TIE_RUNOFF' ? 'Runoff Vote' : session.tieBreakMethod === 'TIE_ADMIN_DECIDES' ? 'Admin Decides' : session.tieBreakMethod === 'SCORE_FALLBACK' ? 'Score Fallback' : 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>
|
||||
|
||||
{/* Participant Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="juryGroup">Jury Group *</Label>
|
||||
<Select
|
||||
value={selectedJuryGroupId}
|
||||
onValueChange={(value) => {
|
||||
setSelectedJuryGroupId(value);
|
||||
setFormData({ ...formData, participantUserIds: [] });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="juryGroup">
|
||||
<SelectValue placeholder="Select jury group" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{juryGroups.map((group: any) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name} ({group._count?.members ?? 0} members)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{juryMembers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Participants ({formData.participantUserIds.length}/{juryMembers.length})</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const allIds = juryMembers.map((m: any) => m.user.id);
|
||||
const allSelected = allIds.every((id: string) => formData.participantUserIds.includes(id));
|
||||
setFormData({
|
||||
...formData,
|
||||
participantUserIds: allSelected ? [] : allIds,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{juryMembers.every((m: any) => formData.participantUserIds.includes(m.user.id))
|
||||
? 'Deselect All'
|
||||
: 'Select All'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto rounded-md border p-3">
|
||||
{juryMembers.map((member: any) => (
|
||||
<div key={member.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`member-${member.user.id}`}
|
||||
checked={formData.participantUserIds.includes(member.user.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
participantUserIds: checked
|
||||
? [...formData.participantUserIds, member.user.id]
|
||||
: formData.participantUserIds.filter((id: string) => id !== member.user.id),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`member-${member.user.id}`} className="flex-1 font-normal">
|
||||
{member.user.name || member.user.email}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{member.role === 'CHAIR' ? 'Chair' : member.role === 'OBSERVER' ? 'Observer' : 'Member'}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
'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 },
|
||||
{ refetchInterval: 30_000 }
|
||||
)
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,585 +0,0 @@
|
||||
'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,
|
||||
FolderKanban,
|
||||
ClipboardList,
|
||||
Settings,
|
||||
MoreHorizontal,
|
||||
Archive,
|
||||
Loader2,
|
||||
Plus,
|
||||
CalendarDays,
|
||||
} 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.competitionId 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 },
|
||||
{ refetchInterval: 30_000 }
|
||||
)
|
||||
|
||||
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.filter((r: any) => !r.specialAwardId).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">
|
||||
<FolderKanban className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm font-medium">Projects</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{(competition as any).distinctProjectCount ?? 0}
|
||||
</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.filter((r: any) => !r.specialAwardId)}
|
||||
/>
|
||||
</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.filter((r: any) => !r.specialAwardId).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.filter((r: any) => !r.specialAwardId).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="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{competition.rounds.filter((r: any) => !r.specialAwardId).map((round: any, index: number) => {
|
||||
const projectCount = round._count?.projectRoundStates ?? 0
|
||||
const assignmentCount = round._count?.assignments ?? 0
|
||||
const statusLabel = round.status.replace('ROUND_', '')
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-600',
|
||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||
CLOSED: 'bg-blue-100 text-blue-700',
|
||||
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={round.id}
|
||||
href={`/admin/rounds/${round.id}` as Route}
|
||||
>
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||
<CardContent className="pt-4 pb-3 space-y-3">
|
||||
{/* Top: number + name + badges */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold truncate">{round.name}</p>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px]',
|
||||
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
|
||||
)}
|
||||
>
|
||||
{round.roundType.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-[10px]', statusColors[statusLabel])}
|
||||
>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
{(round.roundType === 'EVALUATION' || round.roundType === 'FILTERING') && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<ClipboardList className="h-3.5 w-3.5" />
|
||||
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
{(round.windowOpenAt || round.windowCloseAt) && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{round.windowOpenAt
|
||||
? new Date(round.windowOpenAt).toLocaleDateString()
|
||||
: '?'}
|
||||
{' \u2014 '}
|
||||
{round.windowCloseAt
|
||||
? new Date(round.windowCloseAt).toLocaleDateString()
|
||||
: '?'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jury group */}
|
||||
{round.juryGroup && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{round.juryGroup.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</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.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>
|
||||
)
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
'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 +0,0 @@
|
||||
'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, refetchInterval: 30_000 }
|
||||
)
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -9,6 +10,17 @@ import {
|
||||
AlertTriangle,
|
||||
Upload,
|
||||
UserPlus,
|
||||
Settings,
|
||||
ClipboardCheck,
|
||||
Users,
|
||||
Send,
|
||||
FileDown,
|
||||
Calendar,
|
||||
Eye,
|
||||
Presentation,
|
||||
Vote,
|
||||
Play,
|
||||
Lock,
|
||||
} from 'lucide-react'
|
||||
import { GeographicSummaryCard } from '@/components/charts'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
@@ -29,6 +41,77 @@ type DashboardContentProps = {
|
||||
sessionName: string
|
||||
}
|
||||
|
||||
type QuickAction = {
|
||||
label: string
|
||||
href: string
|
||||
icon: React.ElementType
|
||||
}
|
||||
|
||||
function getContextualActions(
|
||||
activeRound: { id: string; roundType: string } | null
|
||||
): QuickAction[] {
|
||||
if (!activeRound) {
|
||||
return [
|
||||
{ label: 'Rounds', href: '/admin/rounds', icon: CircleDot },
|
||||
{ label: 'Import', href: '/admin/projects/new', icon: Upload },
|
||||
{ label: 'Invite', href: '/admin/members', icon: UserPlus },
|
||||
]
|
||||
}
|
||||
|
||||
const roundHref = `/admin/rounds/${activeRound.id}`
|
||||
|
||||
switch (activeRound.roundType) {
|
||||
case 'INTAKE':
|
||||
return [
|
||||
{ label: 'Import Projects', href: '/admin/projects/new', icon: Upload },
|
||||
{ label: 'Review', href: roundHref, icon: ClipboardCheck },
|
||||
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
|
||||
]
|
||||
case 'FILTERING':
|
||||
return [
|
||||
{ label: 'Run Screening', href: roundHref, icon: ClipboardCheck },
|
||||
{ label: 'Review Results', href: `${roundHref}?tab=filtering`, icon: Eye },
|
||||
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
|
||||
]
|
||||
case 'EVALUATION':
|
||||
return [
|
||||
{ label: 'Assignments', href: `${roundHref}?tab=assignments`, icon: Users },
|
||||
{ label: 'Send Reminders', href: `${roundHref}?tab=assignments`, icon: Send },
|
||||
{ label: 'Export', href: roundHref, icon: FileDown },
|
||||
]
|
||||
case 'SUBMISSION':
|
||||
return [
|
||||
{ label: 'Submissions', href: roundHref, icon: ClipboardCheck },
|
||||
{ label: 'Deadlines', href: `${roundHref}?tab=config`, icon: Calendar },
|
||||
{ label: 'Status', href: `${roundHref}?tab=projects`, icon: Eye },
|
||||
]
|
||||
case 'MENTORING':
|
||||
return [
|
||||
{ label: 'Mentors', href: `${roundHref}?tab=projects`, icon: Users },
|
||||
{ label: 'Progress', href: roundHref, icon: Eye },
|
||||
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
|
||||
]
|
||||
case 'LIVE_FINAL':
|
||||
return [
|
||||
{ label: 'Live Control', href: roundHref, icon: Presentation },
|
||||
{ label: 'Results', href: `${roundHref}?tab=projects`, icon: Vote },
|
||||
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
|
||||
]
|
||||
case 'DELIBERATION':
|
||||
return [
|
||||
{ label: 'Sessions', href: roundHref, icon: Play },
|
||||
{ label: 'Results', href: `${roundHref}?tab=projects`, icon: Eye },
|
||||
{ label: 'Lock Results', href: roundHref, icon: Lock },
|
||||
]
|
||||
default:
|
||||
return [
|
||||
{ label: 'Rounds', href: '/admin/rounds', icon: CircleDot },
|
||||
{ label: 'Import', href: '/admin/projects/new', icon: Upload },
|
||||
{ label: 'Invite', href: '/admin/members', icon: UserPlus },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
|
||||
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
|
||||
{ editionId },
|
||||
@@ -83,6 +166,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
evaluationStats,
|
||||
totalAssignments,
|
||||
latestProjects,
|
||||
recentlyActiveProjects,
|
||||
categoryBreakdown,
|
||||
oceanIssueBreakdown,
|
||||
recentActivity,
|
||||
@@ -92,6 +176,17 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
? pipelineRounds.find((r) => r.id === activeRoundId) ?? null
|
||||
: null
|
||||
|
||||
// Find next draft round for summary panel
|
||||
const lastActiveSortOrder = Math.max(
|
||||
...pipelineRounds.filter((r) => r.status === 'ROUND_ACTIVE').map((r) => r.sortOrder),
|
||||
-1
|
||||
)
|
||||
const nextDraftRound = pipelineRounds.find(
|
||||
(r) => r.status === 'ROUND_DRAFT' && r.sortOrder > lastActiveSortOrder
|
||||
) ?? null
|
||||
|
||||
const quickActions = getContextualActions(activeRound)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Page Header */}
|
||||
@@ -109,25 +204,15 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
Welcome back, {sessionName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/admin/rounds">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickActions.map((action) => (
|
||||
<Link key={action.label} href={action.href as Route}>
|
||||
<Button size="sm" variant="outline">
|
||||
<CircleDot className="mr-1.5 h-3.5 w-3.5" />
|
||||
Rounds
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/admin/projects/new">
|
||||
<Button size="sm" variant="outline">
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
Import
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/admin/members">
|
||||
<Button size="sm" variant="outline">
|
||||
<UserPlus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Invite
|
||||
<action.icon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{action.label}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -147,6 +232,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
totalAssignments={totalAssignments}
|
||||
evaluationStats={evaluationStats}
|
||||
actionsCount={nextActions.length}
|
||||
nextDraftRound={nextDraftRound ? { name: nextDraftRound.name, roundType: nextDraftRound.roundType } : null}
|
||||
/>
|
||||
</AnimatedCard>
|
||||
|
||||
@@ -161,7 +247,11 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
)}
|
||||
|
||||
<AnimatedCard index={3}>
|
||||
<ProjectListCompact projects={latestProjects} />
|
||||
<ProjectListCompact
|
||||
projects={latestProjects}
|
||||
activeProjects={recentlyActiveProjects}
|
||||
mode={activeRound && activeRound.roundType !== 'INTAKE' ? 'active' : 'recent'}
|
||||
/>
|
||||
</AnimatedCard>
|
||||
|
||||
{recentEvals && recentEvals.length > 0 && (
|
||||
|
||||
@@ -34,8 +34,8 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Plus, Scale, Users, Loader2 } from 'lucide-react'
|
||||
import { cn, formatEnumLabel } from '@/lib/utils'
|
||||
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot } from 'lucide-react'
|
||||
|
||||
const capModeLabels = {
|
||||
HARD: 'Hard Cap',
|
||||
@@ -267,33 +267,82 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
|
||||
No jury groups configured for this competition.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-3">
|
||||
{juryGroups.map((group) => (
|
||||
<Link key={group.id} href={`/admin/juries/${group.id}` as Route}>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer">
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer group">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-sm line-clamp-1">{group.name}</h3>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10 shrink-0">
|
||||
<Scale className="h-4 w-4 text-brand-blue" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-sm line-clamp-1 group-hover:text-brand-blue transition-colors">
|
||||
{group.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{group._count.members} member{group._count.members !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
{group._count.assignments} assignment{group._count.assignments !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('text-[10px] shrink-0', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
|
||||
className={cn('text-[10px]', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
|
||||
>
|
||||
{capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
<span>{group._count.members} members</span>
|
||||
</div>
|
||||
<div>
|
||||
{group._count.assignments} assignments
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-brand-blue transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Default max: {group.defaultMaxAssignments}
|
||||
|
||||
{/* Round assignments */}
|
||||
{(group as any).rounds?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(group as any).rounds.map((r: any) => (
|
||||
<Badge
|
||||
key={r.id}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] gap-1',
|
||||
r.status === 'ROUND_ACTIVE' && 'border-blue-300 bg-blue-50 text-blue-700',
|
||||
r.status === 'ROUND_CLOSED' && 'border-emerald-300 bg-emerald-50 text-emerald-700',
|
||||
r.status === 'ROUND_DRAFT' && 'border-slate-200 text-slate-500',
|
||||
)}
|
||||
>
|
||||
<CircleDot className="h-2.5 w-2.5" />
|
||||
{r.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member preview */}
|
||||
{(group as any).members?.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex -space-x-1.5">
|
||||
{(group as any).members.slice(0, 5).map((m: any) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="h-6 w-6 rounded-full bg-brand-blue/10 border-2 border-white flex items-center justify-center text-[9px] font-semibold text-brand-blue"
|
||||
title={m.user?.name || m.user?.email}
|
||||
>
|
||||
{(m.user?.name || m.user?.email || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{group._count.members > 5 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
+{group._count.members - 5} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@/components/ui/table'
|
||||
import { ArrowLeft, Pencil, Plus } from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { CompetitionSettings } from '@/components/admin/program/competition-settings'
|
||||
|
||||
interface ProgramDetailPageProps {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -84,6 +85,24 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const comp = (program as any).competitions?.[0]
|
||||
if (!comp) return null
|
||||
return (
|
||||
<CompetitionSettings
|
||||
competitionId={comp.id}
|
||||
initialSettings={{
|
||||
categoryMode: comp.categoryMode ?? 'SHARED',
|
||||
startupFinalistCount: comp.startupFinalistCount ?? 3,
|
||||
conceptFinalistCount: comp.conceptFinalistCount ?? 3,
|
||||
notifyOnRoundAdvance: comp.notifyOnRoundAdvance ?? true,
|
||||
notifyOnDeadlineApproach: comp.notifyOnDeadlineApproach ?? true,
|
||||
deadlineReminderDays: comp.deadlineReminderDays ?? [7, 3, 1],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
@@ -93,9 +112,9 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href={`/admin/competitions?programId=${id}` as Route}>
|
||||
<Link href={'/admin/rounds' as Route}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Manage Competitions
|
||||
Manage Rounds
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
@@ -637,7 +637,7 @@ export default function ProjectsPage() {
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/projects/pool">
|
||||
<Link href="/admin/projects?hasAssign=false">
|
||||
<Layers className="mr-2 h-4 w-4" />
|
||||
Assign to Round
|
||||
</Link>
|
||||
|
||||
@@ -1,558 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Loader2, X, Layers, Info } from 'lucide-react'
|
||||
|
||||
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 ProjectPoolPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const { currentEdition, isLoading: editionLoading } = useEdition()
|
||||
|
||||
// URL params for deep-linking context
|
||||
const urlRoundId = searchParams.get('roundId') || ''
|
||||
const urlCompetitionId = searchParams.get('competitionId') || ''
|
||||
|
||||
// Auto-select programId from edition
|
||||
const programId = currentEdition?.id || ''
|
||||
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
|
||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
|
||||
const [assignAllDialogOpen, setAssignAllDialogOpen] = useState(false)
|
||||
const [targetRoundId, setTargetRoundId] = useState<string>(urlRoundId)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
|
||||
const [showUnassignedOnly, setShowUnassignedOnly] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const perPage = 50
|
||||
|
||||
// Pre-select target round from URL param
|
||||
useEffect(() => {
|
||||
if (urlRoundId) setTargetRoundId(urlRoundId)
|
||||
}, [urlRoundId])
|
||||
|
||||
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
|
||||
{
|
||||
programId,
|
||||
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
||||
search: searchQuery || undefined,
|
||||
unassignedOnly: showUnassignedOnly,
|
||||
excludeRoundId: urlRoundId || undefined,
|
||||
page: currentPage,
|
||||
perPage,
|
||||
},
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
// Load rounds from program (flattened from all competitions, now with competitionId)
|
||||
const { data: programData, isLoading: isLoadingRounds } = trpc.program.get.useQuery(
|
||||
{ id: programId },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
// Get round name for context banner
|
||||
const allRounds = useMemo(() => {
|
||||
return (programData?.rounds || []) as Array<{
|
||||
id: string
|
||||
name: string
|
||||
competitionId: string
|
||||
status: string
|
||||
_count: { projects: number; assignments: number }
|
||||
}>
|
||||
}, [programData])
|
||||
|
||||
// Filter rounds by competitionId if URL param is set
|
||||
const filteredRounds = useMemo(() => {
|
||||
if (urlCompetitionId) {
|
||||
return allRounds.filter((r) => r.competitionId === urlCompetitionId)
|
||||
}
|
||||
return allRounds
|
||||
}, [allRounds, urlCompetitionId])
|
||||
|
||||
const contextRound = urlRoundId ? allRounds.find((r) => r.id === urlRoundId) : null
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const assignMutation = trpc.projectPool.assignToRound.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.project.list.invalidate()
|
||||
utils.projectPool.listUnassigned.invalidate()
|
||||
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
|
||||
setSelectedProjects([])
|
||||
setAssignDialogOpen(false)
|
||||
setTargetRoundId(urlRoundId)
|
||||
refetch()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error((error as { message?: string }).message || 'Failed to assign projects')
|
||||
},
|
||||
})
|
||||
|
||||
const assignAllMutation = trpc.projectPool.assignAllToRound.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.project.list.invalidate()
|
||||
utils.projectPool.listUnassigned.invalidate()
|
||||
toast.success(`Assigned all ${result.assignedCount} projects to round`)
|
||||
setSelectedProjects([])
|
||||
setAssignAllDialogOpen(false)
|
||||
setTargetRoundId(urlRoundId)
|
||||
refetch()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error((error as { message?: string }).message || 'Failed to assign projects')
|
||||
},
|
||||
})
|
||||
|
||||
const isPending = assignMutation.isPending || assignAllMutation.isPending
|
||||
|
||||
const handleBulkAssign = () => {
|
||||
if (selectedProjects.length === 0 || !targetRoundId) return
|
||||
assignMutation.mutate({
|
||||
projectIds: selectedProjects,
|
||||
roundId: targetRoundId,
|
||||
})
|
||||
}
|
||||
|
||||
const handleAssignAll = () => {
|
||||
if (!targetRoundId || !programId) return
|
||||
assignAllMutation.mutate({
|
||||
programId,
|
||||
roundId: targetRoundId,
|
||||
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
||||
unassignedOnly: showUnassignedOnly,
|
||||
})
|
||||
}
|
||||
|
||||
const handleQuickAssign = (projectId: string, roundId: string) => {
|
||||
assignMutation.mutate({
|
||||
projectIds: [projectId],
|
||||
roundId,
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (!poolData?.projects) return
|
||||
if (selectedProjects.length === poolData.projects.length) {
|
||||
setSelectedProjects([])
|
||||
} else {
|
||||
setSelectedProjects(poolData.projects.map((p) => p.id))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectProject = (projectId: string) => {
|
||||
if (selectedProjects.includes(projectId)) {
|
||||
setSelectedProjects(selectedProjects.filter((id) => id !== projectId))
|
||||
} else {
|
||||
setSelectedProjects([...selectedProjects, projectId])
|
||||
}
|
||||
}
|
||||
|
||||
if (editionLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Link href={"/admin/projects" as Route} className="mt-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-semibold">Project Pool</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{currentEdition
|
||||
? `${currentEdition.name} ${currentEdition.year} \u2014 ${poolData?.total ?? '...'} projects`
|
||||
: 'No edition selected'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context banner when coming from a round */}
|
||||
{contextRound && (
|
||||
<Card className="border-blue-200 bg-blue-50/50">
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-blue-600 shrink-0" />
|
||||
<p className="text-sm">
|
||||
Assigning to <span className="font-semibold">{contextRound.name}</span>
|
||||
{' \u2014 '}
|
||||
<span className="text-muted-foreground">
|
||||
projects already in this round are hidden
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/admin/rounds/${urlRoundId}` as Route}
|
||||
>
|
||||
<Button variant="outline" size="sm" className="shrink-0">
|
||||
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
|
||||
Back to Round
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">Category</label>
|
||||
<Select value={categoryFilter} onValueChange={(value: string) => {
|
||||
setCategoryFilter(value as 'STARTUP' | 'BUSINESS_CONCEPT' | 'all')
|
||||
setCurrentPage(1)
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
<SelectItem value="STARTUP">Startup</SelectItem>
|
||||
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">Search</label>
|
||||
<Input
|
||||
placeholder="Project or team name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pb-0.5">
|
||||
<Switch
|
||||
id="unassigned-only"
|
||||
checked={showUnassignedOnly}
|
||||
onCheckedChange={(checked) => {
|
||||
setShowUnassignedOnly(checked)
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="unassigned-only" className="text-sm font-medium cursor-pointer whitespace-nowrap">
|
||||
Unassigned only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Action bar */}
|
||||
{programId && poolData && poolData.total > 0 && (
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{poolData.total}</span> project{poolData.total !== 1 ? 's' : ''}
|
||||
{showUnassignedOnly && ' (unassigned only)'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedProjects.length > 0 && (
|
||||
<Button onClick={() => setAssignDialogOpen(true)} size="sm">
|
||||
Assign {selectedProjects.length} Selected
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setAssignAllDialogOpen(true)}
|
||||
>
|
||||
Assign All {poolData.total} to Round
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Projects Table */}
|
||||
{programId ? (
|
||||
<>
|
||||
{isLoadingPool ? (
|
||||
<Card className="p-4">
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : poolData && poolData.total > 0 ? (
|
||||
<>
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b">
|
||||
<tr className="text-sm">
|
||||
<th className="p-3 text-left w-[40px]">
|
||||
<Checkbox
|
||||
checked={poolData.projects.length > 0 && selectedProjects.length === poolData.projects.length}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">Project</th>
|
||||
<th className="p-3 text-left font-medium">Category</th>
|
||||
<th className="p-3 text-left font-medium">Rounds</th>
|
||||
<th className="p-3 text-left font-medium">Country</th>
|
||||
<th className="p-3 text-left font-medium">Submitted</th>
|
||||
<th className="p-3 text-left font-medium">Quick Assign</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{poolData.projects.map((project) => (
|
||||
<tr key={project.id} className="border-b hover:bg-muted/50">
|
||||
<td className="p-3">
|
||||
<Checkbox
|
||||
checked={selectedProjects.includes(project.id)}
|
||||
onCheckedChange={() => toggleSelectProject(project.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/admin/projects/${project.id}` as Route}
|
||||
className="hover:underline"
|
||||
>
|
||||
<div className="font-medium">{project.title}</div>
|
||||
<div className="text-sm text-muted-foreground">{project.teamName}</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{(project as any).projectRoundStates?.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(project as any).projectRoundStates.map((prs: any) => (
|
||||
<Badge
|
||||
key={prs.roundId}
|
||||
variant="secondary"
|
||||
className={`text-[10px] ${roundTypeColors[prs.round?.roundType] || 'bg-gray-100 text-gray-700'}`}
|
||||
>
|
||||
{prs.round?.name || 'Round'}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">None</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{project.country ? (() => {
|
||||
const code = normalizeCountryToCode(project.country)
|
||||
const flag = code ? getCountryFlag(code) : null
|
||||
const name = code ? getCountryName(code) : project.country
|
||||
return <>{flag && <span>{flag} </span>}{name}</>
|
||||
})() : '-'}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{project.submittedAt
|
||||
? new Date(project.submittedAt).toLocaleDateString()
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{isLoadingRounds ? (
|
||||
<Skeleton className="h-9 w-[200px]" />
|
||||
) : (
|
||||
<Select
|
||||
onValueChange={(roundId) => handleQuickAssign(project.id, roundId)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Assign to round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredRounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{poolData.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {((currentPage - 1) * perPage) + 1} to {Math.min(currentPage * perPage, poolData.total)} of {poolData.total}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === poolData.totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Layers className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p>
|
||||
{showUnassignedOnly
|
||||
? 'No unassigned projects found'
|
||||
: urlRoundId
|
||||
? 'All projects are already assigned to this round'
|
||||
: 'No projects found for this program'}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
No edition selected. Please select an edition from the sidebar.
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bulk Assignment Dialog (selected projects) */}
|
||||
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Selected Projects</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to a round:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredRounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBulkAssign}
|
||||
disabled={!targetRoundId || isPending}
|
||||
>
|
||||
{assignMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Assign {selectedProjects.length} Projects
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Assign ALL Dialog */}
|
||||
<Dialog open={assignAllDialogOpen} onOpenChange={setAssignAllDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign All Projects</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will assign all {poolData?.total || 0}{categoryFilter !== 'all' ? ` ${categoryFilter === 'STARTUP' ? 'Startup' : 'Business Concept'}` : ''}{showUnassignedOnly ? ' unassigned' : ''} projects to a round in one operation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredRounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAssignAllDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAssignAll}
|
||||
disabled={!targetRoundId || isPending}
|
||||
>
|
||||
{assignAllMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Assign All {poolData?.total || 0} Projects
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,43 +46,28 @@ import {
|
||||
ArrowRight,
|
||||
} from 'lucide-react'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import {
|
||||
roundTypeConfig,
|
||||
roundStatusConfig,
|
||||
awardStatusConfig,
|
||||
ROUND_TYPE_OPTIONS,
|
||||
} from '@/lib/round-config'
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
// ─── Constants (derived from shared config) ──────────────────────────────────
|
||||
|
||||
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 ROUND_TYPES = ROUND_TYPE_OPTIONS
|
||||
|
||||
const ROUND_TYPE_COLORS: Record<string, { dot: string; bg: string; text: string; border: string }> = {
|
||||
INTAKE: { dot: '#9ca3af', bg: 'bg-gray-50', text: 'text-gray-600', border: 'border-gray-300' },
|
||||
FILTERING: { dot: '#f59e0b', bg: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-300' },
|
||||
EVALUATION: { dot: '#3b82f6', bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-300' },
|
||||
SUBMISSION: { dot: '#8b5cf6', bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-300' },
|
||||
MENTORING: { dot: '#557f8c', bg: 'bg-teal-50', text: 'text-teal-700', border: 'border-teal-300' },
|
||||
LIVE_FINAL: { dot: '#de0f1e', bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-300' },
|
||||
DELIBERATION: { dot: '#6366f1', bg: 'bg-indigo-50', text: 'text-indigo-700', border: 'border-indigo-300' },
|
||||
}
|
||||
const ROUND_TYPE_COLORS: Record<string, { dot: string; bg: string; text: string; border: string }> = Object.fromEntries(
|
||||
Object.entries(roundTypeConfig).map(([k, v]) => [k, { dot: v.dotColor, bg: v.cardBg, text: v.cardText, border: v.cardBorder }])
|
||||
)
|
||||
|
||||
const ROUND_STATUS_STYLES: Record<string, { color: string; label: string; pulse?: boolean }> = {
|
||||
ROUND_DRAFT: { color: '#9ca3af', label: 'Draft' },
|
||||
ROUND_ACTIVE: { color: '#10b981', label: 'Active', pulse: true },
|
||||
ROUND_CLOSED: { color: '#3b82f6', label: 'Closed' },
|
||||
ROUND_ARCHIVED: { color: '#6b7280', label: 'Archived' },
|
||||
}
|
||||
const ROUND_STATUS_STYLES: Record<string, { color: string; label: string; pulse?: boolean }> = Object.fromEntries(
|
||||
Object.entries(roundStatusConfig).map(([k, v]) => [k, { color: v.dotColor, label: v.label, pulse: v.pulse }])
|
||||
)
|
||||
|
||||
const AWARD_STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: 'text-gray-500',
|
||||
NOMINATIONS_OPEN: 'text-amber-600',
|
||||
VOTING_OPEN: 'text-emerald-600',
|
||||
CLOSED: 'text-blue-600',
|
||||
ARCHIVED: 'text-gray-400',
|
||||
}
|
||||
const AWARD_STATUS_COLORS: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(awardStatusConfig).map(([k, v]) => [k, v.color])
|
||||
)
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -268,12 +253,12 @@ export default function RoundsPage() {
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[#053d57] mb-1">No Competition Configured</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm mb-5">
|
||||
Create a competition to start building the evaluation pipeline.
|
||||
Create a program edition to start building the evaluation pipeline.
|
||||
</p>
|
||||
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
|
||||
<Link href={'/admin/programs' as Route}>
|
||||
<Button className="bg-[#de0f1e] hover:bg-[#de0f1e]/90">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Competition
|
||||
Manage Editions
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
170
src/components/admin/assignment/coi-review-section.tsx
Normal file
170
src/components/admin/assignment/coi-review-section.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ShieldAlert, Eye, CheckCircle2, UserPlus, FileText } from 'lucide-react'
|
||||
|
||||
export type COIReviewSectionProps = {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function COIReviewSection({ roundId }: COIReviewSectionProps) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: declarations, isLoading } = trpc.evaluation.listCOIByStage.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 15_000 },
|
||||
)
|
||||
|
||||
const reviewMutation = trpc.evaluation.reviewCOI.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.evaluation.listCOIByStage.invalidate({ roundId })
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.analytics.getJurorWorkload.invalidate({ roundId })
|
||||
if (data.reassignment) {
|
||||
toast.success(`Reassigned to ${data.reassignment.newJurorName}`)
|
||||
} else {
|
||||
toast.success('COI review updated')
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Show placeholder when no declarations
|
||||
if (!isLoading && (!declarations || declarations.length === 0)) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">No conflict of interest declarations yet.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const conflictCount = declarations?.filter((d) => d.hasConflict).length ?? 0
|
||||
const unreviewedCount = declarations?.filter((d) => d.hasConflict && !d.reviewedAt).length ?? 0
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium flex items-center gap-2">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
Conflict of Interest Declarations
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{declarations?.length ?? 0} declaration{(declarations?.length ?? 0) !== 1 ? 's' : ''}
|
||||
{conflictCount > 0 && (
|
||||
<> — <span className="text-amber-600 font-medium">{conflictCount} conflict{conflictCount !== 1 ? 's' : ''}</span></>
|
||||
)}
|
||||
{unreviewedCount > 0 && (
|
||||
<> ({unreviewedCount} pending review)</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||||
<div className="grid grid-cols-[1fr_1fr_80px_100px_100px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
|
||||
<span>Juror</span>
|
||||
<span>Project</span>
|
||||
<span>Conflict</span>
|
||||
<span>Type</span>
|
||||
<span>Action</span>
|
||||
</div>
|
||||
{declarations?.map((coi: any, idx: number) => (
|
||||
<div
|
||||
key={coi.id}
|
||||
className={cn(
|
||||
'grid grid-cols-[1fr_1fr_80px_100px_100px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
|
||||
idx % 2 === 1 ? 'bg-muted/20' : 'hover:bg-muted/20',
|
||||
coi.hasConflict && !coi.reviewedAt && 'border-l-4 border-l-amber-500',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{coi.user?.name || coi.user?.email || 'Unknown'}</span>
|
||||
<span className="truncate text-muted-foreground">{coi.assignment?.project?.title || 'Unknown'}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] justify-center',
|
||||
coi.hasConflict
|
||||
? 'bg-red-50 text-red-700 border-red-200'
|
||||
: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
)}
|
||||
>
|
||||
{coi.hasConflict ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{coi.hasConflict ? (coi.conflictType || 'Unspecified') : '\u2014'}
|
||||
</span>
|
||||
{coi.hasConflict ? (
|
||||
coi.reviewedAt ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] justify-center',
|
||||
coi.reviewAction === 'cleared'
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: coi.reviewAction === 'reassigned'
|
||||
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||
: 'bg-gray-50 text-gray-600 border-gray-200',
|
||||
)}
|
||||
>
|
||||
{coi.reviewAction === 'cleared' ? 'Cleared' : coi.reviewAction === 'reassigned' ? 'Reassigned' : 'Noted'}
|
||||
</Badge>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
Review
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'cleared' })}
|
||||
disabled={reviewMutation.isPending}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 mr-2 text-emerald-600" />
|
||||
Clear — no real conflict
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'reassigned' })}
|
||||
disabled={reviewMutation.isPending}
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 mr-2 text-blue-600" />
|
||||
Reassign to another juror
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'noted' })}
|
||||
disabled={reviewMutation.isPending}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 mr-2 text-gray-600" />
|
||||
Note — keep as is
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
854
src/components/admin/assignment/individual-assignments-table.tsx
Normal file
854
src/components/admin/assignment/individual-assignments-table.tsx
Normal file
@@ -0,0 +1,854 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
|
||||
export type IndividualAssignmentsTableProps = {
|
||||
roundId: string
|
||||
projectStates: any[] | undefined
|
||||
}
|
||||
|
||||
export function IndividualAssignmentsTable({
|
||||
roundId,
|
||||
projectStates,
|
||||
}: IndividualAssignmentsTableProps) {
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: 'reset' | 'delete'; assignment: any } | null>(null)
|
||||
const [assignMode, setAssignMode] = useState<'byJuror' | 'byProject'>('byJuror')
|
||||
// ── By Juror mode state ──
|
||||
const [selectedJurorId, setSelectedJurorId] = useState('')
|
||||
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
|
||||
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
|
||||
const [projectSearch, setProjectSearch] = useState('')
|
||||
// ── By Project mode state ──
|
||||
const [selectedProjectId, setSelectedProjectId] = useState('')
|
||||
const [selectedJurorIds, setSelectedJurorIds] = useState<Set<string>>(new Set())
|
||||
const [projectPopoverOpen, setProjectPopoverOpen] = useState(false)
|
||||
const [jurorSearch, setJurorSearch] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 15_000 },
|
||||
)
|
||||
|
||||
const { data: juryMembers } = trpc.user.getJuryMembers.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: addDialogOpen },
|
||||
)
|
||||
|
||||
const deleteMutation = trpc.assignment.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
toast.success('Assignment removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const resetEvalMutation = trpc.evaluation.resetEvaluation.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
toast.success('Evaluation reset — juror can now start over')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const reassignCOIMutation = trpc.assignment.reassignCOI.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
utils.analytics.getJurorWorkload.invalidate({ roundId })
|
||||
utils.evaluation.listCOIByStage.invalidate({ roundId })
|
||||
toast.success(`Reassigned to ${data.newJurorName}`)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const createMutation = trpc.assignment.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
utils.user.getJuryMembers.invalidate({ roundId })
|
||||
toast.success('Assignment created')
|
||||
resetDialog()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const bulkCreateMutation = trpc.assignment.bulkCreate.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
utils.user.getJuryMembers.invalidate({ roundId })
|
||||
toast.success(`${result.created} assignment(s) created`)
|
||||
resetDialog()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const resetDialog = useCallback(() => {
|
||||
setAddDialogOpen(false)
|
||||
setAssignMode('byJuror')
|
||||
setSelectedJurorId('')
|
||||
setSelectedProjectIds(new Set())
|
||||
setProjectSearch('')
|
||||
setSelectedProjectId('')
|
||||
setSelectedJurorIds(new Set())
|
||||
setJurorSearch('')
|
||||
}, [])
|
||||
|
||||
const selectedJuror = useMemo(
|
||||
() => juryMembers?.find((j: any) => j.id === selectedJurorId),
|
||||
[juryMembers, selectedJurorId],
|
||||
)
|
||||
|
||||
// Filter projects by search term
|
||||
const filteredProjects = useMemo(() => {
|
||||
const items = projectStates ?? []
|
||||
if (!projectSearch) return items
|
||||
const q = projectSearch.toLowerCase()
|
||||
return items.filter((ps: any) =>
|
||||
ps.project?.title?.toLowerCase().includes(q) ||
|
||||
ps.project?.teamName?.toLowerCase().includes(q) ||
|
||||
ps.project?.competitionCategory?.toLowerCase().includes(q)
|
||||
)
|
||||
}, [projectStates, projectSearch])
|
||||
|
||||
// Existing assignments for the selected juror (to grey out already-assigned projects)
|
||||
const jurorExistingProjectIds = useMemo(() => {
|
||||
if (!selectedJurorId || !assignments) return new Set<string>()
|
||||
return new Set(
|
||||
assignments
|
||||
.filter((a: any) => a.userId === selectedJurorId)
|
||||
.map((a: any) => a.projectId)
|
||||
)
|
||||
}, [selectedJurorId, assignments])
|
||||
|
||||
const toggleProject = useCallback((projectId: string) => {
|
||||
setSelectedProjectIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId)
|
||||
} else {
|
||||
next.add(projectId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectAllUnassigned = useCallback(() => {
|
||||
const unassigned = filteredProjects
|
||||
.filter((ps: any) => !jurorExistingProjectIds.has(ps.project?.id))
|
||||
.map((ps: any) => ps.project?.id)
|
||||
.filter(Boolean)
|
||||
setSelectedProjectIds(new Set(unassigned))
|
||||
}, [filteredProjects, jurorExistingProjectIds])
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
if (!selectedJurorId || selectedProjectIds.size === 0) return
|
||||
|
||||
const projectIds = Array.from(selectedProjectIds)
|
||||
if (projectIds.length === 1) {
|
||||
createMutation.mutate({
|
||||
userId: selectedJurorId,
|
||||
projectId: projectIds[0],
|
||||
roundId,
|
||||
})
|
||||
} else {
|
||||
bulkCreateMutation.mutate({
|
||||
roundId,
|
||||
assignments: projectIds.map(projectId => ({
|
||||
userId: selectedJurorId,
|
||||
projectId,
|
||||
})),
|
||||
})
|
||||
}
|
||||
}, [selectedJurorId, selectedProjectIds, roundId, createMutation, bulkCreateMutation])
|
||||
|
||||
const isMutating = createMutation.isPending || bulkCreateMutation.isPending
|
||||
|
||||
// ── By Project mode helpers ──
|
||||
|
||||
// Existing assignments for the selected project (to grey out already-assigned jurors)
|
||||
const projectExistingJurorIds = useMemo(() => {
|
||||
if (!selectedProjectId || !assignments) return new Set<string>()
|
||||
return new Set(
|
||||
assignments
|
||||
.filter((a: any) => a.projectId === selectedProjectId)
|
||||
.map((a: any) => a.userId)
|
||||
)
|
||||
}, [selectedProjectId, assignments])
|
||||
|
||||
// Count assignments per juror in this round (for display)
|
||||
const jurorAssignmentCounts = useMemo(() => {
|
||||
if (!assignments) return new Map<string, number>()
|
||||
const counts = new Map<string, number>()
|
||||
for (const a of assignments) {
|
||||
counts.set(a.userId, (counts.get(a.userId) || 0) + 1)
|
||||
}
|
||||
return counts
|
||||
}, [assignments])
|
||||
|
||||
// Filter jurors by search term
|
||||
const filteredJurors = useMemo(() => {
|
||||
const items = juryMembers ?? []
|
||||
if (!jurorSearch) return items
|
||||
const q = jurorSearch.toLowerCase()
|
||||
return items.filter((j: any) =>
|
||||
j.name?.toLowerCase().includes(q) ||
|
||||
j.email?.toLowerCase().includes(q)
|
||||
)
|
||||
}, [juryMembers, jurorSearch])
|
||||
|
||||
const toggleJuror = useCallback((jurorId: string) => {
|
||||
setSelectedJurorIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(jurorId)) next.delete(jurorId)
|
||||
else next.add(jurorId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleCreateByProject = useCallback(() => {
|
||||
if (!selectedProjectId || selectedJurorIds.size === 0) return
|
||||
|
||||
const jurorIds = Array.from(selectedJurorIds)
|
||||
if (jurorIds.length === 1) {
|
||||
createMutation.mutate({
|
||||
userId: jurorIds[0],
|
||||
projectId: selectedProjectId,
|
||||
roundId,
|
||||
})
|
||||
} else {
|
||||
bulkCreateMutation.mutate({
|
||||
roundId,
|
||||
assignments: jurorIds.map(userId => ({
|
||||
userId,
|
||||
projectId: selectedProjectId,
|
||||
})),
|
||||
})
|
||||
}
|
||||
}, [selectedProjectId, selectedJurorIds, roundId, createMutation, bulkCreateMutation])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{assignments?.length ?? 0} individual assignments</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||
</div>
|
||||
) : !assignments || assignments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
No assignments yet. Generate assignments or add one manually.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-[500px] overflow-y-auto">
|
||||
<div className="grid grid-cols-[1fr_1fr_100px_70px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
|
||||
<span>Juror</span>
|
||||
<span>Project</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{assignments.map((a: any, idx: number) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className={cn(
|
||||
'grid grid-cols-[1fr_1fr_100px_70px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
|
||||
idx % 2 === 1 ? 'bg-muted/20' : 'hover:bg-muted/20',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{a.user?.name || a.user?.email || 'Unknown'}</span>
|
||||
<span className="truncate text-muted-foreground">{a.project?.title || 'Unknown'}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{a.conflictOfInterest?.hasConflict ? (
|
||||
<Badge variant="outline" className="text-[10px] justify-center bg-red-50 text-red-700 border-red-200">
|
||||
COI
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] justify-center',
|
||||
a.evaluation?.status === 'SUBMITTED'
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: a.evaluation?.status === 'DRAFT'
|
||||
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||
: 'bg-gray-50 text-gray-600 border-gray-200',
|
||||
)}
|
||||
>
|
||||
{a.evaluation?.status || 'PENDING'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{a.conflictOfInterest?.hasConflict && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => reassignCOIMutation.mutate({ assignmentId: a.id })}
|
||||
disabled={reassignCOIMutation.isPending}
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 mr-2 text-blue-600" />
|
||||
Reassign (COI)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{a.evaluation && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setConfirmAction({ type: 'reset', assignment: a })}
|
||||
disabled={resetEvalMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-2" />
|
||||
Reset Evaluation
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setConfirmAction({ type: 'delete', assignment: a })}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||
Delete Assignment
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Assignment Dialog */}
|
||||
<Dialog open={addDialogOpen} onOpenChange={(open) => {
|
||||
if (!open) resetDialog()
|
||||
else setAddDialogOpen(true)
|
||||
}}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Assignment</DialogTitle>
|
||||
<DialogDescription>
|
||||
{assignMode === 'byJuror'
|
||||
? 'Select a juror, then choose projects to assign'
|
||||
: 'Select a project, then choose jurors to assign'
|
||||
}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Mode Toggle */}
|
||||
<Tabs value={assignMode} onValueChange={(v) => {
|
||||
setAssignMode(v as 'byJuror' | 'byProject')
|
||||
// Reset selections when switching
|
||||
setSelectedJurorId('')
|
||||
setSelectedProjectIds(new Set())
|
||||
setProjectSearch('')
|
||||
setSelectedProjectId('')
|
||||
setSelectedJurorIds(new Set())
|
||||
setJurorSearch('')
|
||||
}}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="byJuror">By Juror</TabsTrigger>
|
||||
<TabsTrigger value="byProject">By Project</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── By Juror Tab ── */}
|
||||
<TabsContent value="byJuror" className="space-y-4 mt-4">
|
||||
{/* Juror Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Juror</Label>
|
||||
<Popover open={jurorPopoverOpen} onOpenChange={setJurorPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={jurorPopoverOpen}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{selectedJuror
|
||||
? (
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<span className="truncate">{selectedJuror.name || selectedJuror.email}</span>
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
{selectedJuror.currentAssignments}/{selectedJuror.maxAssignments ?? '\u221E'}
|
||||
</Badge>
|
||||
</span>
|
||||
)
|
||||
: <span className="text-muted-foreground">Select a jury member...</span>
|
||||
}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search by name or email..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No jury members found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{juryMembers?.map((juror: any) => {
|
||||
const atCapacity = juror.maxAssignments !== null && juror.availableSlots === 0
|
||||
return (
|
||||
<CommandItem
|
||||
key={juror.id}
|
||||
value={`${juror.name ?? ''} ${juror.email}`}
|
||||
disabled={atCapacity}
|
||||
onSelect={() => {
|
||||
setSelectedJurorId(juror.id === selectedJurorId ? '' : juror.id)
|
||||
setSelectedProjectIds(new Set())
|
||||
setJurorPopoverOpen(false)
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedJurorId === juror.id ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{juror.name || 'Unnamed'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{juror.email}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={atCapacity ? 'destructive' : 'secondary'}
|
||||
className="text-[10px] ml-2 shrink-0"
|
||||
>
|
||||
{juror.currentAssignments}/{juror.maxAssignments ?? '\u221E'}
|
||||
{atCapacity ? ' full' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Project Multi-Select */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
Projects
|
||||
{selectedProjectIds.size > 0 && (
|
||||
<span className="ml-1.5 text-muted-foreground font-normal">
|
||||
({selectedProjectIds.size} selected)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{selectedJurorId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={selectAllUnassigned}
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
{selectedProjectIds.size > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setSelectedProjectIds(new Set())}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter projects..."
|
||||
value={projectSearch}
|
||||
onChange={(e) => setProjectSearch(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project checklist */}
|
||||
<ScrollArea className="h-[320px] rounded-md border">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{!selectedJurorId ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
Select a juror first
|
||||
</p>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No projects found
|
||||
</p>
|
||||
) : (
|
||||
filteredProjects.map((ps: any) => {
|
||||
const project = ps.project
|
||||
if (!project) return null
|
||||
const alreadyAssigned = jurorExistingProjectIds.has(project.id)
|
||||
const isSelected = selectedProjectIds.has(project.id)
|
||||
|
||||
return (
|
||||
<label
|
||||
key={project.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors',
|
||||
alreadyAssigned
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'bg-accent'
|
||||
: 'hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={alreadyAssigned}
|
||||
onCheckedChange={() => toggleProject(project.id)}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||
<span className="truncate">{project.title}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{project.competitionCategory === 'STARTUP'
|
||||
? 'Startup'
|
||||
: project.competitionCategory === 'BUSINESS_CONCEPT'
|
||||
? 'Concept'
|
||||
: project.competitionCategory}
|
||||
</Badge>
|
||||
)}
|
||||
{alreadyAssigned && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Assigned
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={resetDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!selectedJurorId || selectedProjectIds.size === 0 || isMutating}
|
||||
>
|
||||
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
{selectedProjectIds.size <= 1
|
||||
? 'Create Assignment'
|
||||
: `Create ${selectedProjectIds.size} Assignments`
|
||||
}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── By Project Tab ── */}
|
||||
<TabsContent value="byProject" className="space-y-4 mt-4">
|
||||
{/* Project Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Project</Label>
|
||||
<Popover open={projectPopoverOpen} onOpenChange={setProjectPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={projectPopoverOpen}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{selectedProjectId
|
||||
? (
|
||||
<span className="truncate">
|
||||
{(projectStates ?? []).find((ps: any) => ps.project?.id === selectedProjectId)?.project?.title || 'Unknown'}
|
||||
</span>
|
||||
)
|
||||
: <span className="text-muted-foreground">Select a project...</span>
|
||||
}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search by project title..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No projects found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{(projectStates ?? []).map((ps: any) => {
|
||||
const project = ps.project
|
||||
if (!project) return null
|
||||
return (
|
||||
<CommandItem
|
||||
key={project.id}
|
||||
value={`${project.title ?? ''} ${project.teamName ?? ''}`}
|
||||
onSelect={() => {
|
||||
setSelectedProjectId(project.id === selectedProjectId ? '' : project.id)
|
||||
setSelectedJurorIds(new Set())
|
||||
setProjectPopoverOpen(false)
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedProjectId === project.id ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{project.teamName}</p>
|
||||
</div>
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="text-[10px] ml-2 shrink-0">
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Juror Multi-Select */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
Jurors
|
||||
{selectedJurorIds.size > 0 && (
|
||||
<span className="ml-1.5 text-muted-foreground font-normal">
|
||||
({selectedJurorIds.size} selected)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{selectedProjectId && selectedJurorIds.size > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setSelectedJurorIds(new Set())}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter jurors..."
|
||||
value={jurorSearch}
|
||||
onChange={(e) => setJurorSearch(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Juror checklist */}
|
||||
<ScrollArea className="h-[320px] rounded-md border">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{!selectedProjectId ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
Select a project first
|
||||
</p>
|
||||
) : filteredJurors.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No jurors found
|
||||
</p>
|
||||
) : (
|
||||
filteredJurors.map((juror: any) => {
|
||||
const alreadyAssigned = projectExistingJurorIds.has(juror.id)
|
||||
const isSelected = selectedJurorIds.has(juror.id)
|
||||
const assignCount = jurorAssignmentCounts.get(juror.id) ?? 0
|
||||
|
||||
return (
|
||||
<label
|
||||
key={juror.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors',
|
||||
alreadyAssigned
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'bg-accent'
|
||||
: 'hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={alreadyAssigned}
|
||||
onCheckedChange={() => toggleJuror(juror.id)}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium truncate block">{juror.name || 'Unnamed'}</span>
|
||||
<span className="text-xs text-muted-foreground truncate block">{juror.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{assignCount} assigned
|
||||
</Badge>
|
||||
{alreadyAssigned && (
|
||||
<Badge variant="outline" className="text-[10px] bg-amber-50 text-amber-700 border-amber-200">
|
||||
Already on project
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={resetDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateByProject}
|
||||
disabled={!selectedProjectId || selectedJurorIds.size === 0 || isMutating}
|
||||
>
|
||||
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
{selectedJurorIds.size <= 1
|
||||
? 'Create Assignment'
|
||||
: `Create ${selectedJurorIds.size} Assignments`
|
||||
}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Confirmation AlertDialog for reset/delete */}
|
||||
<AlertDialog open={!!confirmAction} onOpenChange={(open) => { if (!open) setConfirmAction(null) }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{confirmAction?.type === 'reset' ? 'Reset evaluation?' : 'Delete assignment?'}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{confirmAction?.type === 'reset'
|
||||
? `Reset evaluation by ${confirmAction.assignment?.user?.name || confirmAction.assignment?.user?.email} for "${confirmAction.assignment?.project?.title}"? This will erase all scores and feedback so they can start over.`
|
||||
: `Remove assignment for ${confirmAction?.assignment?.user?.name || confirmAction?.assignment?.user?.email} on "${confirmAction?.assignment?.project?.title}"?`
|
||||
}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={confirmAction?.type === 'delete' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
|
||||
onClick={() => {
|
||||
if (confirmAction?.type === 'reset') {
|
||||
resetEvalMutation.mutate({ assignmentId: confirmAction.assignment.id })
|
||||
} else if (confirmAction?.type === 'delete') {
|
||||
deleteMutation.mutate({ id: confirmAction.assignment.id })
|
||||
}
|
||||
setConfirmAction(null)
|
||||
}}
|
||||
>
|
||||
{confirmAction?.type === 'reset' ? 'Reset' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
180
src/components/admin/assignment/jury-progress-table.tsx
Normal file
180
src/components/admin/assignment/jury-progress-table.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { Loader2, Mail, ArrowRightLeft, UserPlus } from 'lucide-react'
|
||||
import { TransferAssignmentsDialog } from './transfer-assignments-dialog'
|
||||
|
||||
export type JuryProgressTableProps = {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
|
||||
const utils = trpc.useUtils()
|
||||
const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
|
||||
|
||||
const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 15_000 },
|
||||
)
|
||||
|
||||
const notifyMutation = trpc.assignment.notifySingleJurorOfAssignments.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Notified juror of ${data.projectCount} assignment(s)`)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const reshuffleMutation = trpc.assignment.reassignDroppedJuror.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
utils.analytics.getJurorWorkload.invalidate({ roundId })
|
||||
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
|
||||
|
||||
if (data.failedCount > 0) {
|
||||
toast.warning(`Dropped juror and reassigned ${data.movedCount} project(s). ${data.failedCount} could not be reassigned (all remaining jurors at cap/blocked).`)
|
||||
} else {
|
||||
toast.success(`Dropped juror and reassigned ${data.movedCount} project(s) evenly across available jurors.`)
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Jury Progress</CardTitle>
|
||||
<CardDescription>Evaluation completion per juror. Click the mail icon to notify an individual juror.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : !workload || workload.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
No assignments yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[350px] overflow-y-auto">
|
||||
{workload.map((juror) => {
|
||||
const pct = juror.completionRate
|
||||
const barGradient = pct === 100
|
||||
? 'bg-gradient-to-r from-emerald-400 to-emerald-600'
|
||||
: pct >= 50
|
||||
? 'bg-gradient-to-r from-blue-400 to-blue-600'
|
||||
: pct > 0
|
||||
? 'bg-gradient-to-r from-amber-400 to-amber-600'
|
||||
: 'bg-gray-300'
|
||||
|
||||
return (
|
||||
<div key={juror.id} className="space-y-1 hover:bg-muted/20 rounded px-1 py-0.5 -mx-1 transition-colors group">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="font-medium truncate max-w-[50%]">{juror.name}</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{juror.completed}/{juror.assigned} ({pct}%)
|
||||
</span>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
disabled={notifyMutation.isPending}
|
||||
onClick={() => notifyMutation.mutate({ roundId, userId: juror.id })}
|
||||
>
|
||||
{notifyMutation.isPending && notifyMutation.variables?.userId === juror.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Mail className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left"><p>Notify this juror of their assignments</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setTransferJuror({ id: juror.id, name: juror.name })}
|
||||
>
|
||||
<ArrowRightLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left"><p>Transfer assignments to other jurors</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
disabled={reshuffleMutation.isPending}
|
||||
onClick={() => {
|
||||
const ok = window.confirm(
|
||||
`Remove ${juror.name} from this jury pool and reassign all their unsubmitted projects to other jurors within their caps? Submitted evaluations will be preserved. This cannot be undone.`
|
||||
)
|
||||
if (!ok) return
|
||||
reshuffleMutation.mutate({ roundId, jurorId: juror.id })
|
||||
}}
|
||||
>
|
||||
{reshuffleMutation.isPending && reshuffleMutation.variables?.jurorId === juror.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left"><p>Drop juror + reshuffle pending projects</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', barGradient)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{transferJuror && (
|
||||
<TransferAssignmentsDialog
|
||||
roundId={roundId}
|
||||
sourceJuror={transferJuror}
|
||||
open={!!transferJuror}
|
||||
onClose={() => setTransferJuror(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
61
src/components/admin/assignment/notify-jurors-button.tsx
Normal file
61
src/components/admin/assignment/notify-jurors-button.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Mail, Loader2 } from 'lucide-react'
|
||||
|
||||
export type NotifyJurorsButtonProps = {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function NotifyJurorsButton({ roundId }: NotifyJurorsButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const mutation = trpc.assignment.notifyJurorsOfAssignments.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Notified ${data.jurorCount} juror(s) of their assignments`)
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<Mail className="h-4 w-4 mr-1.5" />
|
||||
Notify Jurors
|
||||
</Button>
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Notify jurors of their assignments?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will send an email to every juror assigned to this round, reminding them of how many projects they need to evaluate.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => mutation.mutate({ roundId })}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Notify Jurors
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
111
src/components/admin/assignment/reassignment-history.tsx
Normal file
111
src/components/admin/assignment/reassignment-history.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { History, ChevronRight } from 'lucide-react'
|
||||
|
||||
export type ReassignmentHistoryProps = {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: expanded },
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<History className="h-4 w-4" />
|
||||
Reassignment History
|
||||
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', expanded && 'rotate-90')} />
|
||||
</CardTitle>
|
||||
<CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription>
|
||||
</CardHeader>
|
||||
{expanded && (
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
||||
</div>
|
||||
) : !events || events.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
No reassignment events for this round
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 max-h-[500px] overflow-y-auto">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="border rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={event.type === 'DROPOUT' ? 'destructive' : 'secondary'}>
|
||||
{event.type === 'DROPOUT' ? 'Juror Dropout' : event.type === 'COI' ? 'COI Reassignment' : event.type === 'TRANSFER' ? 'Assignment Transfer' : 'Cap Redistribution'}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">
|
||||
{event.droppedJuror.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(event.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
By {event.performedBy.name || event.performedBy.email} — {event.movedCount} project(s) reassigned
|
||||
{event.failedCount > 0 && `, ${event.failedCount} failed`}
|
||||
</p>
|
||||
|
||||
{event.moves.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b">
|
||||
<th className="text-left py-1 font-medium">Project</th>
|
||||
<th className="text-left py-1 font-medium">Reassigned To</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{event.moves.map((move, i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="py-1.5 pr-2 max-w-[250px] truncate">
|
||||
{move.projectTitle}
|
||||
</td>
|
||||
<td className="py-1.5 font-medium">
|
||||
{move.newJurorName}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.failedProjects.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<p className="text-xs font-medium text-destructive">Could not reassign:</p>
|
||||
<ul className="text-xs text-muted-foreground list-disc list-inside">
|
||||
{event.failedProjects.map((p, i) => (
|
||||
<li key={i}>{p}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
66
src/components/admin/assignment/round-unassigned-queue.tsx
Normal file
66
src/components/admin/assignment/round-unassigned-queue.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export type RoundUnassignedQueueProps = {
|
||||
roundId: string
|
||||
requiredReviews?: number
|
||||
}
|
||||
|
||||
export function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: RoundUnassignedQueueProps) {
|
||||
const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
|
||||
{ roundId, requiredReviews },
|
||||
{ refetchInterval: 15_000 },
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Unassigned Projects</p>
|
||||
<p className="text-xs text-muted-foreground">Projects with fewer than {requiredReviews} jury assignments</p>
|
||||
</div>
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||
</div>
|
||||
) : unassigned && unassigned.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{unassigned.map((project: any) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={cn(
|
||||
'flex justify-between items-center p-3 border rounded-md hover:bg-muted/30 transition-colors',
|
||||
(project.assignmentCount || 0) === 0 && 'border-l-4 border-l-red-500',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.competitionCategory || 'No category'}
|
||||
{project.teamName && ` \u00b7 ${project.teamName}`}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn(
|
||||
'text-xs shrink-0 ml-3',
|
||||
(project.assignmentCount || 0) === 0
|
||||
? 'bg-red-50 text-red-700 border-red-200'
|
||||
: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
)}>
|
||||
{project.assignmentCount || 0} / {requiredReviews}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
All projects have sufficient assignments
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
src/components/admin/assignment/send-reminders-button.tsx
Normal file
61
src/components/admin/assignment/send-reminders-button.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Send, Loader2 } from 'lucide-react'
|
||||
|
||||
export type SendRemindersButtonProps = {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function SendRemindersButton({ roundId }: SendRemindersButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const mutation = trpc.evaluation.triggerReminders.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Sent ${data.sent} reminder(s)`)
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<Send className="h-4 w-4 mr-1.5" />
|
||||
Send Reminders
|
||||
</Button>
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Send evaluation reminders?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will send reminder emails to all jurors who have incomplete evaluations for this round.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => mutation.mutate({ roundId })}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Send Reminders
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
328
src/components/admin/assignment/transfer-assignments-dialog.tsx
Normal file
328
src/components/admin/assignment/transfer-assignments-dialog.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Loader2, Sparkles } from 'lucide-react'
|
||||
|
||||
export type TransferAssignmentsDialogProps = {
|
||||
roundId: string
|
||||
sourceJuror: { id: string; name: string }
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function TransferAssignmentsDialog({
|
||||
roundId,
|
||||
sourceJuror,
|
||||
open,
|
||||
onClose,
|
||||
}: TransferAssignmentsDialogProps) {
|
||||
const utils = trpc.useUtils()
|
||||
const [step, setStep] = useState<1 | 2>(1)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Fetch source juror's assignments
|
||||
const { data: sourceAssignments, isLoading: loadingAssignments } = trpc.assignment.listByStage.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: open },
|
||||
)
|
||||
|
||||
const jurorAssignments = useMemo(() =>
|
||||
(sourceAssignments ?? []).filter((a: any) => a.userId === sourceJuror.id),
|
||||
[sourceAssignments, sourceJuror.id],
|
||||
)
|
||||
|
||||
// Fetch transfer candidates when in step 2
|
||||
const { data: candidateData, isLoading: loadingCandidates } = trpc.assignment.getTransferCandidates.useQuery(
|
||||
{ roundId, sourceJurorId: sourceJuror.id, assignmentIds: [...selectedIds] },
|
||||
{ enabled: step === 2 && selectedIds.size > 0 },
|
||||
)
|
||||
|
||||
// Per-assignment destination overrides
|
||||
const [destOverrides, setDestOverrides] = useState<Record<string, string>>({})
|
||||
const [forceOverCap, setForceOverCap] = useState(false)
|
||||
|
||||
// Auto-assign: distribute assignments across eligible candidates balanced by load
|
||||
const handleAutoAssign = () => {
|
||||
if (!candidateData) return
|
||||
const movable = candidateData.assignments.filter((a) => a.movable)
|
||||
if (movable.length === 0) return
|
||||
|
||||
// Simulate load starting from each candidate's current load
|
||||
const simLoad = new Map<string, number>()
|
||||
for (const c of candidateData.candidates) {
|
||||
simLoad.set(c.userId, c.currentLoad)
|
||||
}
|
||||
|
||||
const overrides: Record<string, string> = {}
|
||||
|
||||
for (const assignment of movable) {
|
||||
const eligible = candidateData.candidates
|
||||
.filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
|
||||
|
||||
if (eligible.length === 0) continue
|
||||
|
||||
// Sort: prefer not-all-completed, then under cap, then lowest simulated load
|
||||
const sorted = [...eligible].sort((a, b) => {
|
||||
// Prefer jurors who haven't completed all evaluations
|
||||
if (a.allCompleted !== b.allCompleted) return a.allCompleted ? 1 : -1
|
||||
const loadA = simLoad.get(a.userId) ?? 0
|
||||
const loadB = simLoad.get(b.userId) ?? 0
|
||||
// Prefer jurors under their cap
|
||||
const overCapA = loadA >= a.cap ? 1 : 0
|
||||
const overCapB = loadB >= b.cap ? 1 : 0
|
||||
if (overCapA !== overCapB) return overCapA - overCapB
|
||||
// Then pick the least loaded
|
||||
return loadA - loadB
|
||||
})
|
||||
|
||||
const best = sorted[0]
|
||||
overrides[assignment.id] = best.userId
|
||||
simLoad.set(best.userId, (simLoad.get(best.userId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
setDestOverrides(overrides)
|
||||
}
|
||||
|
||||
const transferMutation = trpc.assignment.transferAssignments.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.analytics.getJurorWorkload.invalidate({ roundId })
|
||||
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
|
||||
utils.assignment.getReassignmentHistory.invalidate({ roundId })
|
||||
|
||||
const successCount = data.succeeded.length
|
||||
const failCount = data.failed.length
|
||||
if (failCount > 0) {
|
||||
toast.warning(`Transferred ${successCount} project(s). ${failCount} failed.`)
|
||||
} else {
|
||||
toast.success(`Transferred ${successCount} project(s) successfully.`)
|
||||
}
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Build the transfer plan: for each selected assignment, determine destination
|
||||
const transferPlan = useMemo(() => {
|
||||
if (!candidateData) return []
|
||||
const movable = candidateData.assignments.filter((a) => a.movable)
|
||||
return movable.map((assignment) => {
|
||||
const override = destOverrides[assignment.id]
|
||||
// Default: first eligible candidate
|
||||
const defaultDest = candidateData.candidates.find((c) =>
|
||||
c.eligibleProjectIds.includes(assignment.projectId)
|
||||
)
|
||||
const destId = override || defaultDest?.userId || ''
|
||||
const destName = candidateData.candidates.find((c) => c.userId === destId)?.name || ''
|
||||
return { assignmentId: assignment.id, projectTitle: assignment.projectTitle, destinationJurorId: destId, destName }
|
||||
}).filter((t) => t.destinationJurorId)
|
||||
}, [candidateData, destOverrides])
|
||||
|
||||
// Check if any destination is at or over cap
|
||||
const anyOverCap = useMemo(() => {
|
||||
if (!candidateData) return false
|
||||
const destCounts = new Map<string, number>()
|
||||
for (const t of transferPlan) {
|
||||
destCounts.set(t.destinationJurorId, (destCounts.get(t.destinationJurorId) ?? 0) + 1)
|
||||
}
|
||||
return candidateData.candidates.some((c) => {
|
||||
const extraLoad = destCounts.get(c.userId) ?? 0
|
||||
return c.currentLoad + extraLoad > c.cap
|
||||
})
|
||||
}, [candidateData, transferPlan])
|
||||
|
||||
const handleTransfer = () => {
|
||||
transferMutation.mutate({
|
||||
roundId,
|
||||
sourceJurorId: sourceJuror.id,
|
||||
transfers: transferPlan.map((t) => ({ assignmentId: t.assignmentId, destinationJurorId: t.destinationJurorId })),
|
||||
forceOverCap,
|
||||
})
|
||||
}
|
||||
|
||||
const isMovable = (a: any) => {
|
||||
const status = a.evaluation?.status
|
||||
return !status || status === 'NOT_STARTED' || status === 'DRAFT'
|
||||
}
|
||||
|
||||
const movableAssignments = jurorAssignments.filter(isMovable)
|
||||
const allMovableSelected = movableAssignments.length > 0 && movableAssignments.every((a: any) => selectedIds.has(a.id))
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Transfer Assignments from {sourceJuror.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 1 ? 'Select projects to transfer to other jurors.' : 'Choose destination jurors for each project.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-3">
|
||||
{loadingAssignments ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : jurorAssignments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">No assignments found.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Checkbox
|
||||
checked={allMovableSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedIds(new Set(movableAssignments.map((a: any) => a.id)))
|
||||
} else {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Select all movable ({movableAssignments.length})</span>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||||
{jurorAssignments.map((a: any) => {
|
||||
const movable = isMovable(a)
|
||||
const status = a.evaluation?.status || 'No evaluation'
|
||||
return (
|
||||
<div key={a.id} className={cn('flex items-center gap-3 py-2 px-2 rounded-md', !movable && 'opacity-50')}>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(a.id)}
|
||||
disabled={!movable}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = new Set(selectedIds)
|
||||
if (checked) next.add(a.id)
|
||||
else next.delete(a.id)
|
||||
setSelectedIds(next)
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{a.project?.title || 'Unknown'}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => { setStep(2); setDestOverrides({}) }}
|
||||
>
|
||||
Next ({selectedIds.size} selected)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
{loadingCandidates ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : !candidateData || candidateData.candidates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">No eligible candidates found.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="outline" size="sm" onClick={handleAutoAssign}>
|
||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||
Auto-assign
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-[350px] overflow-y-auto">
|
||||
{candidateData.assignments.filter((a) => a.movable).map((assignment) => {
|
||||
const currentDest = destOverrides[assignment.id] ||
|
||||
candidateData.candidates.find((c) => c.eligibleProjectIds.includes(assignment.projectId))?.userId || ''
|
||||
return (
|
||||
<div key={assignment.id} className="flex items-center gap-3 py-2 px-2 border rounded-md">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{assignment.projectTitle}</p>
|
||||
<p className="text-xs text-muted-foreground">{assignment.evalStatus || 'No evaluation'}</p>
|
||||
</div>
|
||||
<Select
|
||||
value={currentDest}
|
||||
onValueChange={(v) => setDestOverrides((prev) => ({ ...prev, [assignment.id]: v }))}
|
||||
>
|
||||
<SelectTrigger className="w-[200px] h-8 text-xs">
|
||||
<SelectValue placeholder="Select juror" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{candidateData.candidates
|
||||
.filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
|
||||
.map((c) => (
|
||||
<SelectItem key={c.userId} value={c.userId}>
|
||||
<span>{c.name}</span>
|
||||
<span className="text-muted-foreground ml-1">({c.currentLoad}/{c.cap})</span>
|
||||
{c.allCompleted && <span className="text-emerald-600 ml-1">Done</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{transferPlan.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Transfer {transferPlan.length} project(s) from {sourceJuror.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{anyOverCap && (
|
||||
<div className="flex items-center gap-2 p-2 border border-amber-200 bg-amber-50 rounded-md">
|
||||
<Checkbox
|
||||
checked={forceOverCap}
|
||||
onCheckedChange={(checked) => setForceOverCap(!!checked)}
|
||||
/>
|
||||
<span className="text-xs text-amber-800">Force over-cap: some destinations will exceed their assignment limit</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setStep(1)}>Back</Button>
|
||||
<Button
|
||||
disabled={transferPlan.length === 0 || transferMutation.isPending || (anyOverCap && !forceOverCap)}
|
||||
onClick={handleTransfer}
|
||||
>
|
||||
{transferMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : null}
|
||||
Transfer {transferPlan.length} project(s)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -6,24 +6,18 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { format } from 'date-fns'
|
||||
import { CheckCircle2, Circle, Clock } from 'lucide-react'
|
||||
import {
|
||||
roundTypeConfig as sharedRoundTypeConfig,
|
||||
roundStatusConfig as sharedRoundStatusConfig,
|
||||
} from '@/lib/round-config'
|
||||
|
||||
const roundTypeColors: Record<string, string> = {
|
||||
INTAKE: 'bg-gray-100 text-gray-700 border-gray-300',
|
||||
FILTERING: 'bg-amber-100 text-amber-700 border-amber-300',
|
||||
EVALUATION: 'bg-blue-100 text-blue-700 border-blue-300',
|
||||
SUBMISSION: 'bg-purple-100 text-purple-700 border-purple-300',
|
||||
MENTORING: 'bg-teal-100 text-teal-700 border-teal-300',
|
||||
LIVE_FINAL: 'bg-red-100 text-red-700 border-red-300',
|
||||
DELIBERATION: 'bg-indigo-100 text-indigo-700 border-indigo-300',
|
||||
}
|
||||
const roundTypeColors: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(sharedRoundTypeConfig).map(([k, v]) => [k, `${v.badgeClass} ${v.cardBorder}`])
|
||||
)
|
||||
|
||||
const roundStatusConfig: Record<string, { icon: typeof Circle; color: string }> = {
|
||||
ROUND_DRAFT: { icon: Circle, color: 'text-gray-400' },
|
||||
ROUND_ACTIVE: { icon: Clock, color: 'text-emerald-500' },
|
||||
ROUND_CLOSED: { icon: CheckCircle2, color: 'text-blue-500' },
|
||||
ROUND_ARCHIVED: { icon: CheckCircle2, color: 'text-gray-400' },
|
||||
}
|
||||
const roundStatusConfig: Record<string, { icon: React.ElementType; color: string }> = Object.fromEntries(
|
||||
Object.entries(sharedRoundStatusConfig).map(([k, v]) => [k, { icon: v.timelineIcon, color: v.timelineIconColor }])
|
||||
)
|
||||
|
||||
type RoundSummary = {
|
||||
id: string
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
import { roundTypeConfig } from '@/lib/round-config'
|
||||
|
||||
type WizardRound = {
|
||||
tempId: string
|
||||
@@ -40,15 +41,9 @@ type ReviewSectionProps = {
|
||||
state: WizardState
|
||||
}
|
||||
|
||||
const roundTypeColors: Record<string, string> = {
|
||||
INTAKE: 'bg-gray-100 text-gray-700',
|
||||
FILTERING: 'bg-amber-100 text-amber-700',
|
||||
EVALUATION: 'bg-blue-100 text-blue-700',
|
||||
SUBMISSION: 'bg-purple-100 text-purple-700',
|
||||
MENTORING: 'bg-teal-100 text-teal-700',
|
||||
LIVE_FINAL: 'bg-red-100 text-red-700',
|
||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||
}
|
||||
const roundTypeColors: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(roundTypeConfig).map(([k, v]) => [k, v.badgeClass])
|
||||
)
|
||||
|
||||
export function ReviewSection({ state }: ReviewSectionProps) {
|
||||
const warnings: string[] = []
|
||||
|
||||
164
src/components/admin/jury/inline-member-cap.tsx
Normal file
164
src/components/admin/jury/inline-member-cap.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Loader2, Pencil } from 'lucide-react'
|
||||
|
||||
export type InlineMemberCapProps = {
|
||||
memberId: string
|
||||
currentValue: number | null
|
||||
onSave: (val: number | null) => void
|
||||
roundId?: string
|
||||
jurorUserId?: string
|
||||
}
|
||||
|
||||
export function InlineMemberCap({
|
||||
memberId,
|
||||
currentValue,
|
||||
onSave,
|
||||
roundId,
|
||||
jurorUserId,
|
||||
}: InlineMemberCapProps) {
|
||||
const utils = trpc.useUtils()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState(currentValue?.toString() ?? '')
|
||||
const [overCapInfo, setOverCapInfo] = useState<{ total: number; overCapCount: number; movableOverCap: number; immovableOverCap: number } | null>(null)
|
||||
const [showBanner, setShowBanner] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const redistributeMutation = trpc.assignment.redistributeOverCap.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.assignment.listByStage.invalidate()
|
||||
utils.analytics.getJurorWorkload.invalidate()
|
||||
utils.roundAssignment.unassignedQueue.invalidate()
|
||||
setShowBanner(false)
|
||||
setOverCapInfo(null)
|
||||
if (data.failed > 0) {
|
||||
toast.warning(`Redistributed ${data.redistributed} project(s). ${data.failed} could not be reassigned.`)
|
||||
} else {
|
||||
toast.success(`Redistributed ${data.redistributed} project(s) to other jurors.`)
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) inputRef.current?.focus()
|
||||
}, [editing])
|
||||
|
||||
const save = async () => {
|
||||
const trimmed = value.trim()
|
||||
const newVal = trimmed === '' ? null : parseInt(trimmed, 10)
|
||||
if (newVal !== null && (isNaN(newVal) || newVal < 1)) {
|
||||
toast.error('Enter a positive number or leave empty for no cap')
|
||||
return
|
||||
}
|
||||
if (newVal === currentValue) {
|
||||
setEditing(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Check over-cap impact before saving
|
||||
if (newVal !== null && roundId && jurorUserId) {
|
||||
try {
|
||||
const preview = await utils.client.assignment.getOverCapPreview.query({
|
||||
roundId,
|
||||
jurorId: jurorUserId,
|
||||
newCap: newVal,
|
||||
})
|
||||
if (preview.overCapCount > 0) {
|
||||
setOverCapInfo(preview)
|
||||
setShowBanner(true)
|
||||
setEditing(false)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// If preview fails, just save the cap normally
|
||||
}
|
||||
}
|
||||
|
||||
onSave(newVal)
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
const handleRedistribute = () => {
|
||||
const newVal = parseInt(value.trim(), 10)
|
||||
onSave(newVal)
|
||||
if (roundId && jurorUserId) {
|
||||
redistributeMutation.mutate({ roundId, jurorId: jurorUserId, newCap: newVal })
|
||||
}
|
||||
}
|
||||
|
||||
const handleJustSave = () => {
|
||||
const newVal = value.trim() === '' ? null : parseInt(value.trim(), 10)
|
||||
onSave(newVal)
|
||||
setShowBanner(false)
|
||||
setOverCapInfo(null)
|
||||
}
|
||||
|
||||
if (showBanner && overCapInfo) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
|
||||
<p>New cap of <strong>{value}</strong> is below current load (<strong>{overCapInfo.total}</strong> assignments). <strong>{overCapInfo.movableOverCap}</strong> can be redistributed.</p>
|
||||
{overCapInfo.immovableOverCap > 0 && (
|
||||
<p className="text-amber-600 mt-0.5">{overCapInfo.immovableOverCap} submitted evaluation(s) cannot be moved.</p>
|
||||
)}
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-6 text-xs px-2"
|
||||
disabled={redistributeMutation.isPending || overCapInfo.movableOverCap === 0}
|
||||
onClick={handleRedistribute}
|
||||
>
|
||||
{redistributeMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||||
Redistribute
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={handleJustSave}>
|
||||
Just save cap
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 text-xs px-2" onClick={() => { setShowBanner(false); setOverCapInfo(null) }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
min={1}
|
||||
className="h-6 w-16 text-xs"
|
||||
value={value}
|
||||
placeholder="\u221E"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') save()
|
||||
if (e.key === 'Escape') { setValue(currentValue?.toString() ?? ''); setEditing(false) }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs hover:bg-muted transition-colors group"
|
||||
title="Click to set max assignment cap"
|
||||
onClick={() => { setValue(currentValue?.toString() ?? ''); setEditing(true) }}
|
||||
>
|
||||
<span className="text-muted-foreground">max:</span>
|
||||
<span className="font-medium">{currentValue ?? '\u221E'}</span>
|
||||
<Pencil className="h-2.5 w-2.5 text-muted-foreground" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
165
src/components/admin/program/competition-settings.tsx
Normal file
165
src/components/admin/program/competition-settings.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Loader2, Save, Settings } from 'lucide-react'
|
||||
|
||||
type CompetitionSettingsProps = {
|
||||
competitionId: string
|
||||
initialSettings: {
|
||||
categoryMode: string
|
||||
startupFinalistCount: number
|
||||
conceptFinalistCount: number
|
||||
notifyOnRoundAdvance: boolean
|
||||
notifyOnDeadlineApproach: boolean
|
||||
deadlineReminderDays: number[]
|
||||
}
|
||||
}
|
||||
|
||||
export function CompetitionSettings({ competitionId, initialSettings }: CompetitionSettingsProps) {
|
||||
const [settings, setSettings] = useState(initialSettings)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
const updateMutation = trpc.competition.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Competition settings saved')
|
||||
setDirty(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function update<K extends keyof typeof settings>(key: K, value: (typeof settings)[K]) {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
updateMutation.mutate({ id: competitionId, ...settings })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Competition Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Category mode, finalist targets, and notification preferences
|
||||
</CardDescription>
|
||||
</div>
|
||||
{dirty && (
|
||||
<Button onClick={handleSave} disabled={updateMutation.isPending} size="sm">
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Category Mode</Label>
|
||||
<Select value={settings.categoryMode} onValueChange={(v) => update('categoryMode', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SHARED">Shared Pool</SelectItem>
|
||||
<SelectItem value="SEPARATE">Separate Tracks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Startup Finalist Count</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={settings.startupFinalistCount}
|
||||
onChange={(e) => update('startupFinalistCount', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Concept Finalist Count</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={settings.conceptFinalistCount}
|
||||
onChange={(e) => update('conceptFinalistCount', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium">Notifications</h4>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Notify on Round Advance</Label>
|
||||
<p className="text-xs text-muted-foreground">Email applicants when their project advances</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.notifyOnRoundAdvance}
|
||||
onCheckedChange={(v) => update('notifyOnRoundAdvance', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Notify on Deadline Approach</Label>
|
||||
<p className="text-xs text-muted-foreground">Send reminders before deadlines</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.notifyOnDeadlineApproach}
|
||||
onCheckedChange={(v) => update('notifyOnDeadlineApproach', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Reminder Days Before Deadline</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{settings.deadlineReminderDays.map((day, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="gap-1">
|
||||
{day}d
|
||||
<button
|
||||
className="ml-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
const next = settings.deadlineReminderDays.filter((_, i) => i !== idx)
|
||||
update('deadlineReminderDays', next)
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Add..."
|
||||
className="w-20 h-7 text-xs"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const val = parseInt((e.target as HTMLInputElement).value)
|
||||
if (val > 0 && !settings.deadlineReminderDays.includes(val)) {
|
||||
update('deadlineReminderDays', [...settings.deadlineReminderDays, val].sort((a, b) => b - a))
|
||||
;(e.target as HTMLInputElement).value = ''
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
289
src/components/admin/round/advance-projects-dialog.tsx
Normal file
289
src/components/admin/round/advance-projects-dialog.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export type AdvanceProjectsDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
roundId: string
|
||||
roundType?: string
|
||||
projectStates: any[] | undefined
|
||||
config: Record<string, unknown>
|
||||
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string; autoPassPending?: boolean }) => void; isPending: boolean }
|
||||
competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
|
||||
currentSortOrder?: number
|
||||
}
|
||||
|
||||
export function AdvanceProjectsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
roundId,
|
||||
roundType,
|
||||
projectStates,
|
||||
config,
|
||||
advanceMutation,
|
||||
competitionRounds,
|
||||
currentSortOrder,
|
||||
}: AdvanceProjectsDialogProps) {
|
||||
// For non-jury rounds (INTAKE, SUBMISSION, MENTORING), offer a simpler "advance all" flow
|
||||
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(roundType ?? '')
|
||||
// Target round selector
|
||||
const availableTargets = useMemo(() =>
|
||||
(competitionRounds ?? [])
|
||||
.filter((r) => r.sortOrder > (currentSortOrder ?? -1) && r.id !== roundId)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
[competitionRounds, currentSortOrder, roundId])
|
||||
|
||||
const [targetRoundId, setTargetRoundId] = useState<string>('')
|
||||
|
||||
// Default to first available target when dialog opens
|
||||
if (open && !targetRoundId && availableTargets.length > 0) {
|
||||
setTargetRoundId(availableTargets[0].id)
|
||||
}
|
||||
const allProjects = projectStates ?? []
|
||||
const pendingCount = allProjects.filter((ps: any) => ps.state === 'PENDING').length
|
||||
const passedProjects = useMemo(() =>
|
||||
allProjects.filter((ps: any) => ps.state === 'PASSED'),
|
||||
[allProjects])
|
||||
|
||||
const startups = useMemo(() =>
|
||||
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
|
||||
[passedProjects])
|
||||
|
||||
const concepts = useMemo(() =>
|
||||
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'BUSINESS_CONCEPT'),
|
||||
[passedProjects])
|
||||
|
||||
const other = useMemo(() =>
|
||||
passedProjects.filter((ps: any) =>
|
||||
ps.project?.competitionCategory !== 'STARTUP' && ps.project?.competitionCategory !== 'BUSINESS_CONCEPT',
|
||||
),
|
||||
[passedProjects])
|
||||
|
||||
const startupCap = (config.startupAdvanceCount as number) || 0
|
||||
const conceptCap = (config.conceptAdvanceCount as number) || 0
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
|
||||
// Reset selection when dialog opens
|
||||
if (open && selected.size === 0 && passedProjects.length > 0) {
|
||||
const initial = new Set<string>()
|
||||
// Auto-select all (or up to cap if configured)
|
||||
const startupSlice = startupCap > 0 ? startups.slice(0, startupCap) : startups
|
||||
const conceptSlice = conceptCap > 0 ? concepts.slice(0, conceptCap) : concepts
|
||||
for (const ps of startupSlice) initial.add(ps.project?.id)
|
||||
for (const ps of conceptSlice) initial.add(ps.project?.id)
|
||||
for (const ps of other) initial.add(ps.project?.id)
|
||||
setSelected(initial)
|
||||
}
|
||||
|
||||
const toggleProject = (projectId: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectId)) next.delete(projectId)
|
||||
else next.add(projectId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAll = (projects: any[], on: boolean) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const ps of projects) {
|
||||
if (on) next.add(ps.project?.id)
|
||||
else next.delete(ps.project?.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdvance = (autoPass?: boolean) => {
|
||||
if (autoPass) {
|
||||
// Auto-pass all pending then advance all
|
||||
advanceMutation.mutate({
|
||||
roundId,
|
||||
autoPassPending: true,
|
||||
...(targetRoundId ? { targetRoundId } : {}),
|
||||
})
|
||||
} else {
|
||||
const ids = Array.from(selected)
|
||||
if (ids.length === 0) return
|
||||
advanceMutation.mutate({
|
||||
roundId,
|
||||
projectIds: ids,
|
||||
...(targetRoundId ? { targetRoundId } : {}),
|
||||
})
|
||||
}
|
||||
onOpenChange(false)
|
||||
setSelected(new Set())
|
||||
setTargetRoundId('')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false)
|
||||
setSelected(new Set())
|
||||
setTargetRoundId('')
|
||||
}
|
||||
|
||||
const renderCategorySection = (
|
||||
label: string,
|
||||
projects: any[],
|
||||
cap: number,
|
||||
badgeColor: string,
|
||||
) => {
|
||||
const selectedInCategory = projects.filter((ps: any) => selected.has(ps.project?.id)).length
|
||||
const overCap = cap > 0 && selectedInCategory > cap
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={projects.length > 0 && projects.every((ps: any) => selected.has(ps.project?.id))}
|
||||
onCheckedChange={(checked) => toggleAll(projects, !!checked)}
|
||||
/>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<Badge variant="secondary" className={cn('text-[10px]', badgeColor)}>
|
||||
{selectedInCategory}/{projects.length}
|
||||
</Badge>
|
||||
{cap > 0 && (
|
||||
<span className={cn('text-[10px]', overCap ? 'text-red-500 font-medium' : 'text-muted-foreground')}>
|
||||
(target: {cap})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground pl-7">No passed projects in this category</p>
|
||||
) : (
|
||||
<div className="space-y-1 pl-7">
|
||||
{projects.map((ps: any) => (
|
||||
<label
|
||||
key={ps.project?.id}
|
||||
className="flex items-center gap-2 p-2 rounded hover:bg-muted/30 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.has(ps.project?.id)}
|
||||
onCheckedChange={() => toggleProject(ps.project?.id)}
|
||||
/>
|
||||
<span className="text-sm truncate flex-1">{ps.project?.title || 'Untitled'}</span>
|
||||
{ps.project?.teamName && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">{ps.project.teamName}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalProjectCount = allProjects.length
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Advance Projects</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isSimpleAdvance
|
||||
? `Move all ${totalProjectCount} projects to the next round.`
|
||||
: `Select which passed projects to advance. ${selected.size} of ${passedProjects.length} selected.`
|
||||
}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Target round selector */}
|
||||
{availableTargets.length > 0 && (
|
||||
<div className="space-y-2 pb-2 border-b">
|
||||
<Label className="text-sm">Advance to</Label>
|
||||
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select target round" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTargets.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.name} ({r.roundType.replace('_', ' ').toLowerCase()})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{availableTargets.length === 0 && (
|
||||
<div className="text-sm text-amber-600 bg-amber-50 rounded-md p-3">
|
||||
No subsequent rounds found. Projects will advance to the next round by sort order.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSimpleAdvance ? (
|
||||
/* Simple mode for INTAKE/SUBMISSION/MENTORING — no per-project selection needed */
|
||||
<div className="py-4 space-y-3">
|
||||
<div className="rounded-lg border bg-muted/30 p-4 text-center space-y-1">
|
||||
<p className="text-3xl font-bold">{totalProjectCount}</p>
|
||||
<p className="text-sm text-muted-foreground">projects will be advanced</p>
|
||||
</div>
|
||||
{pendingCount > 0 && (
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
|
||||
<p className="text-xs text-blue-700">
|
||||
{pendingCount} pending project{pendingCount !== 1 ? 's' : ''} will be automatically marked as passed and advanced.
|
||||
{passedProjects.length > 0 && ` ${passedProjects.length} already passed.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Detailed mode for jury/evaluation rounds — per-project selection */
|
||||
<div className="flex-1 overflow-y-auto space-y-4 py-2">
|
||||
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
|
||||
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
|
||||
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
||||
{isSimpleAdvance ? (
|
||||
<Button
|
||||
onClick={() => handleAdvance(true)}
|
||||
disabled={totalProjectCount === 0 || advanceMutation.isPending || availableTargets.length === 0}
|
||||
>
|
||||
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Advance All {totalProjectCount} Project{totalProjectCount !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleAdvance()}
|
||||
disabled={selected.size === 0 || advanceMutation.isPending || availableTargets.length === 0}
|
||||
>
|
||||
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
231
src/components/admin/round/ai-recommendations-display.tsx
Normal file
231
src/components/admin/round/ai-recommendations-display.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Loader2, ChevronDown, CheckCircle2, X } from 'lucide-react'
|
||||
|
||||
export type RecommendationItem = {
|
||||
projectId: string
|
||||
rank: number
|
||||
score: number
|
||||
category: string
|
||||
strengths: string[]
|
||||
concerns: string[]
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
export type AIRecommendationsDisplayProps = {
|
||||
recommendations: { STARTUP: RecommendationItem[]; BUSINESS_CONCEPT: RecommendationItem[] }
|
||||
projectStates: any[] | undefined
|
||||
roundId: string
|
||||
onClear: () => void
|
||||
onApplied: () => void
|
||||
}
|
||||
|
||||
export function AIRecommendationsDisplay({
|
||||
recommendations,
|
||||
projectStates,
|
||||
roundId,
|
||||
onClear,
|
||||
onApplied,
|
||||
}: AIRecommendationsDisplayProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
// Initialize selected with all recommended project IDs
|
||||
const allRecommendedIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const item of recommendations.STARTUP) ids.add(item.projectId)
|
||||
for (const item of recommendations.BUSINESS_CONCEPT) ids.add(item.projectId)
|
||||
return ids
|
||||
}, [recommendations])
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set(allRecommendedIds))
|
||||
|
||||
// Build projectId → title map from projectStates
|
||||
const projectTitleMap = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
if (projectStates) {
|
||||
for (const ps of projectStates) {
|
||||
if (ps.project?.id && ps.project?.title) {
|
||||
map.set(ps.project.id, ps.project.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [projectStates])
|
||||
|
||||
const transitionMutation = trpc.roundEngine.transitionProject.useMutation()
|
||||
|
||||
const toggleProject = (projectId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectId)) next.delete(projectId)
|
||||
else next.add(projectId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectedStartups = recommendations.STARTUP.filter((item) => selectedIds.has(item.projectId)).length
|
||||
const selectedConcepts = recommendations.BUSINESS_CONCEPT.filter((item) => selectedIds.has(item.projectId)).length
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true)
|
||||
try {
|
||||
// Transition all selected projects to PASSED
|
||||
const promises = Array.from(selectedIds).map((projectId) =>
|
||||
transitionMutation.mutateAsync({ projectId, roundId, newState: 'PASSED' }).catch(() => {
|
||||
// Project might already be PASSED — that's OK
|
||||
})
|
||||
)
|
||||
await Promise.all(promises)
|
||||
toast.success(`Marked ${selectedIds.size} project(s) as passed`)
|
||||
onApplied()
|
||||
} catch (error) {
|
||||
toast.error('Failed to apply recommendations')
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderCategory = (label: string, items: RecommendationItem[], colorClass: string) => {
|
||||
if (items.length === 0) return (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
No {label.toLowerCase()} projects evaluated
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const isExpanded = expandedId === `${item.category}-${item.projectId}`
|
||||
const isSelected = selectedIds.has(item.projectId)
|
||||
const projectTitle = projectTitleMap.get(item.projectId) || item.projectId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.projectId}
|
||||
className={cn(
|
||||
'border rounded-lg overflow-hidden transition-colors',
|
||||
!isSelected && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleProject(item.projectId)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : `${item.category}-${item.projectId}`)}
|
||||
className="flex-1 flex items-center gap-3 text-left hover:bg-muted/30 rounded transition-colors min-w-0"
|
||||
>
|
||||
<span className={cn(
|
||||
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0 shadow-sm',
|
||||
colorClass === 'bg-blue-500' ? 'bg-gradient-to-br from-blue-400 to-blue-600' : 'bg-gradient-to-br from-purple-400 to-purple-600',
|
||||
)}>
|
||||
{item.rank}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{projectTitle}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{item.recommendation}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 text-xs font-mono">
|
||||
{item.score}/100
|
||||
</Badge>
|
||||
<ChevronDown className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform shrink-0',
|
||||
isExpanded && 'rotate-180',
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 pt-0 space-y-2 border-t bg-muted/10">
|
||||
<div className="pt-2">
|
||||
<p className="text-xs font-medium text-emerald-700 mb-1">Strengths</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
|
||||
{item.strengths.map((s, i) => <li key={i}>{s}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
{item.concerns.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-amber-700 mb-1">Concerns</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
|
||||
{item.concerns.map((c, i) => <li key={i}>{c}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-blue-700 mb-1">Recommendation</p>
|
||||
<p className="text-xs text-muted-foreground">{item.recommendation}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">AI Shortlist Recommendations</CardTitle>
|
||||
<CardDescription>
|
||||
Ranked independently per category — {selectedStartups} of {recommendations.STARTUP.length} startups, {selectedConcepts} of {recommendations.BUSINESS_CONCEPT.length} concepts selected
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onClear}>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
Startup ({recommendations.STARTUP.length})
|
||||
</h4>
|
||||
{renderCategory('Startup', recommendations.STARTUP, 'bg-blue-500')}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-purple-500" />
|
||||
Business Concept ({recommendations.BUSINESS_CONCEPT.length})
|
||||
</h4>
|
||||
{renderCategory('Business Concept', recommendations.BUSINESS_CONCEPT, 'bg-purple-500')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apply button */}
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''} will be marked as <strong>Passed</strong>
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
disabled={selectedIds.size === 0 || applying}
|
||||
className="bg-[#053d57] hover:bg-[#053d57]/90 text-white"
|
||||
>
|
||||
{applying ? (
|
||||
<><Loader2 className="h-4 w-4 mr-1.5 animate-spin" />Applying...</>
|
||||
) : (
|
||||
<><CheckCircle2 className="h-4 w-4 mr-1.5" />Apply & Mark as Passed</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
124
src/components/admin/round/evaluation-criteria-editor.tsx
Normal file
124
src/components/admin/round/evaluation-criteria-editor.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
|
||||
import type { Criterion } from '@/components/forms/evaluation-form-builder'
|
||||
|
||||
export type EvaluationCriteriaEditorProps = {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorProps) {
|
||||
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: form, isLoading } = trpc.evaluation.getForm.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
const upsertMutation = trpc.evaluation.upsertForm.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.evaluation.getForm.invalidate({ roundId })
|
||||
toast.success('Evaluation criteria saved')
|
||||
setPendingCriteria(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Convert server criteriaJson to Criterion[] format
|
||||
const serverCriteria: Criterion[] = useMemo(() => {
|
||||
if (!form?.criteriaJson) return []
|
||||
return (form.criteriaJson as Criterion[]).map((c) => {
|
||||
// Handle legacy numeric-only format: convert "scale" string like "1-10" back to minScore/maxScore
|
||||
const type = c.type || 'numeric'
|
||||
if (type === 'numeric' && typeof c.scale === 'string') {
|
||||
const parts = (c.scale as string).split('-').map(Number)
|
||||
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||
return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion
|
||||
}
|
||||
}
|
||||
return { ...c, type } as Criterion
|
||||
})
|
||||
}, [form?.criteriaJson])
|
||||
|
||||
const handleChange = useCallback((criteria: Criterion[]) => {
|
||||
setPendingCriteria(criteria)
|
||||
}, [])
|
||||
|
||||
const handleSave = () => {
|
||||
const criteria = pendingCriteria ?? serverCriteria
|
||||
const validCriteria = criteria.filter((c) => c.label.trim())
|
||||
if (validCriteria.length === 0) {
|
||||
toast.error('Add at least one criterion')
|
||||
return
|
||||
}
|
||||
// Map to upsertForm format
|
||||
upsertMutation.mutate({
|
||||
roundId,
|
||||
criteria: validCriteria.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
description: c.description,
|
||||
type: c.type || 'numeric',
|
||||
weight: c.weight,
|
||||
scale: typeof c.scale === 'number' ? c.scale : undefined,
|
||||
minScore: (c as any).minScore,
|
||||
maxScore: (c as any).maxScore,
|
||||
required: c.required,
|
||||
maxLength: c.maxLength,
|
||||
placeholder: c.placeholder,
|
||||
trueLabel: c.trueLabel,
|
||||
falseLabel: c.falseLabel,
|
||||
condition: c.condition,
|
||||
sectionId: c.sectionId,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Evaluation Criteria</CardTitle>
|
||||
<CardDescription>
|
||||
{form
|
||||
? `Version ${form.version} \u2014 ${(form.criteriaJson as Criterion[]).filter((c) => (c.type || 'numeric') !== 'section_header').length} criteria`
|
||||
: 'No criteria defined yet. Add numeric scores, yes/no questions, and text fields.'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{pendingCriteria && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setPendingCriteria(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={upsertMutation.isPending}>
|
||||
{upsertMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Save Criteria
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
||||
</div>
|
||||
) : (
|
||||
<EvaluationFormBuilder
|
||||
initialCriteria={serverCriteria}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
43
src/components/admin/round/export-evaluations-dialog.tsx
Normal file
43
src/components/admin/round/export-evaluations-dialog.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||
|
||||
export type ExportEvaluationsDialogProps = {
|
||||
roundId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ExportEvaluationsDialog({
|
||||
roundId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ExportEvaluationsDialogProps) {
|
||||
const [exportData, setExportData] = useState<any>(undefined)
|
||||
const [isLoadingExport, setIsLoadingExport] = useState(false)
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const handleRequestData = async () => {
|
||||
setIsLoadingExport(true)
|
||||
try {
|
||||
const data = await utils.export.evaluations.fetch({ roundId, includeDetails: true })
|
||||
setExportData(data)
|
||||
return data
|
||||
} finally {
|
||||
setIsLoadingExport(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CsvExportDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
exportData={exportData}
|
||||
isLoading={isLoadingExport}
|
||||
filename={`evaluations-${roundId}`}
|
||||
onRequestData={handleRequestData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
|
||||
const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route
|
||||
|
||||
const { data: projectStates, isLoading } = trpc.roundEngine.getProjectStates.useQuery(
|
||||
{ roundId },
|
||||
|
||||
64
src/components/admin/round/score-distribution.tsx
Normal file
64
src/components/admin/round/score-distribution.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export type ScoreDistributionProps = {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function ScoreDistribution({ roundId }: ScoreDistributionProps) {
|
||||
const { data: dist, isLoading } = trpc.analytics.getRoundScoreDistribution.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 15_000 },
|
||||
)
|
||||
|
||||
const maxCount = useMemo(() =>
|
||||
dist ? Math.max(...dist.globalDistribution.map((b) => b.count), 1) : 1,
|
||||
[dist])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Score Distribution</CardTitle>
|
||||
<CardDescription>
|
||||
{dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="flex-1 h-full" />)}
|
||||
</div>
|
||||
) : !dist || dist.totalEvaluations === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
No evaluations submitted yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex gap-1 h-32">
|
||||
{dist.globalDistribution.map((bucket) => {
|
||||
const heightPct = (bucket.count / maxCount) * 100
|
||||
return (
|
||||
<div key={bucket.score} className="flex-1 flex flex-col items-center gap-1 h-full">
|
||||
<span className="text-[9px] text-muted-foreground">{bucket.count || ''}</span>
|
||||
<div className="w-full flex-1 relative">
|
||||
<div className={cn(
|
||||
'absolute inset-x-0 bottom-0 rounded-t transition-all',
|
||||
bucket.score <= 3 ? 'bg-red-400' :
|
||||
bucket.score <= 6 ? 'bg-amber-400' :
|
||||
'bg-emerald-400',
|
||||
)} style={{ height: `${Math.max(heightPct, 4)}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{bucket.score}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
51
src/components/admin/rounds/config/config-section-header.tsx
Normal file
51
src/components/admin/rounds/config/config-section-header.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type CompletionStatus = 'complete' | 'warning' | 'error'
|
||||
|
||||
type ConfigSectionHeaderProps = {
|
||||
title: string
|
||||
description?: string
|
||||
status: CompletionStatus
|
||||
summary?: string
|
||||
}
|
||||
|
||||
const statusDot: Record<CompletionStatus, string> = {
|
||||
complete: 'bg-emerald-500',
|
||||
warning: 'bg-amber-500',
|
||||
error: 'bg-red-500',
|
||||
}
|
||||
|
||||
export function ConfigSectionHeader({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
summary,
|
||||
}: ConfigSectionHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1 h-2.5 w-2.5 rounded-full shrink-0',
|
||||
statusDot[status],
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
{summary && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
— {summary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,13 +3,6 @@
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'motion/react'
|
||||
import {
|
||||
Inbox,
|
||||
Filter,
|
||||
ClipboardCheck,
|
||||
Upload,
|
||||
Users,
|
||||
Radio,
|
||||
Scale,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
} from 'lucide-react'
|
||||
@@ -25,6 +18,7 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import { StatusBadge } from '@/components/shared/status-badge'
|
||||
import { cn, formatEnumLabel, daysUntil } from '@/lib/utils'
|
||||
import { roundTypeConfig, projectStateConfig } from '@/lib/round-config'
|
||||
|
||||
export type PipelineRound = {
|
||||
id: string
|
||||
@@ -60,24 +54,13 @@ type ActiveRoundPanelProps = {
|
||||
round: PipelineRound
|
||||
}
|
||||
|
||||
const roundTypeIcons: Record<string, React.ElementType> = {
|
||||
INTAKE: Inbox,
|
||||
FILTERING: Filter,
|
||||
EVALUATION: ClipboardCheck,
|
||||
SUBMISSION: Upload,
|
||||
MENTORING: Users,
|
||||
LIVE_FINAL: Radio,
|
||||
DELIBERATION: Scale,
|
||||
}
|
||||
const roundTypeIcons: Record<string, React.ElementType> = Object.fromEntries(
|
||||
Object.entries(roundTypeConfig).map(([k, v]) => [k, v.icon])
|
||||
)
|
||||
|
||||
const stateColors: Record<string, { bg: string; label: string }> = {
|
||||
PENDING: { bg: 'bg-slate-300', label: 'Pending' },
|
||||
IN_PROGRESS: { bg: 'bg-blue-400', label: 'In Progress' },
|
||||
PASSED: { bg: 'bg-emerald-500', label: 'Passed' },
|
||||
REJECTED: { bg: 'bg-red-400', label: 'Rejected' },
|
||||
COMPLETED: { bg: 'bg-[#557f8c]', label: 'Completed' },
|
||||
WITHDRAWN: { bg: 'bg-slate-400', label: 'Withdrawn' },
|
||||
}
|
||||
const stateColors: Record<string, { bg: string; label: string }> = Object.fromEntries(
|
||||
Object.entries(projectStateConfig).map(([k, v]) => [k, { bg: v.bg, label: v.label }])
|
||||
)
|
||||
|
||||
function DeadlineCountdown({ date }: { date: Date }) {
|
||||
const days = daysUntil(date)
|
||||
@@ -264,7 +247,7 @@ function RoundTypeContent({ round }: { round: PipelineRound }) {
|
||||
}
|
||||
|
||||
export function ActiveRoundPanel({ round }: ActiveRoundPanelProps) {
|
||||
const Icon = roundTypeIcons[round.roundType] || ClipboardCheck
|
||||
const Icon = roundTypeIcons[round.roundType] || roundTypeConfig.INTAKE.icon
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { motion } from 'motion/react'
|
||||
import { Workflow, ArrowRight } from 'lucide-react'
|
||||
import { Workflow, ArrowRight, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
PipelineRoundNode,
|
||||
type PipelineRound,
|
||||
@@ -12,27 +12,46 @@ import {
|
||||
|
||||
function Connector({
|
||||
prevStatus,
|
||||
nextStatus,
|
||||
index,
|
||||
}: {
|
||||
prevStatus: string
|
||||
nextStatus: string
|
||||
index: number
|
||||
}) {
|
||||
const isCompleted =
|
||||
prevStatus === 'ROUND_CLOSED' || prevStatus === 'ROUND_ARCHIVED'
|
||||
const isNextActive = nextStatus === 'ROUND_ACTIVE'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 + index * 0.06 }}
|
||||
className="flex items-center self-center origin-left"
|
||||
initial={{ scaleX: 0, opacity: 0 }}
|
||||
animate={{ scaleX: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.15 + index * 0.06 }}
|
||||
className="flex items-center self-center origin-left px-0.5"
|
||||
>
|
||||
<div className="flex items-center gap-0">
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 w-6',
|
||||
isCompleted ? 'bg-emerald-300' : 'bg-slate-200'
|
||||
'h-0.5 w-5 transition-colors',
|
||||
isCompleted
|
||||
? 'bg-emerald-400'
|
||||
: isNextActive
|
||||
? 'bg-blue-300'
|
||||
: 'bg-slate-200'
|
||||
)}
|
||||
/>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 -ml-1',
|
||||
isCompleted
|
||||
? 'text-emerald-400'
|
||||
: isNextActive
|
||||
? 'text-blue-300'
|
||||
: 'text-slate-200'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -88,15 +107,17 @@ export function CompetitionPipeline({
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto pb-2">
|
||||
<div className="flex items-start gap-0 min-w-max">
|
||||
<CardContent className="px-4 pb-4">
|
||||
{/* Scrollable container with padding to prevent cutoff */}
|
||||
<div className="overflow-x-auto -mx-1 px-1 pt-2 pb-3">
|
||||
<div className="flex items-center gap-0 min-w-max">
|
||||
{rounds.map((round, index) => (
|
||||
<div key={round.id} className="flex items-start">
|
||||
<div key={round.id} className="flex items-center">
|
||||
<PipelineRoundNode round={round} index={index} />
|
||||
{index < rounds.length - 1 && (
|
||||
<Connector
|
||||
prevStatus={round.status}
|
||||
nextStatus={rounds[index + 1].status}
|
||||
index={index}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -5,14 +5,9 @@ import type { Route } from 'next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { motion } from 'motion/react'
|
||||
import {
|
||||
Upload,
|
||||
Filter,
|
||||
ClipboardCheck,
|
||||
FileUp,
|
||||
GraduationCap,
|
||||
Radio,
|
||||
Scale,
|
||||
} from 'lucide-react'
|
||||
roundTypeConfig as sharedRoundTypeConfig,
|
||||
roundStatusConfig as sharedRoundStatusConfig,
|
||||
} from '@/lib/round-config'
|
||||
|
||||
type PipelineRound = {
|
||||
id: string
|
||||
@@ -55,66 +50,11 @@ type PipelineRound = {
|
||||
deliberationCount: number
|
||||
}
|
||||
|
||||
const roundTypeConfig: Record<
|
||||
string,
|
||||
{ icon: typeof Upload; iconColor: string; iconBg: string }
|
||||
> = {
|
||||
INTAKE: { icon: Upload, iconColor: 'text-sky-600', iconBg: 'bg-sky-100' },
|
||||
FILTERING: {
|
||||
icon: Filter,
|
||||
iconColor: 'text-amber-600',
|
||||
iconBg: 'bg-amber-100',
|
||||
},
|
||||
EVALUATION: {
|
||||
icon: ClipboardCheck,
|
||||
iconColor: 'text-violet-600',
|
||||
iconBg: 'bg-violet-100',
|
||||
},
|
||||
SUBMISSION: {
|
||||
icon: FileUp,
|
||||
iconColor: 'text-blue-600',
|
||||
iconBg: 'bg-blue-100',
|
||||
},
|
||||
MENTORING: {
|
||||
icon: GraduationCap,
|
||||
iconColor: 'text-teal-600',
|
||||
iconBg: 'bg-teal-100',
|
||||
},
|
||||
LIVE_FINAL: {
|
||||
icon: Radio,
|
||||
iconColor: 'text-red-600',
|
||||
iconBg: 'bg-red-100',
|
||||
},
|
||||
DELIBERATION: {
|
||||
icon: Scale,
|
||||
iconColor: 'text-indigo-600',
|
||||
iconBg: 'bg-indigo-100',
|
||||
},
|
||||
}
|
||||
const roundTypeConfig = sharedRoundTypeConfig
|
||||
|
||||
const statusStyles: Record<
|
||||
string,
|
||||
{ container: string; label: string }
|
||||
> = {
|
||||
ROUND_DRAFT: {
|
||||
container:
|
||||
'bg-slate-50 border-slate-200 text-slate-400 border-dashed',
|
||||
label: 'Draft',
|
||||
},
|
||||
ROUND_ACTIVE: {
|
||||
container:
|
||||
'bg-blue-50 border-blue-300 text-blue-700 ring-2 ring-blue-400/30 shadow-lg shadow-blue-500/10',
|
||||
label: 'Active',
|
||||
},
|
||||
ROUND_CLOSED: {
|
||||
container: 'bg-emerald-50 border-emerald-200 text-emerald-600',
|
||||
label: 'Closed',
|
||||
},
|
||||
ROUND_ARCHIVED: {
|
||||
container: 'bg-slate-50/50 border-slate-100 text-slate-300',
|
||||
label: 'Archived',
|
||||
},
|
||||
}
|
||||
const statusStyles: Record<string, { container: string; label: string }> = Object.fromEntries(
|
||||
Object.entries(sharedRoundStatusConfig).map(([k, v]) => [k, { container: v.pipelineContainer, label: v.label }])
|
||||
)
|
||||
|
||||
function getMetric(round: PipelineRound): string {
|
||||
const { roundType, projectStates, filteringTotal, filteringPassed, evalTotal, evalSubmitted, assignmentCount, liveSessionStatus, deliberationCount } = round
|
||||
@@ -147,6 +87,30 @@ function getMetric(round: PipelineRound): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getProgressPct(round: PipelineRound): number | null {
|
||||
if (round.status !== 'ROUND_ACTIVE') return null
|
||||
|
||||
switch (round.roundType) {
|
||||
case 'FILTERING': {
|
||||
const processed = round.filteringPassed + round.filteringRejected + round.filteringFlagged
|
||||
const total = round.projectStates.total || round.filteringTotal
|
||||
return total > 0 ? Math.round((processed / total) * 100) : 0
|
||||
}
|
||||
case 'EVALUATION':
|
||||
return round.evalTotal > 0 ? Math.round((round.evalSubmitted / round.evalTotal) * 100) : 0
|
||||
case 'SUBMISSION': {
|
||||
const total = round.projectStates.total
|
||||
return total > 0 ? Math.round((round.projectStates.COMPLETED / total) * 100) : 0
|
||||
}
|
||||
case 'MENTORING': {
|
||||
const total = round.projectStates.total
|
||||
return total > 0 ? Math.round((round.projectStates.COMPLETED / total) * 100) : 0
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function PipelineRoundNode({
|
||||
round,
|
||||
index,
|
||||
@@ -158,7 +122,9 @@ export function PipelineRoundNode({
|
||||
const Icon = typeConfig.icon
|
||||
const status = statusStyles[round.status] ?? statusStyles.ROUND_DRAFT
|
||||
const isActive = round.status === 'ROUND_ACTIVE'
|
||||
const isCompleted = round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED'
|
||||
const metric = getMetric(round)
|
||||
const progressPct = getProgressPct(round)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -172,8 +138,8 @@ export function PipelineRoundNode({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex flex-col items-center rounded-xl border-2 p-3 transition-all hover:-translate-y-0.5 hover:shadow-md',
|
||||
isActive ? 'w-44' : 'w-36',
|
||||
'relative flex flex-col items-center rounded-xl border-2 transition-all hover:-translate-y-0.5 hover:shadow-md',
|
||||
isActive ? 'w-48 px-4 py-4' : 'w-40 px-3 py-3.5',
|
||||
status.container
|
||||
)}
|
||||
>
|
||||
@@ -185,30 +151,64 @@ export function PipelineRoundNode({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Completed check */}
|
||||
{isCompleted && (
|
||||
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500 text-white">
|
||||
<svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||
'flex items-center justify-center rounded-lg',
|
||||
isActive ? 'h-10 w-10' : 'h-9 w-9',
|
||||
typeConfig.iconBg
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-4 w-4', typeConfig.iconColor)} />
|
||||
<Icon className={cn(isActive ? 'h-5 w-5' : 'h-4 w-4', typeConfig.iconColor)} />
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<p className="mt-2 text-center text-xs font-semibold leading-tight line-clamp-2 group-hover:text-foreground transition-colors">
|
||||
<p className="mt-2.5 text-center text-xs font-semibold leading-tight line-clamp-2 group-hover:text-foreground transition-colors">
|
||||
{round.name}
|
||||
</p>
|
||||
|
||||
{/* Type label */}
|
||||
<span className="mt-1 text-[10px] font-medium text-muted-foreground/70">
|
||||
{typeConfig.label}
|
||||
</span>
|
||||
|
||||
{/* Status label */}
|
||||
<span className="mt-1.5 text-[10px] font-medium uppercase tracking-wider opacity-70">
|
||||
<span className="mt-1 text-[10px] font-semibold uppercase tracking-wider opacity-70">
|
||||
{status.label}
|
||||
</span>
|
||||
|
||||
{/* Progress bar for active rounds */}
|
||||
{progressPct !== null && (
|
||||
<div className="mt-2 w-full">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-black/5">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-blue-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progressPct}%` }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-0.5 text-center text-[10px] font-medium tabular-nums text-blue-600">
|
||||
{progressPct}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metric */}
|
||||
<p className="mt-1 text-[11px] font-medium tabular-nums opacity-80">
|
||||
{progressPct === null && (
|
||||
<p className="mt-1.5 text-[11px] font-medium tabular-nums opacity-80">
|
||||
{metric}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
@@ -13,10 +13,9 @@ import {
|
||||
import { StatusBadge } from '@/components/shared/status-badge'
|
||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||
import { getCountryName } from '@/lib/countries'
|
||||
import { formatDateOnly, truncate } from '@/lib/utils'
|
||||
import { formatDateOnly, truncate, formatRelativeTime } from '@/lib/utils'
|
||||
|
||||
type ProjectListCompactProps = {
|
||||
projects: Array<{
|
||||
type BaseProject = {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
@@ -27,10 +26,28 @@ type ProjectListCompactProps = {
|
||||
createdAt: Date
|
||||
submittedAt: Date | null
|
||||
status: string
|
||||
}>
|
||||
}
|
||||
|
||||
export function ProjectListCompact({ projects }: ProjectListCompactProps) {
|
||||
type ActiveProject = BaseProject & {
|
||||
latestEvaluator: string | null
|
||||
latestScore: number | null
|
||||
evaluatedAt: Date | null
|
||||
}
|
||||
|
||||
type ProjectListCompactProps = {
|
||||
projects: BaseProject[]
|
||||
activeProjects?: ActiveProject[]
|
||||
mode?: 'recent' | 'active'
|
||||
}
|
||||
|
||||
export function ProjectListCompact({
|
||||
projects,
|
||||
activeProjects,
|
||||
mode = 'recent',
|
||||
}: ProjectListCompactProps) {
|
||||
const isActiveMode = mode === 'active' && activeProjects && activeProjects.length > 0
|
||||
const displayProjects = isActiveMode ? activeProjects : projects
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
@@ -40,8 +57,12 @@ export function ProjectListCompact({ projects }: ProjectListCompactProps) {
|
||||
<ClipboardList className="h-4 w-4 text-brand-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">Recent Projects</CardTitle>
|
||||
<CardDescription className="text-xs">Latest submissions</CardDescription>
|
||||
<CardTitle className="text-base">
|
||||
{isActiveMode ? 'Recently Active' : 'Recent Projects'}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{isActiveMode ? 'Latest evaluation activity' : 'Latest submissions'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
@@ -53,7 +74,7 @@ export function ProjectListCompact({ projects }: ProjectListCompactProps) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{projects.length === 0 ? (
|
||||
{displayProjects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<ClipboardList className="h-7 w-7 text-muted-foreground/40" />
|
||||
@@ -64,7 +85,10 @@ export function ProjectListCompact({ projects }: ProjectListCompactProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{projects.map((project, idx) => (
|
||||
{displayProjects.map((project, idx) => {
|
||||
const activeProject = isActiveMode ? (project as ActiveProject) : null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -86,26 +110,44 @@ export function ProjectListCompact({ projects }: ProjectListCompactProps) {
|
||||
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
|
||||
{truncate(project.title, 50)}
|
||||
</p>
|
||||
{activeProject?.latestScore != null ? (
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums text-brand-blue">
|
||||
{activeProject.latestScore.toFixed(1)}/10
|
||||
</span>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status={project.status ?? 'SUBMITTED'}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{[
|
||||
{isActiveMode && activeProject ? (
|
||||
<>
|
||||
{activeProject.latestEvaluator && (
|
||||
<span>{activeProject.latestEvaluator}</span>
|
||||
)}
|
||||
{activeProject.evaluatedAt && (
|
||||
<span> · {formatRelativeTime(activeProject.evaluatedAt)}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
[
|
||||
project.teamName,
|
||||
project.country ? getCountryName(project.country) : null,
|
||||
formatDateOnly(project.submittedAt || project.createdAt),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' \u00b7 ')}
|
||||
.join(' \u00b7 ')
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
99
src/components/dashboard/round-stats-deliberation.tsx
Normal file
99
src/components/dashboard/round-stats-deliberation.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
type PipelineRound = {
|
||||
id: string
|
||||
name: string
|
||||
roundType: string
|
||||
status: string
|
||||
projectStates: {
|
||||
PENDING: number
|
||||
IN_PROGRESS: number
|
||||
PASSED: number
|
||||
REJECTED: number
|
||||
COMPLETED: number
|
||||
WITHDRAWN: number
|
||||
total: number
|
||||
}
|
||||
deliberationCount: number
|
||||
}
|
||||
|
||||
type RoundStatsDeliberationProps = {
|
||||
round: PipelineRound
|
||||
}
|
||||
|
||||
export function RoundStatsDeliberation({ round }: RoundStatsDeliberationProps) {
|
||||
const { projectStates, deliberationCount } = round
|
||||
const decided = projectStates.PASSED + projectStates.REJECTED
|
||||
const decidedPct = projectStates.total > 0
|
||||
? ((decided / projectStates.total) * 100).toFixed(0)
|
||||
: '0'
|
||||
|
||||
const stats = [
|
||||
{
|
||||
value: deliberationCount,
|
||||
label: 'Sessions',
|
||||
detail: deliberationCount > 0 ? 'Deliberation sessions' : 'No sessions yet',
|
||||
accent: deliberationCount > 0 ? 'text-brand-blue' : 'text-amber-600',
|
||||
},
|
||||
{
|
||||
value: projectStates.total,
|
||||
label: 'Under review',
|
||||
detail: 'Projects in deliberation',
|
||||
accent: 'text-brand-blue',
|
||||
},
|
||||
{
|
||||
value: decided,
|
||||
label: 'Decided',
|
||||
detail: `${decidedPct}% resolved`,
|
||||
accent: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
value: projectStates.PENDING,
|
||||
label: 'Pending',
|
||||
detail: projectStates.PENDING > 0 ? 'Awaiting vote' : 'All voted',
|
||||
accent: projectStates.PENDING > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||
{round.name} — Deliberation
|
||||
</p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||
>
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||
{stats.map((s, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
105
src/components/dashboard/round-stats-live-final.tsx
Normal file
105
src/components/dashboard/round-stats-live-final.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { formatEnumLabel } from '@/lib/utils'
|
||||
|
||||
type PipelineRound = {
|
||||
id: string
|
||||
name: string
|
||||
roundType: string
|
||||
status: string
|
||||
projectStates: {
|
||||
PENDING: number
|
||||
IN_PROGRESS: number
|
||||
PASSED: number
|
||||
REJECTED: number
|
||||
COMPLETED: number
|
||||
WITHDRAWN: number
|
||||
total: number
|
||||
}
|
||||
liveSessionStatus: string | null
|
||||
assignmentCount: number
|
||||
}
|
||||
|
||||
type RoundStatsLiveFinalProps = {
|
||||
round: PipelineRound
|
||||
}
|
||||
|
||||
export function RoundStatsLiveFinal({ round }: RoundStatsLiveFinalProps) {
|
||||
const { projectStates, liveSessionStatus, assignmentCount } = round
|
||||
const sessionLabel = liveSessionStatus
|
||||
? formatEnumLabel(liveSessionStatus)
|
||||
: 'Not started'
|
||||
|
||||
const stats = [
|
||||
{
|
||||
value: projectStates.total,
|
||||
label: 'Presenting',
|
||||
detail: 'Projects in finals',
|
||||
accent: 'text-brand-blue',
|
||||
},
|
||||
{
|
||||
value: sessionLabel,
|
||||
label: 'Session',
|
||||
detail: liveSessionStatus ? 'Live session active' : 'No session yet',
|
||||
accent: liveSessionStatus ? 'text-emerald-600' : 'text-amber-600',
|
||||
isText: true,
|
||||
},
|
||||
{
|
||||
value: projectStates.COMPLETED,
|
||||
label: 'Scored',
|
||||
detail: `${projectStates.total - projectStates.COMPLETED} remaining`,
|
||||
accent: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
value: assignmentCount,
|
||||
label: 'Jury votes',
|
||||
detail: 'Jury assignments',
|
||||
accent: 'text-brand-teal',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||
{round.name} — Live Finals
|
||||
</p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||
>
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||
<span className={`font-bold tabular-nums tracking-tight ${(s as any).isText ? 'text-sm' : 'text-xl'}`}>
|
||||
{s.value}
|
||||
</span>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||
{stats.map((s, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<span className={`font-bold tabular-nums tracking-tight ${(s as any).isText ? 'text-lg' : 'text-3xl'}`}>
|
||||
{s.value}
|
||||
</span>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
99
src/components/dashboard/round-stats-mentoring.tsx
Normal file
99
src/components/dashboard/round-stats-mentoring.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
type PipelineRound = {
|
||||
id: string
|
||||
name: string
|
||||
roundType: string
|
||||
status: string
|
||||
projectStates: {
|
||||
PENDING: number
|
||||
IN_PROGRESS: number
|
||||
PASSED: number
|
||||
REJECTED: number
|
||||
COMPLETED: number
|
||||
WITHDRAWN: number
|
||||
total: number
|
||||
}
|
||||
assignmentCount: number
|
||||
}
|
||||
|
||||
type RoundStatsMentoringProps = {
|
||||
round: PipelineRound
|
||||
}
|
||||
|
||||
export function RoundStatsMentoring({ round }: RoundStatsMentoringProps) {
|
||||
const { projectStates, assignmentCount } = round
|
||||
const withMentor = projectStates.IN_PROGRESS + projectStates.COMPLETED + projectStates.PASSED
|
||||
const completedPct = projectStates.total > 0
|
||||
? ((projectStates.COMPLETED / projectStates.total) * 100).toFixed(0)
|
||||
: '0'
|
||||
|
||||
const stats = [
|
||||
{
|
||||
value: assignmentCount,
|
||||
label: 'Assignments',
|
||||
detail: 'Mentor-project pairs',
|
||||
accent: 'text-brand-blue',
|
||||
},
|
||||
{
|
||||
value: withMentor,
|
||||
label: 'With mentor',
|
||||
detail: withMentor > 0 ? 'Actively mentored' : 'None assigned',
|
||||
accent: withMentor > 0 ? 'text-emerald-600' : 'text-amber-600',
|
||||
},
|
||||
{
|
||||
value: projectStates.COMPLETED,
|
||||
label: 'Completed',
|
||||
detail: `${completedPct}% done`,
|
||||
accent: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
value: projectStates.total,
|
||||
label: 'Total',
|
||||
detail: 'Projects in round',
|
||||
accent: 'text-brand-teal',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||
{round.name} — Mentoring
|
||||
</p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||
>
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||
{stats.map((s, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
109
src/components/dashboard/round-stats-submission.tsx
Normal file
109
src/components/dashboard/round-stats-submission.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { daysUntil } from '@/lib/utils'
|
||||
|
||||
type PipelineRound = {
|
||||
id: string
|
||||
name: string
|
||||
roundType: string
|
||||
status: string
|
||||
projectStates: {
|
||||
PENDING: number
|
||||
IN_PROGRESS: number
|
||||
PASSED: number
|
||||
REJECTED: number
|
||||
COMPLETED: number
|
||||
WITHDRAWN: number
|
||||
total: number
|
||||
}
|
||||
windowCloseAt: Date | null
|
||||
}
|
||||
|
||||
type RoundStatsSubmissionProps = {
|
||||
round: PipelineRound
|
||||
}
|
||||
|
||||
export function RoundStatsSubmission({ round }: RoundStatsSubmissionProps) {
|
||||
const { projectStates } = round
|
||||
const completedPct = projectStates.total > 0
|
||||
? ((projectStates.COMPLETED / projectStates.total) * 100).toFixed(0)
|
||||
: '0'
|
||||
|
||||
const deadlineDays = round.windowCloseAt ? daysUntil(new Date(round.windowCloseAt)) : null
|
||||
const deadlineLabel =
|
||||
deadlineDays === null
|
||||
? 'No deadline'
|
||||
: deadlineDays <= 0
|
||||
? 'Closed'
|
||||
: deadlineDays === 1
|
||||
? '1 day left'
|
||||
: `${deadlineDays} days left`
|
||||
|
||||
const stats = [
|
||||
{
|
||||
value: projectStates.total,
|
||||
label: 'In round',
|
||||
detail: 'Total projects',
|
||||
accent: 'text-brand-blue',
|
||||
},
|
||||
{
|
||||
value: projectStates.COMPLETED,
|
||||
label: 'Completed',
|
||||
detail: `${completedPct}% done`,
|
||||
accent: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
value: projectStates.IN_PROGRESS,
|
||||
label: 'In progress',
|
||||
detail: projectStates.IN_PROGRESS > 0 ? 'Working on submissions' : 'None in progress',
|
||||
accent: projectStates.IN_PROGRESS > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
value: deadlineDays ?? '—',
|
||||
label: 'Deadline',
|
||||
detail: deadlineLabel,
|
||||
accent: deadlineDays !== null && deadlineDays <= 3 ? 'text-red-600' : 'text-brand-teal',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||
{round.name} — Submission
|
||||
</p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||
>
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||
{stats.map((s, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
type RoundStatsGenericProps = {
|
||||
type RoundStatsSummaryProps = {
|
||||
projectCount: number
|
||||
newProjectsThisWeek: number
|
||||
totalJurors: number
|
||||
@@ -10,52 +10,58 @@ type RoundStatsGenericProps = {
|
||||
totalAssignments: number
|
||||
evaluationStats: Array<{ status: string; _count: number }>
|
||||
actionsCount: number
|
||||
nextDraftRound?: { name: string; roundType: string } | null
|
||||
}
|
||||
|
||||
export function RoundStatsGeneric({
|
||||
export function RoundStatsSummary({
|
||||
projectCount,
|
||||
newProjectsThisWeek,
|
||||
totalJurors,
|
||||
activeJurors,
|
||||
totalAssignments,
|
||||
evaluationStats,
|
||||
actionsCount,
|
||||
}: RoundStatsGenericProps) {
|
||||
nextDraftRound,
|
||||
}: RoundStatsSummaryProps) {
|
||||
const submittedCount =
|
||||
evaluationStats.find((e) => e.status === 'SUBMITTED')?._count ?? 0
|
||||
const completionPct =
|
||||
totalAssignments > 0 ? ((submittedCount / totalAssignments) * 100).toFixed(0) : '0'
|
||||
totalAssignments > 0 ? ((submittedCount / totalAssignments) * 100).toFixed(0) : '—'
|
||||
|
||||
const stats = [
|
||||
{
|
||||
value: projectCount,
|
||||
label: 'Projects',
|
||||
detail: newProjectsThisWeek > 0 ? `+${newProjectsThisWeek} this week` : null,
|
||||
label: 'Total projects',
|
||||
detail: 'In this edition',
|
||||
accent: 'text-brand-blue',
|
||||
},
|
||||
{
|
||||
value: totalJurors,
|
||||
label: 'Jurors',
|
||||
detail: `${activeJurors} active`,
|
||||
value: `${activeJurors}/${totalJurors}`,
|
||||
label: 'Jury coverage',
|
||||
detail: totalJurors > 0 ? `${activeJurors} active jurors` : 'No jurors assigned',
|
||||
accent: 'text-brand-teal',
|
||||
},
|
||||
{
|
||||
value: `${submittedCount}/${totalAssignments}`,
|
||||
label: 'Evaluations',
|
||||
detail: `${completionPct}% complete`,
|
||||
value: totalAssignments > 0 ? `${completionPct}%` : '—',
|
||||
label: 'Completion',
|
||||
detail: totalAssignments > 0 ? `${submittedCount}/${totalAssignments} evaluations` : 'No evaluations yet',
|
||||
accent: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
value: actionsCount,
|
||||
label: actionsCount === 1 ? 'Action' : 'Actions',
|
||||
detail: actionsCount > 0 ? 'Pending' : 'All clear',
|
||||
detail: nextDraftRound
|
||||
? `Next: ${nextDraftRound.name}`
|
||||
: actionsCount > 0 ? 'Pending' : 'All clear',
|
||||
accent: actionsCount > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile: horizontal data strip */}
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||
No active round — Competition Summary
|
||||
</p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -70,7 +76,6 @@ export function RoundStatsGeneric({
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Desktop: editorial stat row */}
|
||||
<div className="hidden md:block">
|
||||
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||
{stats.map((s, i) => (
|
||||
@@ -81,13 +86,9 @@ export function RoundStatsGeneric({
|
||||
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||
{s.detail && (
|
||||
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
@@ -3,7 +3,11 @@
|
||||
import { RoundStatsIntake } from '@/components/dashboard/round-stats-intake'
|
||||
import { RoundStatsFiltering } from '@/components/dashboard/round-stats-filtering'
|
||||
import { RoundStatsEvaluation } from '@/components/dashboard/round-stats-evaluation'
|
||||
import { RoundStatsGeneric } from '@/components/dashboard/round-stats-generic'
|
||||
import { RoundStatsSubmission } from '@/components/dashboard/round-stats-submission'
|
||||
import { RoundStatsMentoring } from '@/components/dashboard/round-stats-mentoring'
|
||||
import { RoundStatsLiveFinal } from '@/components/dashboard/round-stats-live-final'
|
||||
import { RoundStatsDeliberation } from '@/components/dashboard/round-stats-deliberation'
|
||||
import { RoundStatsSummary } from '@/components/dashboard/round-stats-summary'
|
||||
|
||||
type PipelineRound = {
|
||||
id: string
|
||||
@@ -37,6 +41,7 @@ type PipelineRound = {
|
||||
|
||||
type RoundStatsProps = {
|
||||
activeRound: PipelineRound | null
|
||||
allActiveRounds?: PipelineRound[]
|
||||
projectCount: number
|
||||
newProjectsThisWeek: number
|
||||
totalJurors: number
|
||||
@@ -44,6 +49,7 @@ type RoundStatsProps = {
|
||||
totalAssignments: number
|
||||
evaluationStats: Array<{ status: string; _count: number }>
|
||||
actionsCount: number
|
||||
nextDraftRound?: { name: string; roundType: string } | null
|
||||
}
|
||||
|
||||
export function RoundStats({
|
||||
@@ -55,10 +61,11 @@ export function RoundStats({
|
||||
totalAssignments,
|
||||
evaluationStats,
|
||||
actionsCount,
|
||||
nextDraftRound,
|
||||
}: RoundStatsProps) {
|
||||
if (!activeRound) {
|
||||
return (
|
||||
<RoundStatsGeneric
|
||||
<RoundStatsSummary
|
||||
projectCount={projectCount}
|
||||
newProjectsThisWeek={newProjectsThisWeek}
|
||||
totalJurors={totalJurors}
|
||||
@@ -66,6 +73,7 @@ export function RoundStats({
|
||||
totalAssignments={totalAssignments}
|
||||
evaluationStats={evaluationStats}
|
||||
actionsCount={actionsCount}
|
||||
nextDraftRound={nextDraftRound}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -89,9 +97,25 @@ export function RoundStats({
|
||||
activeJurors={activeJurors}
|
||||
/>
|
||||
)
|
||||
case 'SUBMISSION':
|
||||
return (
|
||||
<RoundStatsSubmission round={activeRound} />
|
||||
)
|
||||
case 'MENTORING':
|
||||
return (
|
||||
<RoundStatsMentoring round={activeRound} />
|
||||
)
|
||||
case 'LIVE_FINAL':
|
||||
return (
|
||||
<RoundStatsLiveFinal round={activeRound} />
|
||||
)
|
||||
case 'DELIBERATION':
|
||||
return (
|
||||
<RoundStatsDeliberation round={activeRound} />
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<RoundStatsGeneric
|
||||
<RoundStatsSummary
|
||||
projectCount={projectCount}
|
||||
newProjectsThisWeek={newProjectsThisWeek}
|
||||
totalJurors={totalJurors}
|
||||
@@ -99,6 +123,7 @@ export function RoundStats({
|
||||
totalAssignments={totalAssignments}
|
||||
evaluationStats={evaluationStats}
|
||||
actionsCount={actionsCount}
|
||||
nextDraftRound={nextDraftRound}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
264
src/lib/round-config.ts
Normal file
264
src/lib/round-config.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Shared round type, status, and project state configuration.
|
||||
*
|
||||
* Single source of truth for colors, labels, icons, and descriptions
|
||||
* used across the admin dashboard, round detail, pipeline, and timeline.
|
||||
*/
|
||||
|
||||
import type { RoundType, RoundStatus, ProjectRoundStateValue } from '@prisma/client'
|
||||
import {
|
||||
Upload,
|
||||
Filter,
|
||||
ClipboardCheck,
|
||||
FileUp,
|
||||
GraduationCap,
|
||||
Radio,
|
||||
Scale,
|
||||
Inbox,
|
||||
Users,
|
||||
Circle,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
// ── Round Type Configuration ─────────────────────────────────────────────────
|
||||
|
||||
export type RoundTypeConfig = {
|
||||
label: string
|
||||
description: string
|
||||
/** Badge classes: bg + text color */
|
||||
badgeClass: string
|
||||
/** Hex dot color for pipeline nodes */
|
||||
dotColor: string
|
||||
/** Lighter variant: bg, text, border classes for cards/filters */
|
||||
cardBg: string
|
||||
cardText: string
|
||||
cardBorder: string
|
||||
/** Icon for pipeline nodes and round detail */
|
||||
icon: LucideIcon
|
||||
iconColor: string
|
||||
iconBg: string
|
||||
}
|
||||
|
||||
export const roundTypeConfig: Record<RoundType, RoundTypeConfig> = {
|
||||
INTAKE: {
|
||||
label: 'Intake',
|
||||
description: 'Collecting applications',
|
||||
badgeClass: 'bg-gray-100 text-gray-700',
|
||||
dotColor: '#9ca3af',
|
||||
cardBg: 'bg-gray-50',
|
||||
cardText: 'text-gray-600',
|
||||
cardBorder: 'border-gray-300',
|
||||
icon: Inbox,
|
||||
iconColor: 'text-sky-600',
|
||||
iconBg: 'bg-sky-100',
|
||||
},
|
||||
FILTERING: {
|
||||
label: 'Filtering',
|
||||
description: 'AI + manual screening',
|
||||
badgeClass: 'bg-amber-100 text-amber-700',
|
||||
dotColor: '#f59e0b',
|
||||
cardBg: 'bg-amber-50',
|
||||
cardText: 'text-amber-700',
|
||||
cardBorder: 'border-amber-300',
|
||||
icon: Filter,
|
||||
iconColor: 'text-amber-600',
|
||||
iconBg: 'bg-amber-100',
|
||||
},
|
||||
EVALUATION: {
|
||||
label: 'Evaluation',
|
||||
description: 'Jury evaluation & scoring',
|
||||
badgeClass: 'bg-blue-100 text-blue-700',
|
||||
dotColor: '#3b82f6',
|
||||
cardBg: 'bg-blue-50',
|
||||
cardText: 'text-blue-700',
|
||||
cardBorder: 'border-blue-300',
|
||||
icon: ClipboardCheck,
|
||||
iconColor: 'text-violet-600',
|
||||
iconBg: 'bg-violet-100',
|
||||
},
|
||||
SUBMISSION: {
|
||||
label: 'Submission',
|
||||
description: 'Document submission',
|
||||
badgeClass: 'bg-purple-100 text-purple-700',
|
||||
dotColor: '#8b5cf6',
|
||||
cardBg: 'bg-purple-50',
|
||||
cardText: 'text-purple-700',
|
||||
cardBorder: 'border-purple-300',
|
||||
icon: FileUp,
|
||||
iconColor: 'text-blue-600',
|
||||
iconBg: 'bg-blue-100',
|
||||
},
|
||||
MENTORING: {
|
||||
label: 'Mentoring',
|
||||
description: 'Mentor-guided development',
|
||||
badgeClass: 'bg-teal-100 text-teal-700',
|
||||
dotColor: '#557f8c',
|
||||
cardBg: 'bg-teal-50',
|
||||
cardText: 'text-teal-700',
|
||||
cardBorder: 'border-teal-300',
|
||||
icon: GraduationCap,
|
||||
iconColor: 'text-teal-600',
|
||||
iconBg: 'bg-teal-100',
|
||||
},
|
||||
LIVE_FINAL: {
|
||||
label: 'Live Final',
|
||||
description: 'Live presentations & voting',
|
||||
badgeClass: 'bg-red-100 text-red-700',
|
||||
dotColor: '#de0f1e',
|
||||
cardBg: 'bg-red-50',
|
||||
cardText: 'text-red-700',
|
||||
cardBorder: 'border-red-300',
|
||||
icon: Radio,
|
||||
iconColor: 'text-red-600',
|
||||
iconBg: 'bg-red-100',
|
||||
},
|
||||
DELIBERATION: {
|
||||
label: 'Deliberation',
|
||||
description: 'Final jury deliberation',
|
||||
badgeClass: 'bg-indigo-100 text-indigo-700',
|
||||
dotColor: '#6366f1',
|
||||
cardBg: 'bg-indigo-50',
|
||||
cardText: 'text-indigo-700',
|
||||
cardBorder: 'border-indigo-300',
|
||||
icon: Scale,
|
||||
iconColor: 'text-indigo-600',
|
||||
iconBg: 'bg-indigo-100',
|
||||
},
|
||||
}
|
||||
|
||||
// ── Round Status Configuration ───────────────────────────────────────────────
|
||||
|
||||
export type RoundStatusConfig = {
|
||||
label: string
|
||||
description: string
|
||||
/** Badge classes for status badges */
|
||||
bgClass: string
|
||||
/** Dot color class (with optional animation) */
|
||||
dotClass: string
|
||||
/** Hex color for pipeline dot */
|
||||
dotColor: string
|
||||
/** Whether the dot should pulse (active round) */
|
||||
pulse: boolean
|
||||
/** Icon for timeline displays */
|
||||
timelineIcon: LucideIcon
|
||||
timelineIconColor: string
|
||||
/** Container classes for pipeline nodes */
|
||||
pipelineContainer: string
|
||||
}
|
||||
|
||||
export const roundStatusConfig: Record<RoundStatus, RoundStatusConfig> = {
|
||||
ROUND_DRAFT: {
|
||||
label: 'Draft',
|
||||
description: 'Not yet active. Configure before launching.',
|
||||
bgClass: 'bg-gray-100 text-gray-700',
|
||||
dotClass: 'bg-gray-500',
|
||||
dotColor: '#9ca3af',
|
||||
pulse: false,
|
||||
timelineIcon: Circle,
|
||||
timelineIconColor: 'text-gray-400',
|
||||
pipelineContainer: 'bg-slate-50 border-slate-200 text-slate-400 border-dashed',
|
||||
},
|
||||
ROUND_ACTIVE: {
|
||||
label: 'Active',
|
||||
description: 'Round is live. Projects can be processed.',
|
||||
bgClass: 'bg-emerald-100 text-emerald-700',
|
||||
dotClass: 'bg-emerald-500 animate-pulse',
|
||||
dotColor: '#10b981',
|
||||
pulse: true,
|
||||
timelineIcon: Clock,
|
||||
timelineIconColor: 'text-emerald-500',
|
||||
pipelineContainer: 'bg-blue-50 border-blue-300 text-blue-700 ring-2 ring-blue-400/30 shadow-lg shadow-blue-500/10',
|
||||
},
|
||||
ROUND_CLOSED: {
|
||||
label: 'Closed',
|
||||
description: 'No longer accepting changes. Results are final.',
|
||||
bgClass: 'bg-blue-100 text-blue-700',
|
||||
dotClass: 'bg-blue-500',
|
||||
dotColor: '#3b82f6',
|
||||
pulse: false,
|
||||
timelineIcon: CheckCircle2,
|
||||
timelineIconColor: 'text-blue-500',
|
||||
pipelineContainer: 'bg-emerald-50 border-emerald-200 text-emerald-600',
|
||||
},
|
||||
ROUND_ARCHIVED: {
|
||||
label: 'Archived',
|
||||
description: 'Historical record only.',
|
||||
bgClass: 'bg-muted text-muted-foreground',
|
||||
dotClass: 'bg-muted-foreground',
|
||||
dotColor: '#6b7280',
|
||||
pulse: false,
|
||||
timelineIcon: CheckCircle2,
|
||||
timelineIconColor: 'text-gray-400',
|
||||
pipelineContainer: 'bg-gray-50 border-gray-200 text-gray-400 opacity-60',
|
||||
},
|
||||
}
|
||||
|
||||
// ── Project Round State Colors ───────────────────────────────────────────────
|
||||
|
||||
export type ProjectStateConfig = {
|
||||
label: string
|
||||
/** Background color class for bars and dots */
|
||||
bg: string
|
||||
/** Text color class */
|
||||
text: string
|
||||
/** Badge variant classes */
|
||||
badgeClass: string
|
||||
}
|
||||
|
||||
export const projectStateConfig: Record<ProjectRoundStateValue, ProjectStateConfig> = {
|
||||
PENDING: {
|
||||
label: 'Pending',
|
||||
bg: 'bg-slate-300',
|
||||
text: 'text-slate-700',
|
||||
badgeClass: 'bg-gray-100 text-gray-700',
|
||||
},
|
||||
IN_PROGRESS: {
|
||||
label: 'In Progress',
|
||||
bg: 'bg-blue-400',
|
||||
text: 'text-blue-700',
|
||||
badgeClass: 'bg-blue-100 text-blue-700',
|
||||
},
|
||||
PASSED: {
|
||||
label: 'Passed',
|
||||
bg: 'bg-emerald-500',
|
||||
text: 'text-emerald-700',
|
||||
badgeClass: 'bg-emerald-100 text-emerald-700',
|
||||
},
|
||||
REJECTED: {
|
||||
label: 'Rejected',
|
||||
bg: 'bg-red-400',
|
||||
text: 'text-red-700',
|
||||
badgeClass: 'bg-red-100 text-red-700',
|
||||
},
|
||||
COMPLETED: {
|
||||
label: 'Completed',
|
||||
bg: 'bg-[#557f8c]',
|
||||
text: 'text-teal-700',
|
||||
badgeClass: 'bg-teal-100 text-teal-700',
|
||||
},
|
||||
WITHDRAWN: {
|
||||
label: 'Withdrawn',
|
||||
bg: 'bg-slate-400',
|
||||
text: 'text-slate-600',
|
||||
badgeClass: 'bg-orange-100 text-orange-700',
|
||||
},
|
||||
}
|
||||
|
||||
// ── Award Status Configuration ───────────────────────────────────────────────
|
||||
|
||||
export const awardStatusConfig = {
|
||||
DRAFT: { label: 'Draft', color: 'text-gray-500', bgClass: 'bg-gray-100 text-gray-700' },
|
||||
NOMINATIONS_OPEN: { label: 'Nominations Open', color: 'text-amber-600', bgClass: 'bg-amber-100 text-amber-700' },
|
||||
VOTING_OPEN: { label: 'Voting Open', color: 'text-emerald-600', bgClass: 'bg-emerald-100 text-emerald-700' },
|
||||
CLOSED: { label: 'Closed', color: 'text-blue-500', bgClass: 'bg-blue-100 text-blue-700' },
|
||||
ARCHIVED: { label: 'Archived', color: 'text-gray-400', bgClass: 'bg-gray-100 text-gray-600' },
|
||||
} as const
|
||||
|
||||
// ── Round type option list (for selects/filters) ────────────────────────────
|
||||
|
||||
export const ROUND_TYPE_OPTIONS = Object.entries(roundTypeConfig).map(([value, cfg]) => ({
|
||||
value: value as RoundType,
|
||||
label: cfg.label,
|
||||
}))
|
||||
@@ -109,6 +109,8 @@ export const dashboardRouter = router({
|
||||
categoryBreakdown,
|
||||
oceanIssueBreakdown,
|
||||
recentActivity,
|
||||
// Recently active
|
||||
recentlyActiveEvals,
|
||||
// Action signals
|
||||
pendingCOIs,
|
||||
] = await Promise.all([
|
||||
@@ -257,7 +259,43 @@ export const dashboardRouter = router({
|
||||
},
|
||||
}),
|
||||
|
||||
// 17. Pending COIs
|
||||
// 17. Recently active projects (with recent evaluations)
|
||||
ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
status: 'SUBMITTED',
|
||||
assignment: {
|
||||
round: { competition: { programId: editionId } },
|
||||
},
|
||||
},
|
||||
orderBy: { submittedAt: 'desc' },
|
||||
take: 8,
|
||||
select: {
|
||||
id: true,
|
||||
globalScore: true,
|
||||
submittedAt: true,
|
||||
assignment: {
|
||||
select: {
|
||||
user: { select: { name: true } },
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
country: true,
|
||||
competitionCategory: true,
|
||||
oceanIssue: true,
|
||||
logoKey: true,
|
||||
createdAt: true,
|
||||
submittedAt: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// 18. Pending COIs
|
||||
ctx.prisma.conflictOfInterest.count({
|
||||
where: {
|
||||
hasConflict: true,
|
||||
@@ -443,6 +481,22 @@ export const dashboardRouter = router({
|
||||
|
||||
// ── Return ──────────────────────────────────────────────────────
|
||||
|
||||
// Deduplicate recently active projects (same project may have multiple evals)
|
||||
const seenProjectIds = new Set<string>()
|
||||
const recentlyActiveProjects = recentlyActiveEvals
|
||||
.filter((e) => {
|
||||
const pid = e.assignment.project.id
|
||||
if (seenProjectIds.has(pid)) return false
|
||||
seenProjectIds.add(pid)
|
||||
return true
|
||||
})
|
||||
.map((e) => ({
|
||||
...e.assignment.project,
|
||||
latestEvaluator: e.assignment.user.name,
|
||||
latestScore: e.globalScore,
|
||||
evaluatedAt: e.submittedAt,
|
||||
}))
|
||||
|
||||
return {
|
||||
edition,
|
||||
// Pipeline
|
||||
@@ -460,6 +514,7 @@ export const dashboardRouter = router({
|
||||
pendingCOIs,
|
||||
// Lists
|
||||
latestProjects,
|
||||
recentlyActiveProjects,
|
||||
categoryBreakdown,
|
||||
oceanIssueBreakdown,
|
||||
recentActivity,
|
||||
|
||||
@@ -96,6 +96,19 @@ export const juryGroupRouter = router({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
_count: { select: { members: true, assignments: true } },
|
||||
rounds: {
|
||||
select: { id: true, name: true, roundType: true, status: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
members: {
|
||||
take: 5,
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user