Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m23s

- Phase 1: 7 round config sub-components covering all ~65 Zod schema fields across INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION
- Phase 2: Replace Competitions nav with Rounds + add Juries; new /admin/rounds and /admin/rounds/[roundId] pages with tabbed detail (Config, Projects, Windows, Documents, Awards)
- Phase 3: Top-level /admin/juries with list + detail pages (members table, settings panel, self-service review)
- Phase 4: File requirements editor in round config; project detail per-requirement upload slots replacing generic drop zone
- Phase 5: Awards edit page with source round dropdown, eligibility mode, auto-tag rules builder; round detail Awards tab; specialAward router enhanced with evaluationRoundId/eligibilityMode fields
- Phase 6: Evaluation page rewrite supporting all 3 scoring modes (criteria/global/binary) with config-driven behavior; live voting UI polish
- Phase 7: UI design polish across admin pages — consistent headers, cards, hover transitions, empty states, brand colors
- Bulk upload page for admin project imports
- File router enhanced with admin upload and submission window procedures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 01:16:55 +01:00
parent fbb194067d
commit 4c0efb232c
23 changed files with 5745 additions and 891 deletions

View File

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

View File

@@ -0,0 +1,176 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
type DeliberationConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
juryGroups?: Array<{ id: string; name: string }>
}
export function DeliberationConfig({ config, onChange, juryGroups }: DeliberationConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
return (
<div className="space-y-6">
{/* Jury Group Selection */}
<Card>
<CardHeader>
<CardTitle className="text-base">Deliberation Jury</CardTitle>
<CardDescription>Which jury group participates in this deliberation round</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="juryGroupId">Jury Group</Label>
<p className="text-xs text-muted-foreground">
The jury group that will cast votes during deliberation
</p>
{juryGroups && juryGroups.length > 0 ? (
<Select
value={(config.juryGroupId as string) ?? ''}
onValueChange={(v) => update('juryGroupId', v)}
>
<SelectTrigger id="juryGroupId" className="w-72">
<SelectValue placeholder="Select a jury group" />
</SelectTrigger>
<SelectContent>
{juryGroups.map((g) => (
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="juryGroupId"
placeholder="Jury group ID"
value={(config.juryGroupId as string) ?? ''}
onChange={(e) => update('juryGroupId', e.target.value)}
/>
)}
</div>
</CardContent>
</Card>
{/* Voting Settings */}
<Card>
<CardHeader>
<CardTitle className="text-base">Voting Settings</CardTitle>
<CardDescription>How deliberation votes are structured</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="mode">Deliberation Mode</Label>
<p className="text-xs text-muted-foreground">How the final decision is made</p>
<Select
value={(config.mode as string) ?? 'SINGLE_WINNER_VOTE'}
onValueChange={(v) => update('mode', v)}
>
<SelectTrigger id="mode" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SINGLE_WINNER_VOTE">Single Winner Vote</SelectItem>
<SelectItem value="FULL_RANKING">Full Ranking</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="votingDuration">Voting Duration (min)</Label>
<p className="text-xs text-muted-foreground">Time limit for voting round</p>
<Input
id="votingDuration"
type="number"
min={1}
className="w-32"
value={(config.votingDuration as number) ?? 60}
onChange={(e) => update('votingDuration', parseInt(e.target.value, 10) || 60)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="topN">Top N Projects</Label>
<p className="text-xs text-muted-foreground">Number of finalists to select</p>
<Input
id="topN"
type="number"
min={1}
className="w-32"
value={(config.topN as number) ?? 3}
onChange={(e) => update('topN', parseInt(e.target.value, 10) || 3)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tieBreakMethod">Tie Break Method</Label>
<Select
value={(config.tieBreakMethod as string) ?? 'ADMIN_DECIDES'}
onValueChange={(v) => update('tieBreakMethod', v)}
>
<SelectTrigger id="tieBreakMethod" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN_DECIDES">Admin Decides</SelectItem>
<SelectItem value="RUNOFF">Runoff Vote</SelectItem>
<SelectItem value="SCORE_FALLBACK">Score Fallback (use prior scores)</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Visibility & Overrides */}
<Card>
<CardHeader>
<CardTitle className="text-base">Visibility & Overrides</CardTitle>
<CardDescription>What information jurors can see during deliberation</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showCollectiveRankings">Show Collective Rankings</Label>
<p className="text-xs text-muted-foreground">Display aggregate rankings to jurors during voting</p>
</div>
<Switch
id="showCollectiveRankings"
checked={(config.showCollectiveRankings as boolean) ?? false}
onCheckedChange={(v) => update('showCollectiveRankings', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showPriorJuryData">Show Prior Jury Data</Label>
<p className="text-xs text-muted-foreground">Display evaluation scores from previous rounds</p>
</div>
<Switch
id="showPriorJuryData"
checked={(config.showPriorJuryData as boolean) ?? false}
onCheckedChange={(v) => update('showPriorJuryData', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="allowAdminOverride">Allow Admin Override</Label>
<p className="text-xs text-muted-foreground">Admin can override deliberation results</p>
</div>
<Switch
id="allowAdminOverride"
checked={(config.allowAdminOverride as boolean) ?? true}
onCheckedChange={(v) => update('allowAdminOverride', v)}
/>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,283 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
type EvaluationConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
const advancementMode = (config.advancementMode as string) ?? 'admin_selection'
const advancementConfig = (config.advancementConfig as {
perCategory?: boolean; startupCount?: number; conceptCount?: number; tieBreaker?: string
}) ?? {}
const updateAdvancement = (key: string, value: unknown) => {
update('advancementConfig', { ...advancementConfig, [key]: value })
}
return (
<div className="space-y-6">
{/* Scoring */}
<Card>
<CardHeader>
<CardTitle className="text-base">Scoring & Reviews</CardTitle>
<CardDescription>How jury members evaluate and score projects</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="requiredReviews">Required Reviews per Project</Label>
<p className="text-xs text-muted-foreground">Minimum number of jury evaluations needed</p>
<Input
id="requiredReviews"
type="number"
min={1}
className="w-32"
value={(config.requiredReviewsPerProject as number) ?? 3}
onChange={(e) => update('requiredReviewsPerProject', parseInt(e.target.value, 10) || 3)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="scoringMode">Scoring Mode</Label>
<p className="text-xs text-muted-foreground">How jurors assign scores to projects</p>
<Select
value={(config.scoringMode as string) ?? 'criteria'}
onValueChange={(v) => update('scoringMode', v)}
>
<SelectTrigger id="scoringMode" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="criteria">Criteria-based (multiple criteria with weights)</SelectItem>
<SelectItem value="global">Global score (single overall score)</SelectItem>
<SelectItem value="binary">Binary (pass/fail)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="anonymizationLevel">Anonymization Level</Label>
<p className="text-xs text-muted-foreground">How much of other jurors&apos; identities are revealed</p>
<Select
value={(config.anonymizationLevel as string) ?? 'fully_anonymous'}
onValueChange={(v) => update('anonymizationLevel', v)}
>
<SelectTrigger id="anonymizationLevel" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fully_anonymous">Fully Anonymous</SelectItem>
<SelectItem value="show_initials">Show Initials</SelectItem>
<SelectItem value="named">Named (full names visible)</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Feedback */}
<Card>
<CardHeader>
<CardTitle className="text-base">Feedback Requirements</CardTitle>
<CardDescription>What jurors must provide alongside scores</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="requireFeedback">Require Written Feedback</Label>
<p className="text-xs text-muted-foreground">Jurors must write feedback text</p>
</div>
<Switch
id="requireFeedback"
checked={(config.requireFeedback as boolean) ?? true}
onCheckedChange={(v) => update('requireFeedback', v)}
/>
</div>
{(config.requireFeedback as boolean) !== false && (
<div className="pl-6 border-l-2 border-muted space-y-2">
<Label htmlFor="feedbackMinLength">Minimum Feedback Length</Label>
<p className="text-xs text-muted-foreground">Minimum characters (0 = no minimum)</p>
<Input
id="feedbackMinLength"
type="number"
min={0}
className="w-32"
value={(config.feedbackMinLength as number) ?? 0}
onChange={(e) => update('feedbackMinLength', parseInt(e.target.value, 10) || 0)}
/>
</div>
)}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="requireAllCriteriaScored">Require All Criteria Scored</Label>
<p className="text-xs text-muted-foreground">Jurors must score every criterion before submitting</p>
</div>
<Switch
id="requireAllCriteriaScored"
checked={(config.requireAllCriteriaScored as boolean) ?? true}
onCheckedChange={(v) => update('requireAllCriteriaScored', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="coiRequired">COI Declaration Required</Label>
<p className="text-xs text-muted-foreground">Jurors must declare conflicts of interest</p>
</div>
<Switch
id="coiRequired"
checked={(config.coiRequired as boolean) ?? true}
onCheckedChange={(v) => update('coiRequired', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="peerReviewEnabled">Peer Review</Label>
<p className="text-xs text-muted-foreground">Allow jurors to see and comment on other evaluations</p>
</div>
<Switch
id="peerReviewEnabled"
checked={(config.peerReviewEnabled as boolean) ?? false}
onCheckedChange={(v) => update('peerReviewEnabled', v)}
/>
</div>
</CardContent>
</Card>
{/* AI Features */}
<Card>
<CardHeader>
<CardTitle className="text-base">AI Features</CardTitle>
<CardDescription>AI-powered evaluation assistance</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="aiSummaryEnabled">AI Evaluation Summary</Label>
<p className="text-xs text-muted-foreground">Generate AI synthesis of all jury evaluations</p>
</div>
<Switch
id="aiSummaryEnabled"
checked={(config.aiSummaryEnabled as boolean) ?? false}
onCheckedChange={(v) => update('aiSummaryEnabled', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="generateAiShortlist">AI Shortlist Recommendations</Label>
<p className="text-xs text-muted-foreground">AI suggests which projects should advance</p>
</div>
<Switch
id="generateAiShortlist"
checked={(config.generateAiShortlist as boolean) ?? false}
onCheckedChange={(v) => update('generateAiShortlist', v)}
/>
</div>
</CardContent>
</Card>
{/* Advancement */}
<Card>
<CardHeader>
<CardTitle className="text-base">Advancement Rules</CardTitle>
<CardDescription>How projects move to the next round</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="advancementMode">Advancement Mode</Label>
<Select
value={advancementMode}
onValueChange={(v) => update('advancementMode', v)}
>
<SelectTrigger id="advancementMode" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin_selection">Admin Selection (manual)</SelectItem>
<SelectItem value="auto_top_n">Auto Top-N (by score)</SelectItem>
<SelectItem value="ai_recommended">AI Recommended</SelectItem>
</SelectContent>
</Select>
</div>
{advancementMode === 'auto_top_n' && (
<div className="pl-6 border-l-2 border-muted space-y-4">
<Label className="text-sm font-medium">Auto Top-N Settings</Label>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="perCategory">Per Category</Label>
<p className="text-xs text-muted-foreground">Apply limits separately for each category</p>
</div>
<Switch
id="perCategory"
checked={advancementConfig.perCategory ?? true}
onCheckedChange={(v) => updateAdvancement('perCategory', v)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="startupCount">Startup Advancement Count</Label>
<p className="text-xs text-muted-foreground">Number of startups to advance</p>
<Input
id="startupCount"
type="number"
min={0}
className="w-32"
value={advancementConfig.startupCount ?? 10}
onChange={(e) => updateAdvancement('startupCount', parseInt(e.target.value, 10) || 0)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="conceptCount">Business Concept Advancement Count</Label>
<p className="text-xs text-muted-foreground">Number of business concepts to advance</p>
<Input
id="conceptCount"
type="number"
min={0}
className="w-32"
value={advancementConfig.conceptCount ?? 10}
onChange={(e) => updateAdvancement('conceptCount', parseInt(e.target.value, 10) || 0)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tieBreaker">Tie Breaker</Label>
<p className="text-xs text-muted-foreground">How to handle tied scores</p>
<Select
value={advancementConfig.tieBreaker ?? 'admin_decides'}
onValueChange={(v) => updateAdvancement('tieBreaker', v)}
>
<SelectTrigger id="tieBreaker" className="w-64">
<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>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,425 +1,425 @@
"use client";
import { useState } from "react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
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 { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import {
Plus,
Pencil,
Trash2,
GripVertical,
ArrowUp,
ArrowDown,
FileText,
Loader2,
} from "lucide-react";
const MIME_TYPE_PRESETS = [
{ label: "PDF", value: "application/pdf" },
{ label: "Images", value: "image/*" },
{ label: "Video", value: "video/*" },
{
label: "Word Documents",
value:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
{
label: "Excel",
value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
},
{
label: "PowerPoint",
value:
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
},
];
function getMimeLabel(mime: string): string {
const preset = MIME_TYPE_PRESETS.find((p) => p.value === mime);
if (preset) return preset.label;
if (mime.endsWith("/*")) return mime.replace("/*", "");
return mime;
}
interface FileRequirementsEditorProps {
roundId: string;
}
interface RequirementFormData {
name: string;
description: string;
acceptedMimeTypes: string[];
maxSizeMB: string;
isRequired: boolean;
}
const emptyForm: RequirementFormData = {
name: "",
description: "",
acceptedMimeTypes: [],
maxSizeMB: "",
isRequired: true,
};
export function FileRequirementsEditor({
roundId,
}: FileRequirementsEditorProps) {
const utils = trpc.useUtils();
const { data: requirements = [], isLoading } =
trpc.file.listRequirements.useQuery({ roundId });
const createMutation = trpc.file.createRequirement.useMutation({
onSuccess: () => {
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({ roundId });
toast.success("Requirement updated");
},
onError: (err) => toast.error(err.message),
});
const deleteMutation = trpc.file.deleteRequirement.useMutation({
onSuccess: () => {
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({ roundId }),
onError: (err) => toast.error(err.message),
});
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<RequirementFormData>(emptyForm);
const openCreate = () => {
setEditingId(null);
setForm(emptyForm);
setDialogOpen(true);
};
const openEdit = (req: (typeof requirements)[number]) => {
setEditingId(req.id);
setForm({
name: req.name,
description: req.description || "",
acceptedMimeTypes: req.acceptedMimeTypes,
maxSizeMB: req.maxSizeMB?.toString() || "",
isRequired: req.isRequired,
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!form.name.trim()) {
toast.error("Name is required");
return;
}
const maxSizeMB = form.maxSizeMB ? parseInt(form.maxSizeMB) : undefined;
if (editingId) {
await updateMutation.mutateAsync({
id: editingId,
name: form.name.trim(),
description: form.description.trim() || null,
acceptedMimeTypes: form.acceptedMimeTypes,
maxSizeMB: maxSizeMB || null,
isRequired: form.isRequired,
});
} else {
await createMutation.mutateAsync({
roundId,
name: form.name.trim(),
description: form.description.trim() || undefined,
acceptedMimeTypes: form.acceptedMimeTypes,
maxSizeMB,
isRequired: form.isRequired,
sortOrder: requirements.length,
});
}
setDialogOpen(false);
};
const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync({ id });
};
const handleMove = async (index: number, direction: "up" | "down") => {
const newOrder = [...requirements];
const swapIndex = direction === "up" ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= newOrder.length) return;
[newOrder[index], newOrder[swapIndex]] = [
newOrder[swapIndex],
newOrder[index],
];
await reorderMutation.mutateAsync({
roundId,
orderedIds: newOrder.map((r) => r.id),
});
};
const toggleMimeType = (mime: string) => {
setForm((prev) => ({
...prev,
acceptedMimeTypes: prev.acceptedMimeTypes.includes(mime)
? prev.acceptedMimeTypes.filter((m) => m !== mime)
: [...prev.acceptedMimeTypes, mime],
}));
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
File Requirements
</CardTitle>
<CardDescription>
Define required files applicants must upload for this round
</CardDescription>
</div>
<Button type="button" onClick={openCreate} size="sm">
<Plus className="mr-1 h-4 w-4" />
Add Requirement
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : requirements.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No file requirements defined. Applicants can still upload files
freely.
</div>
) : (
<div className="space-y-2">
{requirements.map((req, index) => (
<div
key={req.id}
className="flex items-center gap-3 rounded-lg border p-3 bg-background"
>
<div className="flex flex-col gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMove(index, "up")}
disabled={index === 0}
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMove(index, "down")}
disabled={index === requirements.length - 1}
>
<ArrowDown className="h-3 w-3" />
</Button>
</div>
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium truncate">{req.name}</span>
<Badge
variant={req.isRequired ? "destructive" : "secondary"}
className="text-xs shrink-0"
>
{req.isRequired ? "Required" : "Optional"}
</Badge>
</div>
{req.description && (
<p className="text-sm text-muted-foreground line-clamp-1">
{req.description}
</p>
)}
<div className="flex flex-wrap gap-1 mt-1">
{req.acceptedMimeTypes.map((mime) => (
<Badge key={mime} variant="outline" className="text-xs">
{getMimeLabel(mime)}
</Badge>
))}
{req.maxSizeMB && (
<Badge variant="outline" className="text-xs">
Max {req.maxSizeMB}MB
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(req)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleDelete(req.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editingId ? "Edit" : "Add"} File Requirement
</DialogTitle>
<DialogDescription>
Define what file applicants need to upload for this round.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="req-name">Name *</Label>
<Input
id="req-name"
value={form.name}
onChange={(e) =>
setForm((p) => ({ ...p, name: e.target.value }))
}
placeholder="e.g., Executive Summary"
/>
</div>
<div className="space-y-2">
<Label htmlFor="req-desc">Description</Label>
<Textarea
id="req-desc"
value={form.description}
onChange={(e) =>
setForm((p) => ({ ...p, description: e.target.value }))
}
placeholder="Describe what this file should contain..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Accepted File Types</Label>
<div className="flex flex-wrap gap-2">
{MIME_TYPE_PRESETS.map((preset) => (
<Badge
key={preset.value}
variant={
form.acceptedMimeTypes.includes(preset.value)
? "default"
: "outline"
}
className="cursor-pointer"
onClick={() => toggleMimeType(preset.value)}
>
{preset.label}
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground">
Leave empty to accept any file type
</p>
</div>
<div className="space-y-2">
<Label htmlFor="req-size">Max File Size (MB)</Label>
<Input
id="req-size"
type="number"
value={form.maxSizeMB}
onChange={(e) =>
setForm((p) => ({ ...p, maxSizeMB: e.target.value }))
}
placeholder="No limit"
min={1}
max={5000}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="req-required">Required</Label>
<p className="text-xs text-muted-foreground">
Applicants must upload this file
</p>
</div>
<Switch
id="req-required"
checked={form.isRequired}
onCheckedChange={(checked) =>
setForm((p) => ({ ...p, isRequired: checked }))
}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
>
Cancel
</Button>
<Button type="button" onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editingId ? "Update" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
"use client";
import { useState } from "react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
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 { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import {
Plus,
Pencil,
Trash2,
GripVertical,
ArrowUp,
ArrowDown,
FileText,
Loader2,
} from "lucide-react";
const MIME_TYPE_PRESETS = [
{ label: "PDF", value: "application/pdf" },
{ label: "Images", value: "image/*" },
{ label: "Video", value: "video/*" },
{
label: "Word Documents",
value:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
{
label: "Excel",
value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
},
{
label: "PowerPoint",
value:
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
},
];
function getMimeLabel(mime: string): string {
const preset = MIME_TYPE_PRESETS.find((p) => p.value === mime);
if (preset) return preset.label;
if (mime.endsWith("/*")) return mime.replace("/*", "");
return mime;
}
interface FileRequirementsEditorProps {
roundId: string;
}
interface RequirementFormData {
name: string;
description: string;
acceptedMimeTypes: string[];
maxSizeMB: string;
isRequired: boolean;
}
const emptyForm: RequirementFormData = {
name: "",
description: "",
acceptedMimeTypes: [],
maxSizeMB: "",
isRequired: true,
};
export function FileRequirementsEditor({
roundId,
}: FileRequirementsEditorProps) {
const utils = trpc.useUtils();
const { data: requirements = [], isLoading } =
trpc.file.listRequirements.useQuery({ roundId });
const createMutation = trpc.file.createRequirement.useMutation({
onSuccess: () => {
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({ roundId });
toast.success("Requirement updated");
},
onError: (err) => toast.error(err.message),
});
const deleteMutation = trpc.file.deleteRequirement.useMutation({
onSuccess: () => {
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({ roundId }),
onError: (err) => toast.error(err.message),
});
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<RequirementFormData>(emptyForm);
const openCreate = () => {
setEditingId(null);
setForm(emptyForm);
setDialogOpen(true);
};
const openEdit = (req: (typeof requirements)[number]) => {
setEditingId(req.id);
setForm({
name: req.name,
description: req.description || "",
acceptedMimeTypes: req.acceptedMimeTypes,
maxSizeMB: req.maxSizeMB?.toString() || "",
isRequired: req.isRequired,
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!form.name.trim()) {
toast.error("Name is required");
return;
}
const maxSizeMB = form.maxSizeMB ? parseInt(form.maxSizeMB) : undefined;
if (editingId) {
await updateMutation.mutateAsync({
id: editingId,
name: form.name.trim(),
description: form.description.trim() || null,
acceptedMimeTypes: form.acceptedMimeTypes,
maxSizeMB: maxSizeMB || null,
isRequired: form.isRequired,
});
} else {
await createMutation.mutateAsync({
roundId,
name: form.name.trim(),
description: form.description.trim() || undefined,
acceptedMimeTypes: form.acceptedMimeTypes,
maxSizeMB,
isRequired: form.isRequired,
sortOrder: requirements.length,
});
}
setDialogOpen(false);
};
const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync({ id });
};
const handleMove = async (index: number, direction: "up" | "down") => {
const newOrder = [...requirements];
const swapIndex = direction === "up" ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= newOrder.length) return;
[newOrder[index], newOrder[swapIndex]] = [
newOrder[swapIndex],
newOrder[index],
];
await reorderMutation.mutateAsync({
roundId,
orderedIds: newOrder.map((r) => r.id),
});
};
const toggleMimeType = (mime: string) => {
setForm((prev) => ({
...prev,
acceptedMimeTypes: prev.acceptedMimeTypes.includes(mime)
? prev.acceptedMimeTypes.filter((m) => m !== mime)
: [...prev.acceptedMimeTypes, mime],
}));
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
File Requirements
</CardTitle>
<CardDescription>
Define required files applicants must upload for this round
</CardDescription>
</div>
<Button type="button" onClick={openCreate} size="sm">
<Plus className="mr-1 h-4 w-4" />
Add Requirement
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : requirements.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No file requirements defined. Applicants can still upload files
freely.
</div>
) : (
<div className="space-y-2">
{requirements.map((req, index) => (
<div
key={req.id}
className="flex items-center gap-3 rounded-lg border p-3 bg-background"
>
<div className="flex flex-col gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMove(index, "up")}
disabled={index === 0}
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMove(index, "down")}
disabled={index === requirements.length - 1}
>
<ArrowDown className="h-3 w-3" />
</Button>
</div>
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium truncate">{req.name}</span>
<Badge
variant={req.isRequired ? "destructive" : "secondary"}
className="text-xs shrink-0"
>
{req.isRequired ? "Required" : "Optional"}
</Badge>
</div>
{req.description && (
<p className="text-sm text-muted-foreground line-clamp-1">
{req.description}
</p>
)}
<div className="flex flex-wrap gap-1 mt-1">
{req.acceptedMimeTypes.map((mime) => (
<Badge key={mime} variant="outline" className="text-xs">
{getMimeLabel(mime)}
</Badge>
))}
{req.maxSizeMB && (
<Badge variant="outline" className="text-xs">
Max {req.maxSizeMB}MB
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(req)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleDelete(req.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editingId ? "Edit" : "Add"} File Requirement
</DialogTitle>
<DialogDescription>
Define what file applicants need to upload for this round.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="req-name">Name *</Label>
<Input
id="req-name"
value={form.name}
onChange={(e) =>
setForm((p) => ({ ...p, name: e.target.value }))
}
placeholder="e.g., Executive Summary"
/>
</div>
<div className="space-y-2">
<Label htmlFor="req-desc">Description</Label>
<Textarea
id="req-desc"
value={form.description}
onChange={(e) =>
setForm((p) => ({ ...p, description: e.target.value }))
}
placeholder="Describe what this file should contain..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Accepted File Types</Label>
<div className="flex flex-wrap gap-2">
{MIME_TYPE_PRESETS.map((preset) => (
<Badge
key={preset.value}
variant={
form.acceptedMimeTypes.includes(preset.value)
? "default"
: "outline"
}
className="cursor-pointer"
onClick={() => toggleMimeType(preset.value)}
>
{preset.label}
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground">
Leave empty to accept any file type
</p>
</div>
<div className="space-y-2">
<Label htmlFor="req-size">Max File Size (MB)</Label>
<Input
id="req-size"
type="number"
value={form.maxSizeMB}
onChange={(e) =>
setForm((p) => ({ ...p, maxSizeMB: e.target.value }))
}
placeholder="No limit"
min={1}
max={5000}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="req-required">Required</Label>
<p className="text-xs text-muted-foreground">
Applicants must upload this file
</p>
</div>
<Switch
id="req-required"
checked={form.isRequired}
onCheckedChange={(checked) =>
setForm((p) => ({ ...p, isRequired: checked }))
}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
>
Cancel
</Button>
<Button type="button" onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editingId ? "Update" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,193 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider'
type FilteringConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
export function FilteringConfig({ config, onChange }: FilteringConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
const aiEnabled = (config.aiScreeningEnabled as boolean) ?? true
const thresholds = (config.aiConfidenceThresholds as { high: number; medium: number; low: number }) ?? {
high: 0.85,
medium: 0.6,
low: 0.4,
}
const updateThreshold = (key: 'high' | 'medium' | 'low', value: number) => {
update('aiConfidenceThresholds', { ...thresholds, [key]: value })
}
return (
<div className="space-y-6">
{/* AI Screening Section */}
<Card>
<CardHeader>
<CardTitle className="text-base">AI Screening</CardTitle>
<CardDescription>
AI analyzes each project against your criteria and assigns confidence scores.
Projects above the high threshold auto-pass, below low auto-reject, and between are flagged for manual review.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="aiScreeningEnabled">Enable AI Screening</Label>
<p className="text-xs text-muted-foreground">Use AI to pre-screen applications</p>
</div>
<Switch
id="aiScreeningEnabled"
checked={aiEnabled}
onCheckedChange={(v) => update('aiScreeningEnabled', v)}
/>
</div>
{aiEnabled && (
<div className="pl-6 border-l-2 border-muted space-y-4">
<div className="space-y-2">
<Label htmlFor="aiCriteriaText">Screening Criteria</Label>
<p className="text-xs text-muted-foreground">
Describe what makes a project eligible. The AI uses this text to evaluate each submission.
Be specific about requirements, disqualifiers, and what constitutes a strong application.
</p>
<Textarea
id="aiCriteriaText"
rows={6}
placeholder="e.g., Projects must address ocean conservation directly. They should have a clear implementation plan, measurable impact metrics, and a team with relevant expertise. Disqualify projects focused solely on freshwater or landlocked environments..."
value={(config.aiCriteriaText as string) ?? ''}
onChange={(e) => update('aiCriteriaText', e.target.value)}
/>
</div>
<div className="space-y-4 rounded-lg border p-4">
<Label className="text-sm font-medium">Confidence Thresholds</Label>
<p className="text-xs text-muted-foreground">
Set the AI confidence boundaries. Projects scoring above &quot;High&quot; auto-pass,
below &quot;Low&quot; auto-reject, and everything between gets flagged for manual review.
</p>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-emerald-600">High (auto-pass above)</Label>
<span className="text-xs font-mono font-medium">{thresholds.high.toFixed(2)}</span>
</div>
<Slider
min={0}
max={1}
step={0.05}
value={[thresholds.high]}
onValueChange={([v]) => updateThreshold('high', v)}
className="[&_[role=slider]]:bg-emerald-500"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-amber-600">Medium (review threshold)</Label>
<span className="text-xs font-mono font-medium">{thresholds.medium.toFixed(2)}</span>
</div>
<Slider
min={0}
max={1}
step={0.05}
value={[thresholds.medium]}
onValueChange={([v]) => updateThreshold('medium', v)}
className="[&_[role=slider]]:bg-amber-500"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-red-600">Low (auto-reject below)</Label>
<span className="text-xs font-mono font-medium">{thresholds.low.toFixed(2)}</span>
</div>
<Slider
min={0}
max={1}
step={0.05}
value={[thresholds.low]}
onValueChange={([v]) => updateThreshold('low', v)}
className="[&_[role=slider]]:bg-red-500"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="autoAdvanceEligible">Auto-Advance Eligible</Label>
<p className="text-xs text-muted-foreground">
Automatically advance projects that score above the high threshold
</p>
</div>
<Switch
id="autoAdvanceEligible"
checked={(config.autoAdvanceEligible as boolean) ?? false}
onCheckedChange={(v) => update('autoAdvanceEligible', v)}
/>
</div>
</div>
)}
</CardContent>
</Card>
{/* Manual Review & Detection */}
<Card>
<CardHeader>
<CardTitle className="text-base">Review Settings</CardTitle>
<CardDescription>Manual review and quality checks</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="manualReviewEnabled">Manual Review</Label>
<p className="text-xs text-muted-foreground">Enable admin manual review of flagged projects</p>
</div>
<Switch
id="manualReviewEnabled"
checked={(config.manualReviewEnabled as boolean) ?? true}
onCheckedChange={(v) => update('manualReviewEnabled', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="duplicateDetectionEnabled">Duplicate Detection</Label>
<p className="text-xs text-muted-foreground">Flag potential duplicate submissions</p>
</div>
<Switch
id="duplicateDetectionEnabled"
checked={(config.duplicateDetectionEnabled as boolean) ?? true}
onCheckedChange={(v) => update('duplicateDetectionEnabled', v)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="batchSize">Processing Batch Size</Label>
<p className="text-xs text-muted-foreground">Number of projects processed per AI batch</p>
<Input
id="batchSize"
type="number"
min={1}
max={100}
className="w-32"
value={(config.batchSize as number) ?? 20}
onChange={(e) => update('batchSize', parseInt(e.target.value, 10) || 20)}
/>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,296 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Plus, Trash2 } from 'lucide-react'
type IntakeConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
const MIME_PRESETS = [
{ label: 'PDF', value: 'application/pdf' },
{ label: 'Word', value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
{ label: 'Excel', value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
{ label: 'PowerPoint', value: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' },
{ label: 'Images', value: 'image/*' },
{ label: 'Video', value: 'video/*' },
]
const FIELD_TYPES = [
{ value: 'text', label: 'Text' },
{ value: 'textarea', label: 'Text Area' },
{ value: 'select', label: 'Dropdown' },
{ value: 'checkbox', label: 'Checkbox' },
{ value: 'date', label: 'Date' },
]
export function IntakeConfig({ config, onChange }: IntakeConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
const acceptedCategories = (config.acceptedCategories as string[]) ?? ['STARTUP', 'BUSINESS_CONCEPT']
const allowedMimeTypes = (config.allowedMimeTypes as string[]) ?? ['application/pdf']
const customFields = (config.customFields as Array<{
id: string; label: string; type: string; required: boolean; options?: string[]
}>) ?? []
const toggleCategory = (cat: string) => {
const current = [...acceptedCategories]
const idx = current.indexOf(cat)
if (idx >= 0) {
current.splice(idx, 1)
} else {
current.push(cat)
}
update('acceptedCategories', current)
}
const toggleMime = (mime: string) => {
const current = [...allowedMimeTypes]
const idx = current.indexOf(mime)
if (idx >= 0) {
current.splice(idx, 1)
} else {
current.push(mime)
}
update('allowedMimeTypes', current)
}
const addCustomField = () => {
update('customFields', [
...customFields,
{ id: `field-${Date.now()}`, label: '', type: 'text', required: false },
])
}
const updateCustomField = (index: number, field: typeof customFields[0]) => {
const updated = [...customFields]
updated[index] = field
update('customFields', updated)
}
const removeCustomField = (index: number) => {
update('customFields', customFields.filter((_, i) => i !== index))
}
return (
<div className="space-y-6">
{/* Basic Settings */}
<Card>
<CardHeader>
<CardTitle className="text-base">Application Settings</CardTitle>
<CardDescription>Configure how projects are submitted during intake</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="allowDrafts">Allow Drafts</Label>
<p className="text-xs text-muted-foreground">Let applicants save incomplete submissions</p>
</div>
<Switch
id="allowDrafts"
checked={(config.allowDrafts as boolean) ?? true}
onCheckedChange={(v) => update('allowDrafts', v)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="draftExpiryDays">Draft Expiry (days)</Label>
<p className="text-xs text-muted-foreground">Days before incomplete drafts are automatically deleted</p>
<Input
id="draftExpiryDays"
type="number"
min={1}
className="w-32"
value={(config.draftExpiryDays as number) ?? 30}
onChange={(e) => update('draftExpiryDays', parseInt(e.target.value, 10) || 30)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="publicFormEnabled">Public Application Form</Label>
<p className="text-xs text-muted-foreground">Allow applications without login</p>
</div>
<Switch
id="publicFormEnabled"
checked={(config.publicFormEnabled as boolean) ?? false}
onCheckedChange={(v) => update('publicFormEnabled', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="lateSubmissionNotification">Late Submission Notification</Label>
<p className="text-xs text-muted-foreground">Notify admins when submissions arrive after deadline</p>
</div>
<Switch
id="lateSubmissionNotification"
checked={(config.lateSubmissionNotification as boolean) ?? true}
onCheckedChange={(v) => update('lateSubmissionNotification', v)}
/>
</div>
</CardContent>
</Card>
{/* Categories */}
<Card>
<CardHeader>
<CardTitle className="text-base">Accepted Categories</CardTitle>
<CardDescription>Which project categories can submit in this round</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{['STARTUP', 'BUSINESS_CONCEPT'].map((cat) => (
<Badge
key={cat}
variant={acceptedCategories.includes(cat) ? 'default' : 'outline'}
className="cursor-pointer select-none"
onClick={() => toggleCategory(cat)}
>
{cat === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
))}
</div>
</CardContent>
</Card>
{/* File Settings */}
<Card>
<CardHeader>
<CardTitle className="text-base">File Upload Settings</CardTitle>
<CardDescription>Constraints for uploaded documents</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="maxFileSizeMB">Max File Size (MB)</Label>
<Input
id="maxFileSizeMB"
type="number"
min={1}
className="w-32"
value={(config.maxFileSizeMB as number) ?? 50}
onChange={(e) => update('maxFileSizeMB', parseInt(e.target.value, 10) || 50)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxFilesPerSlot">Max Files per Slot</Label>
<Input
id="maxFilesPerSlot"
type="number"
min={1}
className="w-32"
value={(config.maxFilesPerSlot as number) ?? 1}
onChange={(e) => update('maxFilesPerSlot', parseInt(e.target.value, 10) || 1)}
/>
</div>
</div>
<div className="space-y-2">
<Label>Allowed File Types</Label>
<div className="flex flex-wrap gap-2">
{MIME_PRESETS.map((preset) => (
<Badge
key={preset.value}
variant={allowedMimeTypes.includes(preset.value) ? 'default' : 'outline'}
className="cursor-pointer select-none"
onClick={() => toggleMime(preset.value)}
>
{preset.label}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
{/* Custom Fields */}
<Card>
<CardHeader>
<CardTitle className="text-base">Custom Application Fields</CardTitle>
<CardDescription>Additional fields applicants must fill in</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{customFields.length === 0 && (
<p className="text-sm text-muted-foreground">No custom fields configured.</p>
)}
{customFields.map((field, idx) => (
<div key={field.id} className="flex items-start gap-3 rounded-lg border p-3">
<div className="flex-1 space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Label</Label>
<Input
value={field.label}
placeholder="Field name"
onChange={(e) => updateCustomField(idx, { ...field, label: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Type</Label>
<Select
value={field.type}
onValueChange={(v) => updateCustomField(idx, { ...field, type: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((ft) => (
<SelectItem key={ft.value} value={ft.value}>{ft.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{field.type === 'select' && (
<div className="space-y-1">
<Label className="text-xs">Options (comma-separated)</Label>
<Input
value={(field.options ?? []).join(', ')}
placeholder="Option 1, Option 2, Option 3"
onChange={(e) => updateCustomField(idx, {
...field,
options: e.target.value.split(',').map((o) => o.trim()).filter(Boolean),
})}
/>
</div>
)}
<div className="flex items-center gap-2">
<Switch
checked={field.required}
onCheckedChange={(v) => updateCustomField(idx, { ...field, required: v })}
/>
<Label className="text-xs">Required</Label>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeCustomField(idx)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addCustomField}>
<Plus className="h-4 w-4 mr-1" />
Add Field
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,286 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
type LiveFinalConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
export function LiveFinalConfig({ config, onChange }: LiveFinalConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
const audienceEnabled = (config.audienceVotingEnabled as boolean) ?? false
const deliberationEnabled = (config.deliberationEnabled as boolean) ?? false
return (
<div className="space-y-6">
{/* Jury Voting */}
<Card>
<CardHeader>
<CardTitle className="text-base">Jury Voting</CardTitle>
<CardDescription>How the jury panel scores projects during the live event</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="juryVotingEnabled">Enable Jury Voting</Label>
<p className="text-xs text-muted-foreground">Jury members can vote during live presentations</p>
</div>
<Switch
id="juryVotingEnabled"
checked={(config.juryVotingEnabled as boolean) ?? true}
onCheckedChange={(v) => update('juryVotingEnabled', v)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="votingMode">Voting Mode</Label>
<p className="text-xs text-muted-foreground">How jury members cast their votes</p>
<Select
value={(config.votingMode as string) ?? 'simple'}
onValueChange={(v) => update('votingMode', v)}
>
<SelectTrigger id="votingMode" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="simple">Simple (single score per project)</SelectItem>
<SelectItem value="criteria">Criteria-based (multiple criteria)</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Audience Voting */}
<Card>
<CardHeader>
<CardTitle className="text-base">Audience Voting</CardTitle>
<CardDescription>Public or audience participation in scoring</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="audienceVotingEnabled">Enable Audience Voting</Label>
<p className="text-xs text-muted-foreground">Allow event attendees to vote</p>
</div>
<Switch
id="audienceVotingEnabled"
checked={audienceEnabled}
onCheckedChange={(v) => update('audienceVotingEnabled', v)}
/>
</div>
{audienceEnabled && (
<div className="pl-6 border-l-2 border-muted space-y-4">
<div className="space-y-2">
<Label htmlFor="audienceVoteWeight">Audience Vote Weight (0-1)</Label>
<p className="text-xs text-muted-foreground">
How much audience votes count relative to jury (0 = no weight, 1 = equal)
</p>
<Input
id="audienceVoteWeight"
type="number"
min={0}
max={1}
step={0.05}
className="w-32"
value={(config.audienceVoteWeight as number) ?? 0}
onChange={(e) => update('audienceVoteWeight', parseFloat(e.target.value) || 0)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="audienceVotingMode">Audience Voting Mode</Label>
<p className="text-xs text-muted-foreground">How audience members cast votes</p>
<Select
value={(config.audienceVotingMode as string) ?? 'per_project'}
onValueChange={(v) => update('audienceVotingMode', v)}
>
<SelectTrigger id="audienceVotingMode" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="per_project">Per Project (vote on each)</SelectItem>
<SelectItem value="per_category">Per Category (one vote per category)</SelectItem>
<SelectItem value="favorites">Favorites (pick top N)</SelectItem>
</SelectContent>
</Select>
</div>
{(config.audienceVotingMode as string) === 'favorites' && (
<div className="pl-6 border-l-2 border-muted space-y-2">
<Label htmlFor="audienceMaxFavorites">Max Favorites</Label>
<p className="text-xs text-muted-foreground">How many projects each audience member can pick</p>
<Input
id="audienceMaxFavorites"
type="number"
min={1}
className="w-32"
value={(config.audienceMaxFavorites as number) ?? 3}
onChange={(e) => update('audienceMaxFavorites', parseInt(e.target.value, 10) || 3)}
/>
</div>
)}
<div className="flex items-center justify-between">
<div>
<Label htmlFor="audienceRequireIdentification">Require Identification</Label>
<p className="text-xs text-muted-foreground">Audience must log in to vote</p>
</div>
<Switch
id="audienceRequireIdentification"
checked={(config.audienceRequireIdentification as boolean) ?? false}
onCheckedChange={(v) => update('audienceRequireIdentification', v)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="audienceRevealTiming">Reveal Audience Results</Label>
<p className="text-xs text-muted-foreground">When audience vote results become visible</p>
<Select
value={(config.audienceRevealTiming as string) ?? 'at_deliberation'}
onValueChange={(v) => update('audienceRevealTiming', v)}
>
<SelectTrigger id="audienceRevealTiming" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="real_time">Real-time (live updates)</SelectItem>
<SelectItem value="after_jury_scores">After Jury Scores</SelectItem>
<SelectItem value="at_deliberation">At Deliberation</SelectItem>
<SelectItem value="never">Never (admin only)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showAudienceVotesToJury">Show Audience Votes to Jury</Label>
<p className="text-xs text-muted-foreground">Jury can see audience results during deliberation</p>
</div>
<Switch
id="showAudienceVotesToJury"
checked={(config.showAudienceVotesToJury as boolean) ?? false}
onCheckedChange={(v) => update('showAudienceVotesToJury', v)}
/>
</div>
</div>
)}
</CardContent>
</Card>
{/* Presentations */}
<Card>
<CardHeader>
<CardTitle className="text-base">Presentation Settings</CardTitle>
<CardDescription>Timing and order for live presentations</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="presentationOrderMode">Presentation Order</Label>
<Select
value={(config.presentationOrderMode as string) ?? 'manual'}
onValueChange={(v) => update('presentationOrderMode', v)}
>
<SelectTrigger id="presentationOrderMode" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual (admin sets order)</SelectItem>
<SelectItem value="random">Random</SelectItem>
<SelectItem value="score_based">Score-based (highest first)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="presentationDuration">Presentation Duration (min)</Label>
<Input
id="presentationDuration"
type="number"
min={1}
className="w-32"
value={(config.presentationDurationMinutes as number) ?? 15}
onChange={(e) => update('presentationDurationMinutes', parseInt(e.target.value, 10) || 15)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="qaDuration">Q&A Duration (min)</Label>
<Input
id="qaDuration"
type="number"
min={0}
className="w-32"
value={(config.qaDurationMinutes as number) ?? 5}
onChange={(e) => update('qaDurationMinutes', parseInt(e.target.value, 10) || 0)}
/>
</div>
</div>
</CardContent>
</Card>
{/* Deliberation */}
<Card>
<CardHeader>
<CardTitle className="text-base">Post-Presentation Deliberation</CardTitle>
<CardDescription>Optional deliberation session after all presentations</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="deliberationEnabled">Enable Deliberation</Label>
<p className="text-xs text-muted-foreground">Add a deliberation phase after presentations</p>
</div>
<Switch
id="deliberationEnabled"
checked={deliberationEnabled}
onCheckedChange={(v) => update('deliberationEnabled', v)}
/>
</div>
{deliberationEnabled && (
<div className="pl-6 border-l-2 border-muted space-y-2">
<Label htmlFor="deliberationDuration">Deliberation Duration (min)</Label>
<p className="text-xs text-muted-foreground">Time allocated for jury deliberation</p>
<Input
id="deliberationDuration"
type="number"
min={1}
className="w-32"
value={(config.deliberationDurationMinutes as number) ?? 30}
onChange={(e) => update('deliberationDurationMinutes', parseInt(e.target.value, 10) || 30)}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="revealPolicy">Results Reveal Policy</Label>
<p className="text-xs text-muted-foreground">When final results are announced</p>
<Select
value={(config.revealPolicy as string) ?? 'ceremony'}
onValueChange={(v) => update('revealPolicy', v)}
>
<SelectTrigger id="revealPolicy" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate">Immediate (show as votes come in)</SelectItem>
<SelectItem value="delayed">Delayed (admin triggers reveal)</SelectItem>
<SelectItem value="ceremony">Ceremony (formal award announcement)</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,133 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
type MentoringConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
export function MentoringConfig({ config, onChange }: MentoringConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Mentoring Eligibility</CardTitle>
<CardDescription>Who receives mentoring and how mentors are assigned</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="eligibility">Eligibility</Label>
<p className="text-xs text-muted-foreground">Which projects receive mentoring</p>
<Select
value={(config.eligibility as string) ?? 'requested_only'}
onValueChange={(v) => update('eligibility', v)}
>
<SelectTrigger id="eligibility" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all_advancing">All Advancing Projects</SelectItem>
<SelectItem value="requested_only">Requested Only</SelectItem>
<SelectItem value="admin_selected">Admin Selected</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="autoAssignMentors">Auto-Assign Mentors</Label>
<p className="text-xs text-muted-foreground">Automatically match mentors to projects based on expertise</p>
</div>
<Switch
id="autoAssignMentors"
checked={(config.autoAssignMentors as boolean) ?? false}
onCheckedChange={(v) => update('autoAssignMentors', v)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Communication & Files</CardTitle>
<CardDescription>Features available to mentors and project teams</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="chatEnabled">Chat</Label>
<p className="text-xs text-muted-foreground">Enable messaging between mentor and team</p>
</div>
<Switch
id="chatEnabled"
checked={(config.chatEnabled as boolean) ?? true}
onCheckedChange={(v) => update('chatEnabled', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="fileUploadEnabled">File Upload</Label>
<p className="text-xs text-muted-foreground">Allow mentors to upload files for teams</p>
</div>
<Switch
id="fileUploadEnabled"
checked={(config.fileUploadEnabled as boolean) ?? true}
onCheckedChange={(v) => update('fileUploadEnabled', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="fileCommentsEnabled">File Comments</Label>
<p className="text-xs text-muted-foreground">Allow mentors to comment on uploaded documents</p>
</div>
<Switch
id="fileCommentsEnabled"
checked={(config.fileCommentsEnabled as boolean) ?? true}
onCheckedChange={(v) => update('fileCommentsEnabled', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="filePromotionEnabled">File Promotion</Label>
<p className="text-xs text-muted-foreground">
Allow mentors to promote mentoring files to a submission window
</p>
</div>
<Switch
id="filePromotionEnabled"
checked={(config.filePromotionEnabled as boolean) ?? true}
onCheckedChange={(v) => update('filePromotionEnabled', v)}
/>
</div>
{(config.filePromotionEnabled as boolean) !== false && (
<div className="pl-6 border-l-2 border-muted space-y-2">
<Label htmlFor="promotionTargetWindowId">Promotion Target Window</Label>
<p className="text-xs text-muted-foreground">
Submission window where promoted files are placed (leave empty for default)
</p>
<Input
id="promotionTargetWindowId"
placeholder="Submission window ID (optional)"
value={(config.promotionTargetWindowId as string) ?? ''}
onChange={(e) => update('promotionTargetWindowId', e.target.value || undefined)}
/>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,108 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
type SubmissionConfigProps = {
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
const STATUSES = [
{ value: 'PENDING', label: 'Pending', color: 'bg-gray-100 text-gray-700' },
{ value: 'IN_PROGRESS', label: 'In Progress', color: 'bg-blue-100 text-blue-700' },
{ value: 'PASSED', label: 'Passed', color: 'bg-emerald-100 text-emerald-700' },
{ value: 'REJECTED', label: 'Rejected', color: 'bg-red-100 text-red-700' },
{ value: 'COMPLETED', label: 'Completed', color: 'bg-purple-100 text-purple-700' },
{ value: 'WITHDRAWN', label: 'Withdrawn', color: 'bg-amber-100 text-amber-700' },
]
export function SubmissionConfig({ config, onChange }: SubmissionConfigProps) {
const update = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
const eligible = (config.eligibleStatuses as string[]) ?? ['PASSED']
const toggleStatus = (status: string) => {
const current = [...eligible]
const idx = current.indexOf(status)
if (idx >= 0) {
current.splice(idx, 1)
} else {
current.push(status)
}
update('eligibleStatuses', current)
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Submission Eligibility</CardTitle>
<CardDescription>
Which project states from the previous round are eligible to submit documents in this round
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Eligible Project Statuses</Label>
<p className="text-xs text-muted-foreground">
Projects with these statuses from the previous round can submit
</p>
<div className="flex flex-wrap gap-2">
{STATUSES.map((s) => (
<Badge
key={s.value}
variant={eligible.includes(s.value) ? 'default' : 'outline'}
className="cursor-pointer select-none"
onClick={() => toggleStatus(s.value)}
>
{s.label}
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Notifications & Locking</CardTitle>
<CardDescription>Behavior when the submission round activates</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="notifyEligibleTeams">Notify Eligible Teams</Label>
<p className="text-xs text-muted-foreground">
Send email notification to teams when submission window opens
</p>
</div>
<Switch
id="notifyEligibleTeams"
checked={(config.notifyEligibleTeams as boolean) ?? true}
onCheckedChange={(v) => update('notifyEligibleTeams', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="lockPreviousWindows">Lock Previous Windows</Label>
<p className="text-xs text-muted-foreground">
Prevent uploads to earlier submission windows when this round activates
</p>
</div>
<Switch
id="lockPreviousWindows"
checked={(config.lockPreviousWindows as boolean) ?? true}
onCheckedChange={(v) => update('lockPreviousWindows', v)}
/>
</div>
</CardContent>
</Card>
</div>
)
}