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