Round system redesign: Phases 1-7 complete

Full pipeline/track/stage architecture replacing the legacy round system.

Schema: 11 new models (Pipeline, Track, Stage, StageTransition,
ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor,
OverrideAction, AudienceVoter) + 8 new enums.

Backend: 9 new routers (pipeline, stage, routing, stageFiltering,
stageAssignment, cohort, live, decision, award) + 6 new services
(stage-engine, routing-engine, stage-filtering, stage-assignment,
stage-notifications, live-control).

Frontend: Pipeline wizard (17 components), jury stage pages (7),
applicant pipeline pages (3), public stage pages (2), admin pipeline
pages (5), shared stage components (3), SSE route, live hook.

Phase 6 refit: 23 routers/services migrated from roundId to stageId,
all frontend components refitted. Deleted round.ts (985 lines),
roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx,
10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs.

Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing,
TypeScript 0 errors, Next.js build succeeds, 13 integrity checks,
legacy symbol sweep clean, auto-seed on first Docker startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 13:57:09 +01:00
parent 8a328357e3
commit 331b67dae0
256 changed files with 29117 additions and 21424 deletions

View File

@@ -1,287 +0,0 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { trpc } from "@/lib/trpc/client";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ArrowRightCircle, Loader2, Info } from "lucide-react";
interface AdvanceProjectsDialogProps {
roundId: string;
programId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function AdvanceProjectsDialog({
roundId,
programId,
open,
onOpenChange,
onSuccess,
}: AdvanceProjectsDialogProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [targetRoundId, setTargetRoundId] = useState<string>("");
const utils = trpc.useUtils();
// Reset state when dialog opens
useEffect(() => {
if (open) {
setSelectedIds(new Set());
setTargetRoundId("");
}
}, [open]);
// Fetch rounds in program
const { data: roundsData } = trpc.round.list.useQuery(
{ programId },
{ enabled: open },
);
// Fetch projects in current round
const { data: projectsData, isLoading } = trpc.project.list.useQuery(
{ roundId, page: 1, perPage: 200 },
{ enabled: open },
);
// Auto-select next round by sortOrder
const otherRounds = useMemo(() => {
if (!roundsData) return [];
return roundsData
.filter((r) => r.id !== roundId)
.sort((a, b) => a.sortOrder - b.sortOrder);
}, [roundsData, roundId]);
const currentRound = useMemo(() => {
return roundsData?.find((r) => r.id === roundId);
}, [roundsData, roundId]);
// Auto-select next round in sort order
useEffect(() => {
if (open && otherRounds.length > 0 && !targetRoundId && currentRound) {
const nextRound = otherRounds.find(
(r) => r.sortOrder > currentRound.sortOrder,
);
setTargetRoundId(nextRound?.id || otherRounds[0].id);
}
}, [open, otherRounds, targetRoundId, currentRound]);
const advanceMutation = trpc.round.advanceProjects.useMutation({
onSuccess: (result) => {
const targetName = otherRounds.find((r) => r.id === targetRoundId)?.name;
toast.success(
`${result.advanced} project${result.advanced !== 1 ? "s" : ""} advanced to ${targetName}`,
);
utils.round.get.invalidate();
utils.project.list.invalidate();
onSuccess?.();
onOpenChange(false);
},
onError: (error) => {
toast.error(error.message);
},
});
const projects = projectsData?.projects ?? [];
const toggleProject = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleAll = useCallback(() => {
if (selectedIds.size === projects.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(projects.map((p) => p.id)));
}
}, [selectedIds.size, projects]);
const handleAdvance = () => {
if (selectedIds.size === 0 || !targetRoundId) return;
advanceMutation.mutate({
fromRoundId: roundId,
toRoundId: targetRoundId,
projectIds: Array.from(selectedIds),
});
};
const targetRoundName = otherRounds.find((r) => r.id === targetRoundId)?.name;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ArrowRightCircle className="h-5 w-5" />
Advance Projects
</DialogTitle>
<DialogDescription>
Select projects to advance to the next round. Projects will remain
visible in the current round.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label>Target Round</Label>
{otherRounds.length === 0 ? (
<p className="text-sm text-muted-foreground">
No other rounds available in this program. Create another round
first.
</p>
) : (
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select target round" />
</SelectTrigger>
<SelectContent>
{otherRounds.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name} (Order: {r.sortOrder})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="flex items-start gap-2 rounded-lg bg-blue-50 p-3 text-sm text-blue-800 dark:bg-blue-950/50 dark:text-blue-200">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<span>
Projects will be copied to the target round with
&quot;Submitted&quot; status. They will remain in the current round
with their existing status.
</span>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="font-medium">No projects in this round</p>
<p className="text-sm text-muted-foreground">
Assign projects to this round first.
</p>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Checkbox
checked={
selectedIds.size === projects.length && projects.length > 0
}
onCheckedChange={toggleAll}
/>
<span className="text-sm text-muted-foreground">
{selectedIds.size} of {projects.length} selected
</span>
</div>
<div className="rounded-lg border max-h-[300px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12" />
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow
key={project.id}
className={
selectedIds.has(project.id)
? "bg-muted/50"
: "cursor-pointer"
}
onClick={() => toggleProject(project.id)}
>
<TableCell>
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => toggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell className="font-medium">
{project.title}
</TableCell>
<TableCell className="text-muted-foreground">
{project.teamName || "—"}
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs">
{(project.status ?? "SUBMITTED").replace("_", " ")}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleAdvance}
disabled={
selectedIds.size === 0 ||
!targetRoundId ||
advanceMutation.isPending
}
>
{advanceMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ArrowRightCircle className="mr-2 h-4 w-4" />
)}
Advance Selected ({selectedIds.size})
{targetRoundName ? ` to ${targetRoundName}` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,282 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { trpc } from "@/lib/trpc/client";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Search, Loader2, Plus, Package, CheckCircle2 } from "lucide-react";
import { getCountryName } from "@/lib/countries";
interface AssignProjectsDialogProps {
roundId: string;
programId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function AssignProjectsDialog({
roundId,
programId,
open,
onOpenChange,
onSuccess,
}: AssignProjectsDialogProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const utils = trpc.useUtils();
// Debounce search
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);
// Reset state when dialog opens
useEffect(() => {
if (open) {
setSelectedIds(new Set());
setSearch("");
setDebouncedSearch("");
}
}, [open]);
const { data, isLoading, error } = trpc.project.list.useQuery(
{
programId,
unassignedOnly: true,
search: debouncedSearch || undefined,
page: 1,
perPage: 200,
},
{ enabled: open },
);
const assignMutation = trpc.round.assignProjects.useMutation({
onSuccess: (result) => {
toast.success(
`${result.assigned} project${result.assigned !== 1 ? "s" : ""} assigned to round`,
);
utils.round.get.invalidate({ id: roundId });
utils.project.list.invalidate();
onSuccess?.();
onOpenChange(false);
},
onError: (error) => {
toast.error(error.message);
},
});
const projects = data?.projects ?? [];
const alreadyInRound = new Set(
projects.filter((p) => p.round?.id === roundId).map((p) => p.id),
);
const assignableProjects = projects.filter((p) => !alreadyInRound.has(p.id));
const toggleProject = useCallback(
(id: string) => {
if (alreadyInRound.has(id)) return;
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
},
[alreadyInRound],
);
const toggleAll = useCallback(() => {
if (selectedIds.size === assignableProjects.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(assignableProjects.map((p) => p.id)));
}
}, [selectedIds.size, assignableProjects]);
const handleAssign = () => {
if (selectedIds.size === 0) return;
assignMutation.mutate({
roundId,
projectIds: Array.from(selectedIds),
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
Assign Projects to Round
</DialogTitle>
<DialogDescription>
Select projects from the program pool to add to this round.
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="mt-2 font-medium">Failed to load projects</p>
<p className="text-sm text-muted-foreground">{error.message}</p>
</div>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No projects found</p>
<p className="text-sm text-muted-foreground">
{debouncedSearch
? "No projects in the pool match your search."
: "This program has no projects in the pool yet."}
</p>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={
assignableProjects.length > 0 &&
selectedIds.size === assignableProjects.length
}
disabled={assignableProjects.length === 0}
onCheckedChange={toggleAll}
/>
<span className="text-sm text-muted-foreground">
{selectedIds.size} of {assignableProjects.length} assignable
selected
{alreadyInRound.size > 0 && (
<span className="ml-1">
({alreadyInRound.size} already in round)
</span>
)}
</span>
</div>
</div>
<div className="rounded-lg border max-h-[400px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12" />
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead>Country</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => {
const isInRound = alreadyInRound.has(project.id);
return (
<TableRow
key={project.id}
className={
isInRound
? "opacity-60"
: selectedIds.has(project.id)
? "bg-muted/50"
: "cursor-pointer"
}
onClick={() => toggleProject(project.id)}
>
<TableCell>
{isInRound ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() =>
toggleProject(project.id)
}
onClick={(e) => e.stopPropagation()}
/>
)}
</TableCell>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{project.title}
{isInRound && (
<Badge variant="secondary" className="text-xs">
In round
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{project.teamName || "—"}
</TableCell>
<TableCell>
{project.country ? (
<Badge variant="outline" className="text-xs">
{getCountryName(project.country)}
</Badge>
) : (
"—"
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleAssign}
disabled={selectedIds.size === 0 || assignMutation.isPending}
>
{assignMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Assign Selected ({selectedIds.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -38,7 +38,7 @@ import { formatDistanceToNow } from 'date-fns'
interface EvaluationSummaryCardProps {
projectId: string
roundId: string
stageId: string
}
interface ScoringPatterns {
@@ -71,7 +71,7 @@ const sentimentColors: Record<string, { badge: 'default' | 'secondary' | 'destru
export function EvaluationSummaryCard({
projectId,
roundId,
stageId,
}: EvaluationSummaryCardProps) {
const [isGenerating, setIsGenerating] = useState(false)
@@ -79,7 +79,7 @@ export function EvaluationSummaryCard({
data: summary,
isLoading,
refetch,
} = trpc.evaluation.getSummary.useQuery({ projectId, roundId })
} = trpc.evaluation.getSummary.useQuery({ projectId, stageId })
const generateMutation = trpc.evaluation.generateSummary.useMutation({
onSuccess: () => {
@@ -95,7 +95,7 @@ export function EvaluationSummaryCard({
const handleGenerate = () => {
setIsGenerating(true)
generateMutation.mutate({ projectId, roundId })
generateMutation.mutate({ projectId, stageId })
}
if (isLoading) {

View File

@@ -63,7 +63,7 @@ function getMimeLabel(mime: string): string {
}
interface FileRequirementsEditorProps {
roundId: string;
stageId: string;
}
interface RequirementFormData {
@@ -83,35 +83,35 @@ const emptyForm: RequirementFormData = {
};
export function FileRequirementsEditor({
roundId,
stageId,
}: FileRequirementsEditorProps) {
const utils = trpc.useUtils();
const { data: requirements = [], isLoading } =
trpc.file.listRequirements.useQuery({ roundId });
trpc.file.listRequirements.useQuery({ stageId });
const createMutation = trpc.file.createRequirement.useMutation({
onSuccess: () => {
utils.file.listRequirements.invalidate({ roundId });
utils.file.listRequirements.invalidate({ stageId });
toast.success("Requirement created");
},
onError: (err) => toast.error(err.message),
});
const updateMutation = trpc.file.updateRequirement.useMutation({
onSuccess: () => {
utils.file.listRequirements.invalidate({ roundId });
utils.file.listRequirements.invalidate({ stageId });
toast.success("Requirement updated");
},
onError: (err) => toast.error(err.message),
});
const deleteMutation = trpc.file.deleteRequirement.useMutation({
onSuccess: () => {
utils.file.listRequirements.invalidate({ roundId });
utils.file.listRequirements.invalidate({ stageId });
toast.success("Requirement deleted");
},
onError: (err) => toast.error(err.message),
});
const reorderMutation = trpc.file.reorderRequirements.useMutation({
onSuccess: () => utils.file.listRequirements.invalidate({ roundId }),
onSuccess: () => utils.file.listRequirements.invalidate({ stageId }),
onError: (err) => toast.error(err.message),
});
@@ -156,7 +156,7 @@ export function FileRequirementsEditor({
});
} else {
await createMutation.mutateAsync({
roundId,
stageId,
name: form.name.trim(),
description: form.description.trim() || undefined,
acceptedMimeTypes: form.acceptedMimeTypes,
@@ -182,7 +182,7 @@ export function FileRequirementsEditor({
newOrder[index],
];
await reorderMutation.mutateAsync({
roundId,
stageId,
orderedIds: newOrder.map((r) => r.id),
});
};

View File

@@ -18,15 +18,15 @@ import {
} from '@/lib/pdf-generator'
interface PdfReportProps {
roundId: string
stageId: string
sections: string[]
}
export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
export function PdfReportGenerator({ stageId, sections }: PdfReportProps) {
const [generating, setGenerating] = useState(false)
const { refetch } = trpc.export.getReportData.useQuery(
{ roundId, sections },
{ stageId, sections },
{ enabled: false }
)

View File

@@ -0,0 +1,121 @@
'use client'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { ArrowRight } from 'lucide-react'
type StageNode = {
id?: string
name: string
stageType: string
sortOrder: number
_count?: { projectStageStates: number }
}
type TrackLane = {
id?: string
name: string
kind: string
sortOrder: number
stages: StageNode[]
}
type PipelineVisualizationProps = {
tracks: TrackLane[]
className?: string
}
const stageColors: Record<string, string> = {
INTAKE: 'bg-blue-50 border-blue-300 text-blue-700',
FILTER: 'bg-amber-50 border-amber-300 text-amber-700',
EVALUATION: 'bg-purple-50 border-purple-300 text-purple-700',
SELECTION: 'bg-rose-50 border-rose-300 text-rose-700',
LIVE_FINAL: 'bg-emerald-50 border-emerald-300 text-emerald-700',
RESULTS: 'bg-cyan-50 border-cyan-300 text-cyan-700',
}
const trackKindBadge: Record<string, string> = {
MAIN: 'bg-blue-100 text-blue-700',
AWARD: 'bg-amber-100 text-amber-700',
SHOWCASE: 'bg-purple-100 text-purple-700',
}
export function PipelineVisualization({
tracks,
className,
}: PipelineVisualizationProps) {
const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder)
return (
<div className={cn('space-y-4', className)}>
{sortedTracks.map((track) => {
const sortedStages = [...track.stages].sort(
(a, b) => a.sortOrder - b.sortOrder
)
return (
<Card key={track.id ?? track.name} className="p-4">
{/* Track header */}
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-semibold">{track.name}</span>
<Badge
variant="secondary"
className={cn(
'text-[10px] h-5',
trackKindBadge[track.kind] ?? ''
)}
>
{track.kind}
</Badge>
</div>
{/* Stage flow */}
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{sortedStages.map((stage, index) => (
<div key={stage.id ?? index} className="flex items-center gap-1 shrink-0">
<div
className={cn(
'flex flex-col items-center rounded-lg border px-3 py-2 min-w-[100px]',
stageColors[stage.stageType] ?? 'bg-gray-50 border-gray-300'
)}
>
<span className="text-xs font-medium text-center leading-tight">
{stage.name}
</span>
<span className="text-[10px] opacity-70 mt-0.5">
{stage.stageType.replace('_', ' ')}
</span>
{stage._count?.projectStageStates !== undefined &&
stage._count.projectStageStates > 0 && (
<Badge
variant="secondary"
className="text-[9px] h-4 px-1 mt-1"
>
{stage._count.projectStageStates}
</Badge>
)}
</div>
{index < sortedStages.length - 1 && (
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
</div>
))}
{sortedStages.length === 0 && (
<span className="text-xs text-muted-foreground italic">
No stages
</span>
)}
</div>
</Card>
)
})}
{tracks.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No tracks to visualize
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,120 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { EvaluationConfig } from '@/types/pipeline-wizard'
type AssignmentSectionProps = {
config: EvaluationConfig
onChange: (config: EvaluationConfig) => void
}
export function AssignmentSection({ config, onChange }: AssignmentSectionProps) {
const updateConfig = (updates: Partial<EvaluationConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>Required Reviews per Project</Label>
<Input
type="number"
min={1}
max={20}
value={config.requiredReviews}
onChange={(e) =>
updateConfig({ requiredReviews: parseInt(e.target.value) || 3 })
}
/>
<p className="text-xs text-muted-foreground">
Minimum number of jury evaluations per project
</p>
</div>
<div className="space-y-2">
<Label>Max Load per Juror</Label>
<Input
type="number"
min={1}
max={100}
value={config.maxLoadPerJuror}
onChange={(e) =>
updateConfig({ maxLoadPerJuror: parseInt(e.target.value) || 20 })
}
/>
<p className="text-xs text-muted-foreground">
Maximum projects assigned to one juror
</p>
</div>
<div className="space-y-2">
<Label>Min Load per Juror</Label>
<Input
type="number"
min={0}
max={50}
value={config.minLoadPerJuror}
onChange={(e) =>
updateConfig({ minLoadPerJuror: parseInt(e.target.value) || 5 })
}
/>
<p className="text-xs text-muted-foreground">
Target minimum projects per juror
</p>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Availability Weighting</Label>
<p className="text-xs text-muted-foreground">
Factor in juror availability when assigning projects
</p>
</div>
<Switch
checked={config.availabilityWeighting}
onCheckedChange={(checked) =>
updateConfig({ availabilityWeighting: checked })
}
/>
</div>
<div className="space-y-2">
<Label>Overflow Policy</Label>
<Select
value={config.overflowPolicy}
onValueChange={(value) =>
updateConfig({
overflowPolicy: value as EvaluationConfig['overflowPolicy'],
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="queue">
Queue Hold unassigned projects for manual assignment
</SelectItem>
<SelectItem value="expand_pool">
Expand Pool Invite additional jurors automatically
</SelectItem>
<SelectItem value="reduce_reviews">
Reduce Reviews Lower required reviews to fit available jurors
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -0,0 +1,241 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Plus, Trash2, Trophy } from 'lucide-react'
import { defaultAwardTrack } from '@/lib/pipeline-defaults'
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client'
type AwardsSectionProps = {
tracks: WizardTrackConfig[]
onChange: (tracks: WizardTrackConfig[]) => void
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
const awardTracks = tracks.filter((t) => t.kind === 'AWARD')
const nonAwardTracks = tracks.filter((t) => t.kind !== 'AWARD')
const addAward = () => {
const newTrack = defaultAwardTrack(awardTracks.length)
newTrack.sortOrder = tracks.length
onChange([...tracks, newTrack])
}
const updateAward = (index: number, updates: Partial<WizardTrackConfig>) => {
const updated = [...tracks]
const awardIndex = tracks.findIndex(
(t) => t.kind === 'AWARD' && awardTracks.indexOf(t) === index
)
if (awardIndex >= 0) {
updated[awardIndex] = { ...updated[awardIndex], ...updates }
onChange(updated)
}
}
const removeAward = (index: number) => {
const toRemove = awardTracks[index]
onChange(tracks.filter((t) => t !== toRemove))
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Configure special award tracks that run alongside the main competition.
</p>
<Button type="button" variant="outline" size="sm" onClick={addAward}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Award Track
</Button>
</div>
{awardTracks.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
<Trophy className="h-8 w-8 mx-auto mb-2 text-muted-foreground/50" />
No award tracks configured. Awards are optional.
</div>
)}
{awardTracks.map((track, index) => (
<Card key={index}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
Award Track {index + 1}
</CardTitle>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Award Track?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the &quot;{track.name}&quot; award track and all
its stages. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => removeAward(index)}>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs">Award Name</Label>
<Input
placeholder="e.g., Innovation Award"
value={track.awardConfig?.name ?? track.name}
onChange={(e) => {
const name = e.target.value
updateAward(index, {
name,
slug: slugify(name),
awardConfig: {
...track.awardConfig,
name,
},
})
}}
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Routing Mode</Label>
<Select
value={track.routingModeDefault ?? 'PARALLEL'}
onValueChange={(value) =>
updateAward(index, {
routingModeDefault: value as RoutingMode,
})
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PARALLEL">
Parallel Runs alongside main track
</SelectItem>
<SelectItem value="EXCLUSIVE">
Exclusive Projects enter only this track
</SelectItem>
<SelectItem value="POST_MAIN">
Post-Main After main track completes
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs">Decision Mode</Label>
<Select
value={track.decisionMode ?? 'JURY_VOTE'}
onValueChange={(value) =>
updateAward(index, { decisionMode: value as DecisionMode })
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="JURY_VOTE">Jury Vote</SelectItem>
<SelectItem value="AWARD_MASTER_DECISION">
Award Master Decision
</SelectItem>
<SelectItem value="ADMIN_DECISION">Admin Decision</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs">Scoring Mode</Label>
<Select
value={track.awardConfig?.scoringMode ?? 'PICK_WINNER'}
onValueChange={(value) =>
updateAward(index, {
awardConfig: {
...track.awardConfig!,
scoringMode: value as AwardScoringMode,
},
})
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
<SelectItem value="RANKED">Ranked</SelectItem>
<SelectItem value="SCORED">Scored</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">Description (optional)</Label>
<Textarea
placeholder="Brief description of this award..."
value={track.awardConfig?.description ?? ''}
rows={2}
className="text-sm"
onChange={(e) =>
updateAward(index, {
awardConfig: {
...track.awardConfig!,
description: e.target.value,
},
})
}
/>
</div>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -0,0 +1,92 @@
'use client'
import { useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { trpc } from '@/lib/trpc/client'
import type { WizardState } from '@/types/pipeline-wizard'
type BasicsSectionProps = {
state: WizardState
onChange: (updates: Partial<WizardState>) => void
isActive?: boolean
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function BasicsSection({ state, onChange, isActive }: BasicsSectionProps) {
const { data: programs, isLoading } = trpc.program.list.useQuery({})
// Auto-generate slug from name
useEffect(() => {
if (state.name && !state.slug) {
onChange({ slug: slugify(state.name) })
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="pipeline-name">Pipeline Name</Label>
<Input
id="pipeline-name"
placeholder="e.g., MOPC 2026"
value={state.name}
onChange={(e) => {
const name = e.target.value
onChange({ name, slug: slugify(name) })
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pipeline-slug">Slug</Label>
<Input
id="pipeline-slug"
placeholder="e.g., mopc-2026"
value={state.slug}
onChange={(e) => onChange({ slug: e.target.value })}
pattern="^[a-z0-9-]+$"
disabled={isActive}
/>
<p className="text-xs text-muted-foreground">
{isActive
? 'Slug cannot be changed on active pipelines'
: 'Lowercase letters, numbers, and hyphens only'}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="pipeline-program">Program</Label>
<Select
value={state.programId}
onValueChange={(value) => onChange({ programId: value })}
>
<SelectTrigger id="pipeline-program">
<SelectValue placeholder={isLoading ? 'Loading...' : 'Select a program'} />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} ({p.year})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -0,0 +1,203 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Plus, Trash2 } from 'lucide-react'
import type { FilterConfig, FilterRuleConfig } from '@/types/pipeline-wizard'
type FilteringSectionProps = {
config: FilterConfig
onChange: (config: FilterConfig) => void
}
export function FilteringSection({ config, onChange }: FilteringSectionProps) {
const updateConfig = (updates: Partial<FilterConfig>) => {
onChange({ ...config, ...updates })
}
const updateRule = (index: number, updates: Partial<FilterRuleConfig>) => {
const updated = [...config.rules]
updated[index] = { ...updated[index], ...updates }
onChange({ ...config, rules: updated })
}
const addRule = () => {
onChange({
...config,
rules: [
...config.rules,
{ field: '', operator: 'equals', value: '', weight: 1 },
],
})
}
const removeRule = (index: number) => {
onChange({ ...config, rules: config.rules.filter((_, i) => i !== index) })
}
return (
<div className="space-y-6">
{/* Deterministic Gate Rules */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label>Gate Rules</Label>
<p className="text-xs text-muted-foreground">
Deterministic rules that projects must pass. Applied in order.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addRule}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
</div>
{config.rules.map((rule, index) => (
<Card key={index}>
<CardContent className="pt-3 pb-3 px-4">
<div className="flex items-center gap-2">
<div className="flex-1 grid gap-2 sm:grid-cols-3">
<Input
placeholder="Field name"
value={rule.field}
className="h-8 text-sm"
onChange={(e) => updateRule(index, { field: e.target.value })}
/>
<Select
value={rule.operator}
onValueChange={(value) => updateRule(index, { operator: value })}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals">Equals</SelectItem>
<SelectItem value="notEquals">Not Equals</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="greaterThan">Greater Than</SelectItem>
<SelectItem value="lessThan">Less Than</SelectItem>
<SelectItem value="exists">Exists</SelectItem>
</SelectContent>
</Select>
<Input
placeholder="Value"
value={String(rule.value)}
className="h-8 text-sm"
onChange={(e) => updateRule(index, { value: e.target.value })}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => removeRule(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
{config.rules.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-3">
No gate rules configured. All projects will pass through.
</p>
)}
</div>
{/* AI Rubric */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>AI Screening</Label>
<p className="text-xs text-muted-foreground">
Use AI to evaluate projects against rubric criteria
</p>
</div>
<Switch
checked={config.aiRubricEnabled}
onCheckedChange={(checked) => updateConfig({ aiRubricEnabled: checked })}
/>
</div>
{config.aiRubricEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
<div className="space-y-2">
<Label className="text-xs">High Confidence Threshold</Label>
<div className="flex items-center gap-3">
<Slider
value={[config.aiConfidenceThresholds.high * 100]}
onValueChange={([v]) =>
updateConfig({
aiConfidenceThresholds: {
...config.aiConfidenceThresholds,
high: v / 100,
},
})
}
min={50}
max={100}
step={5}
className="flex-1"
/>
<span className="text-xs font-mono w-10 text-right">
{Math.round(config.aiConfidenceThresholds.high * 100)}%
</span>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">Medium Confidence Threshold</Label>
<div className="flex items-center gap-3">
<Slider
value={[config.aiConfidenceThresholds.medium * 100]}
onValueChange={([v]) =>
updateConfig({
aiConfidenceThresholds: {
...config.aiConfidenceThresholds,
medium: v / 100,
},
})
}
min={20}
max={80}
step={5}
className="flex-1"
/>
<span className="text-xs font-mono w-10 text-right">
{Math.round(config.aiConfidenceThresholds.medium * 100)}%
</span>
</div>
</div>
</div>
)}
</div>
{/* Manual Queue */}
<div className="flex items-center justify-between">
<div>
<Label>Manual Review Queue</Label>
<p className="text-xs text-muted-foreground">
Projects below medium confidence go to manual review
</p>
</div>
<Switch
checked={config.manualQueueEnabled}
onCheckedChange={(checked) => updateConfig({ manualQueueEnabled: checked })}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,196 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Plus, Trash2, FileText } from 'lucide-react'
import type { IntakeConfig, FileRequirementConfig } from '@/types/pipeline-wizard'
type IntakeSectionProps = {
config: IntakeConfig
onChange: (config: IntakeConfig) => void
}
export function IntakeSection({ config, onChange }: IntakeSectionProps) {
const updateConfig = (updates: Partial<IntakeConfig>) => {
onChange({ ...config, ...updates })
}
const updateFileReq = (index: number, updates: Partial<FileRequirementConfig>) => {
const updated = [...config.fileRequirements]
updated[index] = { ...updated[index], ...updates }
onChange({ ...config, fileRequirements: updated })
}
const addFileReq = () => {
onChange({
...config,
fileRequirements: [
...config.fileRequirements,
{
name: '',
description: '',
acceptedMimeTypes: ['application/pdf'],
maxSizeMB: 50,
isRequired: false,
},
],
})
}
const removeFileReq = (index: number) => {
const updated = config.fileRequirements.filter((_, i) => i !== index)
onChange({ ...config, fileRequirements: updated })
}
return (
<div className="space-y-6">
{/* Submission Window */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Submission Window</Label>
<p className="text-xs text-muted-foreground">
Enable timed submission windows for project intake
</p>
</div>
<Switch
checked={config.submissionWindowEnabled}
onCheckedChange={(checked) =>
updateConfig({ submissionWindowEnabled: checked })
}
/>
</div>
</div>
{/* Late Policy */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Late Submission Policy</Label>
<Select
value={config.lateSubmissionPolicy}
onValueChange={(value) =>
updateConfig({
lateSubmissionPolicy: value as IntakeConfig['lateSubmissionPolicy'],
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reject">Reject late submissions</SelectItem>
<SelectItem value="flag">Accept but flag as late</SelectItem>
<SelectItem value="accept">Accept normally</SelectItem>
</SelectContent>
</Select>
</div>
{config.lateSubmissionPolicy === 'flag' && (
<div className="space-y-2">
<Label>Grace Period (hours)</Label>
<Input
type="number"
min={0}
max={168}
value={config.lateGraceHours}
onChange={(e) =>
updateConfig({ lateGraceHours: parseInt(e.target.value) || 0 })
}
/>
</div>
)}
</div>
{/* File Requirements */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>File Requirements</Label>
<Button type="button" variant="outline" size="sm" onClick={addFileReq}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Requirement
</Button>
</div>
{config.fileRequirements.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">
No file requirements configured. Projects can be submitted without files.
</p>
)}
{config.fileRequirements.map((req, index) => (
<Card key={index}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-start gap-3">
<FileText className="h-4 w-4 text-muted-foreground mt-2 shrink-0" />
<div className="flex-1 grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">File Name</Label>
<Input
placeholder="e.g., Executive Summary"
value={req.name}
onChange={(e) => updateFileReq(index, { name: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Max Size (MB)</Label>
<Input
type="number"
min={1}
max={500}
value={req.maxSizeMB ?? ''}
onChange={(e) =>
updateFileReq(index, {
maxSizeMB: parseInt(e.target.value) || undefined,
})
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Description</Label>
<Input
placeholder="Brief description of this requirement"
value={req.description ?? ''}
onChange={(e) =>
updateFileReq(index, { description: e.target.value })
}
/>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={req.isRequired}
onCheckedChange={(checked) =>
updateFileReq(index, { isRequired: checked })
}
/>
<Label className="text-xs">Required</Label>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeFileReq(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,137 @@
'use client'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
type LiveFinalsSectionProps = {
config: LiveFinalConfig
onChange: (config: LiveFinalConfig) => void
}
export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps) {
const updateConfig = (updates: Partial<LiveFinalConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Label>Jury Voting</Label>
<p className="text-xs text-muted-foreground">
Allow jury members to vote during the live finals event
</p>
</div>
<Switch
checked={config.juryVotingEnabled}
onCheckedChange={(checked) =>
updateConfig({ juryVotingEnabled: checked })
}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Audience Voting</Label>
<p className="text-xs text-muted-foreground">
Allow audience members to vote on projects
</p>
</div>
<Switch
checked={config.audienceVotingEnabled}
onCheckedChange={(checked) =>
updateConfig({ audienceVotingEnabled: checked })
}
/>
</div>
{config.audienceVotingEnabled && (
<div className="pl-4 border-l-2 border-muted space-y-3">
<div className="space-y-2">
<Label className="text-xs">Audience Vote Weight</Label>
<div className="flex items-center gap-3">
<Slider
value={[config.audienceVoteWeight * 100]}
onValueChange={([v]) =>
updateConfig({ audienceVoteWeight: v / 100 })
}
min={0}
max={100}
step={5}
className="flex-1"
/>
<span className="text-xs font-mono w-10 text-right">
{Math.round(config.audienceVoteWeight * 100)}%
</span>
</div>
<p className="text-xs text-muted-foreground">
Percentage weight of audience votes in the final score
</p>
</div>
</div>
)}
</div>
<div className="space-y-2">
<Label>Cohort Setup Mode</Label>
<Select
value={config.cohortSetupMode}
onValueChange={(value) =>
updateConfig({
cohortSetupMode: value as LiveFinalConfig['cohortSetupMode'],
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">
Manual Admin creates cohorts and assigns projects
</SelectItem>
<SelectItem value="auto">
Auto System creates cohorts from pipeline results
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Result Reveal Policy</Label>
<Select
value={config.revealPolicy}
onValueChange={(value) =>
updateConfig({
revealPolicy: value as LiveFinalConfig['revealPolicy'],
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate">
Immediate Results shown after each vote
</SelectItem>
<SelectItem value="delayed">
Delayed Results hidden until admin reveals
</SelectItem>
<SelectItem value="ceremony">
Ceremony Results revealed in dramatic sequence
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -0,0 +1,208 @@
'use client'
import { useCallback } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Card, CardContent } from '@/components/ui/card'
import {
Plus,
Trash2,
GripVertical,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import type { WizardStageConfig } from '@/types/pipeline-wizard'
import type { StageType } from '@prisma/client'
type MainTrackSectionProps = {
stages: WizardStageConfig[]
onChange: (stages: WizardStageConfig[]) => void
}
const STAGE_TYPE_OPTIONS: { value: StageType; label: string; color: string }[] = [
{ value: 'INTAKE', label: 'Intake', color: 'bg-blue-100 text-blue-700' },
{ value: 'FILTER', label: 'Filter', color: 'bg-amber-100 text-amber-700' },
{ value: 'EVALUATION', label: 'Evaluation', color: 'bg-purple-100 text-purple-700' },
{ value: 'SELECTION', label: 'Selection', color: 'bg-emerald-100 text-emerald-700' },
{ value: 'LIVE_FINAL', label: 'Live Final', color: 'bg-rose-100 text-rose-700' },
{ value: 'RESULTS', label: 'Results', color: 'bg-gray-100 text-gray-700' },
]
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
const updateStage = useCallback(
(index: number, updates: Partial<WizardStageConfig>) => {
const updated = [...stages]
updated[index] = { ...updated[index], ...updates }
onChange(updated)
},
[stages, onChange]
)
const addStage = () => {
const maxOrder = Math.max(...stages.map((s) => s.sortOrder), -1)
onChange([
...stages,
{
name: '',
slug: '',
stageType: 'EVALUATION',
sortOrder: maxOrder + 1,
configJson: {},
},
])
}
const removeStage = (index: number) => {
if (stages.length <= 2) return // Minimum 2 stages
const updated = stages.filter((_, i) => i !== index)
// Re-number sortOrder
onChange(updated.map((s, i) => ({ ...s, sortOrder: i })))
}
const moveStage = (index: number, direction: 'up' | 'down') => {
const newIndex = direction === 'up' ? index - 1 : index + 1
if (newIndex < 0 || newIndex >= stages.length) return
const updated = [...stages]
const temp = updated[index]
updated[index] = updated[newIndex]
updated[newIndex] = temp
onChange(updated.map((s, i) => ({ ...s, sortOrder: i })))
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
Define the stages projects flow through in the main competition track.
Drag to reorder. Minimum 2 stages required.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addStage}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Stage
</Button>
</div>
<div className="space-y-2">
{stages.map((stage, index) => {
const typeInfo = STAGE_TYPE_OPTIONS.find((t) => t.value === stage.stageType)
return (
<Card key={index}>
<CardContent className="py-3 px-4">
<div className="flex items-center gap-3">
{/* Reorder */}
<div className="flex flex-col shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
disabled={index === 0}
onClick={() => moveStage(index, 'up')}
>
<ChevronUp className="h-3 w-3" />
</Button>
<GripVertical className="h-4 w-4 text-muted-foreground mx-auto" />
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
disabled={index === stages.length - 1}
onClick={() => moveStage(index, 'down')}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* Order number */}
<span className="text-xs text-muted-foreground font-mono w-5 text-center shrink-0">
{index + 1}
</span>
{/* Stage name */}
<div className="flex-1 min-w-0">
<Input
placeholder="Stage name"
value={stage.name}
className="h-8 text-sm"
onChange={(e) => {
const name = e.target.value
updateStage(index, { name, slug: slugify(name) })
}}
/>
</div>
{/* Stage type */}
<div className="w-36 shrink-0">
<Select
value={stage.stageType}
onValueChange={(value) =>
updateStage(index, { stageType: value as StageType })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STAGE_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Type badge */}
<Badge
variant="secondary"
className={cn('shrink-0 text-[10px]', typeInfo?.color)}
>
{typeInfo?.label}
</Badge>
{/* Remove */}
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={stages.length <= 2}
onClick={() => removeStage(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
{stages.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
No stages configured. Click &quot;Add Stage&quot; to begin.
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,161 @@
'use client'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent } from '@/components/ui/card'
import { Bell } from 'lucide-react'
type NotificationsSectionProps = {
config: Record<string, boolean>
onChange: (config: Record<string, boolean>) => void
overridePolicy: Record<string, unknown>
onOverridePolicyChange: (policy: Record<string, unknown>) => void
}
const NOTIFICATION_EVENTS = [
{
key: 'stage.transitioned',
label: 'Stage Transitioned',
description: 'When a stage changes status (draft → active → closed)',
},
{
key: 'filtering.completed',
label: 'Filtering Completed',
description: 'When batch filtering finishes processing',
},
{
key: 'assignment.generated',
label: 'Assignments Generated',
description: 'When jury assignments are created or updated',
},
{
key: 'routing.executed',
label: 'Routing Executed',
description: 'When projects are routed into tracks/stages',
},
{
key: 'live.cursor.updated',
label: 'Live Cursor Updated',
description: 'When the live presentation moves to next project',
},
{
key: 'cohort.window.changed',
label: 'Cohort Window Changed',
description: 'When a cohort voting window opens or closes',
},
{
key: 'decision.overridden',
label: 'Decision Overridden',
description: 'When an admin overrides an automated decision',
},
{
key: 'award.winner.finalized',
label: 'Award Winner Finalized',
description: 'When a special award winner is selected',
},
]
export function NotificationsSection({
config,
onChange,
overridePolicy,
onOverridePolicyChange,
}: NotificationsSectionProps) {
const toggleEvent = (key: string, enabled: boolean) => {
onChange({ ...config, [key]: enabled })
}
return (
<div className="space-y-6">
<div>
<p className="text-sm text-muted-foreground">
Choose which pipeline events trigger notifications. All events are enabled by default.
</p>
</div>
<div className="space-y-2">
{NOTIFICATION_EVENTS.map((event) => (
<Card key={event.key}>
<CardContent className="py-3 px-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<Bell className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<Label className="text-sm font-medium">{event.label}</Label>
<p className="text-xs text-muted-foreground">{event.description}</p>
</div>
</div>
<Switch
checked={config[event.key] !== false}
onCheckedChange={(checked) => toggleEvent(event.key, checked)}
/>
</div>
</CardContent>
</Card>
))}
</div>
{/* Override Governance */}
<div className="space-y-3 pt-2 border-t">
<Label>Override Governance</Label>
<p className="text-xs text-muted-foreground">
Who can override automated decisions in this pipeline?
</p>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Switch
checked={
Array.isArray(overridePolicy.allowedRoles) &&
overridePolicy.allowedRoles.includes('SUPER_ADMIN')
}
disabled
/>
<Label className="text-sm">Super Admins (always enabled)</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={
Array.isArray(overridePolicy.allowedRoles) &&
overridePolicy.allowedRoles.includes('PROGRAM_ADMIN')
}
onCheckedChange={(checked) => {
const roles = Array.isArray(overridePolicy.allowedRoles)
? [...overridePolicy.allowedRoles]
: ['SUPER_ADMIN']
if (checked && !roles.includes('PROGRAM_ADMIN')) {
roles.push('PROGRAM_ADMIN')
} else if (!checked) {
const idx = roles.indexOf('PROGRAM_ADMIN')
if (idx >= 0) roles.splice(idx, 1)
}
onOverridePolicyChange({ ...overridePolicy, allowedRoles: roles })
}}
/>
<Label className="text-sm">Program Admins</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={
Array.isArray(overridePolicy.allowedRoles) &&
overridePolicy.allowedRoles.includes('AWARD_MASTER')
}
onCheckedChange={(checked) => {
const roles = Array.isArray(overridePolicy.allowedRoles)
? [...overridePolicy.allowedRoles]
: ['SUPER_ADMIN']
if (checked && !roles.includes('AWARD_MASTER')) {
roles.push('AWARD_MASTER')
} else if (!checked) {
const idx = roles.indexOf('AWARD_MASTER')
if (idx >= 0) roles.splice(idx, 1)
}
onOverridePolicyChange({ ...overridePolicy, allowedRoles: roles })
}}
/>
<Label className="text-sm">Award Masters</Label>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { validateAll } from '@/lib/pipeline-validation'
import type { WizardState, ValidationResult } from '@/types/pipeline-wizard'
type ReviewSectionProps = {
state: WizardState
}
function ValidationStatusIcon({ result }: { result: ValidationResult }) {
if (result.valid && result.warnings.length === 0) {
return <CheckCircle2 className="h-4 w-4 text-emerald-500" />
}
if (result.valid && result.warnings.length > 0) {
return <AlertTriangle className="h-4 w-4 text-amber-500" />
}
return <AlertCircle className="h-4 w-4 text-destructive" />
}
function ValidationSection({
label,
result,
}: {
label: string
result: ValidationResult
}) {
return (
<div className="flex items-start gap-3 py-2">
<ValidationStatusIcon result={result} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{label}</p>
{result.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive mt-0.5">
{err}
</p>
))}
{result.warnings.map((warn, i) => (
<p key={i} className="text-xs text-amber-600 mt-0.5">
{warn}
</p>
))}
{result.valid && result.errors.length === 0 && result.warnings.length === 0 && (
<p className="text-xs text-muted-foreground mt-0.5">Looks good</p>
)}
</div>
</div>
)
}
export function ReviewSection({ state }: ReviewSectionProps) {
const validation = validateAll(state)
const totalTracks = state.tracks.length
const mainTracks = state.tracks.filter((t) => t.kind === 'MAIN').length
const awardTracks = state.tracks.filter((t) => t.kind === 'AWARD').length
const totalStages = state.tracks.reduce((sum, t) => sum + t.stages.length, 0)
const totalTransitions = state.tracks.reduce(
(sum, t) => sum + Math.max(0, t.stages.length - 1),
0
)
const enabledNotifications = Object.values(state.notificationConfig).filter(Boolean).length
return (
<div className="space-y-6">
{/* Overall Status */}
<div
className={cn(
'rounded-lg border p-4',
validation.valid
? 'border-emerald-200 bg-emerald-50'
: 'border-destructive/30 bg-destructive/5'
)}
>
<div className="flex items-center gap-2">
{validation.valid ? (
<>
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
<p className="font-medium text-emerald-800">
Pipeline is ready to be saved
</p>
</>
) : (
<>
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="font-medium text-destructive">
Pipeline has validation errors that must be fixed
</p>
</>
)}
</div>
</div>
{/* Validation Checks */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Validation Checks</CardTitle>
</CardHeader>
<CardContent className="divide-y">
<ValidationSection label="Basics" result={validation.sections.basics} />
<ValidationSection label="Tracks & Stages" result={validation.sections.tracks} />
<ValidationSection label="Notifications" result={validation.sections.notifications} />
</CardContent>
</Card>
{/* Structure Summary */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Structure Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="text-center">
<p className="text-2xl font-bold">{totalTracks}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<Layers className="h-3 w-3" />
Tracks
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{totalStages}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<GitBranch className="h-3 w-3" />
Stages
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{totalTransitions}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<ArrowRight className="h-3 w-3" />
Transitions
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{enabledNotifications}</p>
<p className="text-xs text-muted-foreground">Notifications</p>
</div>
</div>
{/* Track breakdown */}
<div className="mt-4 space-y-2">
{state.tracks.map((track, i) => (
<div key={i} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={cn(
'text-[10px]',
track.kind === 'MAIN'
? 'bg-blue-100 text-blue-700'
: track.kind === 'AWARD'
? 'bg-amber-100 text-amber-700'
: 'bg-gray-100 text-gray-700'
)}
>
{track.kind}
</Badge>
<span>{track.name || '(unnamed)'}</span>
</div>
<span className="text-muted-foreground text-xs">
{track.stages.length} stages
</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,136 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Users, ClipboardList, BarChart3 } from 'lucide-react'
import type { EvaluationConfig } from '@/types/pipeline-wizard'
type EvaluationPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function EvaluationPanel({ stageId, configJson }: EvaluationPanelProps) {
const config = configJson as unknown as EvaluationConfig | null
const { data: coverage, isLoading: coverageLoading } =
trpc.stageAssignment.getCoverageReport.useQuery({ stageId })
const { data: projectStates, isLoading: statesLoading } =
trpc.stage.getProjectStates.useQuery({ stageId, limit: 50 })
const totalProjects = projectStates?.items.length ?? 0
const requiredReviews = config?.requiredReviews ?? 3
return (
<div className="space-y-4">
{/* Config Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Required Reviews</span>
</div>
<p className="text-2xl font-bold mt-1">{requiredReviews}</p>
<p className="text-xs text-muted-foreground">per project</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Juror Load</span>
</div>
<p className="text-lg font-bold mt-1">
{config?.minLoadPerJuror ?? 5}{config?.maxLoadPerJuror ?? 20}
</p>
<p className="text-xs text-muted-foreground">projects per juror</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Projects</span>
</div>
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
<p className="text-xs text-muted-foreground">in stage</p>
</CardContent>
</Card>
</div>
{/* Coverage Report */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Assignment Coverage</CardTitle>
</CardHeader>
<CardContent>
{coverageLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : coverage ? (
<div className="space-y-3">
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>Coverage</span>
<span className="font-medium">
{coverage.fullyCoveredProjects}/{coverage.totalProjectsInStage} projects
</span>
</div>
<Progress
value={
coverage.totalProjectsInStage > 0
? (coverage.fullyCoveredProjects / coverage.totalProjectsInStage) * 100
: 0
}
/>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-sm">
<div>
<p className="font-bold text-emerald-600">{coverage.fullyCoveredProjects}</p>
<p className="text-xs text-muted-foreground">Fully Covered</p>
</div>
<div>
<p className="font-bold text-amber-600">{coverage.partiallyCoveredProjects}</p>
<p className="text-xs text-muted-foreground">Partial</p>
</div>
<div>
<p className="font-bold text-destructive">{coverage.uncoveredProjects}</p>
<p className="text-xs text-muted-foreground">Unassigned</p>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground py-3 text-center">
No coverage data available
</p>
)}
</CardContent>
</Card>
{/* Overflow Policy */}
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Overflow Policy</span>
<Badge variant="outline" className="text-xs capitalize">
{config?.overflowPolicy ?? 'queue'}
</Badge>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-sm font-medium">Availability Weighting</span>
<Badge variant="outline" className="text-xs">
{config?.availabilityWeighting ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { Filter, Play, Loader2, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react'
import type { FilterConfig } from '@/types/pipeline-wizard'
type FilterPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function FilterPanel({ stageId, configJson }: FilterPanelProps) {
const config = configJson as unknown as FilterConfig | null
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
stageId,
limit: 50,
})
const runFiltering = trpc.stageFiltering.runStageFiltering.useMutation({
onSuccess: (data) => {
toast.success(
`Filtering complete: ${data.passedCount} passed, ${data.rejectedCount} filtered`
)
},
onError: (err) => toast.error(err.message),
})
const totalProjects = projectStates?.items.length ?? 0
const passed = projectStates?.items.filter((p) => p.state === 'PASSED').length ?? 0
const rejected = projectStates?.items.filter((p) => p.state === 'REJECTED').length ?? 0
const pending = projectStates?.items.filter(
(p) => p.state === 'PENDING' || p.state === 'IN_PROGRESS'
).length ?? 0
return (
<div className="space-y-4">
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-4">
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold">{totalProjects}</p>
<p className="text-xs text-muted-foreground">Total</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-emerald-600">{passed}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<CheckCircle2 className="h-3 w-3" /> Passed
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-destructive">{rejected}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<XCircle className="h-3 w-3" /> Filtered
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-amber-600">{pending}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<AlertTriangle className="h-3 w-3" /> Pending
</p>
</CardContent>
</Card>
</div>
{/* Rules Summary */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Filter className="h-4 w-4" />
Filtering Rules
</CardTitle>
<Button
size="sm"
variant="outline"
disabled={runFiltering.isPending || pending === 0}
onClick={() => runFiltering.mutate({ stageId })}
>
{runFiltering.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Play className="h-3.5 w-3.5 mr-1" />
)}
Run Filtering
</Button>
</div>
</CardHeader>
<CardContent>
{config?.rules && config.rules.length > 0 ? (
<div className="space-y-1">
{config.rules.map((rule, i) => (
<div
key={i}
className="flex items-center gap-2 text-sm py-1.5 border-b last:border-0"
>
<Badge variant="outline" className="text-[10px] font-mono">
{rule.field}
</Badge>
<span className="text-muted-foreground">{rule.operator}</span>
<span className="font-mono text-xs">{String(rule.value)}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-3">
No deterministic rules configured.
{config?.aiRubricEnabled ? ' AI screening is enabled.' : ''}
</p>
)}
{config?.aiRubricEnabled && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs text-muted-foreground">
AI Screening: Enabled (High: {Math.round((config.aiConfidenceThresholds?.high ?? 0.85) * 100)}%,
Medium: {Math.round((config.aiConfidenceThresholds?.medium ?? 0.6) * 100)}%)
</p>
</div>
)}
</CardContent>
</Card>
{/* Projects List */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Projects in Stage</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !projectStates?.items.length ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No projects in this stage
</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{projectStates.items.map((ps) => (
<div
key={ps.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<span className="truncate">{ps.project.title}</span>
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${
ps.state === 'PASSED'
? 'border-emerald-500 text-emerald-600'
: ps.state === 'REJECTED'
? 'border-destructive text-destructive'
: ''
}`}
>
{ps.state}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,130 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { FileText, Upload, Clock, AlertTriangle } from 'lucide-react'
import type { IntakeConfig } from '@/types/pipeline-wizard'
type IntakePanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function IntakePanel({ stageId, configJson }: IntakePanelProps) {
const config = configJson as unknown as IntakeConfig | null
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
stageId,
limit: 10,
})
return (
<div className="space-y-4">
{/* Config Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Upload className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Submission Window</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{config?.submissionWindowEnabled ? 'Enabled' : 'Disabled'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Late Policy</span>
</div>
<p className="text-xs text-muted-foreground mt-1 capitalize">
{config?.lateSubmissionPolicy ?? 'Not set'}
{config?.lateSubmissionPolicy === 'flag' && ` (${config.lateGraceHours}h grace)`}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">File Requirements</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{config?.fileRequirements?.length ?? 0} requirements
</p>
</CardContent>
</Card>
</div>
{/* File Requirements List */}
{config?.fileRequirements && config.fileRequirements.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">File Requirements</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{config.fileRequirements.map((req, i) => (
<div
key={i}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span>{req.name}</span>
{req.isRequired && (
<Badge variant="secondary" className="text-[10px]">
Required
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{req.maxSizeMB ? `${req.maxSizeMB} MB max` : 'No limit'}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Recent Projects */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Recent Submissions</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !projectStates?.items.length ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No projects in this stage yet
</p>
) : (
<div className="space-y-1">
{projectStates.items.map((ps) => (
<div
key={ps.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<span className="truncate">{ps.project.title}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
{ps.state}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { Play, Users, Vote, Radio, Loader2, Layers } from 'lucide-react'
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
type LiveFinalPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function LiveFinalPanel({ stageId, configJson }: LiveFinalPanelProps) {
const config = configJson as unknown as LiveFinalConfig | null
const { data: projectStates } =
trpc.stage.getProjectStates.useQuery({ stageId, limit: 50 })
const { data: cohorts, isLoading: cohortsLoading } = trpc.cohort.list.useQuery({
stageId,
})
const startSession = trpc.live.start.useMutation({
onSuccess: () => toast.success('Live session started'),
onError: (err) => toast.error(err.message),
})
const totalProjects = projectStates?.items.length ?? 0
const totalCohorts = cohorts?.length ?? 0
return (
<div className="space-y-4">
{/* Config Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Vote className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Jury Voting</span>
</div>
<Badge variant="outline" className="mt-1 text-xs">
{config?.juryVotingEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Audience Voting</span>
</div>
<Badge variant="outline" className="mt-1 text-xs">
{config?.audienceVotingEnabled ? 'Enabled' : 'Disabled'}
</Badge>
{config?.audienceVotingEnabled && (
<p className="text-xs text-muted-foreground mt-1">
Weight: {Math.round((config.audienceVoteWeight ?? 0.2) * 100)}%
</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Radio className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Reveal Policy</span>
</div>
<p className="text-sm font-medium mt-1 capitalize">
{config?.revealPolicy ?? 'ceremony'}
</p>
</CardContent>
</Card>
</div>
{/* Cohorts */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Layers className="h-4 w-4" />
Cohorts
</CardTitle>
<Badge variant="secondary" className="text-xs">
{totalCohorts} cohort{totalCohorts !== 1 ? 's' : ''}
</Badge>
</div>
</CardHeader>
<CardContent>
{cohortsLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : !cohorts?.length ? (
<p className="text-sm text-muted-foreground py-3 text-center">
No cohorts configured.{' '}
{config?.cohortSetupMode === 'auto'
? 'Cohorts will be auto-generated when the session starts.'
: 'Create cohorts manually to organize presentations.'}
</p>
) : (
<div className="space-y-1">
{cohorts.map((cohort) => (
<div
key={cohort.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<span>{cohort.name}</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px]">
{cohort._count?.projects ?? 0} projects
</Badge>
<Badge
variant="outline"
className={`text-[10px] ${
cohort.isOpen
? 'border-emerald-500 text-emerald-600'
: ''
}`}
>
{cohort.isOpen ? 'OPEN' : 'CLOSED'}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Live Session Controls */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Live Session</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm">
{totalProjects} project{totalProjects !== 1 ? 's' : ''} ready for
presentation
</p>
<p className="text-xs text-muted-foreground">
Cohort mode: {config?.cohortSetupMode ?? 'auto'}
</p>
</div>
<Button
size="sm"
disabled={startSession.isPending || totalProjects === 0}
onClick={() =>
startSession.mutate({
stageId,
projectOrder: projectStates?.items.map((p) => p.project.id) ?? [],
})
}
>
{startSession.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Play className="h-3.5 w-3.5 mr-1" />
)}
Start Session
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,120 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Trophy, Medal, FileText } from 'lucide-react'
import type { ResultsConfig } from '@/types/pipeline-wizard'
type ResultsPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function ResultsPanel({ stageId, configJson }: ResultsPanelProps) {
const config = configJson as unknown as ResultsConfig | null
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
stageId,
limit: 100,
})
const totalProjects = projectStates?.items.length ?? 0
const winners =
projectStates?.items.filter((p) => p.state === 'PASSED').length ?? 0
return (
<div className="space-y-4">
{/* Config Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Winners</span>
</div>
<p className="text-2xl font-bold mt-1">{winners}</p>
<p className="text-xs text-muted-foreground">
of {totalProjects} finalists
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Publication</span>
</div>
<Badge variant="outline" className="mt-1 text-xs capitalize">
{config?.publicationMode ?? 'manual'}
</Badge>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Medal className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Rankings</span>
</div>
<Badge variant="outline" className="mt-1 text-xs">
{config?.showRankings ? 'Visible' : 'Hidden'}
</Badge>
</CardContent>
</Card>
</div>
{/* Final Rankings */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Final Results</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !projectStates?.items.length ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No results available yet
</p>
) : (
<div className="space-y-1 max-h-80 overflow-y-auto">
{projectStates.items.map((ps, index) => (
<div
key={ps.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<div className="flex items-center gap-2">
{ps.state === 'PASSED' && index < 3 ? (
<span className="text-lg">
{index === 0 ? '🥇' : index === 1 ? '🥈' : '🥉'}
</span>
) : (
<span className="text-xs text-muted-foreground font-mono w-6">
#{index + 1}
</span>
)}
<span className="truncate">{ps.project.title}</span>
</div>
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${
ps.state === 'PASSED'
? 'border-amber-500 text-amber-600'
: ''
}`}
>
{ps.state === 'PASSED' ? 'Winner' : ps.state}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,166 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Trophy, Users, ArrowUpDown } from 'lucide-react'
import type { SelectionConfig } from '@/types/pipeline-wizard'
type SelectionPanelProps = {
stageId: string
configJson: Record<string, unknown> | null
}
export function SelectionPanel({ stageId, configJson }: SelectionPanelProps) {
const config = configJson as unknown as SelectionConfig | null
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
stageId,
limit: 100,
})
const totalProjects = projectStates?.items.length ?? 0
const passed = projectStates?.items.filter((p) => p.state === 'PASSED').length ?? 0
const rejected = projectStates?.items.filter((p) => p.state === 'REJECTED').length ?? 0
const pending =
projectStates?.items.filter(
(p) => p.state === 'PENDING' || p.state === 'IN_PROGRESS'
).length ?? 0
const finalistTarget = config?.finalistCount ?? 6
return (
<div className="space-y-4">
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Finalist Target</span>
</div>
<p className="text-2xl font-bold mt-1">{finalistTarget}</p>
<p className="text-xs text-muted-foreground">to be selected</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Candidates</span>
</div>
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
<p className="text-xs text-muted-foreground">in selection pool</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<ArrowUpDown className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Ranking Mode</span>
</div>
<p className="text-sm font-medium mt-1 capitalize">
{config?.rankingMethod ?? 'score_average'}
</p>
<p className="text-xs text-muted-foreground">
{config?.tieBreaker ?? 'admin_decides'} tiebreak
</p>
</CardContent>
</Card>
</div>
{/* Selection Progress */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Selection Progress</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : (
<div className="space-y-3">
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>Selected</span>
<span className="font-medium">
{passed}/{finalistTarget} finalists
</span>
</div>
<Progress
value={finalistTarget > 0 ? (passed / finalistTarget) * 100 : 0}
/>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-sm">
<div>
<p className="font-bold text-emerald-600">{passed}</p>
<p className="text-xs text-muted-foreground">Selected</p>
</div>
<div>
<p className="font-bold text-destructive">{rejected}</p>
<p className="text-xs text-muted-foreground">Eliminated</p>
</div>
<div>
<p className="font-bold text-amber-600">{pending}</p>
<p className="text-xs text-muted-foreground">Pending</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Project Rankings */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Project Rankings</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !projectStates?.items.length ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No projects in selection stage
</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{projectStates.items.map((ps, index) => (
<div
key={ps.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground font-mono w-6">
#{index + 1}
</span>
<span className="truncate">{ps.project.title}</span>
</div>
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${
ps.state === 'PASSED'
? 'border-emerald-500 text-emerald-600'
: ps.state === 'REJECTED'
? 'border-destructive text-destructive'
: ''
}`}
>
{ps.state}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,82 @@
'use client'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Badge } from '@/components/ui/badge'
import { ChevronDown, CheckCircle2, AlertCircle } from 'lucide-react'
type WizardSectionProps = {
title: string
description?: string
stepNumber: number
isOpen: boolean
onToggle: () => void
isValid: boolean
hasErrors?: boolean
children: React.ReactNode
}
export function WizardSection({
title,
description,
stepNumber,
isOpen,
onToggle,
isValid,
hasErrors,
children,
}: WizardSectionProps) {
return (
<Collapsible open={isOpen} onOpenChange={onToggle}>
<Card className={cn(isOpen && 'ring-1 ring-ring')}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer select-none hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
<Badge
variant={isValid ? 'default' : 'outline'}
className={cn(
'h-7 w-7 shrink-0 rounded-full p-0 flex items-center justify-center text-xs font-bold',
isValid
? 'bg-emerald-500 text-white hover:bg-emerald-500'
: hasErrors
? 'border-destructive text-destructive'
: ''
)}
>
{isValid ? (
<CheckCircle2 className="h-4 w-4" />
) : hasErrors ? (
<AlertCircle className="h-4 w-4" />
) : (
stepNumber
)}
</Badge>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold">{title}</h3>
{description && !isOpen && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{description}
</p>
)}
</div>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isOpen && 'rotate-180'
)}
/>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0">{children}</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
}

View File

@@ -1,256 +0,0 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { trpc } from "@/lib/trpc/client";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Minus, Loader2, AlertTriangle } from "lucide-react";
interface RemoveProjectsDialogProps {
roundId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function RemoveProjectsDialog({
roundId,
open,
onOpenChange,
onSuccess,
}: RemoveProjectsDialogProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [confirmOpen, setConfirmOpen] = useState(false);
const utils = trpc.useUtils();
// Reset state when dialog opens
useEffect(() => {
if (open) {
setSelectedIds(new Set());
setConfirmOpen(false);
}
}, [open]);
const { data, isLoading } = trpc.project.list.useQuery(
{ roundId, page: 1, perPage: 200 },
{ enabled: open },
);
const removeMutation = trpc.round.removeProjects.useMutation({
onSuccess: (result) => {
toast.success(
`${result.removed} project${result.removed !== 1 ? "s" : ""} removed from round`,
);
utils.round.get.invalidate({ id: roundId });
utils.project.list.invalidate();
onSuccess?.();
onOpenChange(false);
},
onError: (error) => {
toast.error(error.message);
},
});
const projects = data?.projects ?? [];
const toggleProject = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleAll = useCallback(() => {
if (selectedIds.size === projects.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(projects.map((p) => p.id)));
}
}, [selectedIds.size, projects]);
const handleRemove = () => {
if (selectedIds.size === 0) return;
removeMutation.mutate({
roundId,
projectIds: Array.from(selectedIds),
});
setConfirmOpen(false);
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Minus className="h-5 w-5" />
Remove Projects from Round
</DialogTitle>
<DialogDescription>
Select projects to remove from this round. The projects will
remain in the program and can be re-assigned later.
</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-2 rounded-lg bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/50 dark:text-amber-200">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<span>
Removing projects from a round will also delete their jury
assignments and evaluations in this round.
</span>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="font-medium">No projects in this round</p>
<p className="text-sm text-muted-foreground">
There are no projects to remove.
</p>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Checkbox
checked={
selectedIds.size === projects.length &&
projects.length > 0
}
onCheckedChange={toggleAll}
/>
<span className="text-sm text-muted-foreground">
{selectedIds.size} of {projects.length} selected
</span>
</div>
<div className="rounded-lg border max-h-[350px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12" />
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow
key={project.id}
className={
selectedIds.has(project.id)
? "bg-muted/50"
: "cursor-pointer"
}
onClick={() => toggleProject(project.id)}
>
<TableCell>
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => toggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell className="font-medium">
{project.title}
</TableCell>
<TableCell className="text-muted-foreground">
{project.teamName || "—"}
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs">
{(project.status ?? "SUBMITTED").replace(
"_",
" ",
)}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => setConfirmOpen(true)}
disabled={selectedIds.size === 0 || removeMutation.isPending}
>
{removeMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Minus className="mr-2 h-4 w-4" />
)}
Remove Selected ({selectedIds.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Removal</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove {selectedIds.size} project
{selectedIds.size !== 1 ? "s" : ""} from this round? Their
assignments and evaluations in this round will be deleted. The
projects will remain in the program.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRemove}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove Projects
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -1,203 +0,0 @@
'use client'
import Link from 'next/link'
import { Badge } from '@/components/ui/badge'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
Filter,
ClipboardCheck,
Zap,
CheckCircle2,
Clock,
Archive,
ChevronRight,
FileText,
Users,
AlertTriangle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type PipelineRound = {
id: string
name: string
status: string
roundType: string
_count?: {
projects: number
assignments: number
}
}
interface RoundPipelineProps {
rounds: PipelineRound[]
programName?: string
}
const typeIcons: Record<string, typeof Filter> = {
FILTERING: Filter,
EVALUATION: ClipboardCheck,
LIVE_EVENT: Zap,
}
const typeColors: Record<string, { bg: string; text: string; border: string }> = {
FILTERING: {
bg: 'bg-amber-50 dark:bg-amber-950/30',
text: 'text-amber-700 dark:text-amber-300',
border: 'border-amber-200 dark:border-amber-800',
},
EVALUATION: {
bg: 'bg-blue-50 dark:bg-blue-950/30',
text: 'text-blue-700 dark:text-blue-300',
border: 'border-blue-200 dark:border-blue-800',
},
LIVE_EVENT: {
bg: 'bg-violet-50 dark:bg-violet-950/30',
text: 'text-violet-700 dark:text-violet-300',
border: 'border-violet-200 dark:border-violet-800',
},
}
const statusConfig: Record<string, { color: string; icon: typeof CheckCircle2; label: string }> = {
DRAFT: { color: 'text-muted-foreground', icon: Clock, label: 'Draft' },
ACTIVE: { color: 'text-green-600', icon: CheckCircle2, label: 'Active' },
CLOSED: { color: 'text-amber-600', icon: Archive, label: 'Closed' },
ARCHIVED: { color: 'text-muted-foreground', icon: Archive, label: 'Archived' },
}
export function RoundPipeline({ rounds }: RoundPipelineProps) {
if (rounds.length === 0) return null
// Detect bottlenecks: rounds with many more incoming projects than outgoing
const projectCounts = rounds.map((r) => r._count?.projects || 0)
return (
<div className="w-full overflow-x-auto pb-2">
<div className="flex items-stretch gap-1 min-w-max px-1 py-2">
{rounds.map((round, index) => {
const TypeIcon = typeIcons[round.roundType] || ClipboardCheck
const colors = typeColors[round.roundType] || typeColors.EVALUATION
const status = statusConfig[round.status] || statusConfig.DRAFT
const StatusIcon = status.icon
const projectCount = round._count?.projects || 0
const prevCount = index > 0 ? projectCounts[index - 1] : 0
const dropRate = prevCount > 0 ? Math.round(((prevCount - projectCount) / prevCount) * 100) : 0
const isBottleneck = dropRate > 50 && index > 0
return (
<div key={round.id} className="flex items-center">
{/* Round Card */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/admin/rounds/${round.id}`}
className={cn(
'group relative flex flex-col items-center gap-2 rounded-xl border-2 px-5 py-4 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 min-w-[140px]',
colors.bg,
colors.border,
round.status === 'ACTIVE' && 'ring-2 ring-green-500/30'
)}
>
{/* Status indicator dot */}
<div className="absolute -top-1.5 -right-1.5">
<div className={cn(
'h-3.5 w-3.5 rounded-full border-2 border-background',
round.status === 'ACTIVE' ? 'bg-green-500' :
round.status === 'CLOSED' ? 'bg-amber-500' :
round.status === 'DRAFT' ? 'bg-muted-foreground/40' :
'bg-muted-foreground/20'
)} />
</div>
{/* Type Icon */}
<div className={cn(
'rounded-lg p-2',
round.status === 'ACTIVE' ? 'bg-green-100 dark:bg-green-900/30' : 'bg-background'
)}>
<TypeIcon className={cn('h-5 w-5', colors.text)} />
</div>
{/* Round Name */}
<p className="text-sm font-medium text-center line-clamp-2 leading-tight max-w-[120px]">
{round.name}
</p>
{/* Stats Row */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" />
{projectCount}
</span>
{round._count?.assignments !== undefined && round._count.assignments > 0 && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{round._count.assignments}
</span>
)}
</div>
{/* Status Badge */}
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', status.color)}
>
<StatusIcon className="mr-1 h-2.5 w-2.5" />
{status.label}
</Badge>
{/* Bottleneck indicator */}
{isBottleneck && (
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2">
<AlertTriangle className="h-4 w-4 text-amber-500" />
</div>
)}
</Link>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="space-y-1">
<p className="font-medium">{round.name}</p>
<p className="text-xs capitalize">
{round.roundType.toLowerCase().replace('_', ' ')} &middot; {round.status.toLowerCase()}
</p>
<p className="text-xs">
{projectCount} projects
{round._count?.assignments ? `, ${round._count.assignments} assignments` : ''}
</p>
{isBottleneck && (
<p className="text-xs text-amber-600">
{dropRate}% drop from previous round
</p>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Arrow connector */}
{index < rounds.length - 1 && (
<div className="flex flex-col items-center px-2">
<ChevronRight className="h-5 w-5 text-muted-foreground/40" />
{prevCount > 0 && index > 0 && dropRate > 0 && (
<span className="text-[10px] text-muted-foreground/60 -mt-0.5">
-{dropRate}%
</span>
)}
{index === 0 && projectCounts[0] > 0 && projectCounts[1] !== undefined && (
<span className="text-[10px] text-muted-foreground/60 -mt-0.5">
{projectCounts[0]} &rarr; {projectCounts[1] || '?'}
</span>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -12,9 +12,9 @@ import {
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface RoundComparison {
roundId: string
roundName: string
interface StageComparison {
stageId: string
stageName: string
projectCount: number
evaluationCount: number
completionRate: number
@@ -22,21 +22,21 @@ interface RoundComparison {
scoreDistribution: { score: number; count: number }[]
}
interface CrossRoundComparisonProps {
data: RoundComparison[]
interface CrossStageComparisonProps {
data: StageComparison[]
}
const ROUND_COLORS = ['#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f']
const STAGE_COLORS = ['#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f']
export function CrossRoundComparisonChart({ data }: CrossRoundComparisonProps) {
export function CrossStageComparisonChart({ data }: CrossStageComparisonProps) {
// Prepare comparison data
const comparisonData = data.map((round, i) => ({
name: round.roundName.length > 20 ? round.roundName.slice(0, 20) + '...' : round.roundName,
projects: round.projectCount,
evaluations: round.evaluationCount,
completionRate: round.completionRate,
avgScore: round.averageScore ? parseFloat(round.averageScore.toFixed(2)) : 0,
color: ROUND_COLORS[i % ROUND_COLORS.length],
const comparisonData = data.map((stage, i) => ({
name: stage.stageName.length > 20 ? stage.stageName.slice(0, 20) + '...' : stage.stageName,
projects: stage.projectCount,
evaluations: stage.evaluationCount,
completionRate: stage.completionRate,
avgScore: stage.averageScore ? parseFloat(stage.averageScore.toFixed(2)) : 0,
color: STAGE_COLORS[i % STAGE_COLORS.length],
}))
return (
@@ -44,7 +44,7 @@ export function CrossRoundComparisonChart({ data }: CrossRoundComparisonProps) {
{/* Metrics Comparison */}
<Card>
<CardHeader>
<CardTitle>Round Metrics Comparison</CardTitle>
<CardTitle>Stage Metrics Comparison</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[350px]">
@@ -82,7 +82,7 @@ export function CrossRoundComparisonChart({ data }: CrossRoundComparisonProps) {
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Completion Rate by Round</CardTitle>
<CardTitle>Completion Rate by Stage</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
@@ -116,7 +116,7 @@ export function CrossRoundComparisonChart({ data }: CrossRoundComparisonProps) {
<Card>
<CardHeader>
<CardTitle>Average Score by Round</CardTitle>
<CardTitle>Average Score by Stage</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">

View File

@@ -7,6 +7,6 @@ export { CriteriaScoresChart } from './criteria-scores'
export { GeographicDistribution } from './geographic-distribution'
export { GeographicSummaryCard } from './geographic-summary-card'
// Advanced analytics charts (F10)
export { CrossRoundComparisonChart } from './cross-round-comparison'
export { CrossStageComparisonChart } from './cross-round-comparison'
export { JurorConsistencyChart } from './juror-consistency'
export { DiversityMetricsChart } from './diversity-metrics'

View File

@@ -51,12 +51,12 @@ import { TeamMemberRole } from '@prisma/client'
// ---------------------------------------------------------------------------
interface ApplyWizardDynamicProps {
mode: 'edition' | 'round'
mode: 'edition' | 'stage' | 'round'
config: WizardConfig
programName: string
programYear: number
programId?: string
roundId?: string
stageId?: string
isOpen: boolean
submissionDeadline?: Date | string | null
onSubmit: (data: Record<string, unknown>) => Promise<void>
@@ -390,7 +390,7 @@ export function ApplyWizardDynamic({
programName,
programYear,
programId,
roundId,
stageId,
isOpen,
submissionDeadline,
onSubmit,

View File

@@ -39,8 +39,7 @@ import { cn } from '@/lib/utils'
interface CSVImportFormProps {
programId: string
roundId?: string
roundName: string
stageName?: string
onSuccess?: () => void
}
@@ -73,7 +72,7 @@ interface MappedProject {
metadataJson?: Record<string, unknown>
}
export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVImportFormProps) {
export function CSVImportForm({ programId, stageName, onSuccess }: CSVImportFormProps) {
const router = useRouter()
const [step, setStep] = useState<Step>('upload')
const [file, setFile] = useState<File | null>(null)
@@ -226,7 +225,6 @@ export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVI
try {
await importMutation.mutateAsync({
programId,
roundId,
projects: valid,
})
setImportProgress(100)
@@ -257,7 +255,7 @@ export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVI
<CardTitle>Upload CSV File</CardTitle>
<CardDescription>
Upload a CSV file containing project data to import into{' '}
<strong>{roundName}</strong>
<strong>{stageName}</strong>
</CardDescription>
</CardHeader>
<CardContent>
@@ -559,7 +557,7 @@ export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVI
<p className="mt-4 text-xl font-semibold">Import Complete!</p>
<p className="text-muted-foreground">
Successfully imported {validationSummary.valid} projects into{' '}
<strong>{roundName}</strong>
<strong>{stageName}</strong>
</p>
<div className="mt-6 flex gap-3">
<Button variant="outline" onClick={() => router.back()}>

View File

@@ -320,7 +320,7 @@ export function EvaluationForm({
toast.success('Evaluation submitted successfully!')
startTransition(() => {
router.push('/jury/assignments')
router.push('/jury/stages')
router.refresh()
})
} catch (error) {

View File

@@ -33,16 +33,16 @@ import {
} from 'lucide-react'
interface NotionImportFormProps {
roundId: string
roundName: string
programId: string
stageName?: string
onSuccess?: () => void
}
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
export function NotionImportForm({
roundId,
roundName,
programId,
stageName,
onSuccess,
}: NotionImportFormProps) {
const [step, setStep] = useState<Step>('connect')
@@ -125,7 +125,7 @@ export function NotionImportForm({
const result = await importMutation.mutateAsync({
apiKey,
databaseId,
roundId,
programId,
mappings: {
title: mappings.title,
teamName: mappings.teamName || undefined,
@@ -419,7 +419,7 @@ export function NotionImportForm({
<AlertTitle>Ready to import</AlertTitle>
<AlertDescription>
This will import all records from the Notion database into{' '}
<strong>{roundName}</strong>.
<strong>{stageName}</strong>.
</AlertDescription>
</Alert>

View File

@@ -1,699 +0,0 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Filter, ClipboardCheck, Zap, Info, Users, ListOrdered } from 'lucide-react'
import {
type FilteringRoundSettings,
type EvaluationRoundSettings,
type LiveEventRoundSettings,
defaultFilteringSettings,
defaultEvaluationSettings,
defaultLiveEventSettings,
roundTypeLabels,
roundTypeDescriptions,
} from '@/types/round-settings'
interface RoundTypeSettingsProps {
roundType: 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'
onRoundTypeChange: (type: 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT') => void
settings: Record<string, unknown>
onSettingsChange: (settings: Record<string, unknown>) => void
requiredReviewsField?: React.ReactNode
}
const roundTypeIcons = {
FILTERING: Filter,
EVALUATION: ClipboardCheck,
LIVE_EVENT: Zap,
}
const roundTypeFeatures: Record<string, string[]> = {
FILTERING: ['AI screening', 'Auto-elimination', 'Batch processing'],
EVALUATION: ['Jury reviews', 'Criteria scoring', 'Voting window'],
LIVE_EVENT: ['Real-time voting', 'Audience votes', 'Presentations'],
}
export function RoundTypeSettings({
roundType,
onRoundTypeChange,
settings,
onSettingsChange,
requiredReviewsField,
}: RoundTypeSettingsProps) {
const Icon = roundTypeIcons[roundType]
// Get typed settings with defaults
const getFilteringSettings = (): FilteringRoundSettings => ({
...defaultFilteringSettings,
...(settings as Partial<FilteringRoundSettings>),
})
const getEvaluationSettings = (): EvaluationRoundSettings => ({
...defaultEvaluationSettings,
...(settings as Partial<EvaluationRoundSettings>),
})
const getLiveEventSettings = (): LiveEventRoundSettings => ({
...defaultLiveEventSettings,
...(settings as Partial<LiveEventRoundSettings>),
})
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon className="h-5 w-5" />
Round Type & Configuration
</CardTitle>
<CardDescription>
Configure the type and behavior for this round
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Round Type Selector - Visual Cards */}
<div className="space-y-3">
<Label>Round Type</Label>
<div className="grid gap-3 sm:grid-cols-3">
{(['FILTERING', 'EVALUATION', 'LIVE_EVENT'] as const).map((type) => {
const TypeIcon = roundTypeIcons[type]
const isSelected = roundType === type
const features = roundTypeFeatures[type]
return (
<button
key={type}
type="button"
onClick={() => onRoundTypeChange(type)}
className={`relative flex flex-col items-start gap-3 rounded-lg border-2 p-4 text-left transition-all duration-200 hover:shadow-md ${
isSelected
? 'border-primary bg-primary/5 shadow-sm'
: 'border-muted hover:border-muted-foreground/30'
}`}
>
{isSelected && (
<div className="absolute top-2 right-2">
<div className="h-2 w-2 rounded-full bg-primary" />
</div>
)}
<div className={`rounded-lg p-2 ${isSelected ? 'bg-primary/10' : 'bg-muted'}`}>
<TypeIcon className={`h-5 w-5 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`} />
</div>
<div>
<p className={`font-medium ${isSelected ? 'text-primary' : ''}`}>
{roundTypeLabels[type]}
</p>
<p className="text-xs text-muted-foreground mt-1">
{roundTypeDescriptions[type]}
</p>
</div>
<div className="flex flex-wrap gap-1 mt-auto">
{features.map((f) => (
<span key={f} className="text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
{f}
</span>
))}
</div>
</button>
)
})}
</div>
</div>
{/* Type-specific settings */}
{roundType === 'FILTERING' && (
<FilteringSettings
settings={getFilteringSettings()}
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
/>
)}
{roundType === 'EVALUATION' && (
<EvaluationSettings
settings={getEvaluationSettings()}
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
requiredReviewsField={requiredReviewsField}
/>
)}
{roundType === 'LIVE_EVENT' && (
<LiveEventSettings
settings={getLiveEventSettings()}
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
/>
)}
</CardContent>
</Card>
)
}
// Filtering Round Settings
function FilteringSettings({
settings,
onChange,
}: {
settings: FilteringRoundSettings
onChange: (settings: FilteringRoundSettings) => void
}) {
return (
<div className="space-y-6 border-t pt-4">
<h4 className="font-medium">Filtering Settings</h4>
{/* Target Advancing */}
<div className="space-y-2">
<Label htmlFor="targetAdvancing">Target Projects to Advance</Label>
<Input
id="targetAdvancing"
type="number"
min="1"
value={settings.targetAdvancing}
onChange={(e) =>
onChange({ ...settings, targetAdvancing: parseInt(e.target.value) || 0 })
}
/>
<p className="text-xs text-muted-foreground">
The target number of projects to advance to the next round
</p>
</div>
{/* Auto-elimination */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Auto-Elimination</Label>
<p className="text-sm text-muted-foreground">
Automatically flag projects below threshold
</p>
</div>
<Switch
checked={settings.autoEliminationEnabled}
onCheckedChange={(v) =>
onChange({ ...settings, autoEliminationEnabled: v })
}
/>
</div>
{settings.autoEliminationEnabled && (
<div className="ml-6 space-y-4 border-l-2 pl-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="threshold">Score Threshold</Label>
<Input
id="threshold"
type="number"
min="1"
max="10"
step="0.5"
value={settings.autoEliminationThreshold}
onChange={(e) =>
onChange({
...settings,
autoEliminationThreshold: parseFloat(e.target.value) || 0,
})
}
/>
<p className="text-xs text-muted-foreground">
Projects averaging below this score will be flagged
</p>
</div>
<div className="space-y-2">
<Label htmlFor="minReviews">Minimum Reviews</Label>
<Input
id="minReviews"
type="number"
min="0"
value={settings.autoEliminationMinReviews}
onChange={(e) =>
onChange({
...settings,
autoEliminationMinReviews: parseInt(e.target.value) || 0,
})
}
/>
<p className="text-xs text-muted-foreground">
Min reviews before auto-elimination applies (0 for AI-only filtering)
</p>
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Auto-elimination only flags projects for review. Final decisions require
admin approval.
</AlertDescription>
</Alert>
</div>
)}
</div>
{/* Auto-Filter on Close */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Auto-Run Filtering on Close</Label>
<p className="text-sm text-muted-foreground">
Automatically start filtering when this round is closed
</p>
</div>
<Switch
checked={settings.autoFilterOnClose}
onCheckedChange={(v) =>
onChange({ ...settings, autoFilterOnClose: v })
}
/>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
When enabled, closing this round will automatically run the configured filtering rules.
Results still require admin review before finalization.
</AlertDescription>
</Alert>
</div>
{/* Display Options */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Display Options</h5>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex items-center justify-between">
<Label htmlFor="showAverage">Show Average Score</Label>
<Switch
id="showAverage"
checked={settings.showAverageScore}
onCheckedChange={(v) =>
onChange({ ...settings, showAverageScore: v })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="showRanking">Show Ranking</Label>
<Switch
id="showRanking"
checked={settings.showRanking}
onCheckedChange={(v) =>
onChange({ ...settings, showRanking: v })
}
/>
</div>
</div>
</div>
</div>
)
}
// Evaluation Round Settings
function EvaluationSettings({
settings,
onChange,
requiredReviewsField,
}: {
settings: EvaluationRoundSettings
onChange: (settings: EvaluationRoundSettings) => void
requiredReviewsField?: React.ReactNode
}) {
return (
<div className="space-y-6 border-t pt-4">
<h4 className="font-medium">Evaluation Settings</h4>
{/* Required Reviews (passed from parent form) */}
{requiredReviewsField}
{/* Target Finalists */}
<div className="space-y-2">
<Label htmlFor="targetFinalists">Target Finalists</Label>
<Input
id="targetFinalists"
type="number"
min="1"
value={settings.targetFinalists}
onChange={(e) =>
onChange({ ...settings, targetFinalists: parseInt(e.target.value) || 0 })
}
/>
<p className="text-xs text-muted-foreground">
The target number of finalists to select
</p>
</div>
{/* Requirements */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Requirements</h5>
<div className="flex items-center justify-between">
<div>
<Label>Require All Criteria</Label>
<p className="text-sm text-muted-foreground">
Jury must score all criteria before submission
</p>
</div>
<Switch
checked={settings.requireAllCriteria}
onCheckedChange={(v) =>
onChange({ ...settings, requireAllCriteria: v })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Detailed Criteria Required</Label>
<p className="text-sm text-muted-foreground">
Use detailed evaluation criteria
</p>
</div>
<Switch
checked={settings.detailedCriteriaRequired}
onCheckedChange={(v) =>
onChange({ ...settings, detailedCriteriaRequired: v })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="minFeedback">Minimum Feedback Length</Label>
<Input
id="minFeedback"
type="number"
min="0"
value={settings.minimumFeedbackLength}
onChange={(e) =>
onChange({
...settings,
minimumFeedbackLength: parseInt(e.target.value) || 0,
})
}
/>
<p className="text-xs text-muted-foreground">
Minimum characters for feedback comments (0 = optional)
</p>
</div>
</div>
{/* Display Options */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Display Options</h5>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex items-center justify-between">
<Label htmlFor="showAverage">Show Average Score</Label>
<Switch
id="showAverage"
checked={settings.showAverageScore}
onCheckedChange={(v) =>
onChange({ ...settings, showAverageScore: v })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="showRanking">Show Ranking</Label>
<Switch
id="showRanking"
checked={settings.showRanking}
onCheckedChange={(v) =>
onChange({ ...settings, showRanking: v })
}
/>
</div>
</div>
</div>
</div>
)
}
// Live Event Round Settings
function LiveEventSettings({
settings,
onChange,
}: {
settings: LiveEventRoundSettings
onChange: (settings: LiveEventRoundSettings) => void
}) {
return (
<div className="space-y-6 border-t pt-4">
<h4 className="font-medium">Live Event Settings</h4>
{/* Presentation */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Presentation</h5>
<div className="space-y-2">
<Label htmlFor="duration">Presentation Duration (minutes)</Label>
<Input
id="duration"
type="number"
min="1"
max="60"
value={settings.presentationDurationMinutes}
onChange={(e) =>
onChange({
...settings,
presentationDurationMinutes: parseInt(e.target.value) || 5,
})
}
/>
</div>
</div>
{/* Voting */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Voting</h5>
<div className="space-y-2">
<Label htmlFor="votingWindow">Voting Window (seconds)</Label>
<Input
id="votingWindow"
type="number"
min="10"
max="300"
value={settings.votingWindowSeconds}
onChange={(e) =>
onChange({
...settings,
votingWindowSeconds: parseInt(e.target.value) || 30,
})
}
/>
<p className="text-xs text-muted-foreground">
Duration of the voting window after each presentation
</p>
</div>
<div className="space-y-2">
<Label>Voting Mode</Label>
<Select
value={settings.votingMode}
onValueChange={(v) =>
onChange({ ...settings, votingMode: v as 'simple' | 'criteria' })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="simple">
<div className="flex items-center gap-2">
<ListOrdered className="h-4 w-4" />
Simple (1-10 score)
</div>
</SelectItem>
<SelectItem value="criteria">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-4 w-4" />
Criteria-Based (per-criterion scoring)
</div>
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{settings.votingMode === 'simple'
? 'Jurors give a single 1-10 score per project'
: 'Jurors score each criterion separately, weighted into a final score'}
</p>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Allow Vote Change</Label>
<p className="text-sm text-muted-foreground">
Allow jury to change their vote during the window
</p>
</div>
<Switch
checked={settings.allowVoteChange}
onCheckedChange={(v) =>
onChange({ ...settings, allowVoteChange: v })
}
/>
</div>
</div>
{/* Audience Voting */}
<div className="space-y-4">
<h5 className="text-sm font-medium flex items-center gap-2">
<Users className="h-4 w-4" />
Audience Voting
</h5>
<div className="space-y-2">
<Label>Audience Voting Mode</Label>
<Select
value={settings.audienceVotingMode}
onValueChange={(v) =>
onChange({
...settings,
audienceVotingMode: v as 'disabled' | 'per_project' | 'per_category' | 'favorites',
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disabled">Disabled</SelectItem>
<SelectItem value="per_project">Per Project (1-10 score)</SelectItem>
<SelectItem value="per_category">Per Category (vote best-in-category)</SelectItem>
<SelectItem value="favorites">Favorites (pick top N)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
How audience members can participate in voting
</p>
</div>
{settings.audienceVotingMode !== 'disabled' && (
<div className="ml-6 space-y-4 border-l-2 pl-4">
<div className="flex items-center justify-between">
<div>
<Label>Require Identification</Label>
<p className="text-sm text-muted-foreground">
Audience must provide email or name to vote
</p>
</div>
<Switch
checked={settings.audienceRequireId}
onCheckedChange={(v) =>
onChange({ ...settings, audienceRequireId: v })
}
/>
</div>
{settings.audienceVotingMode === 'favorites' && (
<div className="space-y-2">
<Label htmlFor="maxFavorites">Max Favorites</Label>
<Input
id="maxFavorites"
type="number"
min="1"
max="20"
value={settings.audienceMaxFavorites}
onChange={(e) =>
onChange({
...settings,
audienceMaxFavorites: parseInt(e.target.value) || 3,
})
}
/>
<p className="text-xs text-muted-foreground">
Number of favorites each audience member can select
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="audienceDuration">
Audience Voting Duration (minutes)
</Label>
<Input
id="audienceDuration"
type="number"
min="1"
max="600"
value={settings.audienceVotingDuration || ''}
placeholder="Same as jury"
onChange={(e) => {
const val = parseInt(e.target.value)
onChange({
...settings,
audienceVotingDuration: isNaN(val) ? null : val,
})
}}
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the same window as jury voting
</p>
</div>
</div>
)}
</div>
{/* Display */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Display</h5>
<div className="flex items-center justify-between">
<div>
<Label>Show Live Scores</Label>
<p className="text-sm text-muted-foreground">
Display scores in real-time during the event
</p>
</div>
<Switch
checked={settings.showLiveScores}
onCheckedChange={(v) =>
onChange({ ...settings, showLiveScores: v })
}
/>
</div>
<div className="space-y-2">
<Label>Display Mode</Label>
<Select
value={settings.displayMode}
onValueChange={(v) =>
onChange({
...settings,
displayMode: v as 'SCORES' | 'RANKING' | 'NONE',
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SCORES">Show Scores</SelectItem>
<SelectItem value="RANKING">Show Ranking</SelectItem>
<SelectItem value="NONE">Hide Until End</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
How results are displayed on the public screen
</p>
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Presentation order and criteria can be configured in the Live Voting section once the round
is activated.
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -33,16 +33,16 @@ import {
} from 'lucide-react'
interface TypeformImportFormProps {
roundId: string
roundName: string
programId: string
stageName?: string
onSuccess?: () => void
}
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
export function TypeformImportForm({
roundId,
roundName,
programId,
stageName,
onSuccess,
}: TypeformImportFormProps) {
const [step, setStep] = useState<Step>('connect')
@@ -126,7 +126,7 @@ export function TypeformImportForm({
const result = await importMutation.mutateAsync({
apiKey,
formId,
roundId,
programId,
mappings: {
title: mappings.title,
teamName: mappings.teamName || undefined,
@@ -446,7 +446,7 @@ export function TypeformImportForm({
<AlertTitle>Ready to import</AlertTitle>
<AlertDescription>
This will import all responses from the Typeform into{' '}
<strong>{roundName}</strong>.
<strong>{stageName}</strong>.
</AlertDescription>
</Alert>

View File

@@ -8,13 +8,13 @@ import { ProjectFilesSection } from './project-files-section'
interface CollapsibleFilesSectionProps {
projectId: string
roundId: string
stageId: string
fileCount: number
}
export function CollapsibleFilesSection({
projectId,
roundId,
stageId,
fileCount,
}: CollapsibleFilesSectionProps) {
const [isExpanded, setIsExpanded] = useState(false)
@@ -63,7 +63,7 @@ export function CollapsibleFilesSection({
{isExpanded && (
<CardContent className="pt-0">
{showFiles ? (
<ProjectFilesSection projectId={projectId} roundId={roundId} />
<ProjectFilesSection projectId={projectId} stageId={stageId} />
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
Loading documents...

View File

@@ -8,13 +8,13 @@ import { AlertCircle, FileX } from 'lucide-react'
interface ProjectFilesSectionProps {
projectId: string
roundId: string
stageId: string
}
export function ProjectFilesSection({ projectId, roundId }: ProjectFilesSectionProps) {
const { data: groupedFiles, isLoading, error } = trpc.file.listByProjectForRound.useQuery({
export function ProjectFilesSection({ projectId, stageId }: ProjectFilesSectionProps) {
const { data: groupedFiles, isLoading, error } = trpc.file.listByProjectForStage.useQuery({
projectId,
roundId,
stageId,
})
if (isLoading) {
@@ -49,16 +49,16 @@ export function ProjectFilesSection({ projectId, roundId }: ProjectFilesSectionP
)
}
// Flatten all files from all round groups for FileViewer
// Flatten all files from all stage groups for FileViewer
const allFiles = groupedFiles.flatMap((group) => group.files)
return (
<div className="space-y-4">
{groupedFiles.map((group) => (
<div key={group.roundId || 'general'} className="space-y-2">
<div key={group.stageId || 'general'} className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
{group.roundName}
{group.stageName}
</h3>
<div className="flex-1 h-px bg-border" />
</div>

View File

@@ -58,6 +58,7 @@ type NavItem = {
icon: typeof LayoutDashboard
activeMatch?: string // pathname must include this to be active
activeExclude?: string // pathname must NOT include this to be active
subItems?: { name: string; href: string }[]
}
// Main navigation - scoped to selected edition
@@ -69,7 +70,7 @@ const navigation: NavItem[] = [
},
{
name: 'Rounds',
href: '/admin/rounds',
href: '/admin/rounds/pipelines',
icon: CircleDot,
},
{
@@ -221,24 +222,51 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
const isActive =
pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href))
const isParentActive = item.subItems
? pathname.startsWith('/admin/rounds')
: false
return (
<Link
key={item.name}
href={item.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150',
isActive
? 'bg-brand-blue text-white shadow-xs'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
<div key={item.name}>
<Link
href={item.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150',
isActive
? 'bg-brand-blue text-white shadow-xs'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className={cn(
'h-4 w-4 transition-colors',
isActive ? 'text-white' : 'text-muted-foreground group-hover:text-foreground'
)} />
{item.name}
</Link>
{item.subItems && isParentActive && (
<div className="ml-7 mt-0.5 space-y-0.5">
{item.subItems.map((sub) => {
const isSubActive = pathname === sub.href ||
(sub.href !== '/admin/rounds' && pathname.startsWith(sub.href))
return (
<Link
key={sub.name}
href={sub.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'block rounded-md px-3 py-1.5 text-xs font-medium transition-colors',
isSubActive
? 'text-brand-blue bg-brand-blue/10'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
{sub.name}
</Link>
)
})}
</div>
)}
>
<item.icon className={cn(
'h-4 w-4 transition-colors',
isActive ? 'text-white' : 'text-muted-foreground group-hover:text-foreground'
)} />
{item.name}
</Link>
</div>
)
})}
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import { Home, Users, FileText, MessageSquare } from 'lucide-react'
import { Home, Users, FileText, MessageSquare, Layers } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface ApplicantNavProps {
@@ -19,6 +19,11 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
href: '/applicant/team',
icon: Users,
},
{
name: 'Pipeline',
href: '/applicant/pipeline',
icon: Layers,
},
{
name: 'Documents',
href: '/applicant/documents',

View File

@@ -1,6 +1,6 @@
'use client'
import { BookOpen, ClipboardList, GitCompare, Home, Trophy } from 'lucide-react'
import { BookOpen, Home, Trophy, Layers } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
@@ -19,15 +19,15 @@ function RemainingBadge() {
const now = new Date()
const remaining = (assignments as Array<{
round: { status: string; votingStartAt: Date | null; votingEndAt: Date | null }
stage: { status: string; windowOpenAt: Date | null; windowCloseAt: Date | null } | null
evaluation: { status: string } | null
}>).filter((a) => {
const isActive =
a.round.status === 'ACTIVE' &&
a.round.votingStartAt &&
a.round.votingEndAt &&
new Date(a.round.votingStartAt) <= now &&
new Date(a.round.votingEndAt) >= now
a.stage?.status === 'STAGE_ACTIVE' &&
a.stage.windowOpenAt &&
a.stage.windowCloseAt &&
new Date(a.stage.windowOpenAt) <= now &&
new Date(a.stage.windowCloseAt) >= now
const isIncomplete = !a.evaluation || a.evaluation.status !== 'SUBMITTED'
return isActive && isIncomplete
}).length
@@ -49,20 +49,15 @@ export function JuryNav({ user }: JuryNavProps) {
icon: Home,
},
{
name: 'Assignments',
href: '/jury/assignments',
icon: ClipboardList,
name: 'Stages',
href: '/jury/stages',
icon: Layers,
},
{
name: 'Awards',
href: '/jury/awards',
icon: Trophy,
},
{
name: 'Compare',
href: '/jury/compare',
icon: GitCompare,
},
{
name: 'Learning Hub',
href: '/jury/learning',

View File

@@ -48,7 +48,7 @@ import { useDebouncedCallback } from 'use-debounce'
const PER_PAGE_OPTIONS = [10, 20, 50]
export function ObserverDashboardContent({ userName }: { userName?: string }) {
const [selectedRoundId, setSelectedRoundId] = useState<string>('all')
const [selectedStageId, setSelectedStageId] = useState<string>('all')
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
@@ -65,8 +65,8 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
debouncedSetSearch(value)
}
const handleRoundChange = (value: string) => {
setSelectedRoundId(value)
const handleStageChange = (value: string) => {
setSelectedStageId(value)
setPage(1)
}
@@ -75,38 +75,38 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
setPage(1)
}
// Fetch programs/rounds for the filter dropdown
const { data: programs } = trpc.program.list.useQuery({ includeRounds: true })
// Fetch programs/stages for the filter dropdown
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap((p) =>
p.rounds.map((r) => ({
id: r.id,
name: r.name,
const stages = programs?.flatMap((p) =>
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map((s) => ({
id: s.id,
name: s.name,
programName: `${p.year} Edition`,
status: r.status,
status: s.status,
}))
) || []
// Fetch dashboard stats
const roundIdParam = selectedRoundId !== 'all' ? selectedRoundId : undefined
const stageIdParam = selectedStageId !== 'all' ? selectedStageId : undefined
const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery(
{ roundId: roundIdParam }
{ stageId: stageIdParam }
)
// Fetch projects
const { data: projectsData, isLoading: projectsLoading } = trpc.analytics.getAllProjects.useQuery({
roundId: roundIdParam,
stageId: stageIdParam,
search: debouncedSearch || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
page,
perPage,
})
// Fetch recent rounds for jury completion
const { data: recentRoundsData } = trpc.program.list.useQuery({ includeRounds: true })
const recentRounds = recentRoundsData?.flatMap((p) =>
p.rounds.map((r) => ({
...r,
// Fetch recent stages for jury completion
const { data: recentStagesData } = trpc.program.list.useQuery({ includeStages: true })
const recentStages = recentStagesData?.flatMap((p) =>
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map((s) => ({
...s,
programName: `${p.year} Edition`,
}))
)?.slice(0, 5) || []
@@ -141,18 +141,18 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</div>
</div>
{/* Round Filter */}
{/* Stage Filter */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<label className="text-sm font-medium">Filter by Round:</label>
<Select value={selectedRoundId} onValueChange={handleRoundChange}>
<label className="text-sm font-medium">Filter by Stage:</label>
<Select value={selectedStageId} onValueChange={handleStageChange}>
<SelectTrigger className="w-full sm:w-[300px]">
<SelectValue placeholder="All Rounds" />
<SelectValue placeholder="All Stages" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Rounds</SelectItem>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
<SelectItem value="all">All Stages</SelectItem>
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
</SelectItem>
))}
</SelectContent>
@@ -184,7 +184,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<p className="text-sm font-medium text-muted-foreground">Programs</p>
<p className="text-2xl font-bold mt-1">{stats.programCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''}
{stats.activeStageCount} active round{stats.activeStageCount !== 1 ? 's' : ''}
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
@@ -203,7 +203,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<p className="text-sm font-medium text-muted-foreground">Projects</p>
<p className="text-2xl font-bold mt-1">{stats.projectCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{selectedRoundId !== 'all' ? 'In selected round' : 'Across all rounds'}
{selectedStageId !== 'all' ? 'In selected stage' : 'Across all stages'}
</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
@@ -341,7 +341,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<TableCell className="max-w-[150px] truncate">{project.teamName || '-'}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs whitespace-nowrap">
{project.roundName}
{project.stageName}
</Badge>
</TableCell>
<TableCell>
@@ -375,7 +375,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<Badge variant="outline" className="text-xs">
{project.roundName}
{project.stageName}
</Badge>
<div className="flex gap-3">
<span>Score: {project.averageScore !== null ? project.averageScore.toFixed(2) : '-'}</span>
@@ -465,8 +465,8 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</AnimatedCard>
)}
{/* Recent Rounds */}
{recentRounds.length > 0 && (
{/* Recent Stages */}
{recentStages.length > 0 && (
<AnimatedCard index={6}>
<Card>
<CardHeader>
@@ -474,40 +474,40 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<div className="rounded-lg bg-violet-500/10 p-1.5">
<BarChart3 className="h-4 w-4 text-violet-500" />
</div>
Recent Rounds
Recent Stages
</CardTitle>
<CardDescription>Overview of the latest voting rounds</CardDescription>
<CardDescription>Overview of the latest evaluation stages</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentRounds.map((round) => (
{recentStages.map((stage) => (
<div
key={round.id}
key={stage.id}
className="flex items-center justify-between rounded-lg border p-4 transition-all hover:shadow-sm"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium">{round.name}</p>
<p className="font-medium">{stage.name}</p>
<Badge
variant={
round.status === 'ACTIVE'
stage.status === 'STAGE_ACTIVE'
? 'default'
: round.status === 'CLOSED'
: stage.status === 'STAGE_CLOSED'
? 'secondary'
: 'outline'
}
>
{round.status}
{stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_CLOSED' ? 'Closed' : stage.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{round.programName}
{stage.programName}
</p>
</div>
<div className="text-right text-sm">
<p>{round._count?.projects || 0} projects</p>
<p>{stage._count?.projects || 0} projects</p>
<p className="text-muted-foreground">
{round._count?.assignments || 0} assignments
{stage._count?.assignments || 0} assignments
</p>
</div>
</div>

View File

@@ -25,7 +25,6 @@ import {
ShieldAlert,
Globe,
Webhook,
LayoutTemplate,
} from 'lucide-react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
@@ -531,27 +530,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
{/* Quick Links to sub-pages */}
<div className="grid gap-4 sm:grid-cols-2">
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<LayoutTemplate className="h-4 w-4" />
Round Templates
</CardTitle>
<CardDescription>
Create reusable round configuration templates
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link href="/admin/settings/templates">
<LayoutTemplate className="mr-2 h-4 w-4" />
Manage Templates
<ExternalLink className="ml-2 h-3 w-3" />
</Link>
</Button>
</CardContent>
</Card>
{isSuperAdmin && (
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader>

View File

@@ -19,7 +19,7 @@ import {
} from '@/lib/pdf-generator'
interface ExportPdfButtonProps {
roundId: string
stageId: string
roundName?: string
programName?: string
chartRefs?: Record<string, RefObject<HTMLDivElement | null>>
@@ -28,7 +28,7 @@ interface ExportPdfButtonProps {
}
export function ExportPdfButton({
roundId,
stageId,
roundName,
programName,
chartRefs,
@@ -38,7 +38,7 @@ export function ExportPdfButton({
const [generating, setGenerating] = useState(false)
const { refetch } = trpc.export.getReportData.useQuery(
{ roundId, sections: [] },
{ stageId, sections: [] },
{ enabled: false }
)

View File

@@ -46,8 +46,8 @@ interface FileUploadProps {
allowedTypes?: string[]
multiple?: boolean
className?: string
roundId?: string
availableRounds?: Array<{ id: string; name: string }>
stageId?: string
availableStages?: Array<{ id: string; name: string }>
}
// Map MIME types to suggested file types
@@ -85,12 +85,12 @@ export function FileUpload({
allowedTypes,
multiple = true,
className,
roundId,
availableRounds,
stageId,
availableStages,
}: FileUploadProps) {
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(roundId ?? null)
const [selectedStageId, setSelectedStageId] = useState<string | null>(stageId ?? null)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.file.getUploadUrl.useMutation()
@@ -129,7 +129,7 @@ export function FileUpload({
fileType,
mimeType: file.type || 'application/octet-stream',
size: file.size,
roundId: selectedRoundId ?? undefined,
stageId: selectedStageId ?? undefined,
})
// Store the DB file ID
@@ -309,24 +309,24 @@ export function FileUpload({
return (
<div className={cn('space-y-4', className)}>
{/* Round selector */}
{availableRounds && availableRounds.length > 0 && (
{/* Stage selector */}
{availableStages && availableStages.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium">
Upload for Round
Upload for Stage
</label>
<Select
value={selectedRoundId ?? 'null'}
onValueChange={(value) => setSelectedRoundId(value === 'null' ? null : value)}
value={selectedStageId ?? 'null'}
onValueChange={(value) => setSelectedStageId(value === 'null' ? null : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a round" />
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="null">General (no specific round)</SelectItem>
{availableRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
<SelectItem value="null">General (no specific stage)</SelectItem>
{availableStages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>

View File

@@ -67,18 +67,18 @@ interface ProjectFile {
requirement?: FileRequirementInfo | null
}
interface RoundGroup {
roundId: string | null
roundName: string
interface StageGroup {
stageId: string | null
stageName: string
sortOrder: number
files: Array<ProjectFile & { isLate?: boolean }>
}
interface FileViewerProps {
files?: ProjectFile[]
groupedFiles?: RoundGroup[]
groupedFiles?: StageGroup[]
projectId?: string
roundId?: string
stageId?: string
className?: string
}
@@ -118,7 +118,7 @@ function getFileTypeLabel(fileType: string) {
}
}
export function FileViewer({ files, groupedFiles, projectId, roundId, className }: FileViewerProps) {
export function FileViewer({ files, groupedFiles, projectId, stageId, className }: FileViewerProps) {
// Render grouped view if groupedFiles is provided
if (groupedFiles) {
return <GroupedFileViewer groupedFiles={groupedFiles} className={className} />
@@ -148,7 +148,7 @@ export function FileViewer({ files, groupedFiles, projectId, roundId, className
return (
<div className={cn('space-y-4', className)}>
{/* Requirement Fulfillment Checklist */}
{roundId && <RequirementChecklist roundId={roundId} files={files} />}
{stageId && <RequirementChecklist stageId={stageId} files={files} />}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
@@ -167,7 +167,7 @@ export function FileViewer({ files, groupedFiles, projectId, roundId, className
)
}
function GroupedFileViewer({ groupedFiles, className }: { groupedFiles: RoundGroup[], className?: string }) {
function GroupedFileViewer({ groupedFiles, className }: { groupedFiles: StageGroup[], className?: string }) {
const hasAnyFiles = groupedFiles.some(group => group.files.length > 0)
if (!hasAnyFiles) {
@@ -204,18 +204,18 @@ function GroupedFileViewer({ groupedFiles, className }: { groupedFiles: RoundGro
)
return (
<div key={group.roundId || 'no-round'} className="space-y-3">
{/* Round header */}
<div key={group.stageId || 'no-stage'} className="space-y-3">
{/* Stage header */}
<div className="flex items-center justify-between border-b pb-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
{group.roundName}
{group.stageName}
</h3>
<Badge variant="outline" className="text-xs">
{group.files.length} {group.files.length === 1 ? 'file' : 'files'}
</Badge>
</div>
{/* Files in this round */}
{/* Files in this stage */}
<div className="space-y-3">
{sortedFiles.map((file) => (
<FileItem key={file.id} file={file} />
@@ -739,8 +739,8 @@ function CompactFileItem({ file }: { file: ProjectFile }) {
* Displays a checklist of file requirements and their fulfillment status.
* Used by admins/jury to see which required files have been uploaded.
*/
function RequirementChecklist({ roundId, files }: { roundId: string; files: ProjectFile[] }) {
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({ roundId })
function RequirementChecklist({ stageId, files }: { stageId: string; files: ProjectFile[] }) {
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({ stageId })
if (requirements.length === 0) return null

View File

@@ -50,7 +50,7 @@ interface RequirementUploadSlotProps {
requirement: FileRequirement
existingFile?: UploadedFile | null
projectId: string
roundId: string
stageId: string
onFileChange?: () => void
disabled?: boolean
}
@@ -59,7 +59,7 @@ export function RequirementUploadSlot({
requirement,
existingFile,
projectId,
roundId,
stageId,
onFileChange,
disabled = false,
}: RequirementUploadSlotProps) {
@@ -110,13 +110,13 @@ export function RequirementUploadSlot({
try {
// Get presigned URL
const { url, bucket, objectKey, isLate, roundId: uploadRoundId } =
const { url, bucket, objectKey, isLate, stageId: uploadStageId } =
await getUploadUrl.mutateAsync({
projectId,
fileName: file.name,
mimeType: file.type,
fileType: 'OTHER',
roundId,
stageId,
requirementId: requirement.id,
})
@@ -150,7 +150,7 @@ export function RequirementUploadSlot({
fileType: 'OTHER',
bucket,
objectKey,
roundId: uploadRoundId || roundId,
stageId: uploadStageId || stageId,
isLate: isLate || false,
requirementId: requirement.id,
})
@@ -164,7 +164,7 @@ export function RequirementUploadSlot({
setProgress(0)
}
},
[projectId, roundId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange]
[projectId, stageId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange]
)
const handleDelete = useCallback(async () => {
@@ -309,20 +309,22 @@ export function RequirementUploadSlot({
interface RequirementUploadListProps {
projectId: string
roundId: string
stageId: string
disabled?: boolean
}
export function RequirementUploadList({ projectId, roundId, disabled }: RequirementUploadListProps) {
export function RequirementUploadList({ projectId, stageId, disabled }: RequirementUploadListProps) {
const utils = trpc.useUtils()
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({ roundId })
const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, roundId })
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({
stageId,
})
const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, stageId })
if (requirements.length === 0) return null
const handleFileChange = () => {
utils.file.listByProject.invalidate({ projectId, roundId })
utils.file.listByProject.invalidate({ projectId, stageId })
}
return (
@@ -351,7 +353,7 @@ export function RequirementUploadList({ projectId, roundId, disabled }: Requirem
: null
}
projectId={projectId}
roundId={roundId}
stageId={stageId}
onFileChange={handleFileChange}
disabled={disabled}
/>

View File

@@ -0,0 +1,47 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
interface StageBreadcrumbProps {
pipelineName: string
trackName: string
stageName: string
stageId?: string
pipelineId?: string
className?: string
basePath?: string // e.g. '/jury/stages' or '/admin/reports/stages'
}
export function StageBreadcrumb({
pipelineName,
trackName,
stageName,
stageId,
pipelineId,
className,
basePath = '/jury/stages',
}: StageBreadcrumbProps) {
return (
<nav className={cn('flex items-center gap-1 text-sm text-muted-foreground', className)}>
<Link href={basePath as Route} className="hover:text-foreground transition-colors truncate max-w-[150px]">
{pipelineName}
</Link>
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
<span className="truncate max-w-[120px]">{trackName}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
{stageId ? (
<Link
href={`${basePath}/${stageId}/assignments` as Route}
className="hover:text-foreground transition-colors font-medium text-foreground truncate max-w-[150px]"
>
{stageName}
</Link>
) : (
<span className="font-medium text-foreground truncate max-w-[150px]">{stageName}</span>
)}
</nav>
)
}

View File

@@ -0,0 +1,205 @@
'use client'
import { cn } from '@/lib/utils'
import {
CheckCircle,
Circle,
Clock,
XCircle,
FileText,
Users,
Vote,
ArrowRightLeft,
Presentation,
Award,
} from 'lucide-react'
interface StageTimelineItem {
id: string
name: string
stageType: string
isCurrent: boolean
state: string // PENDING, IN_PROGRESS, PASSED, REJECTED, etc.
enteredAt?: Date | string | null
}
interface StageTimelineProps {
stages: StageTimelineItem[]
orientation?: 'horizontal' | 'vertical'
className?: string
}
const stageTypeIcons: Record<string, typeof Circle> = {
INTAKE: FileText,
EVALUATION: Users,
VOTING: Vote,
DELIBERATION: ArrowRightLeft,
LIVE_PRESENTATION: Presentation,
AWARD: Award,
}
function getStateColor(state: string, isCurrent: boolean) {
if (state === 'REJECTED' || state === 'ELIMINATED')
return 'bg-destructive text-destructive-foreground'
if (state === 'PASSED' || state === 'COMPLETED')
return 'bg-green-600 text-white dark:bg-green-700'
if (state === 'IN_PROGRESS' || isCurrent)
return 'bg-primary text-primary-foreground'
return 'border-2 border-muted bg-background text-muted-foreground'
}
function getConnectorColor(state: string) {
if (state === 'PASSED' || state === 'COMPLETED' || state === 'IN_PROGRESS')
return 'bg-primary'
if (state === 'REJECTED' || state === 'ELIMINATED')
return 'bg-destructive/30'
return 'bg-muted'
}
function formatDate(date: Date | string | null | undefined) {
if (!date) return null
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
export function StageTimeline({
stages,
orientation = 'horizontal',
className,
}: StageTimelineProps) {
if (stages.length === 0) return null
if (orientation === 'vertical') {
return (
<div className={cn('relative', className)}>
<div className="space-y-0">
{stages.map((stage, index) => {
const Icon = stageTypeIcons[stage.stageType] || Circle
const isPassed = stage.state === 'PASSED' || stage.state === 'COMPLETED'
const isRejected = stage.state === 'REJECTED' || stage.state === 'ELIMINATED'
const isPending = !isPassed && !isRejected && !stage.isCurrent
return (
<div key={stage.id} className="relative flex gap-4">
{index < stages.length - 1 && (
<div
className={cn(
'absolute left-[15px] top-[32px] h-full w-0.5',
getConnectorColor(stage.state)
)}
/>
)}
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full',
getStateColor(stage.state, stage.isCurrent)
)}
>
{isRejected ? (
<XCircle className="h-4 w-4" />
) : isPassed ? (
<CheckCircle className="h-4 w-4" />
) : stage.isCurrent ? (
<Clock className="h-4 w-4" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
</div>
<div className="flex-1 pb-8">
<div className="flex items-center gap-2">
<p
className={cn(
'font-medium text-sm',
isRejected && 'text-destructive',
isPending && 'text-muted-foreground'
)}
>
{stage.name}
</p>
{stage.isCurrent && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
Current
</span>
)}
</div>
<p className="text-xs text-muted-foreground capitalize">
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
</p>
{stage.enteredAt && (
<p className="text-xs text-muted-foreground">
{formatDate(stage.enteredAt)}
</p>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
// Horizontal orientation
return (
<div className={cn('flex items-center gap-0 overflow-x-auto pb-2', className)}>
{stages.map((stage, index) => {
const Icon = stageTypeIcons[stage.stageType] || Circle
const isPassed = stage.state === 'PASSED' || stage.state === 'COMPLETED'
const isRejected = stage.state === 'REJECTED' || stage.state === 'ELIMINATED'
const isPending = !isPassed && !isRejected && !stage.isCurrent
return (
<div key={stage.id} className="flex items-center">
{index > 0 && (
<div
className={cn(
'h-0.5 w-8 lg:w-12 shrink-0',
getConnectorColor(stages[index - 1].state)
)}
/>
)}
<div className="flex flex-col items-center gap-1 shrink-0">
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full transition-colors',
getStateColor(stage.state, stage.isCurrent)
)}
>
{isRejected ? (
<XCircle className="h-4 w-4" />
) : isPassed ? (
<CheckCircle className="h-4 w-4" />
) : stage.isCurrent ? (
<Clock className="h-4 w-4" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
<div className="text-center max-w-[80px]">
<p
className={cn(
'text-xs font-medium leading-tight',
isRejected && 'text-destructive',
isPending && 'text-muted-foreground',
stage.isCurrent && 'text-primary'
)}
>
{stage.name}
</p>
{stage.enteredAt && (
<p className="text-[10px] text-muted-foreground">
{formatDate(stage.enteredAt)}
</p>
)}
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,133 @@
'use client'
import { cn } from '@/lib/utils'
import { Clock, CheckCircle, XCircle, Timer } from 'lucide-react'
import { CountdownTimer } from '@/components/shared/countdown-timer'
interface StageWindowBadgeProps {
windowOpenAt?: Date | string | null
windowCloseAt?: Date | string | null
status?: string
className?: string
}
function toDate(v: Date | string | null | undefined): Date | null {
if (!v) return null
return typeof v === 'string' ? new Date(v) : v
}
export function StageWindowBadge({
windowOpenAt,
windowCloseAt,
status,
className,
}: StageWindowBadgeProps) {
const now = new Date()
const openAt = toDate(windowOpenAt)
const closeAt = toDate(windowCloseAt)
// Determine window state
const isBeforeOpen = openAt && now < openAt
const isOpenEnded = openAt && !closeAt && now >= openAt
const isOpen = openAt && closeAt && now >= openAt && now <= closeAt
const isClosed = closeAt && now > closeAt
if (status === 'COMPLETED' || status === 'CLOSED') {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground bg-muted',
className
)}
>
<CheckCircle className="h-3 w-3 shrink-0" />
<span>Completed</span>
</div>
)
}
if (isClosed) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground bg-muted',
className
)}
>
<XCircle className="h-3 w-3 shrink-0" />
<span>Closed</span>
</div>
)
}
if (isOpenEnded) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>Open</span>
</div>
)
}
if (isOpen && closeAt) {
const remainingMs = closeAt.getTime() - now.getTime()
const isUrgent = remainingMs < 24 * 60 * 60 * 1000 // < 24 hours
if (isUrgent) {
return <CountdownTimer deadline={closeAt} label="Closes in" className={className} />
}
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>Open</span>
</div>
)
}
if (isBeforeOpen && openAt) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground border-dashed',
className
)}
>
<Timer className="h-3 w-3 shrink-0" />
<span>
Opens{' '}
{openAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
</div>
)
}
// No window configured
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground border-dashed',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>No window set</span>
</div>
)
}