Round system redesign: Phases 1-7 complete
Full pipeline/track/stage architecture replacing the legacy round system. Schema: 11 new models (Pipeline, Track, Stage, StageTransition, ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor, OverrideAction, AudienceVoter) + 8 new enums. Backend: 9 new routers (pipeline, stage, routing, stageFiltering, stageAssignment, cohort, live, decision, award) + 6 new services (stage-engine, routing-engine, stage-filtering, stage-assignment, stage-notifications, live-control). Frontend: Pipeline wizard (17 components), jury stage pages (7), applicant pipeline pages (3), public stage pages (2), admin pipeline pages (5), shared stage components (3), SSE route, live hook. Phase 6 refit: 23 routers/services migrated from roundId to stageId, all frontend components refitted. Deleted round.ts (985 lines), roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx, 10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs. Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing, TypeScript 0 errors, Next.js build succeeds, 13 integrity checks, legacy symbol sweep clean, auto-seed on first Docker startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -51,12 +51,12 @@ import { TeamMemberRole } from '@prisma/client'
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ApplyWizardDynamicProps {
|
||||
mode: 'edition' | 'round'
|
||||
mode: 'edition' | 'stage' | 'round'
|
||||
config: WizardConfig
|
||||
programName: string
|
||||
programYear: number
|
||||
programId?: string
|
||||
roundId?: string
|
||||
stageId?: string
|
||||
isOpen: boolean
|
||||
submissionDeadline?: Date | string | null
|
||||
onSubmit: (data: Record<string, unknown>) => Promise<void>
|
||||
@@ -390,7 +390,7 @@ export function ApplyWizardDynamic({
|
||||
programName,
|
||||
programYear,
|
||||
programId,
|
||||
roundId,
|
||||
stageId,
|
||||
isOpen,
|
||||
submissionDeadline,
|
||||
onSubmit,
|
||||
|
||||
@@ -39,8 +39,7 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
interface CSVImportFormProps {
|
||||
programId: string
|
||||
roundId?: string
|
||||
roundName: string
|
||||
stageName?: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
@@ -73,7 +72,7 @@ interface MappedProject {
|
||||
metadataJson?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVImportFormProps) {
|
||||
export function CSVImportForm({ programId, stageName, onSuccess }: CSVImportFormProps) {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<Step>('upload')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
@@ -226,7 +225,6 @@ export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVI
|
||||
try {
|
||||
await importMutation.mutateAsync({
|
||||
programId,
|
||||
roundId,
|
||||
projects: valid,
|
||||
})
|
||||
setImportProgress(100)
|
||||
@@ -257,7 +255,7 @@ export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVI
|
||||
<CardTitle>Upload CSV File</CardTitle>
|
||||
<CardDescription>
|
||||
Upload a CSV file containing project data to import into{' '}
|
||||
<strong>{roundName}</strong>
|
||||
<strong>{stageName}</strong>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -559,7 +557,7 @@ export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVI
|
||||
<p className="mt-4 text-xl font-semibold">Import Complete!</p>
|
||||
<p className="text-muted-foreground">
|
||||
Successfully imported {validationSummary.valid} projects into{' '}
|
||||
<strong>{roundName}</strong>
|
||||
<strong>{stageName}</strong>
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
|
||||
@@ -320,7 +320,7 @@ export function EvaluationForm({
|
||||
toast.success('Evaluation submitted successfully!')
|
||||
|
||||
startTransition(() => {
|
||||
router.push('/jury/assignments')
|
||||
router.push('/jury/stages')
|
||||
router.refresh()
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -33,16 +33,16 @@ import {
|
||||
} from 'lucide-react'
|
||||
|
||||
interface NotionImportFormProps {
|
||||
roundId: string
|
||||
roundName: string
|
||||
programId: string
|
||||
stageName?: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
|
||||
|
||||
export function NotionImportForm({
|
||||
roundId,
|
||||
roundName,
|
||||
programId,
|
||||
stageName,
|
||||
onSuccess,
|
||||
}: NotionImportFormProps) {
|
||||
const [step, setStep] = useState<Step>('connect')
|
||||
@@ -125,7 +125,7 @@ export function NotionImportForm({
|
||||
const result = await importMutation.mutateAsync({
|
||||
apiKey,
|
||||
databaseId,
|
||||
roundId,
|
||||
programId,
|
||||
mappings: {
|
||||
title: mappings.title,
|
||||
teamName: mappings.teamName || undefined,
|
||||
@@ -419,7 +419,7 @@ export function NotionImportForm({
|
||||
<AlertTitle>Ready to import</AlertTitle>
|
||||
<AlertDescription>
|
||||
This will import all records from the Notion database into{' '}
|
||||
<strong>{roundName}</strong>.
|
||||
<strong>{stageName}</strong>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
||||
@@ -1,699 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Filter, ClipboardCheck, Zap, Info, Users, ListOrdered } from 'lucide-react'
|
||||
import {
|
||||
type FilteringRoundSettings,
|
||||
type EvaluationRoundSettings,
|
||||
type LiveEventRoundSettings,
|
||||
defaultFilteringSettings,
|
||||
defaultEvaluationSettings,
|
||||
defaultLiveEventSettings,
|
||||
roundTypeLabels,
|
||||
roundTypeDescriptions,
|
||||
} from '@/types/round-settings'
|
||||
|
||||
interface RoundTypeSettingsProps {
|
||||
roundType: 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'
|
||||
onRoundTypeChange: (type: 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT') => void
|
||||
settings: Record<string, unknown>
|
||||
onSettingsChange: (settings: Record<string, unknown>) => void
|
||||
requiredReviewsField?: React.ReactNode
|
||||
}
|
||||
|
||||
const roundTypeIcons = {
|
||||
FILTERING: Filter,
|
||||
EVALUATION: ClipboardCheck,
|
||||
LIVE_EVENT: Zap,
|
||||
}
|
||||
|
||||
const roundTypeFeatures: Record<string, string[]> = {
|
||||
FILTERING: ['AI screening', 'Auto-elimination', 'Batch processing'],
|
||||
EVALUATION: ['Jury reviews', 'Criteria scoring', 'Voting window'],
|
||||
LIVE_EVENT: ['Real-time voting', 'Audience votes', 'Presentations'],
|
||||
}
|
||||
|
||||
export function RoundTypeSettings({
|
||||
roundType,
|
||||
onRoundTypeChange,
|
||||
settings,
|
||||
onSettingsChange,
|
||||
requiredReviewsField,
|
||||
}: RoundTypeSettingsProps) {
|
||||
const Icon = roundTypeIcons[roundType]
|
||||
|
||||
// Get typed settings with defaults
|
||||
const getFilteringSettings = (): FilteringRoundSettings => ({
|
||||
...defaultFilteringSettings,
|
||||
...(settings as Partial<FilteringRoundSettings>),
|
||||
})
|
||||
|
||||
const getEvaluationSettings = (): EvaluationRoundSettings => ({
|
||||
...defaultEvaluationSettings,
|
||||
...(settings as Partial<EvaluationRoundSettings>),
|
||||
})
|
||||
|
||||
const getLiveEventSettings = (): LiveEventRoundSettings => ({
|
||||
...defaultLiveEventSettings,
|
||||
...(settings as Partial<LiveEventRoundSettings>),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5" />
|
||||
Round Type & Configuration
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the type and behavior for this round
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Round Type Selector - Visual Cards */}
|
||||
<div className="space-y-3">
|
||||
<Label>Round Type</Label>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{(['FILTERING', 'EVALUATION', 'LIVE_EVENT'] as const).map((type) => {
|
||||
const TypeIcon = roundTypeIcons[type]
|
||||
const isSelected = roundType === type
|
||||
const features = roundTypeFeatures[type]
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => onRoundTypeChange(type)}
|
||||
className={`relative flex flex-col items-start gap-3 rounded-lg border-2 p-4 text-left transition-all duration-200 hover:shadow-md ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 shadow-sm'
|
||||
: 'border-muted hover:border-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className={`rounded-lg p-2 ${isSelected ? 'bg-primary/10' : 'bg-muted'}`}>
|
||||
<TypeIcon className={`h-5 w-5 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-medium ${isSelected ? 'text-primary' : ''}`}>
|
||||
{roundTypeLabels[type]}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{roundTypeDescriptions[type]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-auto">
|
||||
{features.map((f) => (
|
||||
<span key={f} className="text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type-specific settings */}
|
||||
{roundType === 'FILTERING' && (
|
||||
<FilteringSettings
|
||||
settings={getFilteringSettings()}
|
||||
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{roundType === 'EVALUATION' && (
|
||||
<EvaluationSettings
|
||||
settings={getEvaluationSettings()}
|
||||
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
|
||||
requiredReviewsField={requiredReviewsField}
|
||||
/>
|
||||
)}
|
||||
|
||||
{roundType === 'LIVE_EVENT' && (
|
||||
<LiveEventSettings
|
||||
settings={getLiveEventSettings()}
|
||||
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Filtering Round Settings
|
||||
function FilteringSettings({
|
||||
settings,
|
||||
onChange,
|
||||
}: {
|
||||
settings: FilteringRoundSettings
|
||||
onChange: (settings: FilteringRoundSettings) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6 border-t pt-4">
|
||||
<h4 className="font-medium">Filtering Settings</h4>
|
||||
|
||||
{/* Target Advancing */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="targetAdvancing">Target Projects to Advance</Label>
|
||||
<Input
|
||||
id="targetAdvancing"
|
||||
type="number"
|
||||
min="1"
|
||||
value={settings.targetAdvancing}
|
||||
onChange={(e) =>
|
||||
onChange({ ...settings, targetAdvancing: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The target number of projects to advance to the next round
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto-elimination */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Auto-Elimination</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically flag projects below threshold
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.autoEliminationEnabled}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, autoEliminationEnabled: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings.autoEliminationEnabled && (
|
||||
<div className="ml-6 space-y-4 border-l-2 pl-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="threshold">Score Threshold</Label>
|
||||
<Input
|
||||
id="threshold"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value={settings.autoEliminationThreshold}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...settings,
|
||||
autoEliminationThreshold: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Projects averaging below this score will be flagged
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minReviews">Minimum Reviews</Label>
|
||||
<Input
|
||||
id="minReviews"
|
||||
type="number"
|
||||
min="0"
|
||||
value={settings.autoEliminationMinReviews}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...settings,
|
||||
autoEliminationMinReviews: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Min reviews before auto-elimination applies (0 for AI-only filtering)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Auto-elimination only flags projects for review. Final decisions require
|
||||
admin approval.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-Filter on Close */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Auto-Run Filtering on Close</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically start filtering when this round is closed
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.autoFilterOnClose}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, autoFilterOnClose: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
When enabled, closing this round will automatically run the configured filtering rules.
|
||||
Results still require admin review before finalization.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{/* Display Options */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium">Display Options</h5>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showAverage">Show Average Score</Label>
|
||||
<Switch
|
||||
id="showAverage"
|
||||
checked={settings.showAverageScore}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, showAverageScore: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showRanking">Show Ranking</Label>
|
||||
<Switch
|
||||
id="showRanking"
|
||||
checked={settings.showRanking}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, showRanking: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Evaluation Round Settings
|
||||
function EvaluationSettings({
|
||||
settings,
|
||||
onChange,
|
||||
requiredReviewsField,
|
||||
}: {
|
||||
settings: EvaluationRoundSettings
|
||||
onChange: (settings: EvaluationRoundSettings) => void
|
||||
requiredReviewsField?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6 border-t pt-4">
|
||||
<h4 className="font-medium">Evaluation Settings</h4>
|
||||
|
||||
{/* Required Reviews (passed from parent form) */}
|
||||
{requiredReviewsField}
|
||||
|
||||
{/* Target Finalists */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="targetFinalists">Target Finalists</Label>
|
||||
<Input
|
||||
id="targetFinalists"
|
||||
type="number"
|
||||
min="1"
|
||||
value={settings.targetFinalists}
|
||||
onChange={(e) =>
|
||||
onChange({ ...settings, targetFinalists: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The target number of finalists to select
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium">Requirements</h5>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Require All Criteria</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Jury must score all criteria before submission
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.requireAllCriteria}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, requireAllCriteria: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Detailed Criteria Required</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use detailed evaluation criteria
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.detailedCriteriaRequired}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, detailedCriteriaRequired: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minFeedback">Minimum Feedback Length</Label>
|
||||
<Input
|
||||
id="minFeedback"
|
||||
type="number"
|
||||
min="0"
|
||||
value={settings.minimumFeedbackLength}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...settings,
|
||||
minimumFeedbackLength: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Minimum characters for feedback comments (0 = optional)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Options */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium">Display Options</h5>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showAverage">Show Average Score</Label>
|
||||
<Switch
|
||||
id="showAverage"
|
||||
checked={settings.showAverageScore}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, showAverageScore: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showRanking">Show Ranking</Label>
|
||||
<Switch
|
||||
id="showRanking"
|
||||
checked={settings.showRanking}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, showRanking: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Live Event Round Settings
|
||||
function LiveEventSettings({
|
||||
settings,
|
||||
onChange,
|
||||
}: {
|
||||
settings: LiveEventRoundSettings
|
||||
onChange: (settings: LiveEventRoundSettings) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6 border-t pt-4">
|
||||
<h4 className="font-medium">Live Event Settings</h4>
|
||||
|
||||
{/* Presentation */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium">Presentation</h5>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duration">Presentation Duration (minutes)</Label>
|
||||
<Input
|
||||
id="duration"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={settings.presentationDurationMinutes}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...settings,
|
||||
presentationDurationMinutes: parseInt(e.target.value) || 5,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voting */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium">Voting</h5>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="votingWindow">Voting Window (seconds)</Label>
|
||||
<Input
|
||||
id="votingWindow"
|
||||
type="number"
|
||||
min="10"
|
||||
max="300"
|
||||
value={settings.votingWindowSeconds}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...settings,
|
||||
votingWindowSeconds: parseInt(e.target.value) || 30,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Duration of the voting window after each presentation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Voting Mode</Label>
|
||||
<Select
|
||||
value={settings.votingMode}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...settings, votingMode: v as 'simple' | 'criteria' })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="simple">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
Simple (1-10 score)
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="criteria">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="h-4 w-4" />
|
||||
Criteria-Based (per-criterion scoring)
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings.votingMode === 'simple'
|
||||
? 'Jurors give a single 1-10 score per project'
|
||||
: 'Jurors score each criterion separately, weighted into a final score'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Allow Vote Change</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allow jury to change their vote during the window
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.allowVoteChange}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, allowVoteChange: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audience Voting */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Audience Voting
|
||||
</h5>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Audience Voting Mode</Label>
|
||||
<Select
|
||||
value={settings.audienceVotingMode}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
...settings,
|
||||
audienceVotingMode: v as 'disabled' | 'per_project' | 'per_category' | 'favorites',
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
<SelectItem value="per_project">Per Project (1-10 score)</SelectItem>
|
||||
<SelectItem value="per_category">Per Category (vote best-in-category)</SelectItem>
|
||||
<SelectItem value="favorites">Favorites (pick top N)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How audience members can participate in voting
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{settings.audienceVotingMode !== 'disabled' && (
|
||||
<div className="ml-6 space-y-4 border-l-2 pl-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Require Identification</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Audience must provide email or name to vote
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.audienceRequireId}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, audienceRequireId: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings.audienceVotingMode === 'favorites' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxFavorites">Max Favorites</Label>
|
||||
<Input
|
||||
id="maxFavorites"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={settings.audienceMaxFavorites}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...settings,
|
||||
audienceMaxFavorites: parseInt(e.target.value) || 3,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Number of favorites each audience member can select
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="audienceDuration">
|
||||
Audience Voting Duration (minutes)
|
||||
</Label>
|
||||
<Input
|
||||
id="audienceDuration"
|
||||
type="number"
|
||||
min="1"
|
||||
max="600"
|
||||
value={settings.audienceVotingDuration || ''}
|
||||
placeholder="Same as jury"
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value)
|
||||
onChange({
|
||||
...settings,
|
||||
audienceVotingDuration: isNaN(val) ? null : val,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the same window as jury voting
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Display */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium">Display</h5>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Show Live Scores</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display scores in real-time during the event
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.showLiveScores}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, showLiveScores: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Display Mode</Label>
|
||||
<Select
|
||||
value={settings.displayMode}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
...settings,
|
||||
displayMode: v as 'SCORES' | 'RANKING' | 'NONE',
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SCORES">Show Scores</SelectItem>
|
||||
<SelectItem value="RANKING">Show Ranking</SelectItem>
|
||||
<SelectItem value="NONE">Hide Until End</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How results are displayed on the public screen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Presentation order and criteria can be configured in the Live Voting section once the round
|
||||
is activated.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -33,16 +33,16 @@ import {
|
||||
} from 'lucide-react'
|
||||
|
||||
interface TypeformImportFormProps {
|
||||
roundId: string
|
||||
roundName: string
|
||||
programId: string
|
||||
stageName?: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
|
||||
|
||||
export function TypeformImportForm({
|
||||
roundId,
|
||||
roundName,
|
||||
programId,
|
||||
stageName,
|
||||
onSuccess,
|
||||
}: TypeformImportFormProps) {
|
||||
const [step, setStep] = useState<Step>('connect')
|
||||
@@ -126,7 +126,7 @@ export function TypeformImportForm({
|
||||
const result = await importMutation.mutateAsync({
|
||||
apiKey,
|
||||
formId,
|
||||
roundId,
|
||||
programId,
|
||||
mappings: {
|
||||
title: mappings.title,
|
||||
teamName: mappings.teamName || undefined,
|
||||
@@ -446,7 +446,7 @@ export function TypeformImportForm({
|
||||
<AlertTitle>Ready to import</AlertTitle>
|
||||
<AlertDescription>
|
||||
This will import all responses from the Typeform into{' '}
|
||||
<strong>{roundName}</strong>.
|
||||
<strong>{stageName}</strong>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user