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
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:
176
src/components/admin/rounds/config/deliberation-config.tsx
Normal file
176
src/components/admin/rounds/config/deliberation-config.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
283
src/components/admin/rounds/config/evaluation-config.tsx
Normal file
283
src/components/admin/rounds/config/evaluation-config.tsx
Normal 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' 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>
|
||||
)
|
||||
}
|
||||
425
src/components/admin/rounds/config/file-requirements-editor.tsx
Normal file
425
src/components/admin/rounds/config/file-requirements-editor.tsx
Normal file
@@ -0,0 +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>
|
||||
);
|
||||
}
|
||||
193
src/components/admin/rounds/config/filtering-config.tsx
Normal file
193
src/components/admin/rounds/config/filtering-config.tsx
Normal 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 "High" auto-pass,
|
||||
below "Low" 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>
|
||||
)
|
||||
}
|
||||
296
src/components/admin/rounds/config/intake-config.tsx
Normal file
296
src/components/admin/rounds/config/intake-config.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
286
src/components/admin/rounds/config/live-final-config.tsx
Normal file
286
src/components/admin/rounds/config/live-final-config.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
src/components/admin/rounds/config/mentoring-config.tsx
Normal file
133
src/components/admin/rounds/config/mentoring-config.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
src/components/admin/rounds/config/submission-config.tsx
Normal file
108
src/components/admin/rounds/config/submission-config.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user