Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s

Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 23:04:15 +01:00
parent 9ab4717f96
commit 6ca39c976b
349 changed files with 69938 additions and 28767 deletions

View File

@@ -0,0 +1,181 @@
'use client'
import { useState, useEffect } from 'react'
import { AlertTriangle, Bot, CheckCircle2 } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
interface AssignmentPreviewSheetProps {
roundId: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function AssignmentPreviewSheet({
roundId,
open,
onOpenChange,
}: AssignmentPreviewSheetProps) {
const utils = trpc.useUtils()
const {
data: preview,
isLoading,
refetch,
} = trpc.roundAssignment.preview.useQuery(
{ roundId, honorIntents: true, requiredReviews: 3 },
{ enabled: open }
)
const { mutate: execute, isPending: isExecuting } = trpc.roundAssignment.execute.useMutation({
onSuccess: (result) => {
toast.success(`Created ${result.created} assignments`)
utils.roundAssignment.coverageReport.invalidate({ roundId })
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
onOpenChange(false)
},
onError: (err) => {
toast.error(err.message)
},
})
useEffect(() => {
if (open) {
refetch()
}
}, [open, refetch])
const handleExecute = () => {
if (!preview?.assignments || preview.assignments.length === 0) {
toast.error('No assignments to execute')
return
}
execute({
roundId,
assignments: preview.assignments.map((a: any) => ({
userId: a.userId,
projectId: a.projectId,
})),
})
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl">
<SheetHeader>
<SheetTitle>Assignment Preview</SheetTitle>
<SheetDescription className="flex items-center gap-2">
<Badge variant="outline" className="text-xs gap-1 shrink-0">
<Bot className="h-3 w-3" />
AI Suggested
</Badge>
Review the proposed assignments before executing. All assignments are admin-approved on execute.
</SheetDescription>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-200px)] mt-6">
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
) : preview ? (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
{preview.stats.assignmentsGenerated || 0} Assignments Proposed
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{preview.stats.totalJurors || 0} jurors will receive assignments
</p>
</CardContent>
</Card>
{preview.warnings && preview.warnings.length > 0 && (
<Card className="border-amber-500">
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600" />
Warnings ({preview.warnings.length})
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{preview.warnings.map((warning: string, idx: number) => (
<li key={idx} className="flex items-start gap-2">
<span className="text-amber-600"></span>
<span>{warning}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{preview.assignments && preview.assignments.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Assignment Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Total assignments:</span>
<span className="font-medium">{preview.assignments.length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Unique projects:</span>
<span className="font-medium">
{new Set(preview.assignments.map((a: any) => a.projectId)).size}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Unique jurors:</span>
<span className="font-medium">
{new Set(preview.assignments.map((a: any) => a.userId)).size}
</span>
</div>
</div>
</CardContent>
</Card>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">No preview data available</p>
)}
</ScrollArea>
<SheetFooter className="mt-6">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleExecute}
disabled={isExecuting || !preview?.assignments || preview.assignments.length === 0}
>
{isExecuting ? 'Executing...' : 'Execute Assignments'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { AlertCircle, CheckCircle2, Users } from 'lucide-react'
interface CoverageReportProps {
roundId: string
}
export function CoverageReport({ roundId }: CoverageReportProps) {
const { data: coverage, isLoading } = trpc.roundAssignment.coverageReport.useQuery({
roundId,
requiredReviews: 3,
})
if (isLoading) {
return (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32" />
))}
</div>
)
}
if (!coverage) {
return <p className="text-muted-foreground">No coverage data available</p>
}
const totalAssigned = coverage.fullyAssigned || 0
const totalProjects = coverage.totalProjects || 0
const avgPerJuror = coverage.avgReviewsPerProject?.toFixed(1) || '0'
const unassignedCount = coverage.unassigned || 0
return (
<div className="space-y-4">
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Assigned</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalAssigned}</div>
<p className="text-xs text-muted-foreground">
{totalProjects > 0
? `${((totalAssigned / totalProjects) * 100).toFixed(1)}% coverage`
: 'No projects'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Per Juror</CardTitle>
<Users className="h-4 w-4 text-blue-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{avgPerJuror}</div>
<p className="text-xs text-muted-foreground">Assignments per juror</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Unassigned</CardTitle>
<AlertCircle className="h-4 w-4 text-amber-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{unassignedCount}</div>
<p className="text-xs text-muted-foreground">
Projects below 3 reviews
</p>
</CardContent>
</Card>
</div>
{coverage.unassigned > 0 && (
<Card className="border-amber-500">
<CardHeader>
<CardTitle className="text-sm">Coverage Warnings</CardTitle>
<CardDescription>Issues detected in assignment coverage</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-1 text-sm">
<li className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<span>{coverage.unassigned} projects have insufficient coverage</span>
</li>
</ul>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,156 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { format } from 'date-fns'
import { CheckCircle2, Circle, Clock } from 'lucide-react'
const roundTypeColors: Record<string, string> = {
INTAKE: 'bg-gray-100 text-gray-700 border-gray-300',
FILTERING: 'bg-amber-100 text-amber-700 border-amber-300',
EVALUATION: 'bg-blue-100 text-blue-700 border-blue-300',
SUBMISSION: 'bg-purple-100 text-purple-700 border-purple-300',
MENTORING: 'bg-teal-100 text-teal-700 border-teal-300',
LIVE_FINAL: 'bg-red-100 text-red-700 border-red-300',
DELIBERATION: 'bg-indigo-100 text-indigo-700 border-indigo-300',
}
const roundStatusConfig: Record<string, { icon: typeof Circle; color: string }> = {
ROUND_DRAFT: { icon: Circle, color: 'text-gray-400' },
ROUND_ACTIVE: { icon: Clock, color: 'text-emerald-500' },
ROUND_CLOSED: { icon: CheckCircle2, color: 'text-blue-500' },
ROUND_ARCHIVED: { icon: CheckCircle2, color: 'text-gray-400' },
}
type RoundSummary = {
id: string
name: string
slug: string
roundType: string
status: string
sortOrder: number
windowOpenAt: Date | string | null
windowCloseAt: Date | string | null
}
export function CompetitionTimeline({
competitionId,
rounds,
}: {
competitionId: string
rounds: RoundSummary[]
}) {
if (rounds.length === 0) {
return (
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No rounds configured yet. Add rounds to see the competition timeline.
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Round Timeline</CardTitle>
</CardHeader>
<CardContent>
{/* Desktop: horizontal timeline */}
<div className="hidden md:block overflow-x-auto pb-2">
<div className="flex items-start gap-0 min-w-max">
{rounds.map((round, index) => {
const statusCfg = roundStatusConfig[round.status] ?? roundStatusConfig.ROUND_DRAFT
const StatusIcon = statusCfg.icon
const isLast = index === rounds.length - 1
return (
<div key={round.id} className="flex items-start">
<Link
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
className="group flex flex-col items-center text-center w-32 shrink-0"
>
<div className="relative">
<StatusIcon className={cn('h-6 w-6', statusCfg.color)} />
</div>
<p className="mt-2 text-xs font-medium group-hover:text-primary transition-colors line-clamp-2">
{round.name}
</p>
<Badge
variant="secondary"
className={cn(
'mt-1 text-[9px]',
roundTypeColors[round.roundType] ?? ''
)}
>
{round.roundType.replace('_', ' ')}
</Badge>
{round.windowOpenAt && (
<p className="mt-1 text-[10px] text-muted-foreground">
{format(new Date(round.windowOpenAt), 'MMM d')}
{round.windowCloseAt && (
<> - {format(new Date(round.windowCloseAt), 'MMM d')}</>
)}
</p>
)}
</Link>
{!isLast && (
<div className="mt-3 h-px w-8 bg-border shrink-0" />
)}
</div>
)
})}
</div>
</div>
{/* Mobile: vertical timeline */}
<div className="md:hidden space-y-0">
{rounds.map((round, index) => {
const statusCfg = roundStatusConfig[round.status] ?? roundStatusConfig.ROUND_DRAFT
const StatusIcon = statusCfg.icon
const isLast = index === rounds.length - 1
return (
<div key={round.id}>
<Link
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
className="flex items-start gap-3 py-2 hover:bg-muted/50 rounded-md px-2 -mx-2 transition-colors"
>
<div className="flex flex-col items-center shrink-0">
<StatusIcon className={cn('h-5 w-5', statusCfg.color)} />
{!isLast && <div className="w-px flex-1 bg-border mt-1 min-h-[16px]" />}
</div>
<div className="flex-1 min-w-0 pb-2">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{round.name}</p>
<Badge
variant="secondary"
className={cn(
'text-[9px] shrink-0',
roundTypeColors[round.roundType] ?? ''
)}
>
{round.roundType.replace('_', ' ')}
</Badge>
</div>
{round.windowOpenAt && (
<p className="text-[11px] text-muted-foreground mt-0.5">
{format(new Date(round.windowOpenAt), 'MMM d, yyyy')}
{round.windowCloseAt && (
<> - {format(new Date(round.windowCloseAt), 'MMM d, yyyy')}</>
)}
</p>
)}
</div>
</Link>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,248 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
type RoundConfigFormProps = {
roundType: string
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
export function RoundConfigForm({ roundType, config, onChange }: RoundConfigFormProps) {
const updateConfig = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
if (roundType === 'INTAKE') {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Intake Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="allowDrafts">Allow Drafts</Label>
<Switch
id="allowDrafts"
checked={(config.allowDrafts as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('allowDrafts', checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="draftExpiryDays">Draft Expiry (days)</Label>
<Input
id="draftExpiryDays"
type="number"
min={1}
value={(config.draftExpiryDays as number) ?? 30}
onChange={(e) => updateConfig('draftExpiryDays', parseInt(e.target.value, 10))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxFileSizeMB">Max File Size (MB)</Label>
<Input
id="maxFileSizeMB"
type="number"
min={1}
value={(config.maxFileSizeMB as number) ?? 50}
onChange={(e) => updateConfig('maxFileSizeMB', parseInt(e.target.value, 10))}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="publicFormEnabled">Public Form Enabled</Label>
<Switch
id="publicFormEnabled"
checked={(config.publicFormEnabled as boolean) ?? false}
onCheckedChange={(checked) => updateConfig('publicFormEnabled', checked)}
/>
</div>
</CardContent>
</Card>
)
}
if (roundType === 'FILTERING') {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Filtering Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="aiScreeningEnabled">AI Screening</Label>
<Switch
id="aiScreeningEnabled"
checked={(config.aiScreeningEnabled as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('aiScreeningEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="duplicateDetectionEnabled">Duplicate Detection</Label>
<Switch
id="duplicateDetectionEnabled"
checked={(config.duplicateDetectionEnabled as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('duplicateDetectionEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="manualReviewEnabled">Manual Review</Label>
<Switch
id="manualReviewEnabled"
checked={(config.manualReviewEnabled as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('manualReviewEnabled', checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="batchSize">Batch Size</Label>
<Input
id="batchSize"
type="number"
min={1}
value={(config.batchSize as number) ?? 20}
onChange={(e) => updateConfig('batchSize', parseInt(e.target.value, 10))}
/>
</div>
</CardContent>
</Card>
)
}
if (roundType === 'EVALUATION') {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Evaluation Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="requiredReviews">Required Reviews per Project</Label>
<Input
id="requiredReviews"
type="number"
min={1}
value={(config.requiredReviewsPerProject as number) ?? 3}
onChange={(e) => updateConfig('requiredReviewsPerProject', parseInt(e.target.value, 10))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="scoringMode">Scoring Mode</Label>
<Select
value={(config.scoringMode as string) ?? 'criteria'}
onValueChange={(value) => updateConfig('scoringMode', value)}
>
<SelectTrigger id="scoringMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="criteria">Criteria-based</SelectItem>
<SelectItem value="global">Global score</SelectItem>
<SelectItem value="binary">Binary (pass/fail)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="requireFeedback">Require Feedback</Label>
<Switch
id="requireFeedback"
checked={(config.requireFeedback as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('requireFeedback', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="coiRequired">COI Declaration Required</Label>
<Switch
id="coiRequired"
checked={(config.coiRequired as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('coiRequired', checked)}
/>
</div>
</CardContent>
</Card>
)
}
if (roundType === 'LIVE_FINAL') {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Live Final Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="juryVotingEnabled">Jury Voting</Label>
<Switch
id="juryVotingEnabled"
checked={(config.juryVotingEnabled as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('juryVotingEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="audienceVotingEnabled">Audience Voting</Label>
<Switch
id="audienceVotingEnabled"
checked={(config.audienceVotingEnabled as boolean) ?? false}
onCheckedChange={(checked) => updateConfig('audienceVotingEnabled', checked)}
/>
</div>
{(config.audienceVotingEnabled as boolean) && (
<div className="space-y-2">
<Label htmlFor="audienceVoteWeight">Audience Vote Weight (0-1)</Label>
<Input
id="audienceVoteWeight"
type="number"
min={0}
max={1}
step={0.1}
value={(config.audienceVoteWeight as number) ?? 0}
onChange={(e) => updateConfig('audienceVoteWeight', parseFloat(e.target.value))}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="presentationDuration">Presentation Duration (min)</Label>
<Input
id="presentationDuration"
type="number"
min={1}
value={(config.presentationDurationMinutes as number) ?? 15}
onChange={(e) => updateConfig('presentationDurationMinutes', parseInt(e.target.value, 10))}
/>
</div>
</CardContent>
</Card>
)
}
// Default view for other types
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{roundType} Configuration</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Configuration UI for {roundType} rounds is not yet implemented.
</p>
<pre className="mt-4 p-3 bg-muted rounded text-xs overflow-auto">
{JSON.stringify(config, null, 2)}
</pre>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,159 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
type WizardState = {
programId: string
name: string
slug: string
categoryMode: string
startupFinalistCount: number
conceptFinalistCount: number
notifyOnRoundAdvance: boolean
notifyOnDeadlineApproach: boolean
deadlineReminderDays: number[]
}
type BasicsSectionProps = {
state: WizardState
onChange: (updates: Partial<WizardState>) => void
}
export function BasicsSection({ state, onChange }: BasicsSectionProps) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Competition Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Competition Name *</Label>
<Input
id="name"
placeholder="e.g., 2026 Ocean Innovation Challenge"
value={state.name}
onChange={(e) => onChange({ name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug *</Label>
<Input
id="slug"
placeholder="e.g., 2026-ocean-innovation"
value={state.slug}
onChange={(e) => onChange({ slug: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
URL-safe identifier (lowercase, hyphens only)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="categoryMode">Category Mode</Label>
<Select value={state.categoryMode} onValueChange={(value) => onChange({ categoryMode: value })}>
<SelectTrigger id="categoryMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SHARED">Shared (all categories together)</SelectItem>
<SelectItem value="SEPARATE">Separate (categories evaluated independently)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="startupFinalists">Startup Finalists</Label>
<Input
id="startupFinalists"
type="number"
min={1}
value={state.startupFinalistCount}
onChange={(e) => onChange({ startupFinalistCount: parseInt(e.target.value, 10) })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="conceptFinalists">Concept Finalists</Label>
<Input
id="conceptFinalists"
type="number"
min={1}
value={state.conceptFinalistCount}
onChange={(e) => onChange({ conceptFinalistCount: parseInt(e.target.value, 10) })}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Notifications</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notifyRoundAdvance">Round Advancement Notifications</Label>
<p className="text-xs text-muted-foreground">
Notify participants when they advance to the next round
</p>
</div>
<Switch
id="notifyRoundAdvance"
checked={state.notifyOnRoundAdvance}
onCheckedChange={(checked) => onChange({ notifyOnRoundAdvance: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notifyDeadline">Deadline Reminders</Label>
<p className="text-xs text-muted-foreground">
Send reminders as deadlines approach
</p>
</div>
<Switch
id="notifyDeadline"
checked={state.notifyOnDeadlineApproach}
onCheckedChange={(checked) => onChange({ notifyOnDeadlineApproach: checked })}
/>
</div>
{state.notifyOnDeadlineApproach && (
<div className="space-y-2">
<Label>Reminder Days</Label>
<div className="flex flex-wrap gap-2">
{state.deadlineReminderDays.map((days, index) => (
<div key={index} className="flex items-center gap-1">
<Input
type="number"
min={1}
className="w-16"
value={days}
onChange={(e) => {
const newDays = [...state.deadlineReminderDays]
newDays[index] = parseInt(e.target.value, 10)
onChange({ deadlineReminderDays: newDays })
}}
/>
<span className="text-xs text-muted-foreground">days</span>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Days before deadline to send reminders
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Plus, Trash2 } from 'lucide-react'
type WizardJuryGroup = {
tempId: string
name: string
slug: string
defaultMaxAssignments: number
defaultCapMode: string
sortOrder: number
}
type JuryGroupsSectionProps = {
juryGroups: WizardJuryGroup[]
onChange: (groups: WizardJuryGroup[]) => void
}
export function JuryGroupsSection({ juryGroups, onChange }: JuryGroupsSectionProps) {
const handleAddGroup = () => {
const newGroup: WizardJuryGroup = {
tempId: crypto.randomUUID(),
name: '',
slug: '',
defaultMaxAssignments: 5,
defaultCapMode: 'SOFT',
sortOrder: juryGroups.length,
}
onChange([...juryGroups, newGroup])
}
const handleRemoveGroup = (tempId: string) => {
const updated = juryGroups.filter((g) => g.tempId !== tempId)
const reordered = updated.map((g, index) => ({ ...g, sortOrder: index }))
onChange(reordered)
}
const handleUpdateGroup = (tempId: string, updates: Partial<WizardJuryGroup>) => {
const updated = juryGroups.map((g) =>
g.tempId === tempId ? { ...g, ...updates } : g
)
onChange(updated)
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Jury Groups</CardTitle>
<p className="text-sm text-muted-foreground">
Create jury groups for evaluation rounds (optional)
</p>
</CardHeader>
<CardContent className="space-y-3">
{juryGroups.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No jury groups yet. Add groups to assign evaluators to rounds.
</div>
) : (
juryGroups.map((group, index) => (
<div key={group.tempId} className="flex items-start gap-2 border rounded-lg p-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0 space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Group Name</Label>
<Input
placeholder="e.g., Technical Jury"
value={group.name}
onChange={(e) => {
const name = e.target.value
const slug = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
handleUpdateGroup(group.tempId, { name, slug })
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Slug</Label>
<Input
placeholder="e.g., technical-jury"
value={group.slug}
onChange={(e) => handleUpdateGroup(group.tempId, { slug: e.target.value })}
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Max Assignments per Juror</Label>
<Input
type="number"
min={1}
value={group.defaultMaxAssignments}
onChange={(e) =>
handleUpdateGroup(group.tempId, {
defaultMaxAssignments: parseInt(e.target.value, 10),
})
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Cap Mode</Label>
<Select
value={group.defaultCapMode}
onValueChange={(value) => handleUpdateGroup(group.tempId, { defaultCapMode: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD">Hard (strict limit)</SelectItem>
<SelectItem value="SOFT">Soft (flexible)</SelectItem>
<SelectItem value="NONE">None (unlimited)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive shrink-0"
onClick={() => handleRemoveGroup(group.tempId)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))
)}
<Button variant="outline" className="w-full" onClick={handleAddGroup}>
<Plus className="h-4 w-4 mr-2" />
Add Jury Group
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,213 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
type WizardRound = {
tempId: string
name: string
slug: string
roundType: string
sortOrder: number
configJson: Record<string, unknown>
}
type WizardJuryGroup = {
tempId: string
name: string
slug: string
defaultMaxAssignments: number
defaultCapMode: string
sortOrder: number
}
type WizardState = {
programId: string
name: string
slug: string
categoryMode: string
startupFinalistCount: number
conceptFinalistCount: number
notifyOnRoundAdvance: boolean
notifyOnDeadlineApproach: boolean
deadlineReminderDays: number[]
rounds: WizardRound[]
juryGroups: WizardJuryGroup[]
}
type ReviewSectionProps = {
state: WizardState
}
const roundTypeColors: Record<string, string> = {
INTAKE: 'bg-gray-100 text-gray-700',
FILTERING: 'bg-amber-100 text-amber-700',
EVALUATION: 'bg-blue-100 text-blue-700',
SUBMISSION: 'bg-purple-100 text-purple-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-red-100 text-red-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
export function ReviewSection({ state }: ReviewSectionProps) {
const warnings: string[] = []
if (!state.name) warnings.push('Competition name is required')
if (!state.slug) warnings.push('Competition slug is required')
if (state.rounds.length === 0) warnings.push('At least one round is required')
if (state.rounds.some((r) => !r.name)) warnings.push('All rounds must have a name')
if (state.rounds.some((r) => !r.slug)) warnings.push('All rounds must have a slug')
if (state.juryGroups.some((g) => !g.name)) warnings.push('All jury groups must have a name')
if (state.juryGroups.some((g) => !g.slug)) warnings.push('All jury groups must have a slug')
return (
<div className="space-y-6">
{/* Validation Status */}
{warnings.length > 0 ? (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="pt-6">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-destructive">
Please fix the following issues:
</p>
<ul className="mt-2 space-y-1 text-sm text-destructive/90">
{warnings.map((warning, index) => (
<li key={index} className="ml-4 list-disc">
{warning}
</li>
))}
</ul>
</div>
</div>
</CardContent>
</Card>
) : (
<Card className="border-emerald-500/50 bg-emerald-500/5">
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
<p className="text-sm font-medium text-emerald-700">
Ready to create competition
</p>
</div>
</CardContent>
</Card>
)}
{/* Competition Summary */}
<Card>
<CardHeader>
<CardTitle className="text-base">Competition Details</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground">Name</p>
<p className="text-sm">{state.name || <em className="text-muted-foreground">Not set</em>}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Slug</p>
<p className="text-sm font-mono">{state.slug || <em className="text-muted-foreground">Not set</em>}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs font-medium text-muted-foreground">Category Mode</p>
<p className="text-sm">{state.categoryMode}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Finalists</p>
<p className="text-sm">
{state.startupFinalistCount} Startup / {state.conceptFinalistCount} Concept
</p>
</div>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">Notifications</p>
<div className="flex flex-wrap gap-1.5">
{state.notifyOnRoundAdvance && (
<Badge variant="secondary" className="text-[10px]">
Round Advance
</Badge>
)}
{state.notifyOnDeadlineApproach && (
<Badge variant="secondary" className="text-[10px]">
Deadline Reminders ({state.deadlineReminderDays.join(', ')} days)
</Badge>
)}
</div>
</div>
</CardContent>
</Card>
{/* Rounds Summary */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Rounds ({state.rounds.length})
</CardTitle>
</CardHeader>
<CardContent>
{state.rounds.length === 0 ? (
<p className="text-sm text-muted-foreground">No rounds configured</p>
) : (
<div className="space-y-2">
{state.rounds.map((round, index) => (
<div key={round.tempId} className="flex items-center gap-3 py-2 border-b last:border-0">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{round.name || <em>Unnamed</em>}</p>
<p className="text-xs text-muted-foreground font-mono truncate">{round.slug || <em>no-slug</em>}</p>
</div>
<Badge
variant="secondary"
className={roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}
>
{round.roundType.replace('_', ' ')}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Jury Groups Summary */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Jury Groups ({state.juryGroups.length})
</CardTitle>
</CardHeader>
<CardContent>
{state.juryGroups.length === 0 ? (
<p className="text-sm text-muted-foreground">No jury groups configured</p>
) : (
<div className="space-y-2">
{state.juryGroups.map((group, index) => (
<div key={group.tempId} className="flex items-center gap-3 py-2 border-b last:border-0">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{group.name || <em>Unnamed</em>}</p>
<p className="text-xs text-muted-foreground font-mono truncate">{group.slug || <em>no-slug</em>}</p>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
<span>Max: {group.defaultMaxAssignments}</span>
<Badge variant="outline" className="text-[10px]">
{group.defaultCapMode}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,195 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
type WizardRound = {
tempId: string
name: string
slug: string
roundType: string
sortOrder: number
configJson: Record<string, unknown>
}
type RoundsSectionProps = {
rounds: WizardRound[]
onChange: (rounds: WizardRound[]) => void
}
const roundTypes = [
{ value: 'INTAKE', label: 'Intake', color: 'bg-gray-100 text-gray-700' },
{ value: 'FILTERING', label: 'Filtering', color: 'bg-amber-100 text-amber-700' },
{ value: 'EVALUATION', label: 'Evaluation', color: 'bg-blue-100 text-blue-700' },
{ value: 'SUBMISSION', label: 'Submission', color: 'bg-purple-100 text-purple-700' },
{ value: 'MENTORING', label: 'Mentoring', color: 'bg-teal-100 text-teal-700' },
{ value: 'LIVE_FINAL', label: 'Live Final', color: 'bg-red-100 text-red-700' },
{ value: 'DELIBERATION', label: 'Deliberation', color: 'bg-indigo-100 text-indigo-700' },
]
export function RoundsSection({ rounds, onChange }: RoundsSectionProps) {
const handleAddRound = () => {
const newRound: WizardRound = {
tempId: crypto.randomUUID(),
name: '',
slug: '',
roundType: 'EVALUATION',
sortOrder: rounds.length,
configJson: {},
}
onChange([...rounds, newRound])
}
const handleRemoveRound = (tempId: string) => {
const updated = rounds.filter((r) => r.tempId !== tempId)
// Reorder
const reordered = updated.map((r, index) => ({ ...r, sortOrder: index }))
onChange(reordered)
}
const handleUpdateRound = (tempId: string, updates: Partial<WizardRound>) => {
const updated = rounds.map((r) =>
r.tempId === tempId ? { ...r, ...updates } : r
)
onChange(updated)
}
const handleMoveUp = (index: number) => {
if (index === 0) return
const updated = [...rounds]
;[updated[index - 1], updated[index]] = [updated[index], updated[index - 1]]
const reordered = updated.map((r, i) => ({ ...r, sortOrder: i }))
onChange(reordered)
}
const handleMoveDown = (index: number) => {
if (index === rounds.length - 1) return
const updated = [...rounds]
;[updated[index], updated[index + 1]] = [updated[index + 1], updated[index]]
const reordered = updated.map((r, i) => ({ ...r, sortOrder: i }))
onChange(reordered)
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Competition Rounds</CardTitle>
<p className="text-sm text-muted-foreground">
Define the stages of your competition workflow
</p>
</CardHeader>
<CardContent className="space-y-3">
{rounds.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No rounds yet. Add your first round to get started.
</div>
) : (
rounds.map((round, index) => {
const typeConfig = roundTypes.find((t) => t.value === round.roundType)
return (
<div key={round.tempId} className="flex items-start gap-2 border rounded-lg p-3">
<div className="flex flex-col gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMoveUp(index)}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMoveDown(index)}
disabled={index === rounds.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0 space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Round Name</Label>
<Input
placeholder="e.g., First Evaluation"
value={round.name}
onChange={(e) => {
const name = e.target.value
const slug = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
handleUpdateRound(round.tempId, { name, slug })
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Slug</Label>
<Input
placeholder="e.g., first-evaluation"
value={round.slug}
onChange={(e) => handleUpdateRound(round.tempId, { slug: e.target.value })}
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<Label className="text-xs">Round Type</Label>
<Select
value={round.roundType}
onValueChange={(value) => handleUpdateRound(round.tempId, { roundType: value })}
>
<SelectTrigger className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{roundTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{typeConfig && (
<Badge variant="secondary" className={typeConfig.color}>
{typeConfig.label}
</Badge>
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive shrink-0"
onClick={() => handleRemoveRound(round.tempId)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
})
)}
<Button variant="outline" className="w-full" onClick={handleAddRound}>
<Plus className="h-4 w-4 mr-2" />
Add Round
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,144 @@
'use client';
import { useState } from 'react';
import { trpc } from '@/lib/trpc/client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';
interface AdminOverrideDialogProps {
sessionId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
projectIds: string[];
}
export function AdminOverrideDialog({
sessionId,
open,
onOpenChange,
projectIds
}: AdminOverrideDialogProps) {
const utils = trpc.useUtils();
const [rankings, setRankings] = useState<Record<string, number>>({});
const [reason, setReason] = useState('');
const { data: session } = trpc.deliberation.getSession.useQuery(
{ sessionId },
{ enabled: open }
);
const adminDecideMutation = trpc.deliberation.adminDecide.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
utils.deliberation.aggregate.invalidate();
toast.success('Admin override applied successfully');
onOpenChange(false);
setRankings({});
setReason('');
},
onError: (err) => {
toast.error(err.message);
}
});
const handleSubmit = () => {
if (!reason.trim()) {
toast.error('Reason is required for admin override');
return;
}
const rankingsArray = Object.entries(rankings).map(([projectId, rank]) => ({
projectId,
rank
}));
if (rankingsArray.length === 0) {
toast.error('Please assign at least one rank');
return;
}
adminDecideMutation.mutate({
sessionId,
rankings: rankingsArray,
reason: reason.trim()
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Admin Override</DialogTitle>
<DialogDescription>
Manually set the final rankings for this deliberation session. This action will be
audited.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-3">
<Label>Project Rankings</Label>
<div className="space-y-2">
{projectIds.map((projectId) => {
const project = session?.projects?.find((p: any) => p.id === projectId);
return (
<div key={projectId} className="flex items-center gap-3">
<Input
type="number"
min="1"
placeholder="Rank"
value={rankings[projectId] || ''}
onChange={(e) =>
setRankings({
...rankings,
[projectId]: parseInt(e.target.value) || 0
})
}
className="w-20"
/>
<span className="flex-1 text-sm">{project?.title || projectId}</span>
</div>
);
})}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="reason">Reason (Required) *</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Explain why this admin override is necessary..."
rows={3}
required
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={adminDecideMutation.isPending || !reason.trim()}
>
{adminDecideMutation.isPending ? 'Applying...' : 'Apply Override'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,179 @@
'use client';
import { useState } from 'react';
import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner';
import { AdminOverrideDialog } from './admin-override-dialog';
interface ResultsPanelProps {
sessionId: string;
}
export function ResultsPanel({ sessionId }: ResultsPanelProps) {
const utils = trpc.useUtils();
const [overrideDialogOpen, setOverrideDialogOpen] = useState(false);
const { data: session } = trpc.deliberation.getSession.useQuery({ sessionId });
const { data: aggregatedResults } = trpc.deliberation.aggregate.useQuery({ sessionId });
const initRunoffMutation = trpc.deliberation.initRunoff.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
utils.deliberation.aggregate.invalidate();
toast.success('Runoff voting initiated');
},
onError: (err) => {
toast.error(err.message);
}
});
const finalizeMutation = trpc.deliberation.finalize.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
toast.success('Results finalized successfully');
},
onError: (err) => {
toast.error(err.message);
}
});
if (!aggregatedResults) {
return (
<Card>
<CardContent className="p-12 text-center">
<p className="text-muted-foreground">No voting results yet</p>
</CardContent>
</Card>
);
}
// Detect ties: check if two or more top-ranked candidates share the same totalScore
const hasTie = (() => {
const rankings = aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }> | undefined;
if (!rankings || rankings.length < 2) return false;
// Group projects by totalScore
const scoreGroups = new Map<number, string[]>();
for (const r of rankings) {
const score = r.totalScore ?? 0;
const group = scoreGroups.get(score) || [];
group.push(r.projectId);
scoreGroups.set(score, group);
}
// A tie exists if the highest score is shared by 2+ projects
const topScore = Math.max(...scoreGroups.keys());
const topGroup = scoreGroups.get(topScore);
return (topGroup?.length ?? 0) >= 2;
})();
const tiedProjectIds = hasTie
? (aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }>)
.filter((r) => r.totalScore === (aggregatedResults.rankings as Array<{ totalScore?: number }>)[0]?.totalScore)
.map((r) => r.projectId)
: [];
const canFinalize = session?.status === 'DELIB_TALLYING' && !hasTie;
return (
<div className="space-y-4">
{/* Results Table */}
<Card>
<CardHeader>
<CardTitle>Voting Results</CardTitle>
<CardDescription>
Aggregated voting results
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{aggregatedResults.rankings?.map((result: any, index: number) => (
<div
key={result.projectId}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-bold">
#{index + 1}
</div>
<div>
<p className="font-medium">{result.projectTitle}</p>
<p className="text-sm text-muted-foreground">
{result.votes} votes {result.averageRank?.toFixed(2)} avg rank
</p>
</div>
</div>
<Badge variant="outline" className="text-lg">
{result.totalScore?.toFixed(1) || 0}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
{/* Tie Warning */}
{hasTie && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Tie Detected</AlertTitle>
<AlertDescription className="space-y-3">
<p>
Multiple projects have the same score. You must resolve this before finalizing
results.
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
variant="outline"
size="sm"
onClick={() => initRunoffMutation.mutate({ sessionId, tiedProjectIds })}
disabled={initRunoffMutation.isPending}
>
Initiate Runoff Vote
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setOverrideDialogOpen(true)}
>
Admin Override
</Button>
</div>
</AlertDescription>
</Alert>
)}
{/* Finalize Button */}
{canFinalize && (
<Card>
<CardContent className="flex items-center justify-between p-6">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<div>
<p className="font-medium">Ready to Finalize</p>
<p className="text-sm text-muted-foreground">
All votes counted, no ties detected
</p>
</div>
</div>
<Button
onClick={() => finalizeMutation.mutate({ sessionId })}
disabled={finalizeMutation.isPending}
>
{finalizeMutation.isPending ? 'Finalizing...' : 'Finalize Results'}
</Button>
</CardContent>
</Card>
)}
{/* Admin Override Dialog */}
<AdminOverrideDialog
sessionId={sessionId}
open={overrideDialogOpen}
onOpenChange={setOverrideDialogOpen}
projectIds={aggregatedResults.rankings?.map((r: any) => r.projectId) || []}
/>
</div>
);
}

View File

@@ -24,6 +24,7 @@ import {
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Bot,
FileText,
RefreshCw,
Loader2,
@@ -38,7 +39,7 @@ import { formatDistanceToNow } from 'date-fns'
interface EvaluationSummaryCardProps {
projectId: string
stageId: string
roundId: string
}
interface ScoringPatterns {
@@ -71,7 +72,7 @@ const sentimentColors: Record<string, { badge: 'default' | 'secondary' | 'destru
export function EvaluationSummaryCard({
projectId,
stageId,
roundId,
}: EvaluationSummaryCardProps) {
const [isGenerating, setIsGenerating] = useState(false)
@@ -79,7 +80,7 @@ export function EvaluationSummaryCard({
data: summary,
isLoading,
refetch,
} = trpc.evaluation.getSummary.useQuery({ projectId, stageId })
} = trpc.evaluation.getSummary.useQuery({ projectId, roundId })
const generateMutation = trpc.evaluation.generateSummary.useMutation({
onSuccess: () => {
@@ -95,7 +96,7 @@ export function EvaluationSummaryCard({
const handleGenerate = () => {
setIsGenerating(true)
generateMutation.mutate({ projectId, stageId })
generateMutation.mutate({ projectId, roundId })
}
if (isLoading) {
@@ -157,6 +158,10 @@ export function EvaluationSummaryCard({
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5" />
AI Evaluation Summary
<Badge variant="outline" className="text-xs gap-1 shrink-0 ml-1">
<Bot className="h-3 w-3" />
AI Generated
</Badge>
</CardTitle>
<CardDescription className="flex items-center gap-2 mt-1">
<Clock className="h-3 w-3" />

View File

@@ -63,7 +63,7 @@ function getMimeLabel(mime: string): string {
}
interface FileRequirementsEditorProps {
stageId: string;
roundId: string;
}
interface RequirementFormData {
@@ -83,35 +83,35 @@ const emptyForm: RequirementFormData = {
};
export function FileRequirementsEditor({
stageId,
roundId,
}: FileRequirementsEditorProps) {
const utils = trpc.useUtils();
const { data: requirements = [], isLoading } =
trpc.file.listRequirements.useQuery({ stageId });
trpc.file.listRequirements.useQuery({ roundId });
const createMutation = trpc.file.createRequirement.useMutation({
onSuccess: () => {
utils.file.listRequirements.invalidate({ stageId });
utils.file.listRequirements.invalidate({ roundId });
toast.success("Requirement created");
},
onError: (err) => toast.error(err.message),
});
const updateMutation = trpc.file.updateRequirement.useMutation({
onSuccess: () => {
utils.file.listRequirements.invalidate({ stageId });
utils.file.listRequirements.invalidate({ roundId });
toast.success("Requirement updated");
},
onError: (err) => toast.error(err.message),
});
const deleteMutation = trpc.file.deleteRequirement.useMutation({
onSuccess: () => {
utils.file.listRequirements.invalidate({ stageId });
utils.file.listRequirements.invalidate({ roundId });
toast.success("Requirement deleted");
},
onError: (err) => toast.error(err.message),
});
const reorderMutation = trpc.file.reorderRequirements.useMutation({
onSuccess: () => utils.file.listRequirements.invalidate({ stageId }),
onSuccess: () => utils.file.listRequirements.invalidate({ roundId }),
onError: (err) => toast.error(err.message),
});
@@ -156,7 +156,7 @@ export function FileRequirementsEditor({
});
} else {
await createMutation.mutateAsync({
stageId,
roundId,
name: form.name.trim(),
description: form.description.trim() || undefined,
acceptedMimeTypes: form.acceptedMimeTypes,
@@ -182,7 +182,7 @@ export function FileRequirementsEditor({
newOrder[index],
];
await reorderMutation.mutateAsync({
stageId,
roundId,
orderedIds: newOrder.map((r) => r.id),
});
};

View File

@@ -0,0 +1,168 @@
'use client'
import { useState } from 'react'
import { Search } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
interface AddMemberDialogProps {
juryGroupId: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) {
const [searchQuery, setSearchQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string>('')
const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
const [maxAssignments, setMaxAssignments] = useState<string>('')
const utils = trpc.useUtils()
const { data: userResponse, isLoading: isSearching } = trpc.user.list.useQuery(
{ search: searchQuery, perPage: 20 },
{ enabled: searchQuery.length > 0 }
)
const users = userResponse?.users || []
const { mutate: addMember, isPending } = trpc.juryGroup.addMember.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: juryGroupId })
toast.success('Member added successfully')
onOpenChange(false)
resetForm()
},
onError: (err) => {
toast.error(err.message)
},
})
const resetForm = () => {
setSearchQuery('')
setSelectedUserId('')
setRole('MEMBER')
setMaxAssignments('')
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!selectedUserId) {
toast.error('Please select a user')
return
}
addMember({
juryGroupId,
userId: selectedUserId,
role,
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Member to Jury Group</DialogTitle>
<DialogDescription>
Search for a user and assign them to this jury group
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="search">Search User</Label>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="search"
placeholder="Search by name or email..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{isSearching && (
<p className="text-sm text-muted-foreground">Searching...</p>
)}
{users && users.length > 0 && (
<div className="max-h-40 overflow-y-auto border rounded-md">
{users.map((user) => (
<button
key={user.id}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-accent ${
selectedUserId === user.id ? 'bg-accent' : ''
}`}
onClick={() => {
setSelectedUserId(user.id)
setSearchQuery(user.email)
}}
>
<div className="font-medium">{user.name || 'Unnamed User'}</div>
<div className="text-muted-foreground text-xs">{user.email}</div>
</button>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={role} onValueChange={(val) => setRole(val as any)}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label>
<Input
id="maxAssignments"
type="number"
min="1"
placeholder="Leave empty to use group default"
value={maxAssignments}
onChange={(e) => setMaxAssignments(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isPending || !selectedUserId}>
{isPending ? 'Adding...' : 'Add Member'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,156 @@
'use client'
import { useState } from 'react'
import { Trash2, UserPlus } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { AddMemberDialog } from './add-member-dialog'
interface JuryMember {
id: string
userId: string
role: string
user: {
id: string
name: string | null
email: string
}
maxAssignmentsOverride: number | null
preferredStartupRatio: number | null
}
interface JuryMembersTableProps {
juryGroupId: string
members: JuryMember[]
}
export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps) {
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
const utils = trpc.useUtils()
const { mutate: removeMember, isPending: isRemoving } = trpc.juryGroup.removeMember.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: juryGroupId })
toast.success('Member removed successfully')
setRemovingMemberId(null)
},
onError: (err) => {
toast.error(err.message)
setRemovingMemberId(null)
},
})
const handleRemove = (memberId: string) => {
removeMember({ id: memberId })
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button onClick={() => setAddDialogOpen(true)}>
<UserPlus className="mr-2 h-4 w-4" />
Add Member
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead className="hidden md:table-cell">Role</TableHead>
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No members yet. Add members to get started.
</TableCell>
</TableRow>
) : (
members.map((member) => (
<TableRow key={member.id}>
<TableCell className="font-medium">
{member.user.name || 'Unnamed User'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{member.user.email}
</TableCell>
<TableCell className="hidden md:table-cell">
<Badge variant={member.role === 'CHAIR' ? 'default' : 'secondary'}>
{member.role}
</Badge>
</TableCell>
<TableCell className="hidden sm:table-cell">
{member.maxAssignmentsOverride ?? '—'}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setRemovingMemberId(member.id)}
disabled={isRemoving}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<AddMemberDialog
juryGroupId={juryGroupId}
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
/>
<AlertDialog open={!!removingMemberId} onOpenChange={() => setRemovingMemberId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove this member from the jury group? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => removingMemberId && handleRemove(removingMemberId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,194 @@
'use client';
import { useState, useEffect } from 'react';
import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ChevronLeft, ChevronRight, Play, Square, Timer } from 'lucide-react';
import { toast } from 'sonner';
interface LiveControlPanelProps {
roundId: string;
competitionId: string;
}
export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) {
const utils = trpc.useUtils();
const [timerSeconds, setTimerSeconds] = useState(300); // 5 minutes default
const [isTimerRunning, setIsTimerRunning] = useState(false);
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId });
// TODO: Add getScores to live router
const scores: any[] = [];
// TODO: Implement cursor mutation
const moveCursorMutation = {
mutate: () => {},
isPending: false
};
useEffect(() => {
if (!isTimerRunning) return;
const interval = setInterval(() => {
setTimerSeconds((prev) => {
if (prev <= 1) {
setIsTimerRunning(false);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [isTimerRunning]);
const handlePrevious = () => {
// TODO: Implement previous navigation
toast.info('Previous navigation not yet implemented');
};
const handleNext = () => {
// TODO: Implement next navigation
toast.info('Next navigation not yet implemented');
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="space-y-6">
{/* Current Project Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Current Project</CardTitle>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onClick={handlePrevious}
disabled={moveCursorMutation.isPending}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={handleNext}
disabled={moveCursorMutation.isPending}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{cursor?.activeProject ? (
<div className="space-y-4">
<div>
<h3 className="text-2xl font-bold">{cursor.activeProject.title}</h3>
</div>
<div className="text-sm text-muted-foreground">
Total projects: {cursor.totalProjects}
</div>
</div>
) : (
<p className="text-muted-foreground">No project selected</p>
)}
</CardContent>
</Card>
{/* Timer Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Timer className="h-5 w-5" />
Timer
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center">
<div className="text-5xl font-bold tabular-nums">{formatTime(timerSeconds)}</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{!isTimerRunning ? (
<Button
className="flex-1"
onClick={() => setIsTimerRunning(true)}
disabled={timerSeconds === 0}
>
<Play className="mr-2 h-4 w-4" />
Start Timer
</Button>
) : (
<Button className="flex-1" onClick={() => setIsTimerRunning(false)} variant="destructive">
<Square className="mr-2 h-4 w-4" />
Stop Timer
</Button>
)}
<Button
variant="outline"
onClick={() => {
setTimerSeconds(300);
setIsTimerRunning(false);
}}
>
Reset (5:00)
</Button>
</div>
</CardContent>
</Card>
{/* Voting Controls */}
<Card>
<CardHeader>
<CardTitle>Voting Controls</CardTitle>
<CardDescription>Manage jury and audience voting</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button className="w-full" variant="default">
<Play className="mr-2 h-4 w-4" />
Open Jury Voting
</Button>
<Button className="w-full" variant="outline">
Close Voting
</Button>
</CardContent>
</Card>
{/* Scores Display */}
<Card>
<CardHeader>
<CardTitle>Live Scores</CardTitle>
</CardHeader>
<CardContent>
{scores && scores.length > 0 ? (
<div className="space-y-2">
{scores.map((score: any, index: number) => (
<div
key={score.projectId}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="font-medium">
#{index + 1} {score.projectTitle}
</p>
<p className="text-sm text-muted-foreground">{score.votes} votes</p>
</div>
<Badge variant="outline">{score.totalScore.toFixed(1)}</Badge>
</div>
))}
</div>
) : (
<p className="text-center text-muted-foreground">No scores yet</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
interface Project {
id: string;
title: string;
category: string;
}
interface ProjectNavigatorGridProps {
projects: Project[];
currentProjectId?: string;
onSelect: (id: string) => void;
}
export function ProjectNavigatorGrid({
projects,
currentProjectId,
onSelect
}: ProjectNavigatorGridProps) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{projects.map((project, index) => (
<Card
key={project.id}
className={cn(
'cursor-pointer transition-all hover:shadow-md',
currentProjectId === project.id && 'ring-2 ring-primary'
)}
onClick={() => onSelect(project.id)}
>
<CardContent className="p-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-muted-foreground">#{index + 1}</span>
{currentProjectId === project.id && (
<Badge variant="default" className="text-xs">
Current
</Badge>
)}
</div>
<p className="line-clamp-2 text-sm font-medium">{project.title}</p>
<Badge variant="outline" className="text-xs">
{project.category}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -18,15 +18,15 @@ import {
} from '@/lib/pdf-generator'
interface PdfReportProps {
stageId: string
roundId: string
sections: string[]
}
export function PdfReportGenerator({ stageId, sections }: PdfReportProps) {
export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
const [generating, setGenerating] = useState(false)
const { refetch } = trpc.export.getReportData.useQuery(
{ stageId, sections },
{ roundId, sections },
{ enabled: false }
)

View File

@@ -1,358 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Save, Loader2 } from 'lucide-react'
type TrackAwardLite = {
id: string
name: string
decisionMode: 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION' | null
specialAward: {
id: string
name: string
description: string | null
criteriaText: string | null
useAiEligibility: boolean
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
maxRankedPicks: number | null
votingStartAt: Date | null
votingEndAt: Date | null
status: string
} | null
}
type AwardGovernanceEditorProps = {
pipelineId: string
tracks: TrackAwardLite[]
}
type AwardDraft = {
trackId: string
awardId: string
awardName: string
description: string
criteriaText: string
useAiEligibility: boolean
decisionMode: 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
maxRankedPicks: string
votingStartAt: string
votingEndAt: string
}
function toDateTimeInputValue(value: Date | null | undefined): string {
if (!value) return ''
const date = new Date(value)
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
return local.toISOString().slice(0, 16)
}
function toDateOrUndefined(value: string): Date | undefined {
if (!value) return undefined
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? undefined : parsed
}
export function AwardGovernanceEditor({
pipelineId,
tracks,
}: AwardGovernanceEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, AwardDraft>>({})
const awardTracks = useMemo(
() => tracks.filter((track) => !!track.specialAward),
[tracks]
)
const updateAward = trpc.specialAward.update.useMutation({
onError: (error) => toast.error(error.message),
})
const configureGovernance = trpc.award.configureGovernance.useMutation({
onError: (error) => toast.error(error.message),
})
useEffect(() => {
const nextDrafts: Record<string, AwardDraft> = {}
for (const track of awardTracks) {
const award = track.specialAward
if (!award) continue
nextDrafts[track.id] = {
trackId: track.id,
awardId: award.id,
awardName: award.name,
description: award.description ?? '',
criteriaText: award.criteriaText ?? '',
useAiEligibility: award.useAiEligibility,
decisionMode: track.decisionMode ?? 'JURY_VOTE',
scoringMode: award.scoringMode,
maxRankedPicks: award.maxRankedPicks?.toString() ?? '',
votingStartAt: toDateTimeInputValue(award.votingStartAt),
votingEndAt: toDateTimeInputValue(award.votingEndAt),
}
}
setDrafts(nextDrafts)
}, [awardTracks])
const isSaving = updateAward.isPending || configureGovernance.isPending
const handleSave = async (trackId: string) => {
const draft = drafts[trackId]
if (!draft) return
const votingStartAt = toDateOrUndefined(draft.votingStartAt)
const votingEndAt = toDateOrUndefined(draft.votingEndAt)
if (votingStartAt && votingEndAt && votingEndAt <= votingStartAt) {
toast.error('Voting end must be after voting start')
return
}
const maxRankedPicks = draft.maxRankedPicks
? parseInt(draft.maxRankedPicks, 10)
: undefined
await updateAward.mutateAsync({
id: draft.awardId,
name: draft.awardName.trim(),
description: draft.description.trim() || undefined,
criteriaText: draft.criteriaText.trim() || undefined,
useAiEligibility: draft.useAiEligibility,
scoringMode: draft.scoringMode,
maxRankedPicks,
votingStartAt,
votingEndAt,
})
await configureGovernance.mutateAsync({
trackId: draft.trackId,
decisionMode: draft.decisionMode,
scoringMode: draft.scoringMode,
maxRankedPicks,
votingStartAt,
votingEndAt,
})
await utils.pipeline.getDraft.invalidate({ id: pipelineId })
toast.success('Award governance updated')
}
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Award Governance</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{awardTracks.length === 0 && (
<p className="text-sm text-muted-foreground">
No award tracks in this pipeline.
</p>
)}
{awardTracks.map((track) => {
const draft = drafts[track.id]
if (!draft) return null
return (
<div key={track.id} className="rounded-md border p-3 space-y-3">
<p className="text-sm font-medium">{track.name}</p>
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Award Name</Label>
<Input
value={draft.awardName}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, awardName: e.target.value },
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Decision Mode</Label>
<Select
value={draft.decisionMode}
onValueChange={(value) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
decisionMode: value as AwardDraft['decisionMode'],
},
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="JURY_VOTE">Jury Vote</SelectItem>
<SelectItem value="AWARD_MASTER_DECISION">
Award Master Decision
</SelectItem>
<SelectItem value="ADMIN_DECISION">Admin Decision</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">Scoring Mode</Label>
<Select
value={draft.scoringMode}
onValueChange={(value) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
scoringMode: value as AwardDraft['scoringMode'],
},
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
<SelectItem value="RANKED">Ranked</SelectItem>
<SelectItem value="SCORED">Scored</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Max Ranked Picks</Label>
<Input
type="number"
min={1}
max={20}
value={draft.maxRankedPicks}
disabled={draft.scoringMode !== 'RANKED'}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
maxRankedPicks: e.target.value,
},
}))
}
/>
</div>
<div className="flex items-end pb-2">
<div className="flex items-center gap-2">
<Switch
checked={draft.useAiEligibility}
onCheckedChange={(checked) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
useAiEligibility: checked,
},
}))
}
/>
<Label className="text-xs">AI Eligibility</Label>
</div>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Voting Start</Label>
<Input
type="datetime-local"
value={draft.votingStartAt}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, votingStartAt: e.target.value },
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Voting End</Label>
<Input
type="datetime-local"
value={draft.votingEndAt}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, votingEndAt: e.target.value },
}))
}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Description</Label>
<Textarea
rows={2}
value={draft.description}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, description: e.target.value },
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Eligibility Criteria</Label>
<Textarea
rows={3}
value={draft.criteriaText}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, criteriaText: e.target.value },
}))
}
/>
</div>
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={() => handleSave(track.id)}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save Award Settings
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -1,379 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Plus,
Save,
Trash2,
ArrowUp,
ArrowDown,
Loader2,
Play,
} from 'lucide-react'
type FilteringRulesEditorProps = {
stageId: string
}
type RuleDraft = {
id: string
name: string
ruleType: 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING'
priority: number
configText: string
}
const DEFAULT_CONFIG_BY_TYPE: Record<
RuleDraft['ruleType'],
Record<string, unknown>
> = {
FIELD_BASED: {
conditions: [
{
field: 'competitionCategory',
operator: 'equals',
value: 'STARTUP',
},
],
logic: 'AND',
action: 'PASS',
},
DOCUMENT_CHECK: {
requiredFileTypes: ['application/pdf'],
minFileCount: 1,
action: 'REJECT',
},
AI_SCREENING: {
criteriaText:
'Project must clearly demonstrate ocean impact and practical feasibility.',
action: 'FLAG',
},
}
export function FilteringRulesEditor({ stageId }: FilteringRulesEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, RuleDraft>>({})
const { data: rules = [], isLoading } = trpc.filtering.getRules.useQuery({
stageId,
})
const createRule = trpc.filtering.createRule.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
toast.success('Filtering rule created')
},
onError: (error) => toast.error(error.message),
})
const updateRule = trpc.filtering.updateRule.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
toast.success('Filtering rule updated')
},
onError: (error) => toast.error(error.message),
})
const deleteRule = trpc.filtering.deleteRule.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
toast.success('Filtering rule deleted')
},
onError: (error) => toast.error(error.message),
})
const reorderRules = trpc.filtering.reorderRules.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
},
onError: (error) => toast.error(error.message),
})
const executeRules = trpc.filtering.executeRules.useMutation({
onSuccess: (data) => {
toast.success(
`Filtering executed: ${data.passed} passed, ${data.filteredOut} filtered, ${data.flagged} flagged`
)
},
onError: (error) => toast.error(error.message),
})
const orderedRules = useMemo(
() => [...rules].sort((a, b) => a.priority - b.priority),
[rules]
)
useEffect(() => {
const nextDrafts: Record<string, RuleDraft> = {}
for (const rule of orderedRules) {
nextDrafts[rule.id] = {
id: rule.id,
name: rule.name,
ruleType: rule.ruleType,
priority: rule.priority,
configText: JSON.stringify(rule.configJson ?? {}, null, 2),
}
}
setDrafts(nextDrafts)
}, [orderedRules])
const handleCreateRule = async () => {
const priority = orderedRules.length
await createRule.mutateAsync({
stageId,
name: `Rule ${priority + 1}`,
ruleType: 'FIELD_BASED',
priority,
configJson: DEFAULT_CONFIG_BY_TYPE.FIELD_BASED,
})
}
const handleSaveRule = async (ruleId: string) => {
const draft = drafts[ruleId]
if (!draft) return
let parsedConfig: Record<string, unknown>
try {
parsedConfig = JSON.parse(draft.configText) as Record<string, unknown>
} catch {
toast.error('Rule config must be valid JSON')
return
}
await updateRule.mutateAsync({
id: ruleId,
name: draft.name.trim(),
ruleType: draft.ruleType,
priority: draft.priority,
configJson: parsedConfig,
})
}
const handleMoveRule = async (index: number, direction: 'up' | 'down') => {
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= orderedRules.length) return
const reordered = [...orderedRules]
const temp = reordered[index]
reordered[index] = reordered[targetIndex]
reordered[targetIndex] = temp
await reorderRules.mutateAsync({
rules: reordered.map((rule, idx) => ({
id: rule.id,
priority: idx,
})),
})
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Filtering Rules</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Loading rules...
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm">Filtering Rules</CardTitle>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => executeRules.mutate({ stageId })}
disabled={executeRules.isPending}
>
{executeRules.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Play className="mr-1.5 h-3.5 w-3.5" />
)}
Run
</Button>
<Button
type="button"
size="sm"
onClick={handleCreateRule}
disabled={createRule.isPending}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Add Rule
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{orderedRules.length === 0 && (
<p className="text-sm text-muted-foreground">
No filtering rules configured yet.
</p>
)}
{orderedRules.map((rule, index) => {
const draft = drafts[rule.id]
if (!draft) return null
return (
<div key={rule.id} className="rounded-md border p-3 space-y-3">
<div className="grid gap-2 sm:grid-cols-12">
<div className="sm:col-span-5 space-y-1">
<Label className="text-xs">Name</Label>
<Input
value={draft.name}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
name: e.target.value,
},
}))
}
/>
</div>
<div className="sm:col-span-4 space-y-1">
<Label className="text-xs">Rule Type</Label>
<Select
value={draft.ruleType}
onValueChange={(value) => {
const ruleType = value as RuleDraft['ruleType']
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
ruleType,
configText: JSON.stringify(
DEFAULT_CONFIG_BY_TYPE[ruleType],
null,
2
),
},
}))
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FIELD_BASED">Field Based</SelectItem>
<SelectItem value="DOCUMENT_CHECK">Document Check</SelectItem>
<SelectItem value="AI_SCREENING">AI Screening</SelectItem>
</SelectContent>
</Select>
</div>
<div className="sm:col-span-3 space-y-1">
<Label className="text-xs">Priority</Label>
<Input
type="number"
value={draft.priority}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
priority: parseInt(e.target.value, 10) || 0,
},
}))
}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Rule Config (JSON)</Label>
<Textarea
className="font-mono text-xs min-h-28"
value={draft.configText}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
configText: e.target.value,
},
}))
}
/>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleMoveRule(index, 'up')}
disabled={index === 0 || reorderRules.isPending}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleMoveRule(index, 'down')}
disabled={
index === orderedRules.length - 1 || reorderRules.isPending
}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => handleSaveRule(rule.id)}
disabled={updateRule.isPending}
>
{updateRule.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => deleteRule.mutate({ id: rule.id })}
disabled={deleteRule.isPending}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete
</Button>
</div>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -1,276 +0,0 @@
'use client'
import { useRef, useEffect, useState, useCallback } from 'react'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
type StageNode = {
id: string
name: string
stageType: string
sortOrder: number
_count?: { projectStageStates: number }
}
type FlowchartTrack = {
id: string
name: string
kind: string
sortOrder: number
stages: StageNode[]
}
type PipelineFlowchartProps = {
tracks: FlowchartTrack[]
selectedStageId?: string | null
onStageSelect?: (stageId: string) => void
className?: string
compact?: boolean
}
const stageTypeColors: Record<string, { bg: string; border: string; text: string; glow: string }> = {
INTAKE: { bg: '#eff6ff', border: '#93c5fd', text: '#1d4ed8', glow: '#3b82f6' },
FILTER: { bg: '#fffbeb', border: '#fcd34d', text: '#b45309', glow: '#f59e0b' },
EVALUATION: { bg: '#faf5ff', border: '#c084fc', text: '#7e22ce', glow: '#a855f7' },
SELECTION: { bg: '#fff1f2', border: '#fda4af', text: '#be123c', glow: '#f43f5e' },
LIVE_FINAL: { bg: '#ecfdf5', border: '#6ee7b7', text: '#047857', glow: '#10b981' },
RESULTS: { bg: '#ecfeff', border: '#67e8f9', text: '#0e7490', glow: '#06b6d4' },
}
const NODE_WIDTH = 140
const NODE_HEIGHT = 70
const NODE_GAP = 32
const ARROW_SIZE = 6
const TRACK_LABEL_HEIGHT = 28
const TRACK_GAP = 20
export function PipelineFlowchart({
tracks,
selectedStageId,
onStageSelect,
className,
compact = false,
}: PipelineFlowchartProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [hoveredStageId, setHoveredStageId] = useState<string | null>(null)
const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder)
// Calculate dimensions
const nodeW = compact ? 100 : NODE_WIDTH
const nodeH = compact ? 50 : NODE_HEIGHT
const gap = compact ? 20 : NODE_GAP
const maxStages = Math.max(...sortedTracks.map((t) => t.stages.length), 1)
const totalWidth = maxStages * nodeW + (maxStages - 1) * gap + 40
const totalHeight =
sortedTracks.length * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) - TRACK_GAP + 20
const getNodePosition = useCallback(
(trackIndex: number, stageIndex: number) => {
const x = 20 + stageIndex * (nodeW + gap)
const y = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) + TRACK_LABEL_HEIGHT
return { x, y }
},
[nodeW, nodeH, gap]
)
return (
<div
ref={containerRef}
className={cn('relative rounded-lg border bg-card', className)}
>
<div className="overflow-x-auto">
<svg
width={totalWidth}
height={totalHeight}
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
className="min-w-full"
>
<defs>
<marker
id="arrowhead"
markerWidth={ARROW_SIZE}
markerHeight={ARROW_SIZE}
refX={ARROW_SIZE}
refY={ARROW_SIZE / 2}
orient="auto"
>
<path
d={`M 0 0 L ${ARROW_SIZE} ${ARROW_SIZE / 2} L 0 ${ARROW_SIZE} Z`}
fill="#94a3b8"
/>
</marker>
{/* Glow filter for selected node */}
<filter id="selectedGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#3b82f6" floodOpacity="0.3" result="color" />
<feComposite in="color" in2="blur" operator="in" result="glow" />
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{sortedTracks.map((track, trackIndex) => {
const sortedStages = [...track.stages].sort(
(a, b) => a.sortOrder - b.sortOrder
)
const trackY = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP)
return (
<g key={track.id}>
{/* Track label */}
<text
x={20}
y={trackY + 14}
className="fill-muted-foreground text-[11px] font-medium"
style={{ fontFamily: 'inherit' }}
>
{track.name}
{track.kind !== 'MAIN' && ` (${track.kind})`}
</text>
{/* Arrows between stages */}
{sortedStages.map((stage, stageIndex) => {
if (stageIndex === 0) return null
const from = getNodePosition(trackIndex, stageIndex - 1)
const to = getNodePosition(trackIndex, stageIndex)
const arrowY = from.y + nodeH / 2
return (
<line
key={`arrow-${stage.id}`}
x1={from.x + nodeW}
y1={arrowY}
x2={to.x - 2}
y2={arrowY}
stroke="#94a3b8"
strokeWidth={1.5}
markerEnd="url(#arrowhead)"
/>
)
})}
{/* Stage nodes */}
{sortedStages.map((stage, stageIndex) => {
const pos = getNodePosition(trackIndex, stageIndex)
const isSelected = selectedStageId === stage.id
const isHovered = hoveredStageId === stage.id
const colors = stageTypeColors[stage.stageType] ?? {
bg: '#f8fafc',
border: '#cbd5e1',
text: '#475569',
glow: '#64748b',
}
const projectCount = stage._count?.projectStageStates ?? 0
return (
<g
key={stage.id}
onClick={() => onStageSelect?.(stage.id)}
onMouseEnter={() => setHoveredStageId(stage.id)}
onMouseLeave={() => setHoveredStageId(null)}
className={cn(onStageSelect && 'cursor-pointer')}
filter={isSelected ? 'url(#selectedGlow)' : undefined}
>
{/* Selection ring */}
{isSelected && (
<motion.rect
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
x={pos.x - 3}
y={pos.y - 3}
width={nodeW + 6}
height={nodeH + 6}
rx={10}
fill="none"
stroke={colors.glow}
strokeWidth={2}
strokeDasharray="none"
/>
)}
{/* Node background */}
<rect
x={pos.x}
y={pos.y}
width={nodeW}
height={nodeH}
rx={8}
fill={colors.bg}
stroke={isSelected ? colors.glow : colors.border}
strokeWidth={isSelected ? 2 : 1}
style={{
transition: 'stroke 0.15s, stroke-width 0.15s',
transform: isHovered && !isSelected ? 'scale(1.02)' : undefined,
transformOrigin: `${pos.x + nodeW / 2}px ${pos.y + nodeH / 2}px`,
}}
/>
{/* Stage name */}
<text
x={pos.x + nodeW / 2}
y={pos.y + (compact ? 20 : 24)}
textAnchor="middle"
fill={colors.text}
className={cn(compact ? 'text-[10px]' : 'text-xs', 'font-medium')}
style={{ fontFamily: 'inherit' }}
>
{stage.name.length > (compact ? 12 : 16)
? stage.name.slice(0, compact ? 10 : 14) + '...'
: stage.name}
</text>
{/* Type badge */}
<text
x={pos.x + nodeW / 2}
y={pos.y + (compact ? 34 : 40)}
textAnchor="middle"
fill={colors.text}
className="text-[9px]"
style={{ fontFamily: 'inherit', opacity: 0.7 }}
>
{stage.stageType.replace('_', ' ')}
</text>
{/* Project count */}
{!compact && projectCount > 0 && (
<>
<rect
x={pos.x + nodeW / 2 - 14}
y={pos.y + nodeH - 18}
width={28}
height={14}
rx={7}
fill={colors.border}
opacity={0.3}
/>
<text
x={pos.x + nodeW / 2}
y={pos.y + nodeH - 8}
textAnchor="middle"
fill={colors.text}
className="text-[9px] font-medium"
style={{ fontFamily: 'inherit' }}
>
{projectCount}
</text>
</>
)}
</g>
)
})}
</g>
)
})}
</svg>
</div>
{/* Scroll hint gradient for mobile */}
{totalWidth > 400 && (
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-card to-transparent pointer-events-none sm:hidden" />
)}
</div>
)
}

View File

@@ -1,121 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { ArrowRight } from 'lucide-react'
type StageNode = {
id?: string
name: string
stageType: string
sortOrder: number
_count?: { projectStageStates: number }
}
type TrackLane = {
id?: string
name: string
kind: string
sortOrder: number
stages: StageNode[]
}
type PipelineVisualizationProps = {
tracks: TrackLane[]
className?: string
}
const stageColors: Record<string, string> = {
INTAKE: 'bg-blue-50 border-blue-300 text-blue-700',
FILTER: 'bg-amber-50 border-amber-300 text-amber-700',
EVALUATION: 'bg-purple-50 border-purple-300 text-purple-700',
SELECTION: 'bg-rose-50 border-rose-300 text-rose-700',
LIVE_FINAL: 'bg-emerald-50 border-emerald-300 text-emerald-700',
RESULTS: 'bg-cyan-50 border-cyan-300 text-cyan-700',
}
const trackKindBadge: Record<string, string> = {
MAIN: 'bg-blue-100 text-blue-700',
AWARD: 'bg-amber-100 text-amber-700',
SHOWCASE: 'bg-purple-100 text-purple-700',
}
export function PipelineVisualization({
tracks,
className,
}: PipelineVisualizationProps) {
const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder)
return (
<div className={cn('space-y-4', className)}>
{sortedTracks.map((track) => {
const sortedStages = [...track.stages].sort(
(a, b) => a.sortOrder - b.sortOrder
)
return (
<Card key={track.id ?? track.name} className="p-4">
{/* Track header */}
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-semibold">{track.name}</span>
<Badge
variant="secondary"
className={cn(
'text-[10px] h-5',
trackKindBadge[track.kind] ?? ''
)}
>
{track.kind}
</Badge>
</div>
{/* Stage flow */}
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{sortedStages.map((stage, index) => (
<div key={stage.id ?? index} className="flex items-center gap-1 shrink-0">
<div
className={cn(
'flex flex-col items-center rounded-lg border px-3 py-2 min-w-[100px]',
stageColors[stage.stageType] ?? 'bg-gray-50 border-gray-300'
)}
>
<span className="text-xs font-medium text-center leading-tight">
{stage.name}
</span>
<span className="text-[10px] opacity-70 mt-0.5">
{stage.stageType.replace('_', ' ')}
</span>
{stage._count?.projectStageStates !== undefined &&
stage._count.projectStageStates > 0 && (
<Badge
variant="secondary"
className="text-[9px] h-4 px-1 mt-1"
>
{stage._count.projectStageStates}
</Badge>
)}
</div>
{index < sortedStages.length - 1 && (
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
</div>
))}
{sortedStages.length === 0 && (
<span className="text-xs text-muted-foreground italic">
No stages
</span>
)}
</div>
</Card>
)
})}
{tracks.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No tracks to visualize
</p>
)}
</div>
)
}

View File

@@ -1,450 +0,0 @@
'use client'
import { useState, useCallback } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { Plus, X, Sparkles, AlertCircle } from 'lucide-react'
import { toast } from 'sonner'
// ─── Field & Operator Definitions ────────────────────────────────────────────
const FIELD_OPTIONS = [
{ value: 'competitionCategory', label: 'Competition Category', tooltip: 'Values: STARTUP, BUSINESS_CONCEPT' },
{ value: 'oceanIssue', label: 'Ocean Issue', tooltip: 'The ocean issue the project addresses' },
{ value: 'country', label: 'Country', tooltip: 'Country of origin' },
{ value: 'geographicZone', label: 'Geographic Zone', tooltip: 'Geographic zone of the project' },
{ value: 'wantsMentorship', label: 'Wants Mentorship', tooltip: 'Boolean: true or false' },
{ value: 'tags', label: 'Tags', tooltip: 'Project tags (comma-separated for "in" operator)' },
] as const
const OPERATOR_OPTIONS = [
{ value: 'eq', label: 'equals' },
{ value: 'neq', label: 'does not equal' },
{ value: 'in', label: 'is one of' },
{ value: 'contains', label: 'contains' },
{ value: 'gt', label: 'greater than' },
{ value: 'lt', label: 'less than' },
] as const
// ─── Types ───────────────────────────────────────────────────────────────────
type SimpleCondition = {
field: string
operator: string
value: unknown
}
type CompoundPredicate = {
logic: 'and' | 'or'
conditions: SimpleCondition[]
}
type PredicateBuilderProps = {
value: Record<string, unknown>
onChange: (predicate: Record<string, unknown>) => void
pipelineId?: string
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function isSimpleCondition(obj: Record<string, unknown>): obj is SimpleCondition {
return typeof obj.field === 'string' && typeof obj.operator === 'string' && 'value' in obj
}
function isCompoundPredicate(obj: Record<string, unknown>): obj is CompoundPredicate {
return 'logic' in obj && Array.isArray((obj as CompoundPredicate).conditions)
}
function detectInitialMode(value: Record<string, unknown>): 'simple' | 'ai' | 'advanced' {
if (isCompoundPredicate(value)) return 'simple'
if (isSimpleCondition(value)) return 'simple'
// Empty object or unknown shape
if (Object.keys(value).length === 0) return 'simple'
return 'advanced'
}
function valueToConditions(value: Record<string, unknown>): SimpleCondition[] {
if (isCompoundPredicate(value)) {
return value.conditions.map((c) => ({
field: c.field || 'competitionCategory',
operator: c.operator || 'eq',
value: c.value ?? '',
}))
}
if (isSimpleCondition(value)) {
return [{ field: value.field, operator: value.operator, value: value.value }]
}
return [{ field: 'competitionCategory', operator: 'eq', value: '' }]
}
function valueToLogic(value: Record<string, unknown>): 'and' | 'or' {
if (isCompoundPredicate(value)) return value.logic
return 'and'
}
function conditionsToPredicate(
conditions: SimpleCondition[],
logic: 'and' | 'or'
): Record<string, unknown> {
if (conditions.length === 1) {
return conditions[0] as unknown as Record<string, unknown>
}
return { logic, conditions }
}
function displayValue(val: unknown): string {
if (Array.isArray(val)) return val.join(', ')
if (typeof val === 'boolean') return val ? 'true' : 'false'
return String(val ?? '')
}
function parseInputValue(text: string, field: string): unknown {
if (field === 'wantsMentorship') {
return text.toLowerCase() === 'true'
}
if (text.includes(',')) {
return text.split(',').map((s) => s.trim()).filter(Boolean)
}
return text
}
// ─── Simple Mode ─────────────────────────────────────────────────────────────
function SimpleMode({
value,
onChange,
}: {
value: Record<string, unknown>
onChange: (predicate: Record<string, unknown>) => void
}) {
const [conditions, setConditions] = useState<SimpleCondition[]>(() => valueToConditions(value))
const [logic, setLogic] = useState<'and' | 'or'>(() => valueToLogic(value))
const emitChange = useCallback(
(nextConditions: SimpleCondition[], nextLogic: 'and' | 'or') => {
onChange(conditionsToPredicate(nextConditions, nextLogic))
},
[onChange]
)
const updateCondition = (index: number, field: keyof SimpleCondition, val: unknown) => {
const next = conditions.map((c, i) => (i === index ? { ...c, [field]: val } : c))
setConditions(next)
emitChange(next, logic)
}
const addCondition = () => {
const next = [...conditions, { field: 'competitionCategory', operator: 'eq', value: '' }]
setConditions(next)
emitChange(next, logic)
}
const removeCondition = (index: number) => {
if (conditions.length <= 1) return
const next = conditions.filter((_, i) => i !== index)
setConditions(next)
emitChange(next, logic)
}
const toggleLogic = () => {
const nextLogic = logic === 'and' ? 'or' : 'and'
setLogic(nextLogic)
emitChange(conditions, nextLogic)
}
return (
<TooltipProvider delayDuration={300}>
<div className="space-y-2">
{conditions.map((condition, index) => (
<div key={index}>
{index > 0 && (
<div className="flex items-center gap-2 py-1">
<div className="h-px flex-1 bg-border" />
<Button
type="button"
variant="outline"
size="sm"
className="h-5 px-2 text-[10px] font-medium"
onClick={toggleLogic}
>
{logic.toUpperCase()}
</Button>
<div className="h-px flex-1 bg-border" />
</div>
)}
<div className="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<div className="w-[160px] shrink-0">
<Select
value={condition.field}
onValueChange={(v) => updateCondition(index, 'field', v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</TooltipTrigger>
<TooltipContent side="top">
{FIELD_OPTIONS.find((f) => f.value === condition.field)?.tooltip || 'Select a field'}
</TooltipContent>
</Tooltip>
<div className="w-[130px] shrink-0">
<Select
value={condition.operator}
onValueChange={(v) => updateCondition(index, 'operator', v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Input
className="h-8 text-xs flex-1 min-w-[100px]"
value={displayValue(condition.value)}
onChange={(e) =>
updateCondition(index, 'value', parseInputValue(e.target.value, condition.field))
}
placeholder={condition.field === 'wantsMentorship' ? 'true / false' : 'e.g. STARTUP'}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeCondition(index)}
disabled={conditions.length <= 1}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={addCondition}
>
<Plus className="mr-1 h-3 w-3" />
Add condition
</Button>
</div>
</TooltipProvider>
)
}
// ─── AI Mode ─────────────────────────────────────────────────────────────────
function AIMode({
value,
onChange,
pipelineId,
onSwitchToSimple,
}: {
value: Record<string, unknown>
onChange: (predicate: Record<string, unknown>) => void
pipelineId?: string
onSwitchToSimple: () => void
}) {
const [text, setText] = useState('')
const [result, setResult] = useState<{
predicateJson: Record<string, unknown>
explanation: string
} | null>(null)
const handleGenerate = () => {
toast.error('AI rule parsing is not currently available')
}
const handleApply = () => {
if (result) {
onChange(result.predicateJson)
toast.success('Rule applied')
}
}
return (
<div className="space-y-3">
<div className="space-y-2">
<Textarea
className="text-xs min-h-16"
placeholder='Describe your rule in plain English, e.g. "Route startup projects from France to the Fast Track"'
value={text}
onChange={(e) => setText(e.target.value)}
/>
<Button
type="button"
size="sm"
onClick={handleGenerate}
disabled={!text.trim() || !pipelineId}
>
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
Generate Rule
</Button>
</div>
{!pipelineId && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<AlertCircle className="h-3.5 w-3.5" />
Save the pipeline first to enable AI rule generation.
</div>
)}
{result && (
<div className="rounded-md border bg-muted/50 p-3 space-y-2">
<div className="text-xs font-medium">Generated Rule</div>
<p className="text-xs text-muted-foreground">{result.explanation}</p>
<pre className="text-[10px] font-mono bg-background rounded p-2 overflow-x-auto">
{JSON.stringify(result.predicateJson, null, 2)}
</pre>
<div className="flex items-center gap-2">
<Button type="button" size="sm" className="h-7 text-xs" onClick={handleApply}>
Apply Rule
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => {
onChange(result.predicateJson)
onSwitchToSimple()
}}
>
Edit in Simple mode
</Button>
</div>
</div>
)}
{Object.keys(value).length > 0 && !result && (
<div className="text-[10px] text-muted-foreground">
Current predicate: <code className="font-mono">{JSON.stringify(value)}</code>
</div>
)}
</div>
)
}
// ─── Advanced Mode ───────────────────────────────────────────────────────────
function AdvancedMode({
value,
onChange,
}: {
value: Record<string, unknown>
onChange: (predicate: Record<string, unknown>) => void
}) {
const [jsonText, setJsonText] = useState(() => JSON.stringify(value, null, 2))
const [error, setError] = useState<string | null>(null)
const handleChange = (text: string) => {
setJsonText(text)
try {
const parsed = JSON.parse(text) as Record<string, unknown>
setError(null)
onChange(parsed)
} catch (e) {
setError(e instanceof Error ? e.message : 'Invalid JSON')
}
}
return (
<div className="space-y-2">
<Textarea
className="font-mono text-xs min-h-28"
value={jsonText}
onChange={(e) => handleChange(e.target.value)}
placeholder='{ "field": "competitionCategory", "operator": "eq", "value": "STARTUP" }'
/>
{error && (
<div className="flex items-center gap-1.5 text-xs text-destructive">
<AlertCircle className="h-3 w-3" />
{error}
</div>
)}
<p className="text-[10px] text-muted-foreground">
Use <code className="font-mono">{'{ field, operator, value }'}</code> for simple conditions
or <code className="font-mono">{'{ logic: "and"|"or", conditions: [...] }'}</code> for compound rules.
</p>
</div>
)
}
// ─── Main Component ──────────────────────────────────────────────────────────
export function PredicateBuilder({ value, onChange, pipelineId }: PredicateBuilderProps) {
const [activeTab, setActiveTab] = useState<string>(() => detectInitialMode(value))
return (
<div className="space-y-2">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="h-8">
<TabsTrigger value="simple" className="text-xs px-3 h-6">
Simple
</TabsTrigger>
<TabsTrigger value="ai" className="text-xs px-3 h-6">
<Sparkles className="mr-1 h-3 w-3" />
AI
</TabsTrigger>
<TabsTrigger value="advanced" className="text-xs px-3 h-6">
Advanced
</TabsTrigger>
</TabsList>
<TabsContent value="simple">
<SimpleMode value={value} onChange={onChange} />
</TabsContent>
<TabsContent value="ai">
<AIMode
value={value}
onChange={onChange}
pipelineId={pipelineId}
onSwitchToSimple={() => setActiveTab('simple')}
/>
</TabsContent>
<TabsContent value="advanced">
<AdvancedMode value={value} onChange={onChange} />
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -1,243 +0,0 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { EvaluationConfig } from '@/types/pipeline-wizard'
const ASSIGNMENT_CATEGORIES = [
{ key: 'STARTUP', label: 'Startups' },
{ key: 'BUSINESS_CONCEPT', label: 'Business Concepts' },
] as const
type AssignmentSectionProps = {
config: EvaluationConfig
onChange: (config: EvaluationConfig) => void
isActive?: boolean
}
export function AssignmentSection({ config, onChange, isActive }: AssignmentSectionProps) {
const updateConfig = (updates: Partial<EvaluationConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Required Reviews per Project</Label>
<InfoTooltip content="Number of independent jury evaluations needed per project before it can be decided." />
</div>
<Input
type="number"
min={1}
max={20}
value={config.requiredReviews ?? 3}
disabled={isActive}
onChange={(e) =>
updateConfig({ requiredReviews: parseInt(e.target.value) || 3 })
}
/>
<p className="text-xs text-muted-foreground">
Minimum number of jury evaluations per project
</p>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Max Load per Juror</Label>
<InfoTooltip content="Maximum number of projects a single juror can be assigned in this stage." />
</div>
<Input
type="number"
min={1}
max={100}
value={config.maxLoadPerJuror ?? 20}
disabled={isActive}
onChange={(e) =>
updateConfig({ maxLoadPerJuror: parseInt(e.target.value) || 20 })
}
/>
<p className="text-xs text-muted-foreground">
Maximum projects assigned to one juror
</p>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Min Load per Juror</Label>
<InfoTooltip content="Minimum target assignments per juror. The system prioritizes jurors below this threshold." />
</div>
<Input
type="number"
min={0}
max={50}
value={config.minLoadPerJuror ?? 5}
disabled={isActive}
onChange={(e) =>
updateConfig({ minLoadPerJuror: parseInt(e.target.value) || 5 })
}
/>
<p className="text-xs text-muted-foreground">
Target minimum projects per juror
</p>
</div>
</div>
{(config.minLoadPerJuror ?? 0) > (config.maxLoadPerJuror ?? 20) && (
<p className="text-sm text-destructive">
Min load per juror cannot exceed max load per juror.
</p>
)}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Availability Weighting</Label>
<InfoTooltip content="When enabled, jurors who are available during the voting window are prioritized in assignment." />
</div>
<p className="text-xs text-muted-foreground">
Factor in juror availability when assigning projects
</p>
</div>
<Switch
checked={config.availabilityWeighting ?? true}
onCheckedChange={(checked) =>
updateConfig({ availabilityWeighting: checked })
}
disabled={isActive}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Overflow Policy</Label>
<InfoTooltip content="'Queue' holds excess projects, 'Expand Pool' invites more jurors, 'Reduce Reviews' lowers the required review count." />
</div>
<Select
value={config.overflowPolicy ?? 'queue'}
onValueChange={(value) =>
updateConfig({
overflowPolicy: value as EvaluationConfig['overflowPolicy'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="queue">
Queue Hold unassigned projects for manual assignment
</SelectItem>
<SelectItem value="expand_pool">
Expand Pool Invite additional jurors automatically
</SelectItem>
<SelectItem value="reduce_reviews">
Reduce Reviews Lower required reviews to fit available jurors
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Category Balance */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Balance assignments by category</Label>
<InfoTooltip content="Ensure each juror receives a balanced mix of project categories within their assignment limits." />
</div>
<p className="text-xs text-muted-foreground">
Set per-category min/max assignment targets per juror
</p>
</div>
<Switch
checked={config.categoryQuotasEnabled ?? false}
onCheckedChange={(checked) =>
updateConfig({
categoryQuotasEnabled: checked,
categoryQuotas: checked
? config.categoryQuotas ?? {
STARTUP: { min: 0, max: 10 },
BUSINESS_CONCEPT: { min: 0, max: 10 },
}
: config.categoryQuotas,
})
}
disabled={isActive}
/>
</div>
{config.categoryQuotasEnabled && (
<div className="space-y-4 rounded-md border p-4">
{ASSIGNMENT_CATEGORIES.map((cat) => {
const catQuota = (config.categoryQuotas ?? {})[cat.key] ?? { min: 0, max: 10 }
return (
<div key={cat.key} className="space-y-2">
<Label className="text-sm font-medium">{cat.label}</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Min per juror</Label>
<Input
type="number"
min={0}
max={50}
value={catQuota.min}
disabled={isActive}
onChange={(e) =>
updateConfig({
categoryQuotas: {
...config.categoryQuotas,
[cat.key]: {
...catQuota,
min: parseInt(e.target.value, 10) || 0,
},
},
})
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Max per juror</Label>
<Input
type="number"
min={0}
max={100}
value={catQuota.max}
disabled={isActive}
onChange={(e) =>
updateConfig({
categoryQuotas: {
...config.categoryQuotas,
[cat.key]: {
...catQuota,
max: parseInt(e.target.value, 10) || 0,
},
},
})
}
/>
</div>
</div>
{catQuota.min > catQuota.max && (
<p className="text-xs text-destructive">
Min cannot exceed max for {cat.label.toLowerCase()}.
</p>
)}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -1,254 +0,0 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Plus, Trash2, Trophy } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { defaultAwardTrack } from '@/lib/pipeline-defaults'
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client'
type AwardsSectionProps = {
tracks: WizardTrackConfig[]
onChange: (tracks: WizardTrackConfig[]) => void
isActive?: boolean
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps) {
const awardTracks = tracks.filter((t) => t.kind === 'AWARD')
const nonAwardTracks = tracks.filter((t) => t.kind !== 'AWARD')
const addAward = () => {
const newTrack = defaultAwardTrack(awardTracks.length)
newTrack.sortOrder = tracks.length
onChange([...tracks, newTrack])
}
const updateAward = (index: number, updates: Partial<WizardTrackConfig>) => {
const updated = [...tracks]
const awardIndex = tracks.findIndex(
(t) => t.kind === 'AWARD' && awardTracks.indexOf(t) === index
)
if (awardIndex >= 0) {
updated[awardIndex] = { ...updated[awardIndex], ...updates }
onChange(updated)
}
}
const removeAward = (index: number) => {
const toRemove = awardTracks[index]
onChange(tracks.filter((t) => t !== toRemove))
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Configure special award tracks that run alongside the main competition.
</p>
<Button type="button" variant="outline" size="sm" onClick={addAward} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Award Track
</Button>
</div>
{awardTracks.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
<Trophy className="h-8 w-8 mx-auto mb-2 text-muted-foreground/50" />
No award tracks configured. Awards are optional.
</div>
)}
{awardTracks.map((track, index) => (
<Card key={index}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
Award Track {index + 1}
</CardTitle>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={isActive}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Award Track?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the &quot;{track.name}&quot; award track and all
its stages. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => removeAward(index)}>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs">Award Name</Label>
<Input
placeholder="e.g., Innovation Award"
value={track.awardConfig?.name ?? track.name}
disabled={isActive}
onChange={(e) => {
const name = e.target.value
updateAward(index, {
name,
slug: slugify(name),
awardConfig: {
...track.awardConfig,
name,
},
})
}}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Routing Mode</Label>
<InfoTooltip content="Shared: projects compete in the main track and this award simultaneously. Exclusive: projects are routed exclusively to this track." />
</div>
<Select
value={track.routingModeDefault ?? 'SHARED'}
onValueChange={(value) =>
updateAward(index, {
routingModeDefault: value as RoutingMode,
})
}
disabled={isActive}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SHARED">
Shared Projects compete in main + this award
</SelectItem>
<SelectItem value="EXCLUSIVE">
Exclusive Projects enter only this track
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Decision Mode</Label>
<InfoTooltip content="How the winner is determined for this award track." />
</div>
<Select
value={track.decisionMode ?? 'JURY_VOTE'}
onValueChange={(value) =>
updateAward(index, { decisionMode: value as DecisionMode })
}
disabled={isActive}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="JURY_VOTE">Jury Vote</SelectItem>
<SelectItem value="AWARD_MASTER_DECISION">
Award Master Decision
</SelectItem>
<SelectItem value="ADMIN_DECISION">Admin Decision</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Scoring Mode</Label>
<InfoTooltip content="The method used to aggregate scores for this award." />
</div>
<Select
value={track.awardConfig?.scoringMode ?? 'PICK_WINNER'}
onValueChange={(value) =>
updateAward(index, {
awardConfig: {
...track.awardConfig!,
scoringMode: value as AwardScoringMode,
},
})
}
disabled={isActive}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
<SelectItem value="RANKED">Ranked</SelectItem>
<SelectItem value="SCORED">Scored</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">Description (optional)</Label>
<Textarea
placeholder="Brief description of this award..."
value={track.awardConfig?.description ?? ''}
rows={2}
className="text-sm"
onChange={(e) =>
updateAward(index, {
awardConfig: {
...track.awardConfig!,
description: e.target.value,
},
})
}
/>
</div>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -1,99 +0,0 @@
'use client'
import { useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { trpc } from '@/lib/trpc/client'
import type { WizardState } from '@/types/pipeline-wizard'
type BasicsSectionProps = {
state: WizardState
onChange: (updates: Partial<WizardState>) => void
isActive?: boolean
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function BasicsSection({ state, onChange, isActive }: BasicsSectionProps) {
const { data: programs, isLoading } = trpc.program.list.useQuery({})
// Auto-generate slug from name
useEffect(() => {
if (state.name && !state.slug) {
onChange({ slug: slugify(state.name) })
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="pipeline-name">Pipeline Name</Label>
<Input
id="pipeline-name"
placeholder="e.g., MOPC 2026"
value={state.name}
onChange={(e) => {
const name = e.target.value
onChange({ name, slug: slugify(name) })
}}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="pipeline-slug">Slug</Label>
<InfoTooltip content="URL-friendly identifier. Cannot be changed after the pipeline is activated." />
</div>
<Input
id="pipeline-slug"
placeholder="e.g., mopc-2026"
value={state.slug}
onChange={(e) => onChange({ slug: e.target.value })}
pattern="^[a-z0-9-]+$"
disabled={isActive}
/>
<p className="text-xs text-muted-foreground">
{isActive
? 'Slug cannot be changed on active pipelines'
: 'Lowercase letters, numbers, and hyphens only'}
</p>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="pipeline-program">Program</Label>
<InfoTooltip content="The program edition this pipeline belongs to. Each program can have multiple pipelines." />
</div>
<Select
value={state.programId}
onValueChange={(value) => onChange({ programId: value })}
>
<SelectTrigger id="pipeline-program">
<SelectValue placeholder={isLoading ? 'Loading...' : 'Select a program'} />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} ({p.year})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -1,479 +0,0 @@
'use client'
import { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from '@/components/ui/collapsible'
import { Plus, Trash2, ChevronDown, Info, Brain, Shield } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { FilterConfig, FilterRuleConfig } from '@/types/pipeline-wizard'
// ─── Known Fields for Eligibility Rules ──────────────────────────────────────
type KnownField = {
value: string
label: string
operators: string[]
valueType: 'select' | 'text' | 'number' | 'boolean'
placeholder?: string
}
const KNOWN_FIELDS: KnownField[] = [
{ value: 'competitionCategory', label: 'Category', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. STARTUP' },
{ value: 'oceanIssue', label: 'Ocean Issue', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. Pollution' },
{ value: 'country', label: 'Country', operators: ['is', 'is_not', 'is_one_of'], valueType: 'text', placeholder: 'e.g. France' },
{ value: 'geographicZone', label: 'Region', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. Mediterranean' },
{ value: 'foundedAt', label: 'Founded Year', operators: ['after', 'before'], valueType: 'number', placeholder: 'e.g. 2020' },
{ value: 'description', label: 'Has Description', operators: ['exists', 'min_length'], valueType: 'number', placeholder: 'Min chars' },
{ value: 'files', label: 'File Count', operators: ['greaterThan', 'lessThan'], valueType: 'number', placeholder: 'e.g. 1' },
{ value: 'wantsMentorship', label: 'Wants Mentorship', operators: ['equals'], valueType: 'boolean' },
]
const OPERATOR_LABELS: Record<string, string> = {
is: 'is',
is_not: 'is not',
is_one_of: 'is one of',
after: 'after',
before: 'before',
exists: 'exists',
min_length: 'min length',
greaterThan: 'greater than',
lessThan: 'less than',
equals: 'equals',
}
// ─── Human-readable preview for a rule ───────────────────────────────────────
function getRulePreview(rule: FilterRuleConfig): string {
const field = KNOWN_FIELDS.find((f) => f.value === rule.field)
const fieldLabel = field?.label ?? rule.field
const opLabel = OPERATOR_LABELS[rule.operator] ?? rule.operator
if (rule.operator === 'exists') {
return `Projects where ${fieldLabel} exists will pass`
}
const valueStr = typeof rule.value === 'boolean'
? (rule.value ? 'Yes' : 'No')
: String(rule.value)
return `Projects where ${fieldLabel} ${opLabel} ${valueStr} will pass`
}
// ─── AI Screening: Fields the AI Sees ────────────────────────────────────────
const AI_VISIBLE_FIELDS = [
'Project title',
'Description',
'Competition category',
'Ocean issue',
'Country & region',
'Tags',
'Founded year',
'Team size',
'File count',
]
// ─── Props ───────────────────────────────────────────────────────────────────
type FilteringSectionProps = {
config: FilterConfig
onChange: (config: FilterConfig) => void
isActive?: boolean
}
// ─── Component ───────────────────────────────────────────────────────────────
export function FilteringSection({ config, onChange, isActive }: FilteringSectionProps) {
const [rulesOpen, setRulesOpen] = useState(false)
const [aiFieldsOpen, setAiFieldsOpen] = useState(false)
const updateConfig = (updates: Partial<FilterConfig>) => {
onChange({ ...config, ...updates })
}
const rules = config.rules ?? []
const aiCriteriaText = config.aiCriteriaText ?? ''
const thresholds = config.aiConfidenceThresholds ?? { high: 0.85, medium: 0.6, low: 0.4 }
const updateRule = (index: number, updates: Partial<FilterRuleConfig>) => {
const updated = [...rules]
updated[index] = { ...updated[index], ...updates }
onChange({ ...config, rules: updated })
}
const addRule = () => {
onChange({
...config,
rules: [
...rules,
{ field: '', operator: 'is', value: '', weight: 1 },
],
})
}
const removeRule = (index: number) => {
onChange({ ...config, rules: rules.filter((_, i) => i !== index) })
}
const getFieldConfig = (fieldValue: string): KnownField | undefined => {
return KNOWN_FIELDS.find((f) => f.value === fieldValue)
}
const highPct = Math.round(thresholds.high * 100)
const medPct = Math.round(thresholds.medium * 100)
return (
<div className="space-y-6">
{/* ── AI Screening (Primary) ────────────────────────────────────── */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Brain className="h-4 w-4 text-primary" />
<Label>AI Screening</Label>
<InfoTooltip content="Uses AI to evaluate projects against your criteria in natural language. Results are suggestions, not final decisions." />
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Use AI to evaluate projects against your screening criteria
</p>
</div>
<Switch
checked={config.aiRubricEnabled}
onCheckedChange={(checked) => updateConfig({ aiRubricEnabled: checked })}
disabled={isActive}
/>
</div>
{config.aiRubricEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
{/* Criteria Textarea (THE KEY MISSING PIECE) */}
<div className="space-y-2">
<Label className="text-sm font-medium">Screening Criteria</Label>
<p className="text-xs text-muted-foreground">
Describe what makes a project eligible or ineligible in natural language.
The AI will evaluate each project against these criteria.
</p>
<Textarea
value={aiCriteriaText}
onChange={(e) => updateConfig({ aiCriteriaText: e.target.value })}
placeholder="e.g., Projects must demonstrate a clear ocean conservation impact. Reject projects that are purely commercial with no environmental benefit. Flag projects with vague descriptions for manual review."
rows={5}
className="resize-y"
disabled={isActive}
/>
{aiCriteriaText.length > 0 && (
<p className="text-xs text-muted-foreground text-right">
{aiCriteriaText.length} characters
</p>
)}
</div>
{/* "What the AI sees" Info Card */}
<Collapsible open={aiFieldsOpen} onOpenChange={setAiFieldsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
>
<Info className="h-3.5 w-3.5" />
<span>What the AI sees</span>
<ChevronDown className={`h-3.5 w-3.5 ml-auto transition-transform ${aiFieldsOpen ? 'rotate-180' : ''}`} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<Card className="mt-2 bg-muted/50 border-muted">
<CardContent className="pt-3 pb-3 px-4">
<p className="text-xs text-muted-foreground mb-2">
All data is anonymized before being sent to the AI. Only these fields are included:
</p>
<ul className="grid grid-cols-2 sm:grid-cols-3 gap-1">
{AI_VISIBLE_FIELDS.map((field) => (
<li key={field} className="text-xs text-muted-foreground flex items-center gap-1">
<span className="h-1 w-1 rounded-full bg-muted-foreground/50 shrink-0" />
{field}
</li>
))}
</ul>
<p className="text-xs text-muted-foreground/70 mt-2 italic">
No personal identifiers (names, emails, etc.) are sent to the AI.
</p>
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
{/* Confidence Thresholds */}
<div className="space-y-3">
<Label className="text-sm font-medium">Confidence Thresholds</Label>
<p className="text-xs text-muted-foreground">
Control how the AI's confidence score maps to outcomes.
</p>
{/* Visual range preview */}
<div className="flex items-center gap-1 text-[10px] font-medium">
<div className="flex-1 bg-emerald-100 dark:bg-emerald-950 border border-emerald-300 dark:border-emerald-800 rounded-l px-2 py-1 text-center text-emerald-700 dark:text-emerald-400">
Auto-approve above {highPct}%
</div>
<div className="flex-1 bg-amber-100 dark:bg-amber-950 border border-amber-300 dark:border-amber-800 px-2 py-1 text-center text-amber-700 dark:text-amber-400">
Review {medPct}%{'\u2013'}{highPct}%
</div>
<div className="flex-1 bg-red-100 dark:bg-red-950 border border-red-300 dark:border-red-800 rounded-r px-2 py-1 text-center text-red-700 dark:text-red-400">
Auto-reject below {medPct}%
</div>
</div>
{/* High threshold slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-emerald-500 shrink-0" />
<Label className="text-xs">Auto-approve threshold</Label>
</div>
<span className="text-xs font-mono font-medium">{highPct}%</span>
</div>
<Slider
value={[highPct]}
onValueChange={([v]) =>
updateConfig({
aiConfidenceThresholds: {
...thresholds,
high: v / 100,
},
})
}
min={50}
max={100}
step={5}
disabled={isActive}
/>
</div>
{/* Medium threshold slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-amber-500 shrink-0" />
<Label className="text-xs">Manual review threshold</Label>
</div>
<span className="text-xs font-mono font-medium">{medPct}%</span>
</div>
<Slider
value={[medPct]}
onValueChange={([v]) =>
updateConfig({
aiConfidenceThresholds: {
...thresholds,
medium: v / 100,
},
})
}
min={20}
max={80}
step={5}
disabled={isActive}
/>
</div>
</div>
</div>
)}
</div>
{/* ── Manual Review Queue ────────────────────────────────────────── */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Manual Review Queue</Label>
<InfoTooltip content="When enabled, projects that don't meet auto-processing thresholds are queued for admin review instead of being auto-rejected." />
</div>
<p className="text-xs text-muted-foreground">
Projects below medium confidence go to manual review
</p>
</div>
<Switch
checked={config.manualQueueEnabled}
onCheckedChange={(checked) => updateConfig({ manualQueueEnabled: checked })}
disabled={isActive}
/>
</div>
{/* ── Eligibility Rules (Secondary, Collapsible) ─────────────────── */}
<Collapsible open={rulesOpen} onOpenChange={setRulesOpen}>
<div className="flex items-center justify-between">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<Shield className="h-4 w-4 text-muted-foreground" />
<Label className="cursor-pointer">Eligibility Rules</Label>
<span className="text-xs text-muted-foreground">
({rules.length} rule{rules.length !== 1 ? 's' : ''})
</span>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${rulesOpen ? 'rotate-180' : ''}`} />
</button>
</CollapsibleTrigger>
{rulesOpen && (
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
Deterministic rules that projects must pass. Applied before AI screening.
</p>
<CollapsibleContent>
<div className="space-y-3 mt-3">
{rules.map((rule, index) => {
const fieldConfig = getFieldConfig(rule.field)
const availableOperators = fieldConfig?.operators ?? Object.keys(OPERATOR_LABELS)
return (
<Card key={index}>
<CardContent className="pt-3 pb-3 px-4 space-y-2">
{/* Rule inputs */}
<div className="flex items-start gap-2">
<div className="flex-1 grid gap-2 sm:grid-cols-3">
{/* Field dropdown */}
<Select
value={rule.field}
onValueChange={(value) => {
const newFieldConfig = getFieldConfig(value)
const firstOp = newFieldConfig?.operators[0] ?? 'is'
updateRule(index, {
field: value,
operator: firstOp,
value: newFieldConfig?.valueType === 'boolean' ? true : '',
})
}}
disabled={isActive}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select field..." />
</SelectTrigger>
<SelectContent>
{KNOWN_FIELDS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Operator dropdown (filtered by field) */}
<Select
value={rule.operator}
onValueChange={(value) => updateRule(index, { operator: value })}
disabled={isActive || !rule.field}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableOperators.map((op) => (
<SelectItem key={op} value={op}>
{OPERATOR_LABELS[op] ?? op}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value input (adapted by field type) */}
{rule.operator === 'exists' ? (
<div className="h-8 flex items-center text-xs text-muted-foreground italic">
(no value needed)
</div>
) : fieldConfig?.valueType === 'boolean' ? (
<Select
value={String(rule.value)}
onValueChange={(v) => updateRule(index, { value: v === 'true' })}
disabled={isActive}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Yes</SelectItem>
<SelectItem value="false">No</SelectItem>
</SelectContent>
</Select>
) : fieldConfig?.valueType === 'number' ? (
<Input
type="number"
placeholder={fieldConfig.placeholder ?? 'Value'}
value={String(rule.value)}
className="h-8 text-sm"
disabled={isActive}
onChange={(e) => updateRule(index, { value: e.target.value ? Number(e.target.value) : '' })}
/>
) : (
<Input
placeholder={fieldConfig?.placeholder ?? 'Value'}
value={String(rule.value)}
className="h-8 text-sm"
disabled={isActive}
onChange={(e) => updateRule(index, { value: e.target.value })}
/>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive mt-0.5"
onClick={() => removeRule(index)}
disabled={isActive}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Human-readable preview */}
{rule.field && rule.operator && (
<p className="text-xs text-muted-foreground italic pl-1">
{getRulePreview(rule)}
</p>
)}
</CardContent>
</Card>
)
})}
{rules.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-3">
No eligibility rules configured. All projects will pass through to AI screening (if enabled).
</p>
)}
{!rulesOpen ? null : rules.length > 0 && (
<div className="flex justify-end">
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
)
}

View File

@@ -1,289 +0,0 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Plus, Trash2, FileText } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { IntakeConfig, FileRequirementConfig } from '@/types/pipeline-wizard'
import {
FILE_TYPE_CATEGORIES,
getActiveCategoriesFromMimeTypes,
categoriesToMimeTypes,
} from '@/lib/file-type-categories'
type FileTypePickerProps = {
value: string[]
onChange: (mimeTypes: string[]) => void
}
function FileTypePicker({ value, onChange }: FileTypePickerProps) {
const activeCategories = getActiveCategoriesFromMimeTypes(value)
const toggleCategory = (categoryId: string) => {
const isActive = activeCategories.includes(categoryId)
const newCategories = isActive
? activeCategories.filter((id) => id !== categoryId)
: [...activeCategories, categoryId]
onChange(categoriesToMimeTypes(newCategories))
}
return (
<div className="space-y-2">
<Label className="text-xs">Accepted Types</Label>
<div className="flex flex-wrap gap-1.5">
{FILE_TYPE_CATEGORIES.map((cat) => {
const isActive = activeCategories.includes(cat.id)
return (
<Button
key={cat.id}
type="button"
variant={isActive ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => toggleCategory(cat.id)}
>
{cat.label}
</Button>
)
})}
</div>
<div className="flex flex-wrap gap-1">
{activeCategories.length === 0 ? (
<Badge variant="secondary" className="text-[10px]">All types</Badge>
) : (
activeCategories.map((catId) => {
const cat = FILE_TYPE_CATEGORIES.find((c) => c.id === catId)
return cat ? (
<Badge key={catId} variant="secondary" className="text-[10px]">
{cat.label}
</Badge>
) : null
})
)}
</div>
</div>
)
}
type IntakeSectionProps = {
config: IntakeConfig
onChange: (config: IntakeConfig) => void
isActive?: boolean
}
export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps) {
const updateConfig = (updates: Partial<IntakeConfig>) => {
onChange({ ...config, ...updates })
}
const fileRequirements = config.fileRequirements ?? []
const updateFileReq = (index: number, updates: Partial<FileRequirementConfig>) => {
const updated = [...fileRequirements]
updated[index] = { ...updated[index], ...updates }
onChange({ ...config, fileRequirements: updated })
}
const addFileReq = () => {
onChange({
...config,
fileRequirements: [
...fileRequirements,
{
name: '',
description: '',
acceptedMimeTypes: ['application/pdf'],
maxSizeMB: 50,
isRequired: false,
},
],
})
}
const removeFileReq = (index: number) => {
const updated = fileRequirements.filter((_, i) => i !== index)
onChange({ ...config, fileRequirements: updated })
}
return (
<div className="space-y-6">
{isActive && (
<p className="text-sm text-amber-600 bg-amber-50 rounded-md px-3 py-2">
Some settings are locked because this pipeline is active.
</p>
)}
{/* Submission Window */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Submission Window</Label>
<InfoTooltip content="When enabled, projects can only be submitted within the configured date range." />
</div>
<p className="text-xs text-muted-foreground">
Enable timed submission windows for project intake
</p>
</div>
<Switch
checked={config.submissionWindowEnabled ?? true}
onCheckedChange={(checked) =>
updateConfig({ submissionWindowEnabled: checked })
}
disabled={isActive}
/>
</div>
</div>
{/* Late Policy */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Late Submission Policy</Label>
<InfoTooltip content="Controls how submissions after the deadline are handled. 'Reject' blocks them, 'Flag' accepts but marks as late, 'Accept' treats them normally." />
</div>
<Select
value={config.lateSubmissionPolicy ?? 'flag'}
onValueChange={(value) =>
updateConfig({
lateSubmissionPolicy: value as IntakeConfig['lateSubmissionPolicy'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reject">Reject late submissions</SelectItem>
<SelectItem value="flag">Accept but flag as late</SelectItem>
<SelectItem value="accept">Accept normally</SelectItem>
</SelectContent>
</Select>
</div>
{(config.lateSubmissionPolicy ?? 'flag') === 'flag' && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Grace Period (hours)</Label>
<InfoTooltip content="Extra time after the deadline during which late submissions are still accepted but flagged." />
</div>
<Input
type="number"
min={0}
max={168}
value={config.lateGraceHours ?? 24}
onChange={(e) =>
updateConfig({ lateGraceHours: parseInt(e.target.value) || 0 })
}
/>
</div>
)}
</div>
{/* File Requirements */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Label>File Requirements</Label>
<InfoTooltip content="Define what files applicants must upload. Each requirement can specify accepted formats and size limits." />
</div>
<Button type="button" variant="outline" size="sm" onClick={addFileReq} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Requirement
</Button>
</div>
{fileRequirements.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">
No file requirements configured. Projects can be submitted without files.
</p>
)}
{fileRequirements.map((req, index) => (
<Card key={index}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-start gap-3">
<FileText className="h-4 w-4 text-muted-foreground mt-2 shrink-0" />
<div className="flex-1 grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">File Name</Label>
<Input
placeholder="e.g., Executive Summary"
value={req.name}
onChange={(e) => updateFileReq(index, { name: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Max Size (MB)</Label>
<Input
type="number"
min={1}
max={500}
value={req.maxSizeMB ?? ''}
onChange={(e) =>
updateFileReq(index, {
maxSizeMB: parseInt(e.target.value) || undefined,
})
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Description</Label>
<Input
placeholder="Brief description of this requirement"
value={req.description ?? ''}
onChange={(e) =>
updateFileReq(index, { description: e.target.value })
}
/>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={req.isRequired}
onCheckedChange={(checked) =>
updateFileReq(index, { isRequired: checked })
}
/>
<Label className="text-xs">Required</Label>
</div>
</div>
<div className="sm:col-span-2">
<FileTypePicker
value={req.acceptedMimeTypes ?? []}
onChange={(mimeTypes) =>
updateFileReq(index, { acceptedMimeTypes: mimeTypes })
}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeFileReq(index)}
disabled={isActive}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,158 +0,0 @@
'use client'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
type LiveFinalsSectionProps = {
config: LiveFinalConfig
onChange: (config: LiveFinalConfig) => void
isActive?: boolean
}
export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSectionProps) {
const updateConfig = (updates: Partial<LiveFinalConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Jury Voting</Label>
<InfoTooltip content="Enable jury members to cast votes during the live ceremony." />
</div>
<p className="text-xs text-muted-foreground">
Allow jury members to vote during the live finals event
</p>
</div>
<Switch
checked={config.juryVotingEnabled ?? true}
onCheckedChange={(checked) =>
updateConfig({ juryVotingEnabled: checked })
}
disabled={isActive}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Audience Voting</Label>
<InfoTooltip content="Allow audience members to participate in voting alongside the jury." />
</div>
<p className="text-xs text-muted-foreground">
Allow audience members to vote on projects
</p>
</div>
<Switch
checked={config.audienceVotingEnabled ?? false}
onCheckedChange={(checked) =>
updateConfig({ audienceVotingEnabled: checked })
}
disabled={isActive}
/>
</div>
{(config.audienceVotingEnabled ?? false) && (
<div className="pl-4 border-l-2 border-muted space-y-3">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Audience Vote Weight</Label>
<InfoTooltip content="Percentage weight of audience votes vs jury votes in the final score (e.g., 30 means 30% audience, 70% jury)." />
</div>
<div className="flex items-center gap-3">
<Slider
value={[(config.audienceVoteWeight ?? 0) * 100]}
onValueChange={([v]) =>
updateConfig({ audienceVoteWeight: v / 100 })
}
min={0}
max={100}
step={5}
className="flex-1"
/>
<span className="text-xs font-mono w-10 text-right">
{Math.round((config.audienceVoteWeight ?? 0) * 100)}%
</span>
</div>
<p className="text-xs text-muted-foreground">
Percentage weight of audience votes in the final score
</p>
</div>
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Cohort Setup Mode</Label>
<InfoTooltip content="Auto: system assigns projects to presentation groups. Manual: admin defines cohorts." />
</div>
<Select
value={config.cohortSetupMode ?? 'manual'}
onValueChange={(value) =>
updateConfig({
cohortSetupMode: value as LiveFinalConfig['cohortSetupMode'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">
Manual Admin creates cohorts and assigns projects
</SelectItem>
<SelectItem value="auto">
Auto System creates cohorts from pipeline results
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Result Reveal Policy</Label>
<InfoTooltip content="Immediate: show results as votes come in. Delayed: reveal after all votes. Ceremony: reveal during a dedicated announcement." />
</div>
<Select
value={config.revealPolicy ?? 'ceremony'}
onValueChange={(value) =>
updateConfig({
revealPolicy: value as LiveFinalConfig['revealPolicy'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate">
Immediate Results shown after each vote
</SelectItem>
<SelectItem value="delayed">
Delayed Results hidden until admin reveals
</SelectItem>
<SelectItem value="ceremony">
Ceremony Results revealed in dramatic sequence
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -1,228 +0,0 @@
'use client'
import { useCallback } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Card, CardContent } from '@/components/ui/card'
import {
Plus,
Trash2,
GripVertical,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { WizardStageConfig } from '@/types/pipeline-wizard'
import type { StageType } from '@prisma/client'
type MainTrackSectionProps = {
stages: WizardStageConfig[]
onChange: (stages: WizardStageConfig[]) => void
isActive?: boolean
}
const STAGE_TYPE_OPTIONS: { value: StageType; label: string; color: string }[] = [
{ value: 'INTAKE', label: 'Intake', color: 'bg-blue-100 text-blue-700' },
{ value: 'FILTER', label: 'Filter', color: 'bg-amber-100 text-amber-700' },
{ value: 'EVALUATION', label: 'Evaluation', color: 'bg-purple-100 text-purple-700' },
{ value: 'SELECTION', label: 'Selection', color: 'bg-emerald-100 text-emerald-700' },
{ value: 'LIVE_FINAL', label: 'Live Final', color: 'bg-rose-100 text-rose-700' },
{ value: 'RESULTS', label: 'Results', color: 'bg-gray-100 text-gray-700' },
]
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function MainTrackSection({ stages, onChange, isActive }: MainTrackSectionProps) {
const updateStage = useCallback(
(index: number, updates: Partial<WizardStageConfig>) => {
const updated = [...stages]
updated[index] = { ...updated[index], ...updates }
onChange(updated)
},
[stages, onChange]
)
const addStage = () => {
const maxOrder = Math.max(...stages.map((s) => s.sortOrder), -1)
onChange([
...stages,
{
name: '',
slug: '',
stageType: 'EVALUATION',
sortOrder: maxOrder + 1,
configJson: {},
},
])
}
const removeStage = (index: number) => {
if (stages.length <= 2) return // Minimum 2 stages
const updated = stages.filter((_, i) => i !== index)
// Re-number sortOrder
onChange(updated.map((s, i) => ({ ...s, sortOrder: i })))
}
const moveStage = (index: number, direction: 'up' | 'down') => {
const newIndex = direction === 'up' ? index - 1 : index + 1
if (newIndex < 0 || newIndex >= stages.length) return
const updated = [...stages]
const temp = updated[index]
updated[index] = updated[newIndex]
updated[newIndex] = temp
onChange(updated.map((s, i) => ({ ...s, sortOrder: i })))
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5 mb-1">
<p className="text-sm text-muted-foreground">
Define the stages projects flow through in the main competition track.
Drag to reorder. Minimum 2 stages required.
</p>
<InfoTooltip
content="INTAKE: Collect project submissions. FILTER: Automated screening. EVALUATION: Jury review and scoring. SELECTION: Choose finalists. LIVE_FINAL: Live ceremony voting. RESULTS: Publish outcomes."
side="right"
/>
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={addStage} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Stage
</Button>
</div>
{isActive && (
<p className="text-sm text-amber-600 bg-amber-50 rounded-md px-3 py-2">
Stage structure is locked because this pipeline is active. Use the Advanced editor for config changes.
</p>
)}
<div className="space-y-2">
{stages.map((stage, index) => {
const typeInfo = STAGE_TYPE_OPTIONS.find((t) => t.value === stage.stageType)
const hasDuplicateSlug = stage.slug && stages.some((s, i) => i !== index && s.slug === stage.slug)
return (
<Card key={index}>
<CardContent className="py-3 px-4">
<div className="flex items-center gap-3">
{/* Reorder */}
<div className="flex flex-col shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
disabled={isActive || index === 0}
onClick={() => moveStage(index, 'up')}
>
<ChevronUp className="h-3 w-3" />
</Button>
<GripVertical className="h-4 w-4 text-muted-foreground mx-auto" />
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
disabled={isActive || index === stages.length - 1}
onClick={() => moveStage(index, 'down')}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* Order number */}
<span className="text-xs text-muted-foreground font-mono w-5 text-center shrink-0">
{index + 1}
</span>
{/* Stage name */}
<div className="flex-1 min-w-0">
<Input
placeholder="Stage name"
value={stage.name}
className={cn('h-8 text-sm', hasDuplicateSlug && 'border-destructive')}
disabled={isActive}
onChange={(e) => {
const name = e.target.value
updateStage(index, { name, slug: slugify(name) })
}}
/>
{hasDuplicateSlug && (
<p className="text-[10px] text-destructive mt-0.5">Duplicate name</p>
)}
</div>
{/* Stage type */}
<div className="w-36 shrink-0">
<Select
value={stage.stageType}
onValueChange={(value) =>
updateStage(index, { stageType: value as StageType })
}
disabled={isActive}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STAGE_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Type badge */}
<Badge
variant="secondary"
className={cn('shrink-0 text-[10px]', typeInfo?.color)}
>
{typeInfo?.label}
</Badge>
{/* Remove */}
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={isActive || stages.length <= 2}
onClick={() => removeStage(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
{stages.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
No stages configured. Click &quot;Add Stage&quot; to begin.
</div>
)}
</div>
)
}

View File

@@ -1,161 +0,0 @@
'use client'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent } from '@/components/ui/card'
import { Bell } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
type NotificationsSectionProps = {
config: Record<string, boolean>
onChange: (config: Record<string, boolean>) => void
overridePolicy: Record<string, unknown>
onOverridePolicyChange: (policy: Record<string, unknown>) => void
isActive?: boolean
}
const NOTIFICATION_EVENTS = [
{
key: 'stage.transitioned',
label: 'Stage Transitioned',
description: 'When a stage changes status (draft → active → closed)',
},
{
key: 'filtering.completed',
label: 'Filtering Completed',
description: 'When batch filtering finishes processing',
},
{
key: 'assignment.generated',
label: 'Assignments Generated',
description: 'When jury assignments are created or updated',
},
{
key: 'live.cursor.updated',
label: 'Live Cursor Updated',
description: 'When the live presentation moves to next project',
},
{
key: 'cohort.window.changed',
label: 'Cohort Window Changed',
description: 'When a cohort voting window opens or closes',
},
{
key: 'decision.overridden',
label: 'Decision Overridden',
description: 'When an admin overrides an automated decision',
},
{
key: 'award.winner.finalized',
label: 'Award Winner Finalized',
description: 'When a special award winner is selected',
},
]
export function NotificationsSection({
config,
onChange,
overridePolicy,
onOverridePolicyChange,
isActive,
}: NotificationsSectionProps) {
const toggleEvent = (key: string, enabled: boolean) => {
onChange({ ...config, [key]: enabled })
}
return (
<div className="space-y-6">
<div className="flex items-center gap-1.5">
<p className="text-sm text-muted-foreground">
Choose which pipeline events trigger notifications. All events are enabled by default.
</p>
<InfoTooltip content="Configure email notifications for pipeline events. Each event type can be individually enabled or disabled." />
</div>
<div className="space-y-2">
{NOTIFICATION_EVENTS.map((event) => (
<Card key={event.key}>
<CardContent className="py-3 px-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<Bell className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<Label className="text-sm font-medium">{event.label}</Label>
<p className="text-xs text-muted-foreground">{event.description}</p>
</div>
</div>
<Switch
checked={config[event.key] !== false}
onCheckedChange={(checked) => toggleEvent(event.key, checked)}
disabled={isActive}
/>
</div>
</CardContent>
</Card>
))}
</div>
{/* Override Governance */}
<div className="space-y-3 pt-2 border-t">
<Label>Override Governance</Label>
<p className="text-xs text-muted-foreground">
Who can override automated decisions in this pipeline?
</p>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Switch
checked={
Array.isArray(overridePolicy.allowedRoles) &&
overridePolicy.allowedRoles.includes('SUPER_ADMIN')
}
disabled
/>
<Label className="text-sm">Super Admins (always enabled)</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={
Array.isArray(overridePolicy.allowedRoles) &&
overridePolicy.allowedRoles.includes('PROGRAM_ADMIN')
}
onCheckedChange={(checked) => {
const roles = Array.isArray(overridePolicy.allowedRoles)
? [...overridePolicy.allowedRoles]
: ['SUPER_ADMIN']
if (checked && !roles.includes('PROGRAM_ADMIN')) {
roles.push('PROGRAM_ADMIN')
} else if (!checked) {
const idx = roles.indexOf('PROGRAM_ADMIN')
if (idx >= 0) roles.splice(idx, 1)
}
onOverridePolicyChange({ ...overridePolicy, allowedRoles: roles })
}}
/>
<Label className="text-sm">Program Admins</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={
Array.isArray(overridePolicy.allowedRoles) &&
overridePolicy.allowedRoles.includes('AWARD_MASTER')
}
onCheckedChange={(checked) => {
const roles = Array.isArray(overridePolicy.allowedRoles)
? [...overridePolicy.allowedRoles]
: ['SUPER_ADMIN']
if (checked && !roles.includes('AWARD_MASTER')) {
roles.push('AWARD_MASTER')
} else if (!checked) {
const idx = roles.indexOf('AWARD_MASTER')
if (idx >= 0) roles.splice(idx, 1)
}
onOverridePolicyChange({ ...overridePolicy, allowedRoles: roles })
}}
/>
<Label className="text-sm">Award Masters</Label>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,93 +0,0 @@
'use client'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ResultsConfig } from '@/types/pipeline-wizard'
type ResultsSectionProps = {
config: ResultsConfig
onChange: (config: ResultsConfig) => void
isActive?: boolean
}
export function ResultsSection({
config,
onChange,
isActive,
}: ResultsSectionProps) {
const updateConfig = (updates: Partial<ResultsConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Publication Mode</Label>
<InfoTooltip content="Manual publish requires explicit admin action. Auto publish triggers on stage close." />
</div>
<Select
value={config.publicationMode ?? 'manual'}
onValueChange={(value) =>
updateConfig({
publicationMode: value as ResultsConfig['publicationMode'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="auto_on_close">Auto on Close</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Show Detailed Scores</Label>
<InfoTooltip content="Expose detailed score breakdowns in published results." />
</div>
<p className="text-xs text-muted-foreground">
Controls score transparency in the results experience
</p>
</div>
<Switch
checked={config.showDetailedScores ?? false}
onCheckedChange={(checked) => updateConfig({ showDetailedScores: checked })}
disabled={isActive}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Show Rankings</Label>
<InfoTooltip content="Display ordered rankings in final results." />
</div>
<p className="text-xs text-muted-foreground">
Disable to show winners only without full ranking table
</p>
</div>
<Switch
checked={config.showRankings ?? true}
onCheckedChange={(checked) => updateConfig({ showRankings: checked })}
disabled={isActive}
/>
</div>
</div>
</div>
)
}

View File

@@ -1,314 +0,0 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight, ShieldCheck } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { cn } from '@/lib/utils'
import { validateAll } from '@/lib/pipeline-validation'
import { normalizeStageConfig } from '@/lib/stage-config-schema'
import type { WizardState, ValidationResult, WizardStageConfig } from '@/types/pipeline-wizard'
type ReviewSectionProps = {
state: WizardState
}
function ValidationStatusIcon({ result }: { result: ValidationResult }) {
if (result.valid && result.warnings.length === 0) {
return <CheckCircle2 className="h-4 w-4 text-emerald-500" />
}
if (result.valid && result.warnings.length > 0) {
return <AlertTriangle className="h-4 w-4 text-amber-500" />
}
return <AlertCircle className="h-4 w-4 text-destructive" />
}
function ValidationSection({
label,
result,
}: {
label: string
result: ValidationResult
}) {
return (
<div className="flex items-start gap-3 py-2">
<ValidationStatusIcon result={result} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{label}</p>
{result.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive mt-0.5">
{err}
</p>
))}
{result.warnings.map((warn, i) => (
<p key={i} className="text-xs text-amber-600 mt-0.5">
{warn}
</p>
))}
{result.valid && result.errors.length === 0 && result.warnings.length === 0 && (
<p className="text-xs text-muted-foreground mt-0.5">Looks good</p>
)}
</div>
</div>
)
}
function stagePolicySummary(stage: WizardStageConfig): string {
const config = normalizeStageConfig(
stage.stageType,
stage.configJson as Record<string, unknown>
)
switch (stage.stageType) {
case 'INTAKE':
return `${String(config.lateSubmissionPolicy)} late policy, ${Array.isArray(config.fileRequirements) ? config.fileRequirements.length : 0} file reqs`
case 'FILTER':
return `${Array.isArray(config.rules) ? config.rules.length : 0} rules, AI ${config.aiRubricEnabled ? 'on' : 'off'}`
case 'EVALUATION':
return `${String(config.requiredReviews)} reviews, load ${String(config.minLoadPerJuror)}-${String(config.maxLoadPerJuror)}`
case 'SELECTION':
return `ranking ${String(config.rankingMethod)}, tie ${String(config.tieBreaker)}`
case 'LIVE_FINAL':
return `jury ${config.juryVotingEnabled ? 'on' : 'off'}, audience ${config.audienceVotingEnabled ? 'on' : 'off'}`
case 'RESULTS':
return `publication ${String(config.publicationMode)}, rankings ${config.showRankings ? 'shown' : 'hidden'}`
default:
return 'Configured'
}
}
export function ReviewSection({ state }: ReviewSectionProps) {
const validation = validateAll(state)
const totalTracks = state.tracks.length
const totalStages = state.tracks.reduce((sum, t) => sum + t.stages.length, 0)
const totalTransitions = state.tracks.reduce(
(sum, t) => sum + Math.max(0, t.stages.length - 1),
0
)
const enabledNotifications = Object.values(state.notificationConfig).filter(Boolean).length
const blockers = [
...validation.sections.basics.errors,
...validation.sections.tracks.errors,
...validation.sections.notifications.errors,
]
const warnings = [
...validation.sections.basics.warnings,
...validation.sections.tracks.warnings,
...validation.sections.notifications.warnings,
]
const hasMainTrack = state.tracks.some((track) => track.kind === 'MAIN')
const hasStages = totalStages > 0
const hasNotificationDefaults = enabledNotifications > 0
const publishReady = validation.valid && hasMainTrack && hasStages
return (
<div className="space-y-6">
<div
className={cn(
'rounded-lg border p-4',
publishReady
? 'border-emerald-200 bg-emerald-50'
: 'border-destructive/30 bg-destructive/5'
)}
>
<div className="flex items-start gap-2">
{publishReady ? (
<CheckCircle2 className="h-5 w-5 text-emerald-600 mt-0.5" />
) : (
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
)}
<div>
<p className={cn('font-medium', publishReady ? 'text-emerald-800' : 'text-destructive')}>
{publishReady
? 'Pipeline is ready for publish'
: 'Pipeline has publish blockers'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Draft save can proceed with warnings. Publish should only proceed with zero blockers.
</p>
</div>
</div>
</div>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-1.5">
<CardTitle className="text-sm">Readiness Checks</CardTitle>
<InfoTooltip content="Critical blockers prevent publish. Warnings indicate recommended fixes." />
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{blockers.length}</p>
<p className="text-xs text-muted-foreground">Blockers</p>
</div>
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{warnings.length}</p>
<p className="text-xs text-muted-foreground">Warnings</p>
</div>
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{totalTracks}</p>
<p className="text-xs text-muted-foreground">Tracks</p>
</div>
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{totalStages}</p>
<p className="text-xs text-muted-foreground">Stages</p>
</div>
</div>
{blockers.length > 0 && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
<p className="text-xs font-medium text-destructive mb-1">Publish Blockers</p>
{blockers.map((blocker, i) => (
<p key={i} className="text-xs text-destructive">
{blocker}
</p>
))}
</div>
)}
{warnings.length > 0 && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3">
<p className="text-xs font-medium text-amber-700 mb-1">Warnings</p>
{warnings.map((warn, i) => (
<p key={i} className="text-xs text-amber-700">
{warn}
</p>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-1.5">
<CardTitle className="text-sm">Validation Detail</CardTitle>
<InfoTooltip content="Automated checks per setup section." />
</div>
</CardHeader>
<CardContent className="divide-y">
<ValidationSection label="Basics" result={validation.sections.basics} />
<ValidationSection label="Tracks & Stages" result={validation.sections.tracks} />
<ValidationSection label="Notifications" result={validation.sections.notifications} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-1.5">
<CardTitle className="text-sm">Structure and Policy Matrix</CardTitle>
<InfoTooltip content="Stage-by-stage policy preview used for final sanity check before creation." />
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="text-center">
<p className="text-2xl font-bold">{totalTracks}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<Layers className="h-3 w-3" />
Tracks
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{totalStages}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<GitBranch className="h-3 w-3" />
Stages
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{totalTransitions}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<ArrowRight className="h-3 w-3" />
Transitions
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{enabledNotifications}</p>
<p className="text-xs text-muted-foreground">Notifications</p>
</div>
</div>
<div className="space-y-3">
{state.tracks.map((track, i) => (
<div key={i} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={cn(
'text-[10px]',
track.kind === 'MAIN'
? 'bg-blue-100 text-blue-700'
: track.kind === 'AWARD'
? 'bg-amber-100 text-amber-700'
: 'bg-gray-100 text-gray-700'
)}
>
{track.kind}
</Badge>
<span className="text-sm font-medium">{track.name || '(unnamed track)'}</span>
</div>
<span className="text-xs text-muted-foreground">{track.stages.length} stages</span>
</div>
<div className="space-y-1">
{track.stages.map((stage, stageIndex) => (
<div
key={stageIndex}
className="flex items-center justify-between text-xs border-b last:border-0 py-1.5"
>
<span className="font-medium">
{stageIndex + 1}. {stage.name || '(unnamed stage)'} ({stage.stageType})
</span>
<span className="text-muted-foreground">{stagePolicySummary(stage)}</span>
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<ShieldCheck className="h-4 w-4" />
Publish Guardrails
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex items-center justify-between rounded-md border p-2">
<span>Main track present</span>
<Badge variant={hasMainTrack ? 'default' : 'destructive'}>
{hasMainTrack ? 'Pass' : 'Fail'}
</Badge>
</div>
<div className="flex items-center justify-between rounded-md border p-2">
<span>At least one stage configured</span>
<Badge variant={hasStages ? 'default' : 'destructive'}>
{hasStages ? 'Pass' : 'Fail'}
</Badge>
</div>
<div className="flex items-center justify-between rounded-md border p-2">
<span>Validation blockers cleared</span>
<Badge variant={blockers.length === 0 ? 'default' : 'destructive'}>
{blockers.length === 0 ? 'Pass' : 'Fail'}
</Badge>
</div>
<div className="flex items-center justify-between rounded-md border p-2">
<span>Notification policy configured</span>
<Badge variant={hasNotificationDefaults ? 'default' : 'secondary'}>
{hasNotificationDefaults ? 'Configured' : 'Optional'}
</Badge>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,179 +0,0 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { SelectionConfig } from '@/types/pipeline-wizard'
type SelectionSectionProps = {
config: SelectionConfig
onChange: (config: SelectionConfig) => void
isActive?: boolean
}
const CATEGORIES = [
{ key: 'STARTUP', label: 'Startups' },
{ key: 'BUSINESS_CONCEPT', label: 'Business Concepts' },
] as const
export function SelectionSection({
config,
onChange,
isActive,
}: SelectionSectionProps) {
const updateConfig = (updates: Partial<SelectionConfig>) => {
onChange({ ...config, ...updates })
}
const quotas = config.categoryQuotas ?? {}
const quotaTotal = CATEGORIES.reduce((sum, c) => sum + (quotas[c.key] ?? 0), 0)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Per-category quotas</Label>
<InfoTooltip content="Set separate finalist targets per competition category. When enabled, projects are selected independently within each category." />
</div>
<p className="text-xs text-muted-foreground">
Define finalist targets for each category separately
</p>
</div>
<Switch
checked={config.categoryQuotasEnabled ?? false}
onCheckedChange={(checked) =>
updateConfig({
categoryQuotasEnabled: checked,
categoryQuotas: checked
? config.categoryQuotas ?? { STARTUP: 3, BUSINESS_CONCEPT: 3 }
: config.categoryQuotas,
})
}
disabled={isActive}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{config.categoryQuotasEnabled ? (
<>
{CATEGORIES.map((cat) => (
<div key={cat.key} className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>{cat.label}</Label>
<InfoTooltip content={`Finalist target for ${cat.label.toLowerCase()}.`} />
</div>
<Input
type="number"
min={0}
max={250}
value={quotas[cat.key] ?? 0}
disabled={isActive}
onChange={(e) =>
updateConfig({
categoryQuotas: {
...quotas,
[cat.key]: parseInt(e.target.value, 10) || 0,
},
})
}
/>
</div>
))}
<div className="sm:col-span-2 text-sm text-muted-foreground">
Total: {quotaTotal} finalists (
{CATEGORIES.map((c, i) => (
<span key={c.key}>
{i > 0 && ' + '}
{quotas[c.key] ?? 0} {c.label}
</span>
))}
)
</div>
</>
) : (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Finalist Count</Label>
<InfoTooltip content="Optional fixed finalist target for this stage." />
</div>
<Input
type="number"
min={1}
max={500}
value={config.finalistCount ?? ''}
placeholder="e.g. 6"
disabled={isActive}
onChange={(e) =>
updateConfig({
finalistCount:
e.target.value.trim().length === 0
? undefined
: parseInt(e.target.value, 10) || undefined,
})
}
/>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Ranking Method</Label>
<InfoTooltip content="How projects are ranked before finalist selection." />
</div>
<Select
value={config.rankingMethod ?? 'score_average'}
onValueChange={(value) =>
updateConfig({
rankingMethod: value as SelectionConfig['rankingMethod'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="score_average">Score Average</SelectItem>
<SelectItem value="weighted_criteria">Weighted Criteria</SelectItem>
<SelectItem value="binary_pass">Binary Pass</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Tie Breaker</Label>
<InfoTooltip content="Fallback policy used when projects tie in rank." />
</div>
<Select
value={config.tieBreaker ?? 'admin_decides'}
onValueChange={(value) =>
updateConfig({
tieBreaker: value as SelectionConfig['tieBreaker'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin_decides">Admin Decides</SelectItem>
<SelectItem value="highest_individual">Highest Individual Score</SelectItem>
<SelectItem value="revote">Re-vote</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -1,416 +0,0 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { EditableCard } from '@/components/ui/editable-card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import {
Inbox,
Filter,
ClipboardCheck,
Trophy,
Tv,
BarChart3,
} from 'lucide-react'
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
import { SelectionSection } from '@/components/admin/pipeline/sections/selection-section'
import { ResultsSection } from '@/components/admin/pipeline/sections/results-section'
import {
defaultIntakeConfig,
defaultFilterConfig,
defaultEvaluationConfig,
defaultLiveConfig,
defaultSelectionConfig,
defaultResultsConfig,
} from '@/lib/pipeline-defaults'
import type {
IntakeConfig,
FilterConfig,
EvaluationConfig,
LiveFinalConfig,
SelectionConfig,
ResultsConfig,
} from '@/types/pipeline-wizard'
type StageConfigEditorProps = {
stageId: string
stageName: string
stageType: string
configJson: Record<string, unknown> | null
onSave: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
isSaving?: boolean
alwaysEditable?: boolean
}
const stageIcons: Record<string, React.ReactNode> = {
INTAKE: <Inbox className="h-4 w-4" />,
FILTER: <Filter className="h-4 w-4" />,
EVALUATION: <ClipboardCheck className="h-4 w-4" />,
SELECTION: <Trophy className="h-4 w-4" />,
LIVE_FINAL: <Tv className="h-4 w-4" />,
RESULTS: <BarChart3 className="h-4 w-4" />,
}
function ConfigSummary({
stageType,
configJson,
}: {
stageType: string
configJson: Record<string, unknown> | null
}) {
if (!configJson) {
return (
<p className="text-sm text-muted-foreground italic">
No configuration set
</p>
)
}
switch (stageType) {
case 'INTAKE': {
const config = configJson as unknown as IntakeConfig
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Submission Window:</span>
<Badge variant="outline" className="text-[10px]">
{config.submissionWindowEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Late Policy:</span>
<span className="capitalize">{config.lateSubmissionPolicy ?? 'flag'}</span>
{(config.lateGraceHours ?? 0) > 0 && (
<span className="text-muted-foreground">
({config.lateGraceHours}h grace)
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">File Requirements:</span>
<span>{config.fileRequirements?.length ?? 0} configured</span>
</div>
</div>
)
}
case 'FILTER': {
const raw = configJson as Record<string, unknown>
const seedRules = (raw.deterministic as Record<string, unknown>)?.rules as unknown[] | undefined
const ruleCount = (raw.rules as unknown[])?.length ?? seedRules?.length ?? 0
const aiEnabled = (raw.aiRubricEnabled as boolean) ?? !!(raw.ai)
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Rules:</span>
<span>{ruleCount} eligibility rules</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">AI Screening:</span>
<Badge variant="outline" className="text-[10px]">
{aiEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Manual Queue:</span>
<Badge variant="outline" className="text-[10px]">
{(raw.manualQueueEnabled as boolean) ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</div>
)
}
case 'EVALUATION': {
const raw = configJson as Record<string, unknown>
const reviews = (raw.requiredReviews as number) ?? 3
const minLoad = (raw.minLoadPerJuror as number) ?? (raw.minAssignmentsPerJuror as number) ?? 5
const maxLoad = (raw.maxLoadPerJuror as number) ?? (raw.maxAssignmentsPerJuror as number) ?? 20
const overflow = (raw.overflowPolicy as string) ?? 'queue'
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Required Reviews:</span>
<span>{reviews}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Load per Juror:</span>
<span>
{minLoad} - {maxLoad}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Overflow Policy:</span>
<span className="capitalize">
{overflow.replace('_', ' ')}
</span>
</div>
</div>
)
}
case 'SELECTION': {
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Ranking Method:</span>
<span className="capitalize">
{((configJson.rankingMethod as string) ?? 'score_average').replace(
/_/g,
' '
)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Tie Breaker:</span>
<span className="capitalize">
{((configJson.tieBreaker as string) ?? 'admin_decides').replace(
/_/g,
' '
)}
</span>
</div>
{configJson.finalistCount != null && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Finalist Count:</span>
<span>{String(configJson.finalistCount)}</span>
</div>
)}
</div>
)
}
case 'LIVE_FINAL': {
const raw = configJson as Record<string, unknown>
const juryEnabled = (raw.juryVotingEnabled as boolean) ?? (raw.votingEnabled as boolean) ?? false
const audienceEnabled = (raw.audienceVotingEnabled as boolean) ?? (raw.audienceVoting as boolean) ?? false
const audienceWeight = (raw.audienceVoteWeight as number) ?? 0
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Jury Voting:</span>
<Badge variant="outline" className="text-[10px]">
{juryEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Audience Voting:</span>
<Badge variant="outline" className="text-[10px]">
{audienceEnabled ? 'Enabled' : 'Disabled'}
</Badge>
{audienceEnabled && (
<span className="text-muted-foreground">
({Math.round(audienceWeight * 100)}% weight)
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Reveal:</span>
<span className="capitalize">{(raw.revealPolicy as string) ?? 'ceremony'}</span>
</div>
</div>
)
}
case 'RESULTS': {
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Publication:</span>
<span className="capitalize">
{((configJson.publicationMode as string) ?? 'manual').replace(
/_/g,
' '
)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Show Scores:</span>
<Badge variant="outline" className="text-[10px]">
{configJson.showDetailedScores ? 'Yes' : 'No'}
</Badge>
</div>
</div>
)
}
default:
return (
<p className="text-sm text-muted-foreground italic">
Configuration view not available for this stage type
</p>
)
}
}
export function StageConfigEditor({
stageId,
stageName,
stageType,
configJson,
onSave,
isSaving = false,
alwaysEditable = false,
}: StageConfigEditorProps) {
const [localConfig, setLocalConfig] = useState<Record<string, unknown>>(
() => configJson ?? {}
)
useEffect(() => {
setLocalConfig(configJson ?? {})
}, [stageId, configJson])
const handleSave = useCallback(async () => {
await onSave(stageId, localConfig)
}, [stageId, localConfig, onSave])
const renderEditor = () => {
switch (stageType) {
case 'INTAKE': {
const rawConfig = {
...defaultIntakeConfig(),
...(localConfig as object),
} as IntakeConfig
// Deep-normalize fileRequirements to handle DB shape mismatches
const config: IntakeConfig = {
...rawConfig,
fileRequirements: (rawConfig.fileRequirements ?? []).map((req) => ({
name: req.name ?? '',
description: req.description ?? '',
acceptedMimeTypes: req.acceptedMimeTypes ?? ['application/pdf'],
maxSizeMB: req.maxSizeMB ?? 50,
isRequired: req.isRequired ?? (req as Record<string, unknown>).required === true,
})),
}
return (
<IntakeSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'FILTER': {
const raw = localConfig as Record<string, unknown>
// Normalize seed data shape: deterministic.rules → rules, confidenceBands → aiConfidenceThresholds
const seedRules = (raw.deterministic as Record<string, unknown>)?.rules as FilterConfig['rules'] | undefined
const seedBands = raw.confidenceBands as Record<string, Record<string, number>> | undefined
const config: FilterConfig = {
...defaultFilterConfig(),
...raw,
rules: (raw.rules as FilterConfig['rules']) ?? seedRules ?? defaultFilterConfig().rules,
aiRubricEnabled: (raw.aiRubricEnabled as boolean | undefined) ?? !!raw.ai,
aiConfidenceThresholds: (raw.aiConfidenceThresholds as FilterConfig['aiConfidenceThresholds']) ?? (seedBands ? {
high: seedBands.high?.threshold ?? 0.85,
medium: seedBands.medium?.threshold ?? 0.6,
low: seedBands.low?.threshold ?? 0.4,
} : defaultFilterConfig().aiConfidenceThresholds),
}
return (
<FilteringSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'EVALUATION': {
const raw = localConfig as Record<string, unknown>
// Normalize seed data shape: minAssignmentsPerJuror → minLoadPerJuror, etc.
const config: EvaluationConfig = {
...defaultEvaluationConfig(),
...raw,
requiredReviews: (raw.requiredReviews as number) ?? defaultEvaluationConfig().requiredReviews,
minLoadPerJuror: (raw.minLoadPerJuror as number) ?? (raw.minAssignmentsPerJuror as number) ?? defaultEvaluationConfig().minLoadPerJuror,
maxLoadPerJuror: (raw.maxLoadPerJuror as number) ?? (raw.maxAssignmentsPerJuror as number) ?? defaultEvaluationConfig().maxLoadPerJuror,
}
return (
<AssignmentSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'LIVE_FINAL': {
const raw = localConfig as Record<string, unknown>
// Normalize seed data shape: votingEnabled → juryVotingEnabled, audienceVoting → audienceVotingEnabled
const config: LiveFinalConfig = {
...defaultLiveConfig(),
...raw,
juryVotingEnabled: (raw.juryVotingEnabled as boolean) ?? (raw.votingEnabled as boolean) ?? true,
audienceVotingEnabled: (raw.audienceVotingEnabled as boolean) ?? (raw.audienceVoting as boolean) ?? false,
audienceVoteWeight: (raw.audienceVoteWeight as number) ?? 0,
}
return (
<LiveFinalsSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'SELECTION':
return (
<SelectionSection
config={{
...defaultSelectionConfig(),
...(localConfig as SelectionConfig),
}}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
case 'RESULTS':
return (
<ResultsSection
config={{
...defaultResultsConfig(),
...(localConfig as ResultsConfig),
}}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
default:
return null
}
}
if (alwaysEditable) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
{stageIcons[stageType] && (
<span className="text-muted-foreground">{stageIcons[stageType]}</span>
)}
<h3 className="text-sm font-semibold">{stageName} Configuration</h3>
<Badge variant="outline" className="text-[10px]">
{stageType.replace('_', ' ')}
</Badge>
</div>
{renderEditor()}
<div className="flex justify-end pt-2 border-t">
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
Save Changes
</Button>
</div>
</div>
)
}
return (
<EditableCard
title={`${stageName} Configuration`}
icon={stageIcons[stageType]}
summary={<ConfigSummary stageType={stageType} configJson={configJson} />}
onSave={handleSave}
isSaving={isSaving}
>
{renderEditor()}
</EditableCard>
)
}

View File

@@ -1,178 +0,0 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Loader2 } from 'lucide-react'
import { StageConfigEditor } from '@/components/admin/pipeline/stage-config-editor'
import { FileRequirementsEditor } from '@/components/admin/file-requirements-editor'
import { FilteringRulesEditor } from '@/components/admin/pipeline/filtering-rules-editor'
import { IntakePanel } from '@/components/admin/pipeline/stage-panels/intake-panel'
import { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-panel'
import { EvaluationPanel } from '@/components/admin/pipeline/stage-panels/evaluation-panel'
import { SelectionPanel } from '@/components/admin/pipeline/stage-panels/selection-panel'
import { LiveFinalPanel } from '@/components/admin/pipeline/stage-panels/live-final-panel'
import { ResultsPanel } from '@/components/admin/pipeline/stage-panels/results-panel'
type StageType = 'INTAKE' | 'FILTER' | 'EVALUATION' | 'SELECTION' | 'LIVE_FINAL' | 'RESULTS'
type StageDetailSheetProps = {
open: boolean
onOpenChange: (open: boolean) => void
stage: {
id: string
name: string
stageType: StageType
configJson: Record<string, unknown> | null
} | null
onSaveConfig: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
isSaving: boolean
pipelineId: string
materializeRequirements?: (stageId: string) => void
isMaterializing?: boolean
}
function StagePanelContent({
stageId,
stageType,
configJson,
}: {
stageId: string
stageType: string
configJson: Record<string, unknown> | null
}) {
switch (stageType) {
case 'INTAKE':
return <IntakePanel stageId={stageId} configJson={configJson} />
case 'FILTER':
return <FilterPanel stageId={stageId} configJson={configJson} />
case 'EVALUATION':
return <EvaluationPanel stageId={stageId} configJson={configJson} />
case 'SELECTION':
return <SelectionPanel stageId={stageId} configJson={configJson} />
case 'LIVE_FINAL':
return <LiveFinalPanel stageId={stageId} configJson={configJson} />
case 'RESULTS':
return <ResultsPanel stageId={stageId} configJson={configJson} />
default:
return (
<p className="text-sm text-muted-foreground py-4">
Unknown stage type: {stageType}
</p>
)
}
}
const stageTypeLabels: Record<string, string> = {
INTAKE: 'Intake',
FILTER: 'Filter',
EVALUATION: 'Evaluation',
SELECTION: 'Selection',
LIVE_FINAL: 'Live Final',
RESULTS: 'Results',
}
export function StageDetailSheet({
open,
onOpenChange,
stage,
onSaveConfig,
isSaving,
pipelineId: _pipelineId,
materializeRequirements,
isMaterializing = false,
}: StageDetailSheetProps) {
if (!stage) return null
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-full sm:w-[540px] lg:w-[640px] sm:max-w-[640px] overflow-y-auto p-0"
>
<div className="p-6 pb-0">
<SheetHeader>
<div className="flex items-center gap-2">
<SheetTitle className="text-base">{stage.name}</SheetTitle>
<Badge variant="outline" className="text-[10px]">
{stageTypeLabels[stage.stageType] ?? stage.stageType}
</Badge>
</div>
<SheetDescription>
Configure settings and view activity for this stage
</SheetDescription>
</SheetHeader>
</div>
<div className="px-6 pt-4 pb-6">
<Tabs defaultValue="configuration">
<TabsList className="w-full">
<TabsTrigger value="configuration" className="flex-1">
Configuration
</TabsTrigger>
<TabsTrigger value="activity" className="flex-1">
Activity
</TabsTrigger>
</TabsList>
<TabsContent value="configuration" className="space-y-4 mt-4">
<StageConfigEditor
stageId={stage.id}
stageName={stage.name}
stageType={stage.stageType}
configJson={stage.configJson}
onSave={onSaveConfig}
isSaving={isSaving}
alwaysEditable
/>
{stage.stageType === 'INTAKE' && (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">Intake File Requirements</h3>
{materializeRequirements && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => materializeRequirements(stage.id)}
disabled={isMaterializing}
>
{isMaterializing && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
Import Legacy Requirements
</Button>
)}
</div>
<FileRequirementsEditor stageId={stage.id} />
</div>
)}
{stage.stageType === 'FILTER' && (
<FilteringRulesEditor stageId={stage.id} />
)}
</TabsContent>
<TabsContent value="activity" className="mt-4">
<StagePanelContent
stageId={stage.id}
stageType={stage.stageType}
configJson={stage.configJson}
/>
</TabsContent>
</Tabs>
</div>
</SheetContent>
</Sheet>
)
}

View File

@@ -1,136 +0,0 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Users, ClipboardList, BarChart3 } from 'lucide-react'
import type { EvaluationConfig } from '@/types/pipeline-wizard'
type EvaluationPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function EvaluationPanel({ stageId, configJson }: EvaluationPanelProps) {
const config = configJson as unknown as EvaluationConfig | null
const { data: coverage, isLoading: coverageLoading } =
trpc.stageAssignment.getCoverageReport.useQuery({ stageId })
const { data: projectStates, isLoading: statesLoading } =
trpc.stage.getProjectStates.useQuery({ stageId, limit: 50 })
const totalProjects = projectStates?.items.length ?? 0
const requiredReviews = config?.requiredReviews ?? 3
return (
<div className="space-y-4">
{/* Config Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Required Reviews</span>
</div>
<p className="text-2xl font-bold mt-1">{requiredReviews}</p>
<p className="text-xs text-muted-foreground">per project</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Juror Load</span>
</div>
<p className="text-lg font-bold mt-1">
{config?.minLoadPerJuror ?? 5}{config?.maxLoadPerJuror ?? 20}
</p>
<p className="text-xs text-muted-foreground">projects per juror</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<BarChart3 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">{totalProjects}</p>
<p className="text-xs text-muted-foreground">in stage</p>
</CardContent>
</Card>
</div>
{/* Coverage Report */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Assignment Coverage</CardTitle>
</CardHeader>
<CardContent>
{coverageLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : coverage ? (
<div className="space-y-3">
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>Coverage</span>
<span className="font-medium">
{coverage.fullyCoveredProjects}/{coverage.totalProjectsInStage} projects
</span>
</div>
<Progress
value={
coverage.totalProjectsInStage > 0
? (coverage.fullyCoveredProjects / coverage.totalProjectsInStage) * 100
: 0
}
/>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-sm">
<div>
<p className="font-bold text-emerald-600">{coverage.fullyCoveredProjects}</p>
<p className="text-xs text-muted-foreground">Fully Covered</p>
</div>
<div>
<p className="font-bold text-amber-600">{coverage.partiallyCoveredProjects}</p>
<p className="text-xs text-muted-foreground">Partial</p>
</div>
<div>
<p className="font-bold text-destructive">{coverage.uncoveredProjects}</p>
<p className="text-xs text-muted-foreground">Unassigned</p>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground py-3 text-center">
No coverage data available
</p>
)}
</CardContent>
</Card>
{/* Overflow Policy */}
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Overflow Policy</span>
<Badge variant="outline" className="text-xs capitalize">
{config?.overflowPolicy ?? 'queue'}
</Badge>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-sm font-medium">Availability Weighting</span>
<Badge variant="outline" className="text-xs">
{config?.availabilityWeighting ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,184 +0,0 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { Filter, Play, Loader2, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react'
import type { FilterConfig } from '@/types/pipeline-wizard'
type FilterPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function FilterPanel({ stageId, configJson }: FilterPanelProps) {
const config = configJson as unknown as FilterConfig | null
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
stageId,
limit: 50,
})
const runFiltering = trpc.stageFiltering.runStageFiltering.useMutation({
onSuccess: (data) => {
toast.success(
`Filtering complete: ${data.passedCount} passed, ${data.rejectedCount} filtered`
)
},
onError: (err) => toast.error(err.message),
})
const totalProjects = projectStates?.items.length ?? 0
const passed = projectStates?.items.filter((p) => p.state === 'PASSED').length ?? 0
const rejected = projectStates?.items.filter((p) => p.state === 'REJECTED').length ?? 0
const pending = projectStates?.items.filter(
(p) => p.state === 'PENDING' || p.state === 'IN_PROGRESS'
).length ?? 0
return (
<div className="space-y-4">
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-4">
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold">{totalProjects}</p>
<p className="text-xs text-muted-foreground">Total</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-emerald-600">{passed}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<CheckCircle2 className="h-3 w-3" /> Passed
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-destructive">{rejected}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<XCircle className="h-3 w-3" /> Filtered
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-amber-600">{pending}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<AlertTriangle className="h-3 w-3" /> Pending
</p>
</CardContent>
</Card>
</div>
{/* Rules Summary */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Filter className="h-4 w-4" />
Filtering Rules
</CardTitle>
<Button
size="sm"
variant="outline"
disabled={runFiltering.isPending || pending === 0}
onClick={() => runFiltering.mutate({ stageId })}
>
{runFiltering.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Play className="h-3.5 w-3.5 mr-1" />
)}
Run Filtering
</Button>
</div>
</CardHeader>
<CardContent>
{config?.rules && config.rules.length > 0 ? (
<div className="space-y-1">
{config.rules.map((rule, i) => (
<div
key={i}
className="flex items-center gap-2 text-sm py-1.5 border-b last:border-0"
>
<Badge variant="outline" className="text-[10px] font-mono">
{rule.field}
</Badge>
<span className="text-muted-foreground">{rule.operator}</span>
<span className="font-mono text-xs">{String(rule.value)}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-3">
No deterministic rules configured.
{config?.aiRubricEnabled ? ' AI screening is enabled.' : ''}
</p>
)}
{config?.aiRubricEnabled && (
<div className="mt-3 pt-3 border-t space-y-1">
<p className="text-xs text-muted-foreground">
AI Screening: Enabled (High: {Math.round((config.aiConfidenceThresholds?.high ?? 0.85) * 100)}%,
Medium: {Math.round((config.aiConfidenceThresholds?.medium ?? 0.6) * 100)}%)
</p>
{config.aiCriteriaText && (
<p className="text-xs text-muted-foreground line-clamp-2">
Criteria: {config.aiCriteriaText}
</p>
)}
</div>
)}
</CardContent>
</Card>
{/* Projects List */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Projects in Stage</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !projectStates?.items.length ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No projects in this stage
</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{projectStates.items.map((ps) => (
<div
key={ps.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<span className="truncate">{ps.project.title}</span>
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${
ps.state === 'PASSED'
? 'border-emerald-500 text-emerald-600'
: ps.state === 'REJECTED'
? 'border-destructive text-destructive'
: ''
}`}
>
{ps.state}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,135 +0,0 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { FileText, Upload, Clock, AlertTriangle } from 'lucide-react'
import type { IntakeConfig } from '@/types/pipeline-wizard'
type IntakePanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function IntakePanel({ stageId, configJson }: IntakePanelProps) {
const config = configJson as unknown as IntakeConfig | null
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
stageId,
limit: 10,
})
return (
<div className="space-y-4">
{/* Config Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Upload className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Submission Window</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{config?.submissionWindowEnabled ? 'Enabled' : 'Disabled'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Late Policy</span>
</div>
<p className="text-xs text-muted-foreground mt-1 capitalize">
{config?.lateSubmissionPolicy ?? 'Not set'}
{config?.lateSubmissionPolicy === 'flag' && ` (${config.lateGraceHours}h grace)`}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">File Requirements</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{config?.fileRequirements?.length ?? 0} requirements
</p>
</CardContent>
</Card>
</div>
{/* File Requirements List */}
{config?.fileRequirements && config.fileRequirements.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">File Requirements</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{config.fileRequirements.map((req, i) => (
<div
key={i}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span>{req.name}</span>
{req.isRequired && (
<Badge variant="secondary" className="text-[10px]">
Required
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{req.maxSizeMB ? `${req.maxSizeMB} MB max` : 'No limit'}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Recent Projects */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Recent Submissions</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !projectStates?.items.length ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No projects in this stage yet
</p>
) : (
<div className="space-y-1">
{projectStates.items.map((ps) => (
<Link
key={ps.id}
href={`/admin/projects/${ps.project.id}` as Route}
className="block"
>
<div className="flex items-center justify-between text-sm py-1.5 border-b last:border-0 hover:bg-muted/50 cursor-pointer rounded-md px-1 transition-colors">
<span className="truncate">{ps.project.title}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
{ps.state}
</Badge>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,173 +0,0 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { Play, Users, Vote, Radio, Loader2, Layers } from 'lucide-react'
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
type LiveFinalPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function LiveFinalPanel({ stageId, configJson }: LiveFinalPanelProps) {
const config = configJson as unknown as LiveFinalConfig | null
const { data: projectStates } =
trpc.stage.getProjectStates.useQuery({ stageId, limit: 50 })
const { data: cohorts, isLoading: cohortsLoading } = trpc.cohort.list.useQuery({
stageId,
})
const startSession = trpc.live.start.useMutation({
onSuccess: () => toast.success('Live session started'),
onError: (err) => toast.error(err.message),
})
const totalProjects = projectStates?.items.length ?? 0
const totalCohorts = cohorts?.length ?? 0
return (
<div className="space-y-4">
{/* Config Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Vote className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Jury Voting</span>
</div>
<Badge variant="outline" className="mt-1 text-xs">
{config?.juryVotingEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Audience Voting</span>
</div>
<Badge variant="outline" className="mt-1 text-xs">
{config?.audienceVotingEnabled ? 'Enabled' : 'Disabled'}
</Badge>
{config?.audienceVotingEnabled && (
<p className="text-xs text-muted-foreground mt-1">
Weight: {Math.round((config.audienceVoteWeight ?? 0.2) * 100)}%
</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Radio className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Reveal Policy</span>
</div>
<p className="text-sm font-medium mt-1 capitalize">
{config?.revealPolicy ?? 'ceremony'}
</p>
</CardContent>
</Card>
</div>
{/* Cohorts */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Layers className="h-4 w-4" />
Cohorts
</CardTitle>
<Badge variant="secondary" className="text-xs">
{totalCohorts} cohort{totalCohorts !== 1 ? 's' : ''}
</Badge>
</div>
</CardHeader>
<CardContent>
{cohortsLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : !cohorts?.length ? (
<p className="text-sm text-muted-foreground py-3 text-center">
No cohorts configured.{' '}
{config?.cohortSetupMode === 'auto'
? 'Cohorts will be auto-generated when the session starts.'
: 'Create cohorts manually to organize presentations.'}
</p>
) : (
<div className="space-y-1">
{cohorts.map((cohort) => (
<div
key={cohort.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<span>{cohort.name}</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px]">
{cohort._count?.projects ?? 0} projects
</Badge>
<Badge
variant="outline"
className={`text-[10px] ${
cohort.isOpen
? 'border-emerald-500 text-emerald-600'
: ''
}`}
>
{cohort.isOpen ? 'OPEN' : 'CLOSED'}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Live Session Controls */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Live Session</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm">
{totalProjects} project{totalProjects !== 1 ? 's' : ''} ready for
presentation
</p>
<p className="text-xs text-muted-foreground">
Cohort mode: {config?.cohortSetupMode ?? 'auto'}
</p>
</div>
<Button
size="sm"
disabled={startSession.isPending || totalProjects === 0}
onClick={() =>
startSession.mutate({
stageId,
projectOrder: projectStates?.items.map((p) => p.project.id) ?? [],
})
}
>
{startSession.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Play className="h-3.5 w-3.5 mr-1" />
)}
Start Session
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,120 +0,0 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Trophy, Medal, FileText } from 'lucide-react'
import type { ResultsConfig } from '@/types/pipeline-wizard'
type ResultsPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function ResultsPanel({ stageId, configJson }: ResultsPanelProps) {
const config = configJson as unknown as ResultsConfig | null
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
stageId,
limit: 100,
})
const totalProjects = projectStates?.items.length ?? 0
const winners =
projectStates?.items.filter((p) => p.state === 'PASSED').length ?? 0
return (
<div className="space-y-4">
{/* Config Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Winners</span>
</div>
<p className="text-2xl font-bold mt-1">{winners}</p>
<p className="text-xs text-muted-foreground">
of {totalProjects} finalists
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Publication</span>
</div>
<Badge variant="outline" className="mt-1 text-xs capitalize">
{config?.publicationMode ?? 'manual'}
</Badge>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Medal className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Rankings</span>
</div>
<Badge variant="outline" className="mt-1 text-xs">
{config?.showRankings ? 'Visible' : 'Hidden'}
</Badge>
</CardContent>
</Card>
</div>
{/* Final Rankings */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Final Results</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !projectStates?.items.length ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No results available yet
</p>
) : (
<div className="space-y-1 max-h-80 overflow-y-auto">
{projectStates.items.map((ps, index) => (
<div
key={ps.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<div className="flex items-center gap-2">
{ps.state === 'PASSED' && index < 3 ? (
<span className="text-lg">
{index === 0 ? '🥇' : index === 1 ? '🥈' : '🥉'}
</span>
) : (
<span className="text-xs text-muted-foreground font-mono w-6">
#{index + 1}
</span>
)}
<span className="truncate">{ps.project.title}</span>
</div>
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${
ps.state === 'PASSED'
? 'border-amber-500 text-amber-600'
: ''
}`}
>
{ps.state === 'PASSED' ? 'Winner' : ps.state}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,259 +0,0 @@
'use client'
import { useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Trophy, Users, ArrowUpDown, LayoutGrid } from 'lucide-react'
import type { SelectionConfig } from '@/types/pipeline-wizard'
type SelectionPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
const CATEGORY_LABELS: Record<string, string> = {
STARTUP: 'Startups',
BUSINESS_CONCEPT: 'Business Concepts',
}
export function SelectionPanel({ stageId, configJson }: SelectionPanelProps) {
const config = configJson as unknown as SelectionConfig | null
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
stageId,
limit: 100,
})
const totalProjects = projectStates?.items.length ?? 0
const passed = projectStates?.items.filter((p) => p.state === 'PASSED').length ?? 0
const rejected = projectStates?.items.filter((p) => p.state === 'REJECTED').length ?? 0
const pending =
projectStates?.items.filter(
(p) => p.state === 'PENDING' || p.state === 'IN_PROGRESS'
).length ?? 0
const quotasEnabled = config?.categoryQuotasEnabled ?? false
const quotas = config?.categoryQuotas ?? {}
const finalistTarget = quotasEnabled
? Object.values(quotas).reduce((a, b) => a + b, 0)
: (config?.finalistCount ?? 6)
const categoryBreakdown = useMemo(() => {
if (!projectStates?.items) return []
const groups: Record<string, { total: number; selected: number }> = {}
for (const ps of projectStates.items) {
const cat = ps.project.competitionCategory ?? 'UNCATEGORIZED'
if (!groups[cat]) groups[cat] = { total: 0, selected: 0 }
groups[cat].total++
if (ps.state === 'PASSED') groups[cat].selected++
}
// Sort: known categories first, then uncategorized
return Object.entries(groups).sort(([a], [b]) => {
if (a === 'UNCATEGORIZED') return 1
if (b === 'UNCATEGORIZED') return -1
return a.localeCompare(b)
})
}, [projectStates?.items])
const finalistDisplay = quotasEnabled
? Object.entries(quotas).map((e) => e[1]).join('+')
: String(finalistTarget)
return (
<div className="space-y-4">
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Finalist Target</span>
</div>
<p className="text-2xl font-bold mt-1">{finalistDisplay}</p>
<p className="text-xs text-muted-foreground">
{quotasEnabled ? 'per-category quotas' : 'to be selected'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Candidates</span>
</div>
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
<p className="text-xs text-muted-foreground">in selection pool</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<ArrowUpDown className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Ranking Mode</span>
</div>
<p className="text-sm font-medium mt-1 capitalize">
{config?.rankingMethod ?? 'score_average'}
</p>
<p className="text-xs text-muted-foreground">
{config?.tieBreaker ?? 'admin_decides'} tiebreak
</p>
</CardContent>
</Card>
</div>
{/* Category Breakdown */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
Category Breakdown
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : categoryBreakdown.length === 0 ? (
<p className="text-sm text-muted-foreground py-2 text-center">
No projects to categorize
</p>
) : (
<div className="space-y-3">
{categoryBreakdown.map(([cat, data]) => {
const label = CATEGORY_LABELS[cat] ?? (cat === 'UNCATEGORIZED' ? 'Uncategorized' : cat)
const quota = quotasEnabled ? (quotas[cat] ?? 0) : 0
const target = quotasEnabled ? quota : data.total
const pct = target > 0 ? Math.min((data.selected / target) * 100, 100) : 0
let barColor = ''
if (quotasEnabled) {
if (data.selected > quota) barColor = '[&>div]:bg-destructive'
else if (data.selected === quota) barColor = '[&>div]:bg-emerald-500'
else barColor = '[&>div]:bg-amber-500'
}
return (
<div key={cat} className="space-y-1">
<div className="flex justify-between text-sm">
<span>{label}</span>
<span className="font-medium text-muted-foreground">
{data.total} total &middot; {data.selected} selected
{quotasEnabled && quota > 0 && (
<span className="ml-1">/ {quota} target</span>
)}
</span>
</div>
{quotasEnabled ? (
<Progress value={pct} className={barColor} />
) : (
<Progress
value={data.total > 0 ? (data.selected / data.total) * 100 : 0}
/>
)}
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Selection Progress */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Selection Progress</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : (
<div className="space-y-3">
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>Selected</span>
<span className="font-medium">
{passed}/{finalistTarget} finalists
</span>
</div>
<Progress
value={finalistTarget > 0 ? (passed / finalistTarget) * 100 : 0}
/>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-sm">
<div>
<p className="font-bold text-emerald-600">{passed}</p>
<p className="text-xs text-muted-foreground">Selected</p>
</div>
<div>
<p className="font-bold text-destructive">{rejected}</p>
<p className="text-xs text-muted-foreground">Eliminated</p>
</div>
<div>
<p className="font-bold text-amber-600">{pending}</p>
<p className="text-xs text-muted-foreground">Pending</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Project Rankings */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Project Rankings</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !projectStates?.items.length ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No projects in selection stage
</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{projectStates.items.map((ps, index) => (
<div
key={ps.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground font-mono w-6">
#{index + 1}
</span>
<span className="truncate">{ps.project.title}</span>
</div>
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${
ps.state === 'PASSED'
? 'border-emerald-500 text-emerald-600'
: ps.state === 'REJECTED'
? 'border-destructive text-destructive'
: ''
}`}
>
{ps.state}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,344 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Plus, Save, Trash2, Loader2 } from 'lucide-react'
type StageLite = {
id: string
name: string
sortOrder: number
}
type StageTransitionsEditorProps = {
trackId: string
stages: StageLite[]
}
type TransitionDraft = {
id: string
isDefault: boolean
guardText: string
}
export function StageTransitionsEditor({
trackId,
stages,
}: StageTransitionsEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, TransitionDraft>>({})
const [fromStageId, setFromStageId] = useState<string>('')
const [toStageId, setToStageId] = useState<string>('')
const [newIsDefault, setNewIsDefault] = useState<boolean>(false)
const [newGuardText, setNewGuardText] = useState<string>('{}')
const { data: transitions = [], isLoading } =
trpc.stage.listTransitions.useQuery({ trackId })
const createTransition = trpc.stage.createTransition.useMutation({
onSuccess: async () => {
await utils.stage.listTransitions.invalidate({ trackId })
toast.success('Transition created')
setNewGuardText('{}')
setNewIsDefault(false)
},
onError: (error) => toast.error(error.message),
})
const updateTransition = trpc.stage.updateTransition.useMutation({
onSuccess: async () => {
await utils.stage.listTransitions.invalidate({ trackId })
toast.success('Transition updated')
},
onError: (error) => toast.error(error.message),
})
const deleteTransition = trpc.stage.deleteTransition.useMutation({
onSuccess: async () => {
await utils.stage.listTransitions.invalidate({ trackId })
toast.success('Transition deleted')
},
onError: (error) => toast.error(error.message),
})
const orderedTransitions = useMemo(
() =>
[...transitions].sort((a, b) => {
const aFromOrder =
stages.find((stage) => stage.id === a.fromStageId)?.sortOrder ?? 0
const bFromOrder =
stages.find((stage) => stage.id === b.fromStageId)?.sortOrder ?? 0
if (aFromOrder !== bFromOrder) return aFromOrder - bFromOrder
const aToOrder =
stages.find((stage) => stage.id === a.toStageId)?.sortOrder ?? 0
const bToOrder =
stages.find((stage) => stage.id === b.toStageId)?.sortOrder ?? 0
return aToOrder - bToOrder
}),
[stages, transitions]
)
useEffect(() => {
if (!fromStageId && stages.length > 0) {
setFromStageId(stages[0].id)
}
if (!toStageId && stages.length > 1) {
setToStageId(stages[1].id)
}
}, [fromStageId, toStageId, stages])
useEffect(() => {
const nextDrafts: Record<string, TransitionDraft> = {}
for (const transition of orderedTransitions) {
nextDrafts[transition.id] = {
id: transition.id,
isDefault: transition.isDefault,
guardText: JSON.stringify(transition.guardJson ?? {}, null, 2),
}
}
setDrafts(nextDrafts)
}, [orderedTransitions])
const handleCreateTransition = async () => {
if (!fromStageId || !toStageId) {
toast.error('Select both from and to stages')
return
}
if (fromStageId === toStageId) {
toast.error('From and to stages must be different')
return
}
let guardJson: Record<string, unknown> | null = null
try {
const parsed = JSON.parse(newGuardText) as Record<string, unknown>
guardJson = Object.keys(parsed).length > 0 ? parsed : null
} catch {
toast.error('Guard JSON must be valid')
return
}
await createTransition.mutateAsync({
fromStageId,
toStageId,
isDefault: newIsDefault,
guardJson,
})
}
const handleSaveTransition = async (id: string) => {
const draft = drafts[id]
if (!draft) return
let guardJson: Record<string, unknown> | null = null
try {
const parsed = JSON.parse(draft.guardText) as Record<string, unknown>
guardJson = Object.keys(parsed).length > 0 ? parsed : null
} catch {
toast.error('Guard JSON must be valid')
return
}
await updateTransition.mutateAsync({
id,
isDefault: draft.isDefault,
guardJson,
})
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Stage Transitions</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Loading transitions...
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Stage Transitions</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border p-3 space-y-3">
<p className="text-sm font-medium">Create Transition</p>
<div className="grid gap-2 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">From Stage</Label>
<Select value={fromStageId} onValueChange={setFromStageId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{stages
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">To Stage</Label>
<Select value={toStageId} onValueChange={setToStageId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{stages
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end justify-start pb-2">
<div className="flex items-center gap-2">
<Switch
checked={newIsDefault}
onCheckedChange={setNewIsDefault}
/>
<Label className="text-xs">Default</Label>
</div>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Guard JSON (optional)</Label>
<Textarea
className="font-mono text-xs min-h-20"
value={newGuardText}
onChange={(e) => setNewGuardText(e.target.value)}
/>
</div>
<Button
type="button"
size="sm"
onClick={handleCreateTransition}
disabled={createTransition.isPending}
>
{createTransition.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="mr-1.5 h-3.5 w-3.5" />
)}
Add Transition
</Button>
</div>
{orderedTransitions.length === 0 && (
<p className="text-sm text-muted-foreground">
No transitions configured yet.
</p>
)}
{orderedTransitions.map((transition) => {
const fromName =
stages.find((stage) => stage.id === transition.fromStageId)?.name ??
transition.fromStage?.name ??
'Unknown'
const toName =
stages.find((stage) => stage.id === transition.toStageId)?.name ??
transition.toStage?.name ??
'Unknown'
const draft = drafts[transition.id]
if (!draft) return null
return (
<div key={transition.id} className="rounded-md border p-3 space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">
{fromName} {'->'} {toName}
</p>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<Switch
checked={draft.isDefault}
onCheckedChange={(checked) =>
setDrafts((prev) => ({
...prev,
[transition.id]: {
...draft,
isDefault: checked,
},
}))
}
/>
<Label className="text-xs">Default</Label>
</div>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Guard JSON</Label>
<Textarea
className="font-mono text-xs min-h-20"
value={draft.guardText}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[transition.id]: {
...draft,
guardText: e.target.value,
},
}))
}
/>
</div>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => handleSaveTransition(transition.id)}
disabled={updateTransition.isPending}
>
{updateTransition.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => deleteTransition.mutate({ id: transition.id })}
disabled={deleteTransition.isPending}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,239 @@
'use client';
import { useState } from 'react';
import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Lock, Unlock, History } from 'lucide-react';
import { toast } from 'sonner';
import type { CompetitionCategory } from '@prisma/client';
interface ResultLockControlsProps {
competitionId: string;
roundId: string;
category: CompetitionCategory;
}
export function ResultLockControls({ competitionId, roundId, category }: ResultLockControlsProps) {
const utils = trpc.useUtils();
const [lockDialogOpen, setLockDialogOpen] = useState(false);
const [unlockDialogOpen, setUnlockDialogOpen] = useState(false);
const [unlockReason, setUnlockReason] = useState('');
const { data: lockStatus } = trpc.resultLock.isLocked.useQuery({
competitionId,
roundId,
category
});
const { data: history } = trpc.resultLock.history.useQuery({
competitionId
});
const lockMutation = trpc.resultLock.lock.useMutation({
onSuccess: () => {
utils.resultLock.isLocked.invalidate();
utils.resultLock.history.invalidate();
toast.success('Results locked successfully');
setLockDialogOpen(false);
},
onError: (err) => {
toast.error(err.message);
}
});
const unlockMutation = trpc.resultLock.unlock.useMutation({
onSuccess: () => {
utils.resultLock.isLocked.invalidate();
utils.resultLock.history.invalidate();
toast.success('Results unlocked');
setUnlockDialogOpen(false);
setUnlockReason('');
},
onError: (err) => {
toast.error(err.message);
}
});
const handleLock = () => {
lockMutation.mutate({
competitionId,
roundId,
category,
resultSnapshot: {} // This would contain the actual results snapshot
});
};
const handleUnlock = () => {
if (!unlockReason.trim()) {
toast.error('Reason is required to unlock results');
return;
}
if (!lockStatus?.lock?.id) {
toast.error('No active lock found');
return;
}
unlockMutation.mutate({
resultLockId: lockStatus.lock.id,
reason: unlockReason.trim()
});
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Result Lock Status</CardTitle>
<CardDescription>Prevent changes to finalized results</CardDescription>
</div>
<Badge variant={lockStatus?.locked ? 'destructive' : 'outline'}>
{lockStatus?.locked ? 'Locked' : 'Unlocked'}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{lockStatus?.locked ? (
<div className="space-y-3">
<div className="rounded-lg bg-destructive/10 p-4">
<div className="flex items-start gap-3">
<Lock className="h-5 w-5 text-destructive" />
<div className="flex-1">
<p className="font-medium text-destructive">Results are locked</p>
{lockStatus.lock?.lockedAt && (
<p className="mt-1 text-sm text-muted-foreground">
Locked on {new Date(lockStatus.lock.lockedAt).toLocaleString()}
</p>
)}
</div>
</div>
</div>
<Button
variant="outline"
onClick={() => setUnlockDialogOpen(true)}
className="w-full"
>
<Unlock className="mr-2 h-4 w-4" />
Unlock Results (Super Admin Only)
</Button>
</div>
) : (
<Button onClick={() => setLockDialogOpen(true)} className="w-full">
<Lock className="mr-2 h-4 w-4" />
Lock Results
</Button>
)}
</CardContent>
</Card>
{/* Lock History */}
{history && history.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
Lock History
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{history.map((entry: any) => (
<div key={entry.id} className="rounded-lg border p-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
{entry.action === 'LOCK' ? (
<Lock className="h-4 w-4 text-destructive" />
) : (
<Unlock className="h-4 w-4 text-green-600" />
)}
<span className="font-medium">{entry.action}</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{entry.adminUser?.name} - {new Date(entry.timestamp).toLocaleString()}
</p>
{entry.reason && (
<p className="mt-2 text-sm italic">&ldquo;{entry.reason}&rdquo;</p>
)}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Lock Confirmation Dialog */}
<Dialog open={lockDialogOpen} onOpenChange={setLockDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Lock Results</DialogTitle>
<DialogDescription>
This will prevent any further changes to the results. This action can only be
reversed by a super admin.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setLockDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleLock} disabled={lockMutation.isPending}>
{lockMutation.isPending ? 'Locking...' : 'Confirm Lock'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Unlock Dialog */}
<Dialog open={unlockDialogOpen} onOpenChange={setUnlockDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Unlock Results</DialogTitle>
<DialogDescription>
Unlocking results will allow modifications. This action will be audited.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="unlockReason">Reason (Required) *</Label>
<Textarea
id="unlockReason"
value={unlockReason}
onChange={(e) => setUnlockReason(e.target.value)}
placeholder="Explain why results need to be unlocked..."
rows={3}
required
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUnlockDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleUnlock}
disabled={unlockMutation.isPending || !unlockReason.trim()}
>
{unlockMutation.isPending ? 'Unlocking...' : 'Confirm Unlock'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,33 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Layers } from 'lucide-react'
type ProjectStatesTableProps = {
competitionId: string
roundId: string
}
export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTableProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Project States</CardTitle>
<p className="text-sm text-muted-foreground">
Projects participating in this round
</p>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<Layers className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm font-medium">No Active Projects</p>
<p className="text-xs text-muted-foreground mt-1">
Project states will appear here when the round is active
</p>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,291 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Plus, Lock, Unlock, LockKeyhole, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
type SubmissionWindowManagerProps = {
competitionId: string
roundId: string
}
export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWindowManagerProps) {
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [roundNumber, setRoundNumber] = useState(1)
const utils = trpc.useUtils()
// For now, we'll query all windows for the competition
// In a real implementation, we'd filter by round or have a dedicated endpoint
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
id: competitionId,
})
const createWindowMutation = trpc.round.createSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window created')
setIsCreateOpen(false)
setName('')
setSlug('')
setRoundNumber(1)
},
onError: (err) => toast.error(err.message),
})
const openWindowMutation = trpc.round.openSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window opened')
},
onError: (err) => toast.error(err.message),
})
const closeWindowMutation = trpc.round.closeSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window closed')
},
onError: (err) => toast.error(err.message),
})
const lockWindowMutation = trpc.round.lockSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window locked')
},
onError: (err) => toast.error(err.message),
})
const handleCreate = () => {
if (!name || !slug) {
toast.error('Name and slug are required')
return
}
createWindowMutation.mutate({
competitionId,
name,
slug,
roundNumber,
})
}
const handleNameChange = (value: string) => {
setName(value)
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
setSlug(autoSlug)
}
const windows = competition?.submissionWindows ?? []
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-base">Submission Windows</CardTitle>
<p className="text-sm text-muted-foreground">
File upload windows for this round
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="w-full sm:w-auto">
<Plus className="h-4 w-4 mr-1" />
Create Window
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Submission Window</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="windowName">Window Name</Label>
<Input
id="windowName"
placeholder="e.g., Round 1 Submissions"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="windowSlug">Slug</Label>
<Input
id="windowSlug"
placeholder="e.g., round-1-submissions"
value={slug}
onChange={(e) => setSlug(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="roundNumber">Round Number</Label>
<Input
id="roundNumber"
type="number"
min={1}
value={roundNumber}
onChange={(e) => setRoundNumber(parseInt(e.target.value, 10))}
/>
</div>
<div className="flex gap-2 pt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleCreate}
disabled={createWindowMutation.isPending}
>
{createWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Create
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
Loading windows...
</div>
) : windows.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No submission windows yet. Create one to enable file uploads.
</div>
) : (
<div className="space-y-2">
{windows.map((window) => {
const isPending = !window.windowOpenAt
const isOpen = window.windowOpenAt && !window.windowCloseAt
const isClosed = window.windowCloseAt && !window.isLocked
const isLocked = window.isLocked
return (
<div
key={window.id}
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border rounded-lg p-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium truncate">{window.name}</p>
{isPending && (
<Badge variant="secondary" className="text-[10px] bg-gray-100 text-gray-700">
Pending
</Badge>
)}
{isOpen && (
<Badge variant="secondary" className="text-[10px] bg-emerald-100 text-emerald-700">
Open
</Badge>
)}
{isClosed && (
<Badge variant="secondary" className="text-[10px] bg-blue-100 text-blue-700">
Closed
</Badge>
)}
{isLocked && (
<Badge variant="secondary" className="text-[10px] bg-red-100 text-red-700">
<LockKeyhole className="h-2.5 w-2.5 mr-1" />
Locked
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5">{window.slug}</p>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
<span>Round {window.roundNumber}</span>
<span></span>
<span>{window._count.fileRequirements} requirements</span>
<span></span>
<span>{window._count.projectFiles} files</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{isPending && (
<Button
size="sm"
variant="outline"
onClick={() => openWindowMutation.mutate({ windowId: window.id })}
disabled={openWindowMutation.isPending}
>
{openWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Unlock className="h-3 w-3 mr-1" />
)}
Open
</Button>
)}
{isOpen && (
<Button
size="sm"
variant="outline"
onClick={() => closeWindowMutation.mutate({ windowId: window.id })}
disabled={closeWindowMutation.isPending}
>
{closeWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Lock className="h-3 w-3 mr-1" />
)}
Close
</Button>
)}
{isClosed && (
<Button
size="sm"
variant="outline"
onClick={() => lockWindowMutation.mutate({ windowId: window.id })}
disabled={lockWindowMutation.isPending}
>
{lockWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<LockKeyhole className="h-3 w-3 mr-1" />
)}
Lock
</Button>
)}
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,144 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { CheckCircle2, Circle, Clock } from 'lucide-react'
import { toast } from 'sonner'
interface ApplicantCompetitionTimelineProps {
competitionId: string
}
const statusIcons: Record<string, React.ElementType> = {
completed: CheckCircle2,
current: Clock,
upcoming: Circle,
}
const statusColors: Record<string, string> = {
completed: 'text-emerald-600',
current: 'text-brand-blue',
upcoming: 'text-muted-foreground',
}
const statusBgColors: Record<string, string> = {
completed: 'bg-emerald-50',
current: 'bg-brand-blue/10',
upcoming: 'bg-muted',
}
export function ApplicantCompetitionTimeline({ competitionId }: ApplicantCompetitionTimelineProps) {
const { data: competition, isLoading } = trpc.competition.getById.useQuery(
{ id: competitionId },
{ enabled: !!competitionId }
)
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
</CardContent>
</Card>
)
}
if (!competition || !competition.rounds || competition.rounds.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Competition Timeline</CardTitle>
</CardHeader>
<CardContent className="text-center py-8">
<Circle className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No rounds available</p>
</CardContent>
</Card>
)
}
const rounds = competition.rounds || []
const currentRoundIndex = rounds.findIndex(r => r.status === 'ROUND_ACTIVE')
return (
<Card>
<CardHeader>
<CardTitle>Competition Timeline</CardTitle>
</CardHeader>
<CardContent>
<div className="relative space-y-6">
{/* Vertical connecting line */}
<div className="absolute left-5 top-5 bottom-5 w-0.5 bg-border" />
{rounds.map((round, index) => {
const isActive = round.status === 'ROUND_ACTIVE'
const isCompleted = index < currentRoundIndex || round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED'
const isCurrent = index === currentRoundIndex || isActive
const status = isCompleted ? 'completed' : isCurrent ? 'current' : 'upcoming'
const Icon = statusIcons[status]
return (
<div key={round.id} className="relative flex items-start gap-4">
{/* Icon */}
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full ${statusBgColors[status]} shrink-0`}
>
<Icon className={`h-5 w-5 ${statusColors[status]}`} />
</div>
{/* Content */}
<div className="flex-1 min-w-0 pb-6">
<div className="flex items-start justify-between flex-wrap gap-2 mb-2">
<div>
<h3 className="font-semibold">{round.name}</h3>
</div>
<Badge
variant={
status === 'completed'
? 'default'
: status === 'current'
? 'default'
: 'secondary'
}
className={
status === 'completed'
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: status === 'current'
? 'bg-brand-blue text-white'
: ''
}
>
{status === 'completed' && 'Completed'}
{status === 'current' && 'In Progress'}
{status === 'upcoming' && 'Upcoming'}
</Badge>
</div>
{round.windowOpenAt && round.windowCloseAt && (
<div className="text-sm text-muted-foreground space-y-1">
<p>
Opens: {new Date(round.windowOpenAt).toLocaleDateString()}
</p>
<p>
Closes: {new Date(round.windowCloseAt).toLocaleDateString()}
</p>
</div>
)}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,181 @@
'use client'
import { useState, useRef } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Upload, X, FileText, AlertCircle } from 'lucide-react'
interface FileRequirement {
id: string
label: string
description?: string
mimeTypes: string[]
maxSizeMb?: number
required: boolean
}
interface FileUploadSlotProps {
requirement: FileRequirement
isLocked: boolean
onUpload: (file: File) => void
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
function formatMimeTypes(mimeTypes: string[]): string {
const extensions = mimeTypes.map(mime => {
const parts = mime.split('/')
return parts[1] || mime
})
return extensions.join(', ').toUpperCase()
}
export function FileUploadSlot({ requirement, isLocked, onUpload }: FileUploadSlotProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setError(null)
// Validate file type
if (requirement.mimeTypes.length > 0 && !requirement.mimeTypes.includes(file.type)) {
setError(`File type not allowed. Accepted types: ${formatMimeTypes(requirement.mimeTypes)}`)
return
}
// Validate file size
if (requirement.maxSizeMb) {
const maxBytes = requirement.maxSizeMb * 1024 * 1024
if (file.size > maxBytes) {
setError(`File size exceeds ${requirement.maxSizeMb} MB limit`)
return
}
}
setSelectedFile(file)
onUpload(file)
}
const handleRemove = () => {
setSelectedFile(null)
setError(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleClick = () => {
if (!isLocked) {
fileInputRef.current?.click()
}
}
return (
<Card className={isLocked ? 'opacity-60' : ''}>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-medium">{requirement.label}</h3>
{requirement.required && (
<Badge variant="destructive" className="text-xs">
Required
</Badge>
)}
{isLocked && (
<Badge variant="secondary" className="text-xs">
Locked
</Badge>
)}
</div>
{requirement.description && (
<p className="text-sm text-muted-foreground mt-1">
{requirement.description}
</p>
)}
<div className="flex flex-wrap gap-2 mt-2 text-xs text-muted-foreground">
{requirement.mimeTypes.length > 0 && (
<span>Accepted: {formatMimeTypes(requirement.mimeTypes)}</span>
)}
{requirement.maxSizeMb && (
<span> Max size: {requirement.maxSizeMb} MB</span>
)}
</div>
</div>
</div>
{/* File preview or upload button */}
{selectedFile ? (
<div className="flex items-center gap-3 p-3 rounded-lg border bg-muted/50">
<FileText className="h-8 w-8 text-brand-blue shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate" title={selectedFile.name}>
{selectedFile.name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(selectedFile.size)}
</p>
</div>
<div className="flex gap-2 shrink-0">
<Button
size="sm"
variant="outline"
onClick={handleClick}
disabled={isLocked}
>
Replace
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleRemove}
disabled={isLocked}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<div>
<Button
variant="outline"
className="w-full border-dashed"
onClick={handleClick}
disabled={isLocked}
>
<Upload className="mr-2 h-4 w-4" />
{isLocked ? 'Upload Disabled' : 'Choose File'}
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={requirement.mimeTypes.join(',')}
onChange={handleFileSelect}
disabled={isLocked}
/>
</div>
)}
{/* Error message */}
{error && (
<div className="flex items-start gap-2 mt-3 p-2 rounded-md bg-red-50 border border-red-200">
<AlertCircle className="h-4 w-4 text-red-600 shrink-0 mt-0.5" />
<p className="text-sm text-red-700">{error}</p>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -13,8 +13,8 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface StageComparison {
stageId: string
stageName: string
roundId: string
roundName: string
projectCount: number
evaluationCount: number
completionRate: number
@@ -31,7 +31,7 @@ const STAGE_COLORS = ['#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f']
export function CrossStageComparisonChart({ data }: CrossStageComparisonProps) {
// Prepare comparison data
const comparisonData = data.map((stage, i) => ({
name: stage.stageName.length > 20 ? stage.stageName.slice(0, 20) + '...' : stage.stageName,
name: stage.roundName.length > 20 ? stage.roundName.slice(0, 20) + '...' : stage.roundName,
projects: stage.projectCount,
evaluations: stage.evaluationCount,
completionRate: stage.completionRate,

View File

@@ -26,8 +26,8 @@ export function StepContact({ form, config }: StepContactProps) {
const showCity = !config || isFieldVisible(config, 'city')
const phoneRequired = !config || isFieldRequired(config, 'contactPhone')
const countryRequired = !config || isFieldRequired(config, 'country')
const phoneLabel = config ? getFieldConfig(config, 'contactPhone').label : undefined
const countryLabel = config ? getFieldConfig(config, 'country').label : undefined
const phoneLabel = config ? getFieldConfig(config, 'contactPhone')?.label : undefined
const countryLabel = config ? getFieldConfig(config, 'country')?.label : undefined
return (
<WizardStepContent

View File

@@ -29,7 +29,7 @@ export function StepProject({ form, oceanIssues, config }: StepProjectProps) {
const oceanIssue = watch('oceanIssue')
const description = watch('description') || ''
const showTeamName = !config || isFieldVisible(config, 'teamName')
const descriptionLabel = config ? getFieldConfig(config, 'description').label : undefined
const descriptionLabel = config ? getFieldConfig(config, 'description')?.label : undefined
return (
<WizardStepContent

View File

@@ -51,12 +51,12 @@ import { TeamMemberRole } from '@prisma/client'
// ---------------------------------------------------------------------------
interface ApplyWizardDynamicProps {
mode: 'edition' | 'stage' | 'round'
mode: 'edition' | 'round' | 'stage'
config: WizardConfig
programName: string
programYear: number
programId?: string
stageId?: string
roundId?: string
isOpen: boolean
submissionDeadline?: Date | string | null
onSubmit: (data: Record<string, unknown>) => Promise<void>
@@ -390,7 +390,7 @@ export function ApplyWizardDynamic({
programName,
programYear,
programId,
stageId,
roundId,
isOpen,
submissionDeadline,
onSubmit,

View File

@@ -320,7 +320,7 @@ export function EvaluationForm({
toast.success('Evaluation submitted successfully!')
startTransition(() => {
router.push('/jury/stages')
router.push('/jury/competitions')
router.refresh()
})
} catch (error) {

View File

@@ -8,13 +8,13 @@ import { ProjectFilesSection } from './project-files-section'
interface CollapsibleFilesSectionProps {
projectId: string
stageId: string
roundId: string
fileCount: number
}
export function CollapsibleFilesSection({
projectId,
stageId,
roundId,
fileCount,
}: CollapsibleFilesSectionProps) {
const [isExpanded, setIsExpanded] = useState(false)
@@ -63,7 +63,7 @@ export function CollapsibleFilesSection({
{isExpanded && (
<CardContent className="pt-0">
{showFiles ? (
<ProjectFilesSection projectId={projectId} stageId={stageId} />
<ProjectFilesSection projectId={projectId} roundId={roundId} />
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
Loading documents...

View File

@@ -0,0 +1,181 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
interface Project {
id: string;
title: string;
category: string;
}
interface DeliberationRankingFormProps {
projects: Project[];
mode: 'SINGLE_WINNER_VOTE' | 'FULL_RANKING';
onSubmit: (votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>) => void;
disabled?: boolean;
}
export function DeliberationRankingForm({
projects,
mode,
onSubmit,
disabled = false
}: DeliberationRankingFormProps) {
const [selectedWinner, setSelectedWinner] = useState<string>('');
const [rankings, setRankings] = useState<Record<string, number>>({});
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const handleSubmit = () => {
if (mode === 'SINGLE_WINNER_VOTE') {
if (!selectedWinner) {
return;
}
} else {
// FULL_RANKING mode - check if all ranks are assigned
const assignedRanks = Object.values(rankings);
if (assignedRanks.length !== projects.length) {
return;
}
}
setConfirmDialogOpen(true);
};
const handleConfirm = () => {
if (mode === 'SINGLE_WINNER_VOTE') {
onSubmit([
{
projectId: selectedWinner,
isWinnerPick: true
}
]);
} else {
// FULL_RANKING mode
const votes = Object.entries(rankings).map(([projectId, rank]) => ({
projectId,
rank
}));
onSubmit(votes);
}
setConfirmDialogOpen(false);
};
const isValid = mode === 'SINGLE_WINNER_VOTE'
? !!selectedWinner
: Object.keys(rankings).length === projects.length;
return (
<>
<Card>
<CardHeader>
<CardTitle>
{mode === 'SINGLE_WINNER_VOTE' ? 'Select Winner' : 'Rank All Projects'}
</CardTitle>
<CardDescription>
{mode === 'SINGLE_WINNER_VOTE'
? 'Choose your top pick for this category'
: 'Assign a rank (1 = best) to each project'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{mode === 'SINGLE_WINNER_VOTE' ? (
<RadioGroup value={selectedWinner} onValueChange={setSelectedWinner}>
<div className="space-y-3">
{projects.map((project) => (
<div
key={project.id}
className="flex items-center space-x-3 rounded-lg border p-4"
>
<RadioGroupItem value={project.id} id={project.id} />
<Label htmlFor={project.id} className="flex-1 cursor-pointer">
<div>
<p className="font-medium">{project.title}</p>
<Badge variant="outline" className="mt-1">
{project.category}
</Badge>
</div>
</Label>
</div>
))}
</div>
</RadioGroup>
) : (
<div className="space-y-3">
{projects.map((project) => (
<div
key={project.id}
className="flex items-center gap-3 rounded-lg border p-4"
>
<Input
type="number"
min="1"
max={projects.length}
placeholder="Rank"
value={rankings[project.id] || ''}
onChange={(e) => {
const rank = parseInt(e.target.value) || 0;
if (rank > 0 && rank <= projects.length) {
setRankings({
...rankings,
[project.id]: rank
});
} else if (e.target.value === '') {
const newRankings = { ...rankings };
delete newRankings[project.id];
setRankings(newRankings);
}
}}
className="w-20"
/>
<div className="flex-1">
<p className="font-medium">{project.title}</p>
<Badge variant="outline" className="mt-1">
{project.category}
</Badge>
</div>
</div>
))}
</div>
)}
<Button onClick={handleSubmit} disabled={!isValid || disabled} className="w-full" size="lg">
{disabled ? 'Submitting...' : 'Submit Vote'}
</Button>
</CardContent>
</Card>
{/* Confirmation Dialog */}
<AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
<AlertDialogDescription>
{mode === 'SINGLE_WINNER_VOTE'
? 'You have selected your winner. This vote cannot be changed once submitted.'
: 'You have ranked all projects. This ranking cannot be changed once submitted.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Review</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>Confirm Vote</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Slider } from '@/components/ui/slider';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog';
import { CheckCircle2 } from 'lucide-react';
interface LiveVotingFormProps {
sessionId?: string;
projectId: string;
onVoteSubmit: (vote: { score: number }) => void;
disabled?: boolean;
}
export function LiveVotingForm({
sessionId,
projectId,
onVoteSubmit,
disabled = false
}: LiveVotingFormProps) {
const [score, setScore] = useState(50);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [hasSubmitted, setHasSubmitted] = useState(false);
const handleSubmit = () => {
setConfirmDialogOpen(true);
};
const handleConfirm = () => {
onVoteSubmit({ score });
setHasSubmitted(true);
setConfirmDialogOpen(false);
};
if (hasSubmitted || disabled) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" />
<p className="font-medium">Vote Submitted</p>
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/100</p>
</CardContent>
</Card>
);
}
return (
<>
<Card>
<CardHeader>
<CardTitle>Live Voting</CardTitle>
<CardDescription>Rate this project on a scale of 0-100</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Score</Label>
<div className="flex items-center gap-3">
<Input
type="number"
min="0"
max="100"
value={score}
onChange={(e) => setScore(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))}
className="w-20 text-center"
/>
<span className="text-2xl font-bold text-primary">{score}</span>
</div>
</div>
<Slider
value={[score]}
onValueChange={(values) => setScore(values[0])}
min={0}
max={100}
step={1}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Poor (0)</span>
<span>Average (50)</span>
<span>Excellent (100)</span>
</div>
</div>
<Button onClick={handleSubmit} className="w-full" size="lg">
Submit Vote
</Button>
</CardContent>
</Card>
{/* Confirmation Dialog */}
<AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
<AlertDialogDescription>
You are about to submit a score of <strong>{score}/100</strong>. This action cannot
be undone. Are you sure?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>Confirm Vote</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,145 @@
'use client'
import { trpc } from '@/lib/trpc/client'
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 { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { FileText, Download, ExternalLink } from 'lucide-react'
import { toast } from 'sonner'
interface MultiWindowDocViewerProps {
roundId: string
projectId: string
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
function getFileIcon(mimeType: string) {
if (mimeType.startsWith('image/')) return '🖼️'
if (mimeType.startsWith('video/')) return '🎥'
if (mimeType.includes('pdf')) return '📄'
if (mimeType.includes('word') || mimeType.includes('document')) return '📝'
if (mimeType.includes('sheet') || mimeType.includes('excel')) return '📊'
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return '📊'
return '📎'
}
export function MultiWindowDocViewer({ roundId, projectId }: MultiWindowDocViewerProps) {
const { data: windows, isLoading } = trpc.round.getVisibleWindows.useQuery(
{ roundId },
{ enabled: !!roundId }
)
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-64" />
</CardContent>
</Card>
)
}
if (!windows || windows.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Documents</CardTitle>
<CardDescription>Submission windows and uploaded files</CardDescription>
</CardHeader>
<CardContent className="text-center py-8">
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No submission windows available</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Documents</CardTitle>
<CardDescription>Files submitted across all windows</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue={windows[0]?.id || ''} className="w-full">
<TabsList className="w-full flex-wrap justify-start h-auto gap-1 bg-transparent p-0 mb-4">
{windows.map((window: any) => (
<TabsTrigger
key={window.id}
value={window.id}
className="data-[state=active]:bg-brand-blue data-[state=active]:text-white px-4 py-2 rounded-md text-sm"
>
{window.name}
{window.files && window.files.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{window.files.length}
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
{windows.map((window: any) => (
<TabsContent key={window.id} value={window.id} className="mt-0">
{!window.files || window.files.length === 0 ? (
<div className="text-center py-8 border border-dashed rounded-lg">
<FileText className="h-10 w-10 text-muted-foreground/50 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">No files uploaded</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{window.files.map((file: any) => (
<Card key={file.id} className="overflow-hidden">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-2xl">{getFileIcon(file.mimeType || '')}</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate" title={file.filename}>
{file.filename}
</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{file.mimeType?.split('/')[1]?.toUpperCase() || 'FILE'}
</Badge>
{file.size && (
<span className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</span>
)}
</div>
<div className="flex gap-2 mt-3">
<Button size="sm" variant="outline" className="h-7 text-xs">
<Download className="mr-1 h-3 w-3" />
Download
</Button>
<Button size="sm" variant="ghost" className="h-7 text-xs">
<ExternalLink className="mr-1 h-3 w-3" />
Preview
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
))}
</Tabs>
</CardContent>
</Card>
)
}

View File

@@ -8,13 +8,13 @@ import { AlertCircle, FileX } from 'lucide-react'
interface ProjectFilesSectionProps {
projectId: string
stageId: string
roundId: string
}
export function ProjectFilesSection({ projectId, stageId }: ProjectFilesSectionProps) {
const { data: groupedFiles, isLoading, error } = trpc.file.listByProjectForStage.useQuery({
export function ProjectFilesSection({ projectId, roundId }: ProjectFilesSectionProps) {
const { data: files, isLoading, error } = trpc.file.listByProject.useQuery({
projectId,
stageId,
roundId,
})
if (isLoading) {
@@ -35,7 +35,7 @@ export function ProjectFilesSection({ projectId, stageId }: ProjectFilesSectionP
)
}
if (!groupedFiles || groupedFiles.length === 0) {
if (!files || files.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
@@ -49,22 +49,9 @@ export function ProjectFilesSection({ projectId, stageId }: ProjectFilesSectionP
)
}
// Flatten all files from all stage groups for FileViewer
const allFiles = groupedFiles.flatMap((group) => group.files)
return (
<div className="space-y-4">
{groupedFiles.map((group) => (
<div key={group.stageId || 'general'} className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
{group.stageName}
</h3>
<div className="flex-1 h-px bg-border" />
</div>
<FileViewer files={group.files} />
</div>
))}
<FileViewer files={files} />
</div>
)
}

View File

@@ -28,12 +28,12 @@ import {
ChevronRight,
BookOpen,
Handshake,
CircleDot,
History,
Trophy,
User,
MessageSquare,
LayoutTemplate,
Medal,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
@@ -69,9 +69,9 @@ const navigation: NavItem[] = [
icon: LayoutDashboard,
},
{
name: 'Rounds',
href: '/admin/rounds/pipelines',
icon: CircleDot,
name: 'Competitions',
href: '/admin/competitions',
icon: Medal,
},
{
name: 'Awards',
@@ -223,7 +223,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href))
const isParentActive = item.subItems
? pathname.startsWith('/admin/rounds')
? pathname.startsWith('/admin/competitions')
: false
return (
<div key={item.name}>
@@ -247,7 +247,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
<div className="ml-7 mt-0.5 space-y-0.5">
{item.subItems.map((sub) => {
const isSubActive = pathname === sub.href ||
(sub.href !== '/admin/rounds' && pathname.startsWith(sub.href))
(sub.href !== '/admin/competitions' && pathname.startsWith(sub.href))
return (
<Link
key={sub.name}

View File

@@ -20,8 +20,8 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
icon: Users,
},
{
name: 'Pipeline',
href: '/applicant/pipeline',
name: 'Competitions',
href: '/applicant/competitions',
icon: Layers,
},
{

View File

@@ -19,15 +19,15 @@ function RemainingBadge() {
const now = new Date()
const remaining = (assignments as Array<{
stage: { status: string; windowOpenAt: Date | null; windowCloseAt: Date | null } | null
round: { status: string; windowOpenAt: Date | null; windowCloseAt: Date | null } | null
evaluation: { status: string } | null
}>).filter((a) => {
const isActive =
a.stage?.status === 'STAGE_ACTIVE' &&
a.stage.windowOpenAt &&
a.stage.windowCloseAt &&
new Date(a.stage.windowOpenAt) <= now &&
new Date(a.stage.windowCloseAt) >= now
a.round?.status === 'ROUND_ACTIVE' &&
a.round.windowOpenAt &&
a.round.windowCloseAt &&
new Date(a.round.windowOpenAt) <= now &&
new Date(a.round.windowCloseAt) >= now
const isIncomplete = !a.evaluation || a.evaluation.status !== 'SUBMITTED'
return isActive && isIncomplete
}).length
@@ -49,8 +49,8 @@ export function JuryNav({ user }: JuryNavProps) {
icon: Home,
},
{
name: 'Stages',
href: '/jury/stages',
name: 'Competitions',
href: '/jury/competitions',
icon: Layers,
},
{

View File

@@ -0,0 +1,191 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { FileText, Upload, CheckCircle2, ArrowUp } from 'lucide-react'
import { toast } from 'sonner'
interface FilePromotionPanelProps {
mentorAssignmentId: string
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
export function FilePromotionPanel({ mentorAssignmentId }: FilePromotionPanelProps) {
const [selectedSlot, setSelectedSlot] = useState<string>('')
const utils = trpc.useUtils()
// Mock workspace files - in real implementation, would fetch from workspaceGetFiles
const workspaceFiles: any[] = [] // Placeholder
const promoteMutation = trpc.mentor.workspacePromoteFile.useMutation({
onSuccess: () => {
toast.success('File promoted successfully')
setSelectedSlot('')
},
onError: (err) => toast.error(err.message),
})
const handlePromote = (mentorFileId: string) => {
if (!selectedSlot) {
toast.error('Please select a file requirement slot')
return
}
promoteMutation.mutate({
roundId: '', // Would need to get this from context
mentorFileId,
slotKey: selectedSlot,
})
}
const isLoading = false // Placeholder
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
File Promotion
</CardTitle>
<CardDescription>
Promote workspace files to official submission windows
</CardDescription>
</CardHeader>
<CardContent>
{/* Slot selector */}
<div className="mb-6">
<label className="text-sm font-medium mb-2 block">
Target File Requirement
</label>
<Select value={selectedSlot} onValueChange={setSelectedSlot}>
<SelectTrigger>
<SelectValue placeholder="Select a file requirement..." />
</SelectTrigger>
<SelectContent>
{/* Mock slots - in real implementation, would fetch available requirements */}
<SelectItem value="pitch_deck">Pitch Deck</SelectItem>
<SelectItem value="business_plan">Business Plan</SelectItem>
<SelectItem value="presentation">Presentation</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
Select which file requirement to promote files to
</p>
</div>
{/* Workspace files list */}
<div className="space-y-3">
{workspaceFiles.length === 0 ? (
<div className="text-center py-12 border border-dashed rounded-lg">
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No workspace files available</p>
<p className="text-xs text-muted-foreground mt-1">
Files shared in the workspace will appear here
</p>
</div>
) : (
workspaceFiles.map((file: any) => {
const isPromoted = file.promotedToWindow
return (
<Card key={file.id}>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-brand-blue/10 p-3 shrink-0">
<FileText className="h-5 w-5 text-brand-blue" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate" title={file.filename}>
{file.filename}
</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{file.mimeType?.split('/')[1]?.toUpperCase() || 'FILE'}
</Badge>
{file.size && (
<span className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</span>
)}
</div>
</div>
{isPromoted ? (
<Badge variant="default" className="shrink-0 bg-emerald-50 text-emerald-700 border-emerald-200">
<CheckCircle2 className="mr-1 h-3 w-3" />
Promoted
</Badge>
) : (
<Button
size="sm"
onClick={() => handlePromote(file.id)}
disabled={!selectedSlot || promoteMutation.isPending}
className="shrink-0"
>
<ArrowUp className="mr-1 h-3 w-3" />
Promote
</Button>
)}
</div>
{isPromoted && file.promotedToWindow && (
<p className="text-xs text-muted-foreground">
Promoted to: {file.promotedToWindow.name}
</p>
)}
</div>
</div>
</CardContent>
</Card>
)
})
)}
</div>
{workspaceFiles.length > 0 && (
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
<p className="text-sm text-muted-foreground">
<strong>Note:</strong> Promoting a file will make it visible to jurors in the selected
submission window. This action can help teams submit refined versions of their work.
</p>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Send, MessageSquare } from 'lucide-react'
import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
interface WorkspaceChatProps {
mentorAssignmentId: string
}
export function WorkspaceChat({ mentorAssignmentId }: WorkspaceChatProps) {
const { data: session } = useSession()
const [message, setMessage] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const utils = trpc.useUtils()
const { data: messages, isLoading } = trpc.mentor.workspaceGetMessages.useQuery(
{ mentorAssignmentId },
{
enabled: !!mentorAssignmentId,
refetchInterval: 10000, // Poll every 10 seconds
}
)
const sendMutation = trpc.mentor.workspaceSendMessage.useMutation({
onSuccess: () => {
utils.mentor.workspaceGetMessages.invalidate({ mentorAssignmentId })
setMessage('')
toast.success('Message sent')
},
onError: (err) => toast.error(err.message),
})
const handleSend = () => {
if (!message.trim()) return
sendMutation.mutate({
mentorAssignmentId,
message: message,
role: 'MENTOR_ROLE',
})
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16" />
))}
</div>
</CardContent>
</Card>
)
}
const currentUserId = session?.user?.id
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Workspace Chat
</CardTitle>
<CardDescription>
Communicate with the project team
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Messages list */}
<div className="h-[400px] overflow-y-auto space-y-3 p-4 border rounded-lg bg-muted/20">
{!messages || messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<MessageSquare className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">No messages yet</p>
<p className="text-xs text-muted-foreground mt-1">
Start the conversation by sending a message below
</p>
</div>
) : (
<>
{messages.map((msg: any) => {
const isMe = msg.senderId === currentUserId
const isMentor = msg.sender?.role === 'MENTOR'
return (
<div
key={msg.id}
className={`flex gap-3 ${isMe ? 'flex-row-reverse' : 'flex-row'}`}
>
<Avatar className="h-8 w-8 shrink-0">
<AvatarFallback
className={
isMentor
? 'bg-brand-teal text-white'
: 'bg-brand-blue text-white'
}
>
{msg.sender?.name?.charAt(0).toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className={`flex-1 min-w-0 ${isMe ? 'items-end' : 'items-start'} flex flex-col`}>
<div
className={`rounded-lg px-4 py-2 max-w-[80%] ${
isMe
? 'bg-brand-blue text-white'
: isMentor
? 'bg-brand-teal/10 border border-brand-teal/20'
: 'bg-white border'
}`}
>
<p className="text-sm font-medium mb-1">
{msg.sender?.name || 'Unknown'}
{isMentor && !isMe && (
<span className="ml-2 text-xs opacity-75">(Mentor)</span>
)}
</p>
<p className={`text-sm ${isMe ? 'text-white' : 'text-foreground'}`}>
{msg.content}
</p>
<p className={`text-xs mt-1 ${isMe ? 'text-white/70' : 'text-muted-foreground'}`}>
{msg.createdAt && formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true })}
</p>
</div>
</div>
</div>
)
})}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input area */}
<div className="flex gap-2">
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Type your message... (Press Enter to send, Shift+Enter for new line)"
className="flex-1 resize-none"
rows={3}
/>
<Button
onClick={handleSend}
disabled={!message.trim() || sendMutation.isPending}
className="shrink-0 bg-brand-blue hover:bg-brand-blue-light"
>
<Send className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@@ -48,7 +48,7 @@ import { useDebouncedCallback } from 'use-debounce'
const PER_PAGE_OPTIONS = [10, 20, 50]
export function ObserverDashboardContent({ userName }: { userName?: string }) {
const [selectedStageId, setSelectedStageId] = useState<string>('all')
const [selectedRoundId, setSelectedRoundId] = useState<string>('all')
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
@@ -65,8 +65,8 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
debouncedSetSearch(value)
}
const handleStageChange = (value: string) => {
setSelectedStageId(value)
const handleRoundChange = (value: string) => {
setSelectedRoundId(value)
setPage(1)
}
@@ -75,38 +75,38 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
setPage(1)
}
// Fetch programs/stages for the filter dropdown
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
// Fetch programs/rounds for the filter dropdown
const { data: programs } = trpc.program.list.useQuery({})
const stages = programs?.flatMap((p) =>
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map((s) => ({
id: s.id,
name: s.name,
const rounds = programs?.flatMap((p) =>
(p.rounds ?? []).map((r: { id: string; name: string; status: string }) => ({
id: r.id,
name: r.name,
programName: `${p.year} Edition`,
status: s.status,
status: r.status,
}))
) || []
// Fetch dashboard stats
const stageIdParam = selectedStageId !== 'all' ? selectedStageId : undefined
const roundIdParam = selectedRoundId !== 'all' ? selectedRoundId : undefined
const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery(
{ stageId: stageIdParam }
{ roundId: roundIdParam }
)
// Fetch projects
const { data: projectsData, isLoading: projectsLoading } = trpc.analytics.getAllProjects.useQuery({
stageId: stageIdParam,
roundId: roundIdParam,
search: debouncedSearch || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
page,
perPage,
})
// Fetch recent stages for jury completion
const { data: recentStagesData } = trpc.program.list.useQuery({ includeStages: true })
const recentStages = recentStagesData?.flatMap((p) =>
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map((s) => ({
...s,
// Fetch recent rounds for jury completion
const { data: recentRoundsData } = trpc.program.list.useQuery({})
const recentRounds = recentRoundsData?.flatMap((p) =>
(p.rounds ?? []).map((r: { id: string; name: string; status: string }) => ({
...r,
programName: `${p.year} Edition`,
}))
)?.slice(0, 5) || []
@@ -141,18 +141,18 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</div>
</div>
{/* Stage Filter */}
{/* Round Filter */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<label className="text-sm font-medium">Filter by Stage:</label>
<Select value={selectedStageId} onValueChange={handleStageChange}>
<label className="text-sm font-medium">Filter by Round:</label>
<Select value={selectedRoundId} onValueChange={handleRoundChange}>
<SelectTrigger className="w-full sm:w-[300px]">
<SelectValue placeholder="All Stages" />
<SelectValue placeholder="All Rounds" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Stages</SelectItem>
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
<SelectItem value="all">All Rounds</SelectItem>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
@@ -184,7 +184,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<p className="text-sm font-medium text-muted-foreground">Programs</p>
<p className="text-2xl font-bold mt-1">{stats.programCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{stats.activeStageCount} active round{stats.activeStageCount !== 1 ? 's' : ''}
{stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''}
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
@@ -203,7 +203,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<p className="text-sm font-medium text-muted-foreground">Projects</p>
<p className="text-2xl font-bold mt-1">{stats.projectCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{selectedStageId !== 'all' ? 'In selected stage' : 'Across all stages'}
{selectedRoundId !== 'all' ? 'In selected round' : 'Across all rounds'}
</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
@@ -341,7 +341,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<TableCell className="max-w-[150px] truncate">{project.teamName || '-'}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs whitespace-nowrap">
{project.stageName}
{project.roundName}
</Badge>
</TableCell>
<TableCell>
@@ -375,7 +375,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<Badge variant="outline" className="text-xs">
{project.stageName}
{project.roundName}
</Badge>
<div className="flex gap-3">
<span>Score: {project.averageScore !== null ? project.averageScore.toFixed(2) : '-'}</span>
@@ -465,8 +465,8 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</AnimatedCard>
)}
{/* Recent Stages */}
{recentStages.length > 0 && (
{/* Recent Rounds */}
{recentRounds.length > 0 && (
<AnimatedCard index={6}>
<Card>
<CardHeader>
@@ -474,40 +474,40 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<div className="rounded-lg bg-violet-500/10 p-1.5">
<BarChart3 className="h-4 w-4 text-violet-500" />
</div>
Recent Stages
Recent Rounds
</CardTitle>
<CardDescription>Overview of the latest evaluation stages</CardDescription>
<CardDescription>Overview of the latest evaluation rounds</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentStages.map((stage) => (
{recentRounds.map((round) => (
<div
key={stage.id}
key={round.id}
className="flex items-center justify-between rounded-lg border p-4 transition-all hover:shadow-sm"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium">{stage.name}</p>
<p className="font-medium">{round.name}</p>
<Badge
variant={
stage.status === 'STAGE_ACTIVE'
round.status === 'ROUND_ACTIVE'
? 'default'
: stage.status === 'STAGE_CLOSED'
: round.status === 'ROUND_CLOSED'
? 'secondary'
: 'outline'
}
>
{stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_CLOSED' ? 'Closed' : stage.status}
{round.status === 'ROUND_ACTIVE' ? 'Active' : round.status === 'ROUND_CLOSED' ? 'Closed' : round.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{stage.programName}
{round.programName}
</p>
</div>
<div className="text-right text-sm">
<p>{stage._count?.projects || 0} projects</p>
<p>Round details</p>
<p className="text-muted-foreground">
{stage._count?.assignments || 0} assignments
View analytics
</p>
</div>
</div>

View File

@@ -0,0 +1,51 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, ThumbsUp } from 'lucide-react';
interface Project {
id: string;
title: string;
category?: string;
}
interface AudienceVoteCardProps {
project: Project;
onVote: () => void;
hasVoted: boolean;
}
export function AudienceVoteCard({ project, onVote, hasVoted }: AudienceVoteCardProps) {
return (
<Card className="mx-auto max-w-2xl">
<CardHeader>
<CardTitle className="text-2xl sm:text-3xl">{project.title}</CardTitle>
{project.category && (
<CardDescription className="mt-2">
<Badge className="text-sm">{project.category}</Badge>
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-6">
{hasVoted ? (
<div className="flex flex-col items-center justify-center py-8">
<CheckCircle2 className="mb-4 h-16 w-16 text-green-600" />
<p className="text-xl font-medium">Thank You for Voting!</p>
<p className="mt-2 text-sm text-muted-foreground">Your vote has been recorded</p>
</div>
) : (
<Button
onClick={onVote}
size="lg"
className="w-full bg-[#de0f1e] py-8 text-lg font-semibold hover:bg-[#de0f1e]/90 sm:text-xl"
>
<ThumbsUp className="mr-3 h-6 w-6" />
Vote for This Project
</Button>
)}
</CardContent>
</Card>
);
}

View File

@@ -19,7 +19,7 @@ import {
} from '@/lib/pdf-generator'
interface ExportPdfButtonProps {
stageId: string
roundId: string
roundName?: string
programName?: string
chartRefs?: Record<string, RefObject<HTMLDivElement | null>>
@@ -28,7 +28,7 @@ interface ExportPdfButtonProps {
}
export function ExportPdfButton({
stageId,
roundId,
roundName,
programName,
chartRefs,
@@ -38,7 +38,7 @@ export function ExportPdfButton({
const [generating, setGenerating] = useState(false)
const { refetch } = trpc.export.getReportData.useQuery(
{ stageId, sections: [] },
{ roundId, sections: [] },
{ enabled: false }
)

View File

@@ -46,8 +46,8 @@ interface FileUploadProps {
allowedTypes?: string[]
multiple?: boolean
className?: string
stageId?: string
availableStages?: Array<{ id: string; name: string }>
roundId?: string
availableRounds?: Array<{ id: string; name: string }>
}
// Map MIME types to suggested file types
@@ -85,12 +85,12 @@ export function FileUpload({
allowedTypes,
multiple = true,
className,
stageId,
availableStages,
roundId,
availableRounds,
}: FileUploadProps) {
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [selectedStageId, setSelectedStageId] = useState<string | null>(stageId ?? null)
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(roundId ?? null)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.file.getUploadUrl.useMutation()
@@ -129,7 +129,7 @@ export function FileUpload({
fileType,
mimeType: file.type || 'application/octet-stream',
size: file.size,
stageId: selectedStageId ?? undefined,
roundId: selectedRoundId ?? undefined,
})
// Store the DB file ID
@@ -309,24 +309,24 @@ export function FileUpload({
return (
<div className={cn('space-y-4', className)}>
{/* Stage selector */}
{availableStages && availableStages.length > 0 && (
{/* Round selector */}
{availableRounds && availableRounds.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium">
Upload for Stage
Upload for Round
</label>
<Select
value={selectedStageId ?? 'null'}
onValueChange={(value) => setSelectedStageId(value === 'null' ? null : value)}
value={selectedRoundId ?? 'null'}
onValueChange={(value) => setSelectedRoundId(value === 'null' ? null : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a stage" />
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
<SelectItem value="null">General (no specific stage)</SelectItem>
{availableStages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
<SelectItem value="null">General (no specific round)</SelectItem>
{availableRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>

View File

@@ -67,18 +67,18 @@ interface ProjectFile {
requirement?: FileRequirementInfo | null
}
interface StageGroup {
stageId: string | null
stageName: string
interface RoundGroup {
roundId: string | null
roundName: string
sortOrder: number
files: Array<ProjectFile & { isLate?: boolean }>
}
interface FileViewerProps {
files?: ProjectFile[]
groupedFiles?: StageGroup[]
groupedFiles?: RoundGroup[]
projectId?: string
stageId?: string
roundId?: string
className?: string
}
@@ -118,7 +118,7 @@ function getFileTypeLabel(fileType: string) {
}
}
export function FileViewer({ files, groupedFiles, projectId, stageId, className }: FileViewerProps) {
export function FileViewer({ files, groupedFiles, projectId, roundId, className }: FileViewerProps) {
// Render grouped view if groupedFiles is provided
if (groupedFiles) {
return <GroupedFileViewer groupedFiles={groupedFiles} className={className} />
@@ -148,7 +148,7 @@ export function FileViewer({ files, groupedFiles, projectId, stageId, className
return (
<div className={cn('space-y-4', className)}>
{/* Requirement Fulfillment Checklist */}
{stageId && <RequirementChecklist stageId={stageId} files={files} />}
{roundId && <RequirementChecklist roundId={roundId} files={files} />}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
@@ -167,7 +167,7 @@ export function FileViewer({ files, groupedFiles, projectId, stageId, className
)
}
function GroupedFileViewer({ groupedFiles, className }: { groupedFiles: StageGroup[], className?: string }) {
function GroupedFileViewer({ groupedFiles, className }: { groupedFiles: RoundGroup[], className?: string }) {
const hasAnyFiles = groupedFiles.some(group => group.files.length > 0)
if (!hasAnyFiles) {
@@ -204,11 +204,11 @@ function GroupedFileViewer({ groupedFiles, className }: { groupedFiles: StageGro
)
return (
<div key={group.stageId || 'no-stage'} className="space-y-3">
{/* Stage header */}
<div key={group.roundId || 'no-round'} className="space-y-3">
{/* Round header */}
<div className="flex items-center justify-between border-b pb-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
{group.stageName}
{group.roundName}
</h3>
<Badge variant="outline" className="text-xs">
{group.files.length} {group.files.length === 1 ? 'file' : 'files'}
@@ -739,8 +739,8 @@ function CompactFileItem({ file }: { file: ProjectFile }) {
* Displays a checklist of file requirements and their fulfillment status.
* Used by admins/jury to see which required files have been uploaded.
*/
function RequirementChecklist({ stageId, files }: { stageId: string; files: ProjectFile[] }) {
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({ stageId })
function RequirementChecklist({ roundId, files }: { roundId: string; files: ProjectFile[] }) {
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({ roundId })
if (requirements.length === 0) return null

View File

@@ -50,7 +50,7 @@ interface RequirementUploadSlotProps {
requirement: FileRequirement
existingFile?: UploadedFile | null
projectId: string
stageId: string
roundId: string
onFileChange?: () => void
disabled?: boolean
}
@@ -59,7 +59,7 @@ export function RequirementUploadSlot({
requirement,
existingFile,
projectId,
stageId,
roundId,
onFileChange,
disabled = false,
}: RequirementUploadSlotProps) {
@@ -110,13 +110,13 @@ export function RequirementUploadSlot({
try {
// Get presigned URL
const { url, bucket, objectKey, isLate, stageId: uploadStageId } =
const { url, bucket, objectKey, isLate, roundId: uploadRoundId } =
await getUploadUrl.mutateAsync({
projectId,
fileName: file.name,
mimeType: file.type,
fileType: 'OTHER',
stageId,
roundId,
requirementId: requirement.id,
})
@@ -150,7 +150,7 @@ export function RequirementUploadSlot({
fileType: 'OTHER',
bucket,
objectKey,
stageId: uploadStageId || stageId,
roundId: uploadRoundId || roundId,
isLate: isLate || false,
requirementId: requirement.id,
})
@@ -164,7 +164,7 @@ export function RequirementUploadSlot({
setProgress(0)
}
},
[projectId, stageId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange]
[projectId, roundId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange]
)
const handleDelete = useCallback(async () => {
@@ -309,22 +309,22 @@ export function RequirementUploadSlot({
interface RequirementUploadListProps {
projectId: string
stageId: string
roundId: string
disabled?: boolean
}
export function RequirementUploadList({ projectId, stageId, disabled }: RequirementUploadListProps) {
export function RequirementUploadList({ projectId, roundId, disabled }: RequirementUploadListProps) {
const utils = trpc.useUtils()
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({
stageId,
roundId,
})
const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, stageId })
const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, roundId })
if (requirements.length === 0) return null
const handleFileChange = () => {
utils.file.listByProject.invalidate({ projectId, stageId })
utils.file.listByProject.invalidate({ projectId, roundId })
}
return (
@@ -353,7 +353,7 @@ export function RequirementUploadList({ projectId, stageId, disabled }: Requirem
: null
}
projectId={projectId}
stageId={stageId}
roundId={roundId}
onFileChange={handleFileChange}
disabled={disabled}
/>

View File

@@ -1,47 +0,0 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
interface StageBreadcrumbProps {
pipelineName: string
trackName: string
stageName: string
stageId?: string
pipelineId?: string
className?: string
basePath?: string // e.g. '/jury/stages' or '/admin/reports/stages'
}
export function StageBreadcrumb({
pipelineName,
trackName,
stageName,
stageId,
pipelineId,
className,
basePath = '/jury/stages',
}: StageBreadcrumbProps) {
return (
<nav className={cn('flex items-center gap-1 text-sm text-muted-foreground', className)}>
<Link href={basePath as Route} className="hover:text-foreground transition-colors truncate max-w-[150px]">
{pipelineName}
</Link>
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
<span className="truncate max-w-[120px]">{trackName}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
{stageId ? (
<Link
href={`${basePath}/${stageId}/assignments` as Route}
className="hover:text-foreground transition-colors font-medium text-foreground truncate max-w-[150px]"
>
{stageName}
</Link>
) : (
<span className="font-medium text-foreground truncate max-w-[150px]">{stageName}</span>
)}
</nav>
)
}

View File

@@ -1,205 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
import {
CheckCircle,
Circle,
Clock,
XCircle,
FileText,
Users,
Vote,
ArrowRightLeft,
Presentation,
Award,
} from 'lucide-react'
interface StageTimelineItem {
id: string
name: string
stageType: string
isCurrent: boolean
state: string // PENDING, IN_PROGRESS, PASSED, REJECTED, etc.
enteredAt?: Date | string | null
}
interface StageTimelineProps {
stages: StageTimelineItem[]
orientation?: 'horizontal' | 'vertical'
className?: string
}
const stageTypeIcons: Record<string, typeof Circle> = {
INTAKE: FileText,
EVALUATION: Users,
VOTING: Vote,
DELIBERATION: ArrowRightLeft,
LIVE_PRESENTATION: Presentation,
AWARD: Award,
}
function getStateColor(state: string, isCurrent: boolean) {
if (state === 'REJECTED' || state === 'ELIMINATED')
return 'bg-destructive text-destructive-foreground'
if (state === 'PASSED' || state === 'COMPLETED')
return 'bg-green-600 text-white dark:bg-green-700'
if (state === 'IN_PROGRESS' || isCurrent)
return 'bg-primary text-primary-foreground'
return 'border-2 border-muted bg-background text-muted-foreground'
}
function getConnectorColor(state: string) {
if (state === 'PASSED' || state === 'COMPLETED' || state === 'IN_PROGRESS')
return 'bg-primary'
if (state === 'REJECTED' || state === 'ELIMINATED')
return 'bg-destructive/30'
return 'bg-muted'
}
function formatDate(date: Date | string | null | undefined) {
if (!date) return null
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
export function StageTimeline({
stages,
orientation = 'horizontal',
className,
}: StageTimelineProps) {
if (stages.length === 0) return null
if (orientation === 'vertical') {
return (
<div className={cn('relative', className)}>
<div className="space-y-0">
{stages.map((stage, index) => {
const Icon = stageTypeIcons[stage.stageType] || Circle
const isPassed = stage.state === 'PASSED' || stage.state === 'COMPLETED'
const isRejected = stage.state === 'REJECTED' || stage.state === 'ELIMINATED'
const isPending = !isPassed && !isRejected && !stage.isCurrent
return (
<div key={stage.id} className="relative flex gap-4">
{index < stages.length - 1 && (
<div
className={cn(
'absolute left-[15px] top-[32px] h-full w-0.5',
getConnectorColor(stage.state)
)}
/>
)}
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full',
getStateColor(stage.state, stage.isCurrent)
)}
>
{isRejected ? (
<XCircle className="h-4 w-4" />
) : isPassed ? (
<CheckCircle className="h-4 w-4" />
) : stage.isCurrent ? (
<Clock className="h-4 w-4" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
</div>
<div className="flex-1 pb-8">
<div className="flex items-center gap-2">
<p
className={cn(
'font-medium text-sm',
isRejected && 'text-destructive',
isPending && 'text-muted-foreground'
)}
>
{stage.name}
</p>
{stage.isCurrent && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
Current
</span>
)}
</div>
<p className="text-xs text-muted-foreground capitalize">
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
</p>
{stage.enteredAt && (
<p className="text-xs text-muted-foreground">
{formatDate(stage.enteredAt)}
</p>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
// Horizontal orientation
return (
<div className={cn('flex items-center gap-0 overflow-x-auto pb-2', className)}>
{stages.map((stage, index) => {
const Icon = stageTypeIcons[stage.stageType] || Circle
const isPassed = stage.state === 'PASSED' || stage.state === 'COMPLETED'
const isRejected = stage.state === 'REJECTED' || stage.state === 'ELIMINATED'
const isPending = !isPassed && !isRejected && !stage.isCurrent
return (
<div key={stage.id} className="flex items-center">
{index > 0 && (
<div
className={cn(
'h-0.5 w-8 lg:w-12 shrink-0',
getConnectorColor(stages[index - 1].state)
)}
/>
)}
<div className="flex flex-col items-center gap-1 shrink-0">
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full transition-colors',
getStateColor(stage.state, stage.isCurrent)
)}
>
{isRejected ? (
<XCircle className="h-4 w-4" />
) : isPassed ? (
<CheckCircle className="h-4 w-4" />
) : stage.isCurrent ? (
<Clock className="h-4 w-4" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
<div className="text-center max-w-[80px]">
<p
className={cn(
'text-xs font-medium leading-tight',
isRejected && 'text-destructive',
isPending && 'text-muted-foreground',
stage.isCurrent && 'text-primary'
)}
>
{stage.name}
</p>
{stage.enteredAt && (
<p className="text-[10px] text-muted-foreground">
{formatDate(stage.enteredAt)}
</p>
)}
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -1,133 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
import { Clock, CheckCircle, XCircle, Timer } from 'lucide-react'
import { CountdownTimer } from '@/components/shared/countdown-timer'
interface StageWindowBadgeProps {
windowOpenAt?: Date | string | null
windowCloseAt?: Date | string | null
status?: string
className?: string
}
function toDate(v: Date | string | null | undefined): Date | null {
if (!v) return null
return typeof v === 'string' ? new Date(v) : v
}
export function StageWindowBadge({
windowOpenAt,
windowCloseAt,
status,
className,
}: StageWindowBadgeProps) {
const now = new Date()
const openAt = toDate(windowOpenAt)
const closeAt = toDate(windowCloseAt)
// Determine window state
const isBeforeOpen = openAt && now < openAt
const isOpenEnded = openAt && !closeAt && now >= openAt
const isOpen = openAt && closeAt && now >= openAt && now <= closeAt
const isClosed = closeAt && now > closeAt
if (status === 'COMPLETED' || status === 'CLOSED') {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground bg-muted',
className
)}
>
<CheckCircle className="h-3 w-3 shrink-0" />
<span>Completed</span>
</div>
)
}
if (isClosed) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground bg-muted',
className
)}
>
<XCircle className="h-3 w-3 shrink-0" />
<span>Closed</span>
</div>
)
}
if (isOpenEnded) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>Open</span>
</div>
)
}
if (isOpen && closeAt) {
const remainingMs = closeAt.getTime() - now.getTime()
const isUrgent = remainingMs < 24 * 60 * 60 * 1000 // < 24 hours
if (isUrgent) {
return <CountdownTimer deadline={closeAt} label="Closes in" className={className} />
}
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>Open</span>
</div>
)
}
if (isBeforeOpen && openAt) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground border-dashed',
className
)}
>
<Timer className="h-3 w-3 shrink-0" />
<span>
Opens{' '}
{openAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
</div>
)
}
// No window configured
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground border-dashed',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>No window set</span>
</div>
)
}