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