Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s

This commit is contained in:
Matt
2026-02-14 15:26:42 +01:00
parent e56e143a40
commit b5425e705e
374 changed files with 116737 additions and 111969 deletions

View File

@@ -1,335 +1,335 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
FileText,
RefreshCw,
Loader2,
CheckCircle2,
AlertTriangle,
Clock,
Users,
Target,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
interface EvaluationSummaryCardProps {
projectId: string
stageId: string
}
interface ScoringPatterns {
averageGlobalScore: number | null
consensus: number
criterionAverages: Record<string, number>
evaluatorCount: number
}
interface ThemeItem {
theme: string
sentiment: 'positive' | 'negative' | 'mixed'
frequency: number
}
interface SummaryJson {
overallAssessment: string
strengths: string[]
weaknesses: string[]
themes: ThemeItem[]
recommendation: string
scoringPatterns: ScoringPatterns
}
const sentimentColors: Record<string, { badge: 'default' | 'secondary' | 'destructive'; bg: string }> = {
positive: { badge: 'default', bg: 'bg-green-500/10 text-green-700' },
negative: { badge: 'destructive', bg: 'bg-red-500/10 text-red-700' },
mixed: { badge: 'secondary', bg: 'bg-amber-500/10 text-amber-700' },
}
export function EvaluationSummaryCard({
projectId,
stageId,
}: EvaluationSummaryCardProps) {
const [isGenerating, setIsGenerating] = useState(false)
const {
data: summary,
isLoading,
refetch,
} = trpc.evaluation.getSummary.useQuery({ projectId, stageId })
const generateMutation = trpc.evaluation.generateSummary.useMutation({
onSuccess: () => {
toast.success('AI summary generated successfully')
refetch()
setIsGenerating(false)
},
onError: (error) => {
toast.error(error.message || 'Failed to generate summary')
setIsGenerating(false)
},
})
const handleGenerate = () => {
setIsGenerating(true)
generateMutation.mutate({ projectId, stageId })
}
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
)
}
// No summary exists yet
if (!summary) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5" />
AI Evaluation Summary
</CardTitle>
<CardDescription>
Generate an AI-powered analysis of jury evaluations
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-6 text-center">
<FileText className="h-10 w-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground mb-4">
No summary generated yet. Click below to analyze submitted evaluations.
</p>
<Button onClick={handleGenerate} disabled={isGenerating}>
{isGenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
{isGenerating ? 'Generating...' : 'Generate Summary'}
</Button>
</div>
</CardContent>
</Card>
)
}
const summaryData = summary.summaryJson as unknown as SummaryJson
const patterns = summaryData.scoringPatterns
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5" />
AI Evaluation Summary
</CardTitle>
<CardDescription className="flex items-center gap-2 mt-1">
<Clock className="h-3 w-3" />
Generated {formatDistanceToNow(new Date(summary.generatedAt), { addSuffix: true })}
{' '}using {summary.model}
</CardDescription>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={isGenerating}>
{isGenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Regenerate
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Regenerate Summary</AlertDialogTitle>
<AlertDialogDescription>
This will replace the existing AI summary with a new one.
This uses your OpenAI API quota.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleGenerate}>
Regenerate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Scoring Stats */}
<div className="grid gap-3 sm:grid-cols-3">
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<Target className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">
{patterns.averageGlobalScore !== null
? patterns.averageGlobalScore.toFixed(1)
: '-'}
</p>
<p className="text-xs text-muted-foreground">Avg Score</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">
{Math.round(patterns.consensus * 100)}%
</p>
<p className="text-xs text-muted-foreground">Consensus</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<Users className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">{patterns.evaluatorCount}</p>
<p className="text-xs text-muted-foreground">Evaluators</p>
</div>
</div>
</div>
{/* Overall Assessment */}
<div>
<p className="text-sm font-medium mb-2">Overall Assessment</p>
<p className="text-sm text-muted-foreground leading-relaxed">
{summaryData.overallAssessment}
</p>
</div>
{/* Strengths & Weaknesses */}
<div className="grid gap-4 sm:grid-cols-2">
{summaryData.strengths.length > 0 && (
<div>
<p className="text-sm font-medium mb-2 text-green-700">Strengths</p>
<ul className="space-y-1">
{summaryData.strengths.map((s, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-green-500 flex-shrink-0" />
{s}
</li>
))}
</ul>
</div>
)}
{summaryData.weaknesses.length > 0 && (
<div>
<p className="text-sm font-medium mb-2 text-amber-700">Weaknesses</p>
<ul className="space-y-1">
{summaryData.weaknesses.map((w, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-amber-500 flex-shrink-0" />
{w}
</li>
))}
</ul>
</div>
)}
</div>
{/* Themes */}
{summaryData.themes.length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Key Themes</p>
<div className="space-y-2">
{summaryData.themes.map((theme, i) => (
<div
key={i}
className="flex items-center justify-between p-2 rounded-lg border"
>
<div className="flex items-center gap-2">
<Badge
className={sentimentColors[theme.sentiment]?.bg}
variant="outline"
>
{theme.sentiment}
</Badge>
<span className="text-sm">{theme.theme}</span>
</div>
<span className="text-xs text-muted-foreground">
{theme.frequency} mention{theme.frequency !== 1 ? 's' : ''}
</span>
</div>
))}
</div>
</div>
)}
{/* Criterion Averages */}
{Object.keys(patterns.criterionAverages).length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Criterion Averages</p>
<div className="space-y-2">
{Object.entries(patterns.criterionAverages).map(([label, avg]) => (
<div key={label} className="flex items-center gap-3">
<span className="text-sm text-muted-foreground flex-1 min-w-0 truncate">
{label}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="w-24 h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${(avg / 10) * 100}%` }}
/>
</div>
<span className="text-sm font-medium w-8 text-right">
{avg.toFixed(1)}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Recommendation */}
{summaryData.recommendation && (
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-200">
<p className="text-sm font-medium text-blue-900 mb-1">
Recommendation
</p>
<p className="text-sm text-blue-700">
{summaryData.recommendation}
</p>
</div>
)}
</CardContent>
</Card>
)
}
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
FileText,
RefreshCw,
Loader2,
CheckCircle2,
AlertTriangle,
Clock,
Users,
Target,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
interface EvaluationSummaryCardProps {
projectId: string
stageId: string
}
interface ScoringPatterns {
averageGlobalScore: number | null
consensus: number
criterionAverages: Record<string, number>
evaluatorCount: number
}
interface ThemeItem {
theme: string
sentiment: 'positive' | 'negative' | 'mixed'
frequency: number
}
interface SummaryJson {
overallAssessment: string
strengths: string[]
weaknesses: string[]
themes: ThemeItem[]
recommendation: string
scoringPatterns: ScoringPatterns
}
const sentimentColors: Record<string, { badge: 'default' | 'secondary' | 'destructive'; bg: string }> = {
positive: { badge: 'default', bg: 'bg-green-500/10 text-green-700' },
negative: { badge: 'destructive', bg: 'bg-red-500/10 text-red-700' },
mixed: { badge: 'secondary', bg: 'bg-amber-500/10 text-amber-700' },
}
export function EvaluationSummaryCard({
projectId,
stageId,
}: EvaluationSummaryCardProps) {
const [isGenerating, setIsGenerating] = useState(false)
const {
data: summary,
isLoading,
refetch,
} = trpc.evaluation.getSummary.useQuery({ projectId, stageId })
const generateMutation = trpc.evaluation.generateSummary.useMutation({
onSuccess: () => {
toast.success('AI summary generated successfully')
refetch()
setIsGenerating(false)
},
onError: (error) => {
toast.error(error.message || 'Failed to generate summary')
setIsGenerating(false)
},
})
const handleGenerate = () => {
setIsGenerating(true)
generateMutation.mutate({ projectId, stageId })
}
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
)
}
// No summary exists yet
if (!summary) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5" />
AI Evaluation Summary
</CardTitle>
<CardDescription>
Generate an AI-powered analysis of jury evaluations
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-6 text-center">
<FileText className="h-10 w-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground mb-4">
No summary generated yet. Click below to analyze submitted evaluations.
</p>
<Button onClick={handleGenerate} disabled={isGenerating}>
{isGenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
{isGenerating ? 'Generating...' : 'Generate Summary'}
</Button>
</div>
</CardContent>
</Card>
)
}
const summaryData = summary.summaryJson as unknown as SummaryJson
const patterns = summaryData.scoringPatterns
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5" />
AI Evaluation Summary
</CardTitle>
<CardDescription className="flex items-center gap-2 mt-1">
<Clock className="h-3 w-3" />
Generated {formatDistanceToNow(new Date(summary.generatedAt), { addSuffix: true })}
{' '}using {summary.model}
</CardDescription>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={isGenerating}>
{isGenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Regenerate
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Regenerate Summary</AlertDialogTitle>
<AlertDialogDescription>
This will replace the existing AI summary with a new one.
This uses your OpenAI API quota.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleGenerate}>
Regenerate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Scoring Stats */}
<div className="grid gap-3 sm:grid-cols-3">
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<Target className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">
{patterns.averageGlobalScore !== null
? patterns.averageGlobalScore.toFixed(1)
: '-'}
</p>
<p className="text-xs text-muted-foreground">Avg Score</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">
{Math.round(patterns.consensus * 100)}%
</p>
<p className="text-xs text-muted-foreground">Consensus</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<Users className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">{patterns.evaluatorCount}</p>
<p className="text-xs text-muted-foreground">Evaluators</p>
</div>
</div>
</div>
{/* Overall Assessment */}
<div>
<p className="text-sm font-medium mb-2">Overall Assessment</p>
<p className="text-sm text-muted-foreground leading-relaxed">
{summaryData.overallAssessment}
</p>
</div>
{/* Strengths & Weaknesses */}
<div className="grid gap-4 sm:grid-cols-2">
{summaryData.strengths.length > 0 && (
<div>
<p className="text-sm font-medium mb-2 text-green-700">Strengths</p>
<ul className="space-y-1">
{summaryData.strengths.map((s, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-green-500 flex-shrink-0" />
{s}
</li>
))}
</ul>
</div>
)}
{summaryData.weaknesses.length > 0 && (
<div>
<p className="text-sm font-medium mb-2 text-amber-700">Weaknesses</p>
<ul className="space-y-1">
{summaryData.weaknesses.map((w, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-amber-500 flex-shrink-0" />
{w}
</li>
))}
</ul>
</div>
)}
</div>
{/* Themes */}
{summaryData.themes.length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Key Themes</p>
<div className="space-y-2">
{summaryData.themes.map((theme, i) => (
<div
key={i}
className="flex items-center justify-between p-2 rounded-lg border"
>
<div className="flex items-center gap-2">
<Badge
className={sentimentColors[theme.sentiment]?.bg}
variant="outline"
>
{theme.sentiment}
</Badge>
<span className="text-sm">{theme.theme}</span>
</div>
<span className="text-xs text-muted-foreground">
{theme.frequency} mention{theme.frequency !== 1 ? 's' : ''}
</span>
</div>
))}
</div>
</div>
)}
{/* Criterion Averages */}
{Object.keys(patterns.criterionAverages).length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Criterion Averages</p>
<div className="space-y-2">
{Object.entries(patterns.criterionAverages).map(([label, avg]) => (
<div key={label} className="flex items-center gap-3">
<span className="text-sm text-muted-foreground flex-1 min-w-0 truncate">
{label}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="w-24 h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${(avg / 10) * 100}%` }}
/>
</div>
<span className="text-sm font-medium w-8 text-right">
{avg.toFixed(1)}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Recommendation */}
{summaryData.recommendation && (
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-200">
<p className="text-sm font-medium text-blue-900 mb-1">
Recommendation
</p>
<p className="text-sm text-blue-700">
{summaryData.recommendation}
</p>
</div>
)}
</CardContent>
</Card>
)
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,166 +1,166 @@
'use client'
import { useState, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { FileDown, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import {
createReportDocument,
addCoverPage,
addPageBreak,
addHeader,
addSectionTitle,
addStatCards,
addTable,
addAllPageFooters,
savePdf,
} from '@/lib/pdf-generator'
interface PdfReportProps {
stageId: string
sections: string[]
}
export function PdfReportGenerator({ stageId, sections }: PdfReportProps) {
const [generating, setGenerating] = useState(false)
const { refetch } = trpc.export.getReportData.useQuery(
{ stageId, sections },
{ enabled: false }
)
const handleGenerate = useCallback(async () => {
setGenerating(true)
toast.info('Generating PDF report...')
try {
const result = await refetch()
if (!result.data) {
toast.error('Failed to fetch report data')
return
}
const data = result.data as Record<string, unknown>
const rName = String(data.roundName || 'Report')
const pName = String(data.programName || '')
// 1. Create document
const doc = await createReportDocument()
// 2. Cover page
await addCoverPage(doc, {
title: 'Round Report',
subtitle: `${pName} ${data.programYear ? `(${data.programYear})` : ''}`.trim(),
roundName: rName,
programName: pName,
})
// 3. Summary
const summary = data.summary as Record<string, unknown> | undefined
if (summary) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Summary', 28)
y = addStatCards(doc, [
{ label: 'Projects', value: String(summary.projectCount ?? 0) },
{ label: 'Evaluations', value: String(summary.evaluationCount ?? 0) },
{
label: 'Avg Score',
value: summary.averageScore != null
? Number(summary.averageScore).toFixed(1)
: '--',
},
{
label: 'Completion',
value: summary.completionRate != null
? `${Number(summary.completionRate).toFixed(0)}%`
: '--',
},
], y)
}
// 4. Rankings
const rankings = data.rankings as Array<Record<string, unknown>> | undefined
if (rankings && rankings.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Project Rankings', 28)
const headers = ['#', 'Project', 'Team', 'Avg Score', 'Evaluations', 'Yes %']
const rows = rankings.map((r, i) => [
i + 1,
String(r.title ?? ''),
String(r.teamName ?? ''),
r.averageScore != null ? Number(r.averageScore).toFixed(2) : '-',
String(r.evaluationCount ?? 0),
r.yesPercentage != null ? `${Number(r.yesPercentage).toFixed(0)}%` : '-',
])
y = addTable(doc, headers, rows, y)
}
// 5. Juror stats
const jurorStats = data.jurorStats as Array<Record<string, unknown>> | undefined
if (jurorStats && jurorStats.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Juror Statistics', 28)
const headers = ['Juror', 'Assigned', 'Completed', 'Completion %', 'Avg Score']
const rows = jurorStats.map((j) => [
String(j.name ?? ''),
String(j.assigned ?? 0),
String(j.completed ?? 0),
`${Number(j.completionRate ?? 0).toFixed(0)}%`,
j.averageScore != null ? Number(j.averageScore).toFixed(2) : '-',
])
y = addTable(doc, headers, rows, y)
}
// 6. Criteria breakdown
const criteriaBreakdown = data.criteriaBreakdown as Array<Record<string, unknown>> | undefined
if (criteriaBreakdown && criteriaBreakdown.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Criteria Breakdown', 28)
const headers = ['Criterion', 'Avg Score', 'Responses']
const rows = criteriaBreakdown.map((c) => [
String(c.label ?? ''),
c.averageScore != null ? Number(c.averageScore).toFixed(2) : '-',
String(c.count ?? 0),
])
y = addTable(doc, headers, rows, y)
}
// 7. Footers
addAllPageFooters(doc)
// 8. Save
const dateStr = new Date().toISOString().split('T')[0]
savePdf(doc, `MOPC-Report-${rName.replace(/\s+/g, '-')}-${dateStr}.pdf`)
toast.success('PDF report downloaded successfully')
} catch (err) {
console.error('PDF generation error:', err)
toast.error('Failed to generate PDF report')
} finally {
setGenerating(false)
}
}, [refetch])
return (
<Button variant="outline" onClick={handleGenerate} disabled={generating}>
{generating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileDown className="mr-2 h-4 w-4" />
)}
{generating ? 'Generating...' : 'Export PDF Report'}
</Button>
)
}
'use client'
import { useState, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { FileDown, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import {
createReportDocument,
addCoverPage,
addPageBreak,
addHeader,
addSectionTitle,
addStatCards,
addTable,
addAllPageFooters,
savePdf,
} from '@/lib/pdf-generator'
interface PdfReportProps {
stageId: string
sections: string[]
}
export function PdfReportGenerator({ stageId, sections }: PdfReportProps) {
const [generating, setGenerating] = useState(false)
const { refetch } = trpc.export.getReportData.useQuery(
{ stageId, sections },
{ enabled: false }
)
const handleGenerate = useCallback(async () => {
setGenerating(true)
toast.info('Generating PDF report...')
try {
const result = await refetch()
if (!result.data) {
toast.error('Failed to fetch report data')
return
}
const data = result.data as Record<string, unknown>
const rName = String(data.roundName || 'Report')
const pName = String(data.programName || '')
// 1. Create document
const doc = await createReportDocument()
// 2. Cover page
await addCoverPage(doc, {
title: 'Round Report',
subtitle: `${pName} ${data.programYear ? `(${data.programYear})` : ''}`.trim(),
roundName: rName,
programName: pName,
})
// 3. Summary
const summary = data.summary as Record<string, unknown> | undefined
if (summary) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Summary', 28)
y = addStatCards(doc, [
{ label: 'Projects', value: String(summary.projectCount ?? 0) },
{ label: 'Evaluations', value: String(summary.evaluationCount ?? 0) },
{
label: 'Avg Score',
value: summary.averageScore != null
? Number(summary.averageScore).toFixed(1)
: '--',
},
{
label: 'Completion',
value: summary.completionRate != null
? `${Number(summary.completionRate).toFixed(0)}%`
: '--',
},
], y)
}
// 4. Rankings
const rankings = data.rankings as Array<Record<string, unknown>> | undefined
if (rankings && rankings.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Project Rankings', 28)
const headers = ['#', 'Project', 'Team', 'Avg Score', 'Evaluations', 'Yes %']
const rows = rankings.map((r, i) => [
i + 1,
String(r.title ?? ''),
String(r.teamName ?? ''),
r.averageScore != null ? Number(r.averageScore).toFixed(2) : '-',
String(r.evaluationCount ?? 0),
r.yesPercentage != null ? `${Number(r.yesPercentage).toFixed(0)}%` : '-',
])
y = addTable(doc, headers, rows, y)
}
// 5. Juror stats
const jurorStats = data.jurorStats as Array<Record<string, unknown>> | undefined
if (jurorStats && jurorStats.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Juror Statistics', 28)
const headers = ['Juror', 'Assigned', 'Completed', 'Completion %', 'Avg Score']
const rows = jurorStats.map((j) => [
String(j.name ?? ''),
String(j.assigned ?? 0),
String(j.completed ?? 0),
`${Number(j.completionRate ?? 0).toFixed(0)}%`,
j.averageScore != null ? Number(j.averageScore).toFixed(2) : '-',
])
y = addTable(doc, headers, rows, y)
}
// 6. Criteria breakdown
const criteriaBreakdown = data.criteriaBreakdown as Array<Record<string, unknown>> | undefined
if (criteriaBreakdown && criteriaBreakdown.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Criteria Breakdown', 28)
const headers = ['Criterion', 'Avg Score', 'Responses']
const rows = criteriaBreakdown.map((c) => [
String(c.label ?? ''),
c.averageScore != null ? Number(c.averageScore).toFixed(2) : '-',
String(c.count ?? 0),
])
y = addTable(doc, headers, rows, y)
}
// 7. Footers
addAllPageFooters(doc)
// 8. Save
const dateStr = new Date().toISOString().split('T')[0]
savePdf(doc, `MOPC-Report-${rName.replace(/\s+/g, '-')}-${dateStr}.pdf`)
toast.success('PDF report downloaded successfully')
} catch (err) {
console.error('PDF generation error:', err)
toast.error('Failed to generate PDF report')
} finally {
setGenerating(false)
}
}, [refetch])
return (
<Button variant="outline" onClick={handleGenerate} disabled={generating}>
{generating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileDown className="mr-2 h-4 w-4" />
)}
{generating ? 'Generating...' : 'Export PDF Report'}
</Button>
)
}

View File

@@ -0,0 +1,358 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Save, Loader2 } from 'lucide-react'
type TrackAwardLite = {
id: string
name: string
decisionMode: 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION' | null
specialAward: {
id: string
name: string
description: string | null
criteriaText: string | null
useAiEligibility: boolean
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
maxRankedPicks: number | null
votingStartAt: Date | null
votingEndAt: Date | null
status: string
} | null
}
type AwardGovernanceEditorProps = {
pipelineId: string
tracks: TrackAwardLite[]
}
type AwardDraft = {
trackId: string
awardId: string
awardName: string
description: string
criteriaText: string
useAiEligibility: boolean
decisionMode: 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
maxRankedPicks: string
votingStartAt: string
votingEndAt: string
}
function toDateTimeInputValue(value: Date | null | undefined): string {
if (!value) return ''
const date = new Date(value)
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
return local.toISOString().slice(0, 16)
}
function toDateOrUndefined(value: string): Date | undefined {
if (!value) return undefined
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? undefined : parsed
}
export function AwardGovernanceEditor({
pipelineId,
tracks,
}: AwardGovernanceEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, AwardDraft>>({})
const awardTracks = useMemo(
() => tracks.filter((track) => !!track.specialAward),
[tracks]
)
const updateAward = trpc.specialAward.update.useMutation({
onError: (error) => toast.error(error.message),
})
const configureGovernance = trpc.award.configureGovernance.useMutation({
onError: (error) => toast.error(error.message),
})
useEffect(() => {
const nextDrafts: Record<string, AwardDraft> = {}
for (const track of awardTracks) {
const award = track.specialAward
if (!award) continue
nextDrafts[track.id] = {
trackId: track.id,
awardId: award.id,
awardName: award.name,
description: award.description ?? '',
criteriaText: award.criteriaText ?? '',
useAiEligibility: award.useAiEligibility,
decisionMode: track.decisionMode ?? 'JURY_VOTE',
scoringMode: award.scoringMode,
maxRankedPicks: award.maxRankedPicks?.toString() ?? '',
votingStartAt: toDateTimeInputValue(award.votingStartAt),
votingEndAt: toDateTimeInputValue(award.votingEndAt),
}
}
setDrafts(nextDrafts)
}, [awardTracks])
const isSaving = updateAward.isPending || configureGovernance.isPending
const handleSave = async (trackId: string) => {
const draft = drafts[trackId]
if (!draft) return
const votingStartAt = toDateOrUndefined(draft.votingStartAt)
const votingEndAt = toDateOrUndefined(draft.votingEndAt)
if (votingStartAt && votingEndAt && votingEndAt <= votingStartAt) {
toast.error('Voting end must be after voting start')
return
}
const maxRankedPicks = draft.maxRankedPicks
? parseInt(draft.maxRankedPicks, 10)
: undefined
await updateAward.mutateAsync({
id: draft.awardId,
name: draft.awardName.trim(),
description: draft.description.trim() || undefined,
criteriaText: draft.criteriaText.trim() || undefined,
useAiEligibility: draft.useAiEligibility,
scoringMode: draft.scoringMode,
maxRankedPicks,
votingStartAt,
votingEndAt,
})
await configureGovernance.mutateAsync({
trackId: draft.trackId,
decisionMode: draft.decisionMode,
scoringMode: draft.scoringMode,
maxRankedPicks,
votingStartAt,
votingEndAt,
})
await utils.pipeline.getDraft.invalidate({ id: pipelineId })
toast.success('Award governance updated')
}
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Award Governance</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{awardTracks.length === 0 && (
<p className="text-sm text-muted-foreground">
No award tracks in this pipeline.
</p>
)}
{awardTracks.map((track) => {
const draft = drafts[track.id]
if (!draft) return null
return (
<div key={track.id} className="rounded-md border p-3 space-y-3">
<p className="text-sm font-medium">{track.name}</p>
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Award Name</Label>
<Input
value={draft.awardName}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, awardName: e.target.value },
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Decision Mode</Label>
<Select
value={draft.decisionMode}
onValueChange={(value) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
decisionMode: value as AwardDraft['decisionMode'],
},
}))
}
>
<SelectTrigger>
<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>
<div className="grid gap-2 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">Scoring Mode</Label>
<Select
value={draft.scoringMode}
onValueChange={(value) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
scoringMode: value as AwardDraft['scoringMode'],
},
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
<SelectItem value="RANKED">Ranked</SelectItem>
<SelectItem value="SCORED">Scored</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Max Ranked Picks</Label>
<Input
type="number"
min={1}
max={20}
value={draft.maxRankedPicks}
disabled={draft.scoringMode !== 'RANKED'}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
maxRankedPicks: e.target.value,
},
}))
}
/>
</div>
<div className="flex items-end pb-2">
<div className="flex items-center gap-2">
<Switch
checked={draft.useAiEligibility}
onCheckedChange={(checked) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
useAiEligibility: checked,
},
}))
}
/>
<Label className="text-xs">AI Eligibility</Label>
</div>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Voting Start</Label>
<Input
type="datetime-local"
value={draft.votingStartAt}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, votingStartAt: e.target.value },
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Voting End</Label>
<Input
type="datetime-local"
value={draft.votingEndAt}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, votingEndAt: e.target.value },
}))
}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Description</Label>
<Textarea
rows={2}
value={draft.description}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, description: e.target.value },
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Eligibility Criteria</Label>
<Textarea
rows={3}
value={draft.criteriaText}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, criteriaText: e.target.value },
}))
}
/>
</div>
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={() => handleSave(track.id)}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save Award Settings
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,379 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Plus,
Save,
Trash2,
ArrowUp,
ArrowDown,
Loader2,
Play,
} from 'lucide-react'
type FilteringRulesEditorProps = {
stageId: string
}
type RuleDraft = {
id: string
name: string
ruleType: 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING'
priority: number
configText: string
}
const DEFAULT_CONFIG_BY_TYPE: Record<
RuleDraft['ruleType'],
Record<string, unknown>
> = {
FIELD_BASED: {
conditions: [
{
field: 'competitionCategory',
operator: 'equals',
value: 'STARTUP',
},
],
logic: 'AND',
action: 'PASS',
},
DOCUMENT_CHECK: {
requiredFileTypes: ['application/pdf'],
minFileCount: 1,
action: 'REJECT',
},
AI_SCREENING: {
criteriaText:
'Project must clearly demonstrate ocean impact and practical feasibility.',
action: 'FLAG',
},
}
export function FilteringRulesEditor({ stageId }: FilteringRulesEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, RuleDraft>>({})
const { data: rules = [], isLoading } = trpc.filtering.getRules.useQuery({
stageId,
})
const createRule = trpc.filtering.createRule.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
toast.success('Filtering rule created')
},
onError: (error) => toast.error(error.message),
})
const updateRule = trpc.filtering.updateRule.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
toast.success('Filtering rule updated')
},
onError: (error) => toast.error(error.message),
})
const deleteRule = trpc.filtering.deleteRule.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
toast.success('Filtering rule deleted')
},
onError: (error) => toast.error(error.message),
})
const reorderRules = trpc.filtering.reorderRules.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
},
onError: (error) => toast.error(error.message),
})
const executeRules = trpc.filtering.executeRules.useMutation({
onSuccess: (data) => {
toast.success(
`Filtering executed: ${data.passed} passed, ${data.filteredOut} filtered, ${data.flagged} flagged`
)
},
onError: (error) => toast.error(error.message),
})
const orderedRules = useMemo(
() => [...rules].sort((a, b) => a.priority - b.priority),
[rules]
)
useEffect(() => {
const nextDrafts: Record<string, RuleDraft> = {}
for (const rule of orderedRules) {
nextDrafts[rule.id] = {
id: rule.id,
name: rule.name,
ruleType: rule.ruleType,
priority: rule.priority,
configText: JSON.stringify(rule.configJson ?? {}, null, 2),
}
}
setDrafts(nextDrafts)
}, [orderedRules])
const handleCreateRule = async () => {
const priority = orderedRules.length
await createRule.mutateAsync({
stageId,
name: `Rule ${priority + 1}`,
ruleType: 'FIELD_BASED',
priority,
configJson: DEFAULT_CONFIG_BY_TYPE.FIELD_BASED,
})
}
const handleSaveRule = async (ruleId: string) => {
const draft = drafts[ruleId]
if (!draft) return
let parsedConfig: Record<string, unknown>
try {
parsedConfig = JSON.parse(draft.configText) as Record<string, unknown>
} catch {
toast.error('Rule config must be valid JSON')
return
}
await updateRule.mutateAsync({
id: ruleId,
name: draft.name.trim(),
ruleType: draft.ruleType,
priority: draft.priority,
configJson: parsedConfig,
})
}
const handleMoveRule = async (index: number, direction: 'up' | 'down') => {
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= orderedRules.length) return
const reordered = [...orderedRules]
const temp = reordered[index]
reordered[index] = reordered[targetIndex]
reordered[targetIndex] = temp
await reorderRules.mutateAsync({
rules: reordered.map((rule, idx) => ({
id: rule.id,
priority: idx,
})),
})
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Filtering Rules</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Loading rules...
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm">Filtering Rules</CardTitle>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => executeRules.mutate({ stageId })}
disabled={executeRules.isPending}
>
{executeRules.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Play className="mr-1.5 h-3.5 w-3.5" />
)}
Run
</Button>
<Button
type="button"
size="sm"
onClick={handleCreateRule}
disabled={createRule.isPending}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Add Rule
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{orderedRules.length === 0 && (
<p className="text-sm text-muted-foreground">
No filtering rules configured yet.
</p>
)}
{orderedRules.map((rule, index) => {
const draft = drafts[rule.id]
if (!draft) return null
return (
<div key={rule.id} className="rounded-md border p-3 space-y-3">
<div className="grid gap-2 sm:grid-cols-12">
<div className="sm:col-span-5 space-y-1">
<Label className="text-xs">Name</Label>
<Input
value={draft.name}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
name: e.target.value,
},
}))
}
/>
</div>
<div className="sm:col-span-4 space-y-1">
<Label className="text-xs">Rule Type</Label>
<Select
value={draft.ruleType}
onValueChange={(value) => {
const ruleType = value as RuleDraft['ruleType']
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
ruleType,
configText: JSON.stringify(
DEFAULT_CONFIG_BY_TYPE[ruleType],
null,
2
),
},
}))
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FIELD_BASED">Field Based</SelectItem>
<SelectItem value="DOCUMENT_CHECK">Document Check</SelectItem>
<SelectItem value="AI_SCREENING">AI Screening</SelectItem>
</SelectContent>
</Select>
</div>
<div className="sm:col-span-3 space-y-1">
<Label className="text-xs">Priority</Label>
<Input
type="number"
value={draft.priority}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
priority: parseInt(e.target.value, 10) || 0,
},
}))
}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Rule Config (JSON)</Label>
<Textarea
className="font-mono text-xs min-h-28"
value={draft.configText}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
configText: e.target.value,
},
}))
}
/>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleMoveRule(index, 'up')}
disabled={index === 0 || reorderRules.isPending}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleMoveRule(index, 'down')}
disabled={
index === orderedRules.length - 1 || reorderRules.isPending
}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => handleSaveRule(rule.id)}
disabled={updateRule.isPending}
>
{updateRule.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => deleteRule.mutate({ id: rule.id })}
disabled={deleteRule.isPending}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete
</Button>
</div>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -1,276 +1,276 @@
'use client'
import { useRef, useEffect, useState, useCallback } from 'react'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
type StageNode = {
id: string
name: string
stageType: string
sortOrder: number
_count?: { projectStageStates: number }
}
type FlowchartTrack = {
id: string
name: string
kind: string
sortOrder: number
stages: StageNode[]
}
type PipelineFlowchartProps = {
tracks: FlowchartTrack[]
selectedStageId?: string | null
onStageSelect?: (stageId: string) => void
className?: string
compact?: boolean
}
const stageTypeColors: Record<string, { bg: string; border: string; text: string; glow: string }> = {
INTAKE: { bg: '#eff6ff', border: '#93c5fd', text: '#1d4ed8', glow: '#3b82f6' },
FILTER: { bg: '#fffbeb', border: '#fcd34d', text: '#b45309', glow: '#f59e0b' },
EVALUATION: { bg: '#faf5ff', border: '#c084fc', text: '#7e22ce', glow: '#a855f7' },
SELECTION: { bg: '#fff1f2', border: '#fda4af', text: '#be123c', glow: '#f43f5e' },
LIVE_FINAL: { bg: '#ecfdf5', border: '#6ee7b7', text: '#047857', glow: '#10b981' },
RESULTS: { bg: '#ecfeff', border: '#67e8f9', text: '#0e7490', glow: '#06b6d4' },
}
const NODE_WIDTH = 140
const NODE_HEIGHT = 70
const NODE_GAP = 32
const ARROW_SIZE = 6
const TRACK_LABEL_HEIGHT = 28
const TRACK_GAP = 20
export function PipelineFlowchart({
tracks,
selectedStageId,
onStageSelect,
className,
compact = false,
}: PipelineFlowchartProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [hoveredStageId, setHoveredStageId] = useState<string | null>(null)
const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder)
// Calculate dimensions
const nodeW = compact ? 100 : NODE_WIDTH
const nodeH = compact ? 50 : NODE_HEIGHT
const gap = compact ? 20 : NODE_GAP
const maxStages = Math.max(...sortedTracks.map((t) => t.stages.length), 1)
const totalWidth = maxStages * nodeW + (maxStages - 1) * gap + 40
const totalHeight =
sortedTracks.length * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) - TRACK_GAP + 20
const getNodePosition = useCallback(
(trackIndex: number, stageIndex: number) => {
const x = 20 + stageIndex * (nodeW + gap)
const y = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) + TRACK_LABEL_HEIGHT
return { x, y }
},
[nodeW, nodeH, gap]
)
return (
<div
ref={containerRef}
className={cn('relative rounded-lg border bg-card', className)}
>
<div className="overflow-x-auto">
<svg
width={totalWidth}
height={totalHeight}
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
className="min-w-full"
>
<defs>
<marker
id="arrowhead"
markerWidth={ARROW_SIZE}
markerHeight={ARROW_SIZE}
refX={ARROW_SIZE}
refY={ARROW_SIZE / 2}
orient="auto"
>
<path
d={`M 0 0 L ${ARROW_SIZE} ${ARROW_SIZE / 2} L 0 ${ARROW_SIZE} Z`}
fill="#94a3b8"
/>
</marker>
{/* Glow filter for selected node */}
<filter id="selectedGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#3b82f6" floodOpacity="0.3" result="color" />
<feComposite in="color" in2="blur" operator="in" result="glow" />
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{sortedTracks.map((track, trackIndex) => {
const sortedStages = [...track.stages].sort(
(a, b) => a.sortOrder - b.sortOrder
)
const trackY = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP)
return (
<g key={track.id}>
{/* Track label */}
<text
x={20}
y={trackY + 14}
className="fill-muted-foreground text-[11px] font-medium"
style={{ fontFamily: 'inherit' }}
>
{track.name}
{track.kind !== 'MAIN' && ` (${track.kind})`}
</text>
{/* Arrows between stages */}
{sortedStages.map((stage, stageIndex) => {
if (stageIndex === 0) return null
const from = getNodePosition(trackIndex, stageIndex - 1)
const to = getNodePosition(trackIndex, stageIndex)
const arrowY = from.y + nodeH / 2
return (
<line
key={`arrow-${stage.id}`}
x1={from.x + nodeW}
y1={arrowY}
x2={to.x - 2}
y2={arrowY}
stroke="#94a3b8"
strokeWidth={1.5}
markerEnd="url(#arrowhead)"
/>
)
})}
{/* Stage nodes */}
{sortedStages.map((stage, stageIndex) => {
const pos = getNodePosition(trackIndex, stageIndex)
const isSelected = selectedStageId === stage.id
const isHovered = hoveredStageId === stage.id
const colors = stageTypeColors[stage.stageType] ?? {
bg: '#f8fafc',
border: '#cbd5e1',
text: '#475569',
glow: '#64748b',
}
const projectCount = stage._count?.projectStageStates ?? 0
return (
<g
key={stage.id}
onClick={() => onStageSelect?.(stage.id)}
onMouseEnter={() => setHoveredStageId(stage.id)}
onMouseLeave={() => setHoveredStageId(null)}
className={cn(onStageSelect && 'cursor-pointer')}
filter={isSelected ? 'url(#selectedGlow)' : undefined}
>
{/* Selection ring */}
{isSelected && (
<motion.rect
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
x={pos.x - 3}
y={pos.y - 3}
width={nodeW + 6}
height={nodeH + 6}
rx={10}
fill="none"
stroke={colors.glow}
strokeWidth={2}
strokeDasharray="none"
/>
)}
{/* Node background */}
<rect
x={pos.x}
y={pos.y}
width={nodeW}
height={nodeH}
rx={8}
fill={colors.bg}
stroke={isSelected ? colors.glow : colors.border}
strokeWidth={isSelected ? 2 : 1}
style={{
transition: 'stroke 0.15s, stroke-width 0.15s',
transform: isHovered && !isSelected ? 'scale(1.02)' : undefined,
transformOrigin: `${pos.x + nodeW / 2}px ${pos.y + nodeH / 2}px`,
}}
/>
{/* Stage name */}
<text
x={pos.x + nodeW / 2}
y={pos.y + (compact ? 20 : 24)}
textAnchor="middle"
fill={colors.text}
className={cn(compact ? 'text-[10px]' : 'text-xs', 'font-medium')}
style={{ fontFamily: 'inherit' }}
>
{stage.name.length > (compact ? 12 : 16)
? stage.name.slice(0, compact ? 10 : 14) + '...'
: stage.name}
</text>
{/* Type badge */}
<text
x={pos.x + nodeW / 2}
y={pos.y + (compact ? 34 : 40)}
textAnchor="middle"
fill={colors.text}
className="text-[9px]"
style={{ fontFamily: 'inherit', opacity: 0.7 }}
>
{stage.stageType.replace('_', ' ')}
</text>
{/* Project count */}
{!compact && projectCount > 0 && (
<>
<rect
x={pos.x + nodeW / 2 - 14}
y={pos.y + nodeH - 18}
width={28}
height={14}
rx={7}
fill={colors.border}
opacity={0.3}
/>
<text
x={pos.x + nodeW / 2}
y={pos.y + nodeH - 8}
textAnchor="middle"
fill={colors.text}
className="text-[9px] font-medium"
style={{ fontFamily: 'inherit' }}
>
{projectCount}
</text>
</>
)}
</g>
)
})}
</g>
)
})}
</svg>
</div>
{/* Scroll hint gradient for mobile */}
{totalWidth > 400 && (
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-card to-transparent pointer-events-none sm:hidden" />
)}
</div>
)
}
'use client'
import { useRef, useEffect, useState, useCallback } from 'react'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
type StageNode = {
id: string
name: string
stageType: string
sortOrder: number
_count?: { projectStageStates: number }
}
type FlowchartTrack = {
id: string
name: string
kind: string
sortOrder: number
stages: StageNode[]
}
type PipelineFlowchartProps = {
tracks: FlowchartTrack[]
selectedStageId?: string | null
onStageSelect?: (stageId: string) => void
className?: string
compact?: boolean
}
const stageTypeColors: Record<string, { bg: string; border: string; text: string; glow: string }> = {
INTAKE: { bg: '#eff6ff', border: '#93c5fd', text: '#1d4ed8', glow: '#3b82f6' },
FILTER: { bg: '#fffbeb', border: '#fcd34d', text: '#b45309', glow: '#f59e0b' },
EVALUATION: { bg: '#faf5ff', border: '#c084fc', text: '#7e22ce', glow: '#a855f7' },
SELECTION: { bg: '#fff1f2', border: '#fda4af', text: '#be123c', glow: '#f43f5e' },
LIVE_FINAL: { bg: '#ecfdf5', border: '#6ee7b7', text: '#047857', glow: '#10b981' },
RESULTS: { bg: '#ecfeff', border: '#67e8f9', text: '#0e7490', glow: '#06b6d4' },
}
const NODE_WIDTH = 140
const NODE_HEIGHT = 70
const NODE_GAP = 32
const ARROW_SIZE = 6
const TRACK_LABEL_HEIGHT = 28
const TRACK_GAP = 20
export function PipelineFlowchart({
tracks,
selectedStageId,
onStageSelect,
className,
compact = false,
}: PipelineFlowchartProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [hoveredStageId, setHoveredStageId] = useState<string | null>(null)
const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder)
// Calculate dimensions
const nodeW = compact ? 100 : NODE_WIDTH
const nodeH = compact ? 50 : NODE_HEIGHT
const gap = compact ? 20 : NODE_GAP
const maxStages = Math.max(...sortedTracks.map((t) => t.stages.length), 1)
const totalWidth = maxStages * nodeW + (maxStages - 1) * gap + 40
const totalHeight =
sortedTracks.length * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) - TRACK_GAP + 20
const getNodePosition = useCallback(
(trackIndex: number, stageIndex: number) => {
const x = 20 + stageIndex * (nodeW + gap)
const y = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) + TRACK_LABEL_HEIGHT
return { x, y }
},
[nodeW, nodeH, gap]
)
return (
<div
ref={containerRef}
className={cn('relative rounded-lg border bg-card', className)}
>
<div className="overflow-x-auto">
<svg
width={totalWidth}
height={totalHeight}
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
className="min-w-full"
>
<defs>
<marker
id="arrowhead"
markerWidth={ARROW_SIZE}
markerHeight={ARROW_SIZE}
refX={ARROW_SIZE}
refY={ARROW_SIZE / 2}
orient="auto"
>
<path
d={`M 0 0 L ${ARROW_SIZE} ${ARROW_SIZE / 2} L 0 ${ARROW_SIZE} Z`}
fill="#94a3b8"
/>
</marker>
{/* Glow filter for selected node */}
<filter id="selectedGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#3b82f6" floodOpacity="0.3" result="color" />
<feComposite in="color" in2="blur" operator="in" result="glow" />
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{sortedTracks.map((track, trackIndex) => {
const sortedStages = [...track.stages].sort(
(a, b) => a.sortOrder - b.sortOrder
)
const trackY = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP)
return (
<g key={track.id}>
{/* Track label */}
<text
x={20}
y={trackY + 14}
className="fill-muted-foreground text-[11px] font-medium"
style={{ fontFamily: 'inherit' }}
>
{track.name}
{track.kind !== 'MAIN' && ` (${track.kind})`}
</text>
{/* Arrows between stages */}
{sortedStages.map((stage, stageIndex) => {
if (stageIndex === 0) return null
const from = getNodePosition(trackIndex, stageIndex - 1)
const to = getNodePosition(trackIndex, stageIndex)
const arrowY = from.y + nodeH / 2
return (
<line
key={`arrow-${stage.id}`}
x1={from.x + nodeW}
y1={arrowY}
x2={to.x - 2}
y2={arrowY}
stroke="#94a3b8"
strokeWidth={1.5}
markerEnd="url(#arrowhead)"
/>
)
})}
{/* Stage nodes */}
{sortedStages.map((stage, stageIndex) => {
const pos = getNodePosition(trackIndex, stageIndex)
const isSelected = selectedStageId === stage.id
const isHovered = hoveredStageId === stage.id
const colors = stageTypeColors[stage.stageType] ?? {
bg: '#f8fafc',
border: '#cbd5e1',
text: '#475569',
glow: '#64748b',
}
const projectCount = stage._count?.projectStageStates ?? 0
return (
<g
key={stage.id}
onClick={() => onStageSelect?.(stage.id)}
onMouseEnter={() => setHoveredStageId(stage.id)}
onMouseLeave={() => setHoveredStageId(null)}
className={cn(onStageSelect && 'cursor-pointer')}
filter={isSelected ? 'url(#selectedGlow)' : undefined}
>
{/* Selection ring */}
{isSelected && (
<motion.rect
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
x={pos.x - 3}
y={pos.y - 3}
width={nodeW + 6}
height={nodeH + 6}
rx={10}
fill="none"
stroke={colors.glow}
strokeWidth={2}
strokeDasharray="none"
/>
)}
{/* Node background */}
<rect
x={pos.x}
y={pos.y}
width={nodeW}
height={nodeH}
rx={8}
fill={colors.bg}
stroke={isSelected ? colors.glow : colors.border}
strokeWidth={isSelected ? 2 : 1}
style={{
transition: 'stroke 0.15s, stroke-width 0.15s',
transform: isHovered && !isSelected ? 'scale(1.02)' : undefined,
transformOrigin: `${pos.x + nodeW / 2}px ${pos.y + nodeH / 2}px`,
}}
/>
{/* Stage name */}
<text
x={pos.x + nodeW / 2}
y={pos.y + (compact ? 20 : 24)}
textAnchor="middle"
fill={colors.text}
className={cn(compact ? 'text-[10px]' : 'text-xs', 'font-medium')}
style={{ fontFamily: 'inherit' }}
>
{stage.name.length > (compact ? 12 : 16)
? stage.name.slice(0, compact ? 10 : 14) + '...'
: stage.name}
</text>
{/* Type badge */}
<text
x={pos.x + nodeW / 2}
y={pos.y + (compact ? 34 : 40)}
textAnchor="middle"
fill={colors.text}
className="text-[9px]"
style={{ fontFamily: 'inherit', opacity: 0.7 }}
>
{stage.stageType.replace('_', ' ')}
</text>
{/* Project count */}
{!compact && projectCount > 0 && (
<>
<rect
x={pos.x + nodeW / 2 - 14}
y={pos.y + nodeH - 18}
width={28}
height={14}
rx={7}
fill={colors.border}
opacity={0.3}
/>
<text
x={pos.x + nodeW / 2}
y={pos.y + nodeH - 8}
textAnchor="middle"
fill={colors.text}
className="text-[9px] font-medium"
style={{ fontFamily: 'inherit' }}
>
{projectCount}
</text>
</>
)}
</g>
)
})}
</g>
)
})}
</svg>
</div>
{/* Scroll hint gradient for mobile */}
{totalWidth > 400 && (
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-card to-transparent pointer-events-none sm:hidden" />
)}
</div>
)
}

View File

@@ -1,121 +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>
)
}
'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,464 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Plus,
Save,
Trash2,
ArrowUp,
ArrowDown,
Loader2,
Power,
PowerOff,
} from 'lucide-react'
type StageLite = {
id: string
name: string
sortOrder: number
}
type TrackLite = {
id: string
name: string
stages: StageLite[]
}
type RoutingRulesEditorProps = {
pipelineId: string
tracks: TrackLite[]
}
type RuleDraft = {
id: string
name: string
scope: 'global' | 'track' | 'stage'
sourceTrackId: string | null
destinationTrackId: string
destinationStageId: string | null
priority: number
isActive: boolean
predicateText: string
}
const DEFAULT_PREDICATE = {
field: 'competitionCategory',
operator: 'equals',
value: 'STARTUP',
}
export function RoutingRulesEditor({
pipelineId,
tracks,
}: RoutingRulesEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, RuleDraft>>({})
const { data: rules = [], isLoading } = trpc.routing.listRules.useQuery({
pipelineId,
})
const upsertRule = trpc.routing.upsertRule.useMutation({
onSuccess: async () => {
await utils.routing.listRules.invalidate({ pipelineId })
toast.success('Routing rule saved')
},
onError: (error) => toast.error(error.message),
})
const toggleRule = trpc.routing.toggleRule.useMutation({
onSuccess: async () => {
await utils.routing.listRules.invalidate({ pipelineId })
},
onError: (error) => toast.error(error.message),
})
const deleteRule = trpc.routing.deleteRule.useMutation({
onSuccess: async () => {
await utils.routing.listRules.invalidate({ pipelineId })
toast.success('Routing rule deleted')
},
onError: (error) => toast.error(error.message),
})
const reorderRules = trpc.routing.reorderRules.useMutation({
onSuccess: async () => {
await utils.routing.listRules.invalidate({ pipelineId })
},
onError: (error) => toast.error(error.message),
})
const orderedRules = useMemo(
() => [...rules].sort((a, b) => b.priority - a.priority),
[rules]
)
useEffect(() => {
const nextDrafts: Record<string, RuleDraft> = {}
for (const rule of orderedRules) {
nextDrafts[rule.id] = {
id: rule.id,
name: rule.name,
scope: rule.scope as RuleDraft['scope'],
sourceTrackId: rule.sourceTrackId ?? null,
destinationTrackId: rule.destinationTrackId,
destinationStageId: rule.destinationStageId ?? null,
priority: rule.priority,
isActive: rule.isActive,
predicateText: JSON.stringify(rule.predicateJson ?? {}, null, 2),
}
}
setDrafts(nextDrafts)
}, [orderedRules])
const handleCreateRule = async () => {
const defaultTrack = tracks[0]
if (!defaultTrack) {
toast.error('Create a track before adding routing rules')
return
}
await upsertRule.mutateAsync({
pipelineId,
name: `Routing Rule ${orderedRules.length + 1}`,
scope: 'global',
sourceTrackId: null,
destinationTrackId: defaultTrack.id,
destinationStageId: defaultTrack.stages[0]?.id ?? null,
priority: orderedRules.length + 1,
isActive: true,
predicateJson: DEFAULT_PREDICATE,
})
}
const handleSaveRule = async (id: string) => {
const draft = drafts[id]
if (!draft) return
let predicateJson: Record<string, unknown>
try {
predicateJson = JSON.parse(draft.predicateText) as Record<string, unknown>
} catch {
toast.error('Predicate must be valid JSON')
return
}
await upsertRule.mutateAsync({
id: draft.id,
pipelineId,
name: draft.name.trim(),
scope: draft.scope,
sourceTrackId: draft.sourceTrackId,
destinationTrackId: draft.destinationTrackId,
destinationStageId: draft.destinationStageId,
priority: draft.priority,
isActive: draft.isActive,
predicateJson,
})
}
const handleMoveRule = async (index: number, direction: 'up' | 'down') => {
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= orderedRules.length) return
const reordered = [...orderedRules]
const temp = reordered[index]
reordered[index] = reordered[targetIndex]
reordered[targetIndex] = temp
await reorderRules.mutateAsync({
pipelineId,
orderedIds: reordered.map((rule) => rule.id),
})
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Routing Rules</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Loading routing rules...
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm">Routing Rules</CardTitle>
<Button type="button" size="sm" onClick={handleCreateRule}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Add Rule
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{orderedRules.length === 0 && (
<p className="text-sm text-muted-foreground">
No routing rules configured yet.
</p>
)}
{orderedRules.map((rule, index) => {
const draft = drafts[rule.id]
if (!draft) return null
const destinationTrack = tracks.find(
(track) => track.id === draft.destinationTrackId
)
return (
<div key={rule.id} className="rounded-md border p-3 space-y-3">
<div className="grid gap-2 sm:grid-cols-12">
<div className="sm:col-span-5 space-y-1">
<Label className="text-xs">Rule Name</Label>
<Input
value={draft.name}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: { ...draft, name: e.target.value },
}))
}
/>
</div>
<div className="sm:col-span-4 space-y-1">
<Label className="text-xs">Scope</Label>
<Select
value={draft.scope}
onValueChange={(value) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
scope: value as RuleDraft['scope'],
},
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global</SelectItem>
<SelectItem value="track">Track</SelectItem>
<SelectItem value="stage">Stage</SelectItem>
</SelectContent>
</Select>
</div>
<div className="sm:col-span-3 space-y-1">
<Label className="text-xs">Priority</Label>
<Input
type="number"
value={draft.priority}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
priority: parseInt(e.target.value, 10) || 0,
},
}))
}
/>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">Source Track</Label>
<Select
value={draft.sourceTrackId ?? '__none__'}
onValueChange={(value) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
sourceTrackId: value === '__none__' ? null : value,
},
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Any Track</SelectItem>
{tracks.map((track) => (
<SelectItem key={track.id} value={track.id}>
{track.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Destination Track</Label>
<Select
value={draft.destinationTrackId}
onValueChange={(value) => {
const track = tracks.find((t) => t.id === value)
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
destinationTrackId: value,
destinationStageId: track?.stages[0]?.id ?? null,
},
}))
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{tracks.map((track) => (
<SelectItem key={track.id} value={track.id}>
{track.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Destination Stage</Label>
<Select
value={draft.destinationStageId ?? '__none__'}
onValueChange={(value) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
destinationStageId: value === '__none__' ? null : value,
},
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Track Start</SelectItem>
{(destinationTrack?.stages ?? [])
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Predicate (JSON)</Label>
<Textarea
className="font-mono text-xs min-h-24"
value={draft.predicateText}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: { ...draft, predicateText: e.target.value },
}))
}
/>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleMoveRule(index, 'up')}
disabled={index === 0 || reorderRules.isPending}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleMoveRule(index, 'down')}
disabled={
index === orderedRules.length - 1 || reorderRules.isPending
}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
toggleRule.mutate({
id: rule.id,
isActive: !draft.isActive,
})
}
disabled={toggleRule.isPending}
>
{draft.isActive ? (
<Power className="mr-1.5 h-3.5 w-3.5" />
) : (
<PowerOff className="mr-1.5 h-3.5 w-3.5" />
)}
{draft.isActive ? 'Disable' : 'Enable'}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => handleSaveRule(rule.id)}
disabled={upsertRule.isPending}
>
{upsertRule.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => deleteRule.mutate({ id: rule.id })}
disabled={deleteRule.isPending}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete
</Button>
</div>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -1,148 +1,148 @@
'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 { InfoTooltip } from '@/components/ui/info-tooltip'
import type { EvaluationConfig } from '@/types/pipeline-wizard'
type AssignmentSectionProps = {
config: EvaluationConfig
onChange: (config: EvaluationConfig) => void
isActive?: boolean
}
export function AssignmentSection({ config, onChange, isActive }: 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">
<div className="flex items-center gap-1.5">
<Label>Required Reviews per Project</Label>
<InfoTooltip content="Number of independent jury evaluations needed per project before it can be decided." />
</div>
<Input
type="number"
min={1}
max={20}
value={config.requiredReviews ?? 3}
disabled={isActive}
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">
<div className="flex items-center gap-1.5">
<Label>Max Load per Juror</Label>
<InfoTooltip content="Maximum number of projects a single juror can be assigned in this stage." />
</div>
<Input
type="number"
min={1}
max={100}
value={config.maxLoadPerJuror ?? 20}
disabled={isActive}
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">
<div className="flex items-center gap-1.5">
<Label>Min Load per Juror</Label>
<InfoTooltip content="Minimum target assignments per juror. The system prioritizes jurors below this threshold." />
</div>
<Input
type="number"
min={0}
max={50}
value={config.minLoadPerJuror ?? 5}
disabled={isActive}
onChange={(e) =>
updateConfig({ minLoadPerJuror: parseInt(e.target.value) || 5 })
}
/>
<p className="text-xs text-muted-foreground">
Target minimum projects per juror
</p>
</div>
</div>
{(config.minLoadPerJuror ?? 0) > (config.maxLoadPerJuror ?? 20) && (
<p className="text-sm text-destructive">
Min load per juror cannot exceed max load per juror.
</p>
)}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Availability Weighting</Label>
<InfoTooltip content="When enabled, jurors who are available during the voting window are prioritized in assignment." />
</div>
<p className="text-xs text-muted-foreground">
Factor in juror availability when assigning projects
</p>
</div>
<Switch
checked={config.availabilityWeighting ?? true}
onCheckedChange={(checked) =>
updateConfig({ availabilityWeighting: checked })
}
disabled={isActive}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Overflow Policy</Label>
<InfoTooltip content="'Queue' holds excess projects, 'Expand Pool' invites more jurors, 'Reduce Reviews' lowers the required review count." />
</div>
<Select
value={config.overflowPolicy ?? 'queue'}
onValueChange={(value) =>
updateConfig({
overflowPolicy: value as EvaluationConfig['overflowPolicy'],
})
}
disabled={isActive}
>
<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>
)
}
'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 { InfoTooltip } from '@/components/ui/info-tooltip'
import type { EvaluationConfig } from '@/types/pipeline-wizard'
type AssignmentSectionProps = {
config: EvaluationConfig
onChange: (config: EvaluationConfig) => void
isActive?: boolean
}
export function AssignmentSection({ config, onChange, isActive }: 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">
<div className="flex items-center gap-1.5">
<Label>Required Reviews per Project</Label>
<InfoTooltip content="Number of independent jury evaluations needed per project before it can be decided." />
</div>
<Input
type="number"
min={1}
max={20}
value={config.requiredReviews ?? 3}
disabled={isActive}
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">
<div className="flex items-center gap-1.5">
<Label>Max Load per Juror</Label>
<InfoTooltip content="Maximum number of projects a single juror can be assigned in this stage." />
</div>
<Input
type="number"
min={1}
max={100}
value={config.maxLoadPerJuror ?? 20}
disabled={isActive}
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">
<div className="flex items-center gap-1.5">
<Label>Min Load per Juror</Label>
<InfoTooltip content="Minimum target assignments per juror. The system prioritizes jurors below this threshold." />
</div>
<Input
type="number"
min={0}
max={50}
value={config.minLoadPerJuror ?? 5}
disabled={isActive}
onChange={(e) =>
updateConfig({ minLoadPerJuror: parseInt(e.target.value) || 5 })
}
/>
<p className="text-xs text-muted-foreground">
Target minimum projects per juror
</p>
</div>
</div>
{(config.minLoadPerJuror ?? 0) > (config.maxLoadPerJuror ?? 20) && (
<p className="text-sm text-destructive">
Min load per juror cannot exceed max load per juror.
</p>
)}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Availability Weighting</Label>
<InfoTooltip content="When enabled, jurors who are available during the voting window are prioritized in assignment." />
</div>
<p className="text-xs text-muted-foreground">
Factor in juror availability when assigning projects
</p>
</div>
<Switch
checked={config.availabilityWeighting ?? true}
onCheckedChange={(checked) =>
updateConfig({ availabilityWeighting: checked })
}
disabled={isActive}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Overflow Policy</Label>
<InfoTooltip content="'Queue' holds excess projects, 'Expand Pool' invites more jurors, 'Reduce Reviews' lowers the required review count." />
</div>
<Select
value={config.overflowPolicy ?? 'queue'}
onValueChange={(value) =>
updateConfig({
overflowPolicy: value as EvaluationConfig['overflowPolicy'],
})
}
disabled={isActive}
>
<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

@@ -1,257 +1,257 @@
'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 { InfoTooltip } from '@/components/ui/info-tooltip'
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
isActive?: boolean
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function AwardsSection({ tracks, onChange, isActive }: 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} disabled={isActive}>
<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"
disabled={isActive}
>
<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}
disabled={isActive}
onChange={(e) => {
const name = e.target.value
updateAward(index, {
name,
slug: slugify(name),
awardConfig: {
...track.awardConfig,
name,
},
})
}}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Routing Mode</Label>
<InfoTooltip content="Parallel: projects compete for all awards simultaneously. Exclusive: each project can only win one award. Post-main: awards are decided after the main track completes." />
</div>
<Select
value={track.routingModeDefault ?? 'PARALLEL'}
onValueChange={(value) =>
updateAward(index, {
routingModeDefault: value as RoutingMode,
})
}
disabled={isActive}
>
<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">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Decision Mode</Label>
<InfoTooltip content="How the winner is determined for this award track." />
</div>
<Select
value={track.decisionMode ?? 'JURY_VOTE'}
onValueChange={(value) =>
updateAward(index, { decisionMode: value as DecisionMode })
}
disabled={isActive}
>
<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">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Scoring Mode</Label>
<InfoTooltip content="The method used to aggregate scores for this award." />
</div>
<Select
value={track.awardConfig?.scoringMode ?? 'PICK_WINNER'}
onValueChange={(value) =>
updateAward(index, {
awardConfig: {
...track.awardConfig!,
scoringMode: value as AwardScoringMode,
},
})
}
disabled={isActive}
>
<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>
)
}
'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 { InfoTooltip } from '@/components/ui/info-tooltip'
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
isActive?: boolean
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function AwardsSection({ tracks, onChange, isActive }: 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} disabled={isActive}>
<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"
disabled={isActive}
>
<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}
disabled={isActive}
onChange={(e) => {
const name = e.target.value
updateAward(index, {
name,
slug: slugify(name),
awardConfig: {
...track.awardConfig,
name,
},
})
}}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Routing Mode</Label>
<InfoTooltip content="Parallel: projects compete for all awards simultaneously. Exclusive: each project can only win one award. Post-main: awards are decided after the main track completes." />
</div>
<Select
value={track.routingModeDefault ?? 'PARALLEL'}
onValueChange={(value) =>
updateAward(index, {
routingModeDefault: value as RoutingMode,
})
}
disabled={isActive}
>
<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">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Decision Mode</Label>
<InfoTooltip content="How the winner is determined for this award track." />
</div>
<Select
value={track.decisionMode ?? 'JURY_VOTE'}
onValueChange={(value) =>
updateAward(index, { decisionMode: value as DecisionMode })
}
disabled={isActive}
>
<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">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Scoring Mode</Label>
<InfoTooltip content="The method used to aggregate scores for this award." />
</div>
<Select
value={track.awardConfig?.scoringMode ?? 'PICK_WINNER'}
onValueChange={(value) =>
updateAward(index, {
awardConfig: {
...track.awardConfig!,
scoringMode: value as AwardScoringMode,
},
})
}
disabled={isActive}
>
<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

@@ -1,99 +1,99 @@
'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 { InfoTooltip } from '@/components/ui/info-tooltip'
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">
<div className="flex items-center gap-1.5">
<Label htmlFor="pipeline-slug">Slug</Label>
<InfoTooltip content="URL-friendly identifier. Cannot be changed after the pipeline is activated." />
</div>
<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">
<div className="flex items-center gap-1.5">
<Label htmlFor="pipeline-program">Program</Label>
<InfoTooltip content="The program edition this pipeline belongs to. Each program can have multiple pipelines." />
</div>
<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>
)
}
'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 { InfoTooltip } from '@/components/ui/info-tooltip'
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">
<div className="flex items-center gap-1.5">
<Label htmlFor="pipeline-slug">Slug</Label>
<InfoTooltip content="URL-friendly identifier. Cannot be changed after the pipeline is activated." />
</div>
<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">
<div className="flex items-center gap-1.5">
<Label htmlFor="pipeline-program">Program</Label>
<InfoTooltip content="The program edition this pipeline belongs to. Each program can have multiple pipelines." />
</div>
<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

@@ -1,479 +1,479 @@
'use client'
import { useState } from 'react'
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 { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from '@/components/ui/collapsible'
import { Plus, Trash2, ChevronDown, Info, Brain, Shield } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { FilterConfig, FilterRuleConfig } from '@/types/pipeline-wizard'
// ─── Known Fields for Eligibility Rules ──────────────────────────────────────
type KnownField = {
value: string
label: string
operators: string[]
valueType: 'select' | 'text' | 'number' | 'boolean'
placeholder?: string
}
const KNOWN_FIELDS: KnownField[] = [
{ value: 'competitionCategory', label: 'Category', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. STARTUP' },
{ value: 'oceanIssue', label: 'Ocean Issue', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. Pollution' },
{ value: 'country', label: 'Country', operators: ['is', 'is_not', 'is_one_of'], valueType: 'text', placeholder: 'e.g. France' },
{ value: 'geographicZone', label: 'Region', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. Mediterranean' },
{ value: 'foundedAt', label: 'Founded Year', operators: ['after', 'before'], valueType: 'number', placeholder: 'e.g. 2020' },
{ value: 'description', label: 'Has Description', operators: ['exists', 'min_length'], valueType: 'number', placeholder: 'Min chars' },
{ value: 'files', label: 'File Count', operators: ['greaterThan', 'lessThan'], valueType: 'number', placeholder: 'e.g. 1' },
{ value: 'wantsMentorship', label: 'Wants Mentorship', operators: ['equals'], valueType: 'boolean' },
]
const OPERATOR_LABELS: Record<string, string> = {
is: 'is',
is_not: 'is not',
is_one_of: 'is one of',
after: 'after',
before: 'before',
exists: 'exists',
min_length: 'min length',
greaterThan: 'greater than',
lessThan: 'less than',
equals: 'equals',
}
// ─── Human-readable preview for a rule ───────────────────────────────────────
function getRulePreview(rule: FilterRuleConfig): string {
const field = KNOWN_FIELDS.find((f) => f.value === rule.field)
const fieldLabel = field?.label ?? rule.field
const opLabel = OPERATOR_LABELS[rule.operator] ?? rule.operator
if (rule.operator === 'exists') {
return `Projects where ${fieldLabel} exists will pass`
}
const valueStr = typeof rule.value === 'boolean'
? (rule.value ? 'Yes' : 'No')
: String(rule.value)
return `Projects where ${fieldLabel} ${opLabel} ${valueStr} will pass`
}
// ─── AI Screening: Fields the AI Sees ────────────────────────────────────────
const AI_VISIBLE_FIELDS = [
'Project title',
'Description',
'Competition category',
'Ocean issue',
'Country & region',
'Tags',
'Founded year',
'Team size',
'File count',
]
// ─── Props ───────────────────────────────────────────────────────────────────
type FilteringSectionProps = {
config: FilterConfig
onChange: (config: FilterConfig) => void
isActive?: boolean
}
// ─── Component ───────────────────────────────────────────────────────────────
export function FilteringSection({ config, onChange, isActive }: FilteringSectionProps) {
const [rulesOpen, setRulesOpen] = useState(false)
const [aiFieldsOpen, setAiFieldsOpen] = useState(false)
const updateConfig = (updates: Partial<FilterConfig>) => {
onChange({ ...config, ...updates })
}
const rules = config.rules ?? []
const aiCriteriaText = config.aiCriteriaText ?? ''
const thresholds = config.aiConfidenceThresholds ?? { high: 0.85, medium: 0.6, low: 0.4 }
const updateRule = (index: number, updates: Partial<FilterRuleConfig>) => {
const updated = [...rules]
updated[index] = { ...updated[index], ...updates }
onChange({ ...config, rules: updated })
}
const addRule = () => {
onChange({
...config,
rules: [
...rules,
{ field: '', operator: 'is', value: '', weight: 1 },
],
})
}
const removeRule = (index: number) => {
onChange({ ...config, rules: rules.filter((_, i) => i !== index) })
}
const getFieldConfig = (fieldValue: string): KnownField | undefined => {
return KNOWN_FIELDS.find((f) => f.value === fieldValue)
}
const highPct = Math.round(thresholds.high * 100)
const medPct = Math.round(thresholds.medium * 100)
return (
<div className="space-y-6">
{/* ── AI Screening (Primary) ────────────────────────────────────── */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Brain className="h-4 w-4 text-primary" />
<Label>AI Screening</Label>
<InfoTooltip content="Uses AI to evaluate projects against your criteria in natural language. Results are suggestions, not final decisions." />
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Use AI to evaluate projects against your screening criteria
</p>
</div>
<Switch
checked={config.aiRubricEnabled}
onCheckedChange={(checked) => updateConfig({ aiRubricEnabled: checked })}
disabled={isActive}
/>
</div>
{config.aiRubricEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
{/* Criteria Textarea (THE KEY MISSING PIECE) */}
<div className="space-y-2">
<Label className="text-sm font-medium">Screening Criteria</Label>
<p className="text-xs text-muted-foreground">
Describe what makes a project eligible or ineligible in natural language.
The AI will evaluate each project against these criteria.
</p>
<Textarea
value={aiCriteriaText}
onChange={(e) => updateConfig({ aiCriteriaText: e.target.value })}
placeholder="e.g., Projects must demonstrate a clear ocean conservation impact. Reject projects that are purely commercial with no environmental benefit. Flag projects with vague descriptions for manual review."
rows={5}
className="resize-y"
disabled={isActive}
/>
{aiCriteriaText.length > 0 && (
<p className="text-xs text-muted-foreground text-right">
{aiCriteriaText.length} characters
</p>
)}
</div>
{/* "What the AI sees" Info Card */}
<Collapsible open={aiFieldsOpen} onOpenChange={setAiFieldsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
>
<Info className="h-3.5 w-3.5" />
<span>What the AI sees</span>
<ChevronDown className={`h-3.5 w-3.5 ml-auto transition-transform ${aiFieldsOpen ? 'rotate-180' : ''}`} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<Card className="mt-2 bg-muted/50 border-muted">
<CardContent className="pt-3 pb-3 px-4">
<p className="text-xs text-muted-foreground mb-2">
All data is anonymized before being sent to the AI. Only these fields are included:
</p>
<ul className="grid grid-cols-2 sm:grid-cols-3 gap-1">
{AI_VISIBLE_FIELDS.map((field) => (
<li key={field} className="text-xs text-muted-foreground flex items-center gap-1">
<span className="h-1 w-1 rounded-full bg-muted-foreground/50 shrink-0" />
{field}
</li>
))}
</ul>
<p className="text-xs text-muted-foreground/70 mt-2 italic">
No personal identifiers (names, emails, etc.) are sent to the AI.
</p>
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
{/* Confidence Thresholds */}
<div className="space-y-3">
<Label className="text-sm font-medium">Confidence Thresholds</Label>
<p className="text-xs text-muted-foreground">
Control how the AI's confidence score maps to outcomes.
</p>
{/* Visual range preview */}
<div className="flex items-center gap-1 text-[10px] font-medium">
<div className="flex-1 bg-emerald-100 dark:bg-emerald-950 border border-emerald-300 dark:border-emerald-800 rounded-l px-2 py-1 text-center text-emerald-700 dark:text-emerald-400">
Auto-approve above {highPct}%
</div>
<div className="flex-1 bg-amber-100 dark:bg-amber-950 border border-amber-300 dark:border-amber-800 px-2 py-1 text-center text-amber-700 dark:text-amber-400">
Review {medPct}%{'\u2013'}{highPct}%
</div>
<div className="flex-1 bg-red-100 dark:bg-red-950 border border-red-300 dark:border-red-800 rounded-r px-2 py-1 text-center text-red-700 dark:text-red-400">
Auto-reject below {medPct}%
</div>
</div>
{/* High threshold slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-emerald-500 shrink-0" />
<Label className="text-xs">Auto-approve threshold</Label>
</div>
<span className="text-xs font-mono font-medium">{highPct}%</span>
</div>
<Slider
value={[highPct]}
onValueChange={([v]) =>
updateConfig({
aiConfidenceThresholds: {
...thresholds,
high: v / 100,
},
})
}
min={50}
max={100}
step={5}
disabled={isActive}
/>
</div>
{/* Medium threshold slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-amber-500 shrink-0" />
<Label className="text-xs">Manual review threshold</Label>
</div>
<span className="text-xs font-mono font-medium">{medPct}%</span>
</div>
<Slider
value={[medPct]}
onValueChange={([v]) =>
updateConfig({
aiConfidenceThresholds: {
...thresholds,
medium: v / 100,
},
})
}
min={20}
max={80}
step={5}
disabled={isActive}
/>
</div>
</div>
</div>
)}
</div>
{/* ── Manual Review Queue ────────────────────────────────────────── */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Manual Review Queue</Label>
<InfoTooltip content="When enabled, projects that don't meet auto-processing thresholds are queued for admin review instead of being auto-rejected." />
</div>
<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 })}
disabled={isActive}
/>
</div>
{/* ── Eligibility Rules (Secondary, Collapsible) ─────────────────── */}
<Collapsible open={rulesOpen} onOpenChange={setRulesOpen}>
<div className="flex items-center justify-between">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<Shield className="h-4 w-4 text-muted-foreground" />
<Label className="cursor-pointer">Eligibility Rules</Label>
<span className="text-xs text-muted-foreground">
({rules.length} rule{rules.length !== 1 ? 's' : ''})
</span>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${rulesOpen ? 'rotate-180' : ''}`} />
</button>
</CollapsibleTrigger>
{rulesOpen && (
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
Deterministic rules that projects must pass. Applied before AI screening.
</p>
<CollapsibleContent>
<div className="space-y-3 mt-3">
{rules.map((rule, index) => {
const fieldConfig = getFieldConfig(rule.field)
const availableOperators = fieldConfig?.operators ?? Object.keys(OPERATOR_LABELS)
return (
<Card key={index}>
<CardContent className="pt-3 pb-3 px-4 space-y-2">
{/* Rule inputs */}
<div className="flex items-start gap-2">
<div className="flex-1 grid gap-2 sm:grid-cols-3">
{/* Field dropdown */}
<Select
value={rule.field}
onValueChange={(value) => {
const newFieldConfig = getFieldConfig(value)
const firstOp = newFieldConfig?.operators[0] ?? 'is'
updateRule(index, {
field: value,
operator: firstOp,
value: newFieldConfig?.valueType === 'boolean' ? true : '',
})
}}
disabled={isActive}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select field..." />
</SelectTrigger>
<SelectContent>
{KNOWN_FIELDS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Operator dropdown (filtered by field) */}
<Select
value={rule.operator}
onValueChange={(value) => updateRule(index, { operator: value })}
disabled={isActive || !rule.field}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableOperators.map((op) => (
<SelectItem key={op} value={op}>
{OPERATOR_LABELS[op] ?? op}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value input (adapted by field type) */}
{rule.operator === 'exists' ? (
<div className="h-8 flex items-center text-xs text-muted-foreground italic">
(no value needed)
</div>
) : fieldConfig?.valueType === 'boolean' ? (
<Select
value={String(rule.value)}
onValueChange={(v) => updateRule(index, { value: v === 'true' })}
disabled={isActive}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Yes</SelectItem>
<SelectItem value="false">No</SelectItem>
</SelectContent>
</Select>
) : fieldConfig?.valueType === 'number' ? (
<Input
type="number"
placeholder={fieldConfig.placeholder ?? 'Value'}
value={String(rule.value)}
className="h-8 text-sm"
disabled={isActive}
onChange={(e) => updateRule(index, { value: e.target.value ? Number(e.target.value) : '' })}
/>
) : (
<Input
placeholder={fieldConfig?.placeholder ?? 'Value'}
value={String(rule.value)}
className="h-8 text-sm"
disabled={isActive}
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 mt-0.5"
onClick={() => removeRule(index)}
disabled={isActive}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Human-readable preview */}
{rule.field && rule.operator && (
<p className="text-xs text-muted-foreground italic pl-1">
{getRulePreview(rule)}
</p>
)}
</CardContent>
</Card>
)
})}
{rules.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-3">
No eligibility rules configured. All projects will pass through to AI screening (if enabled).
</p>
)}
{!rulesOpen ? null : rules.length > 0 && (
<div className="flex justify-end">
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
)
}
'use client'
import { useState } from 'react'
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 { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from '@/components/ui/collapsible'
import { Plus, Trash2, ChevronDown, Info, Brain, Shield } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { FilterConfig, FilterRuleConfig } from '@/types/pipeline-wizard'
// ─── Known Fields for Eligibility Rules ──────────────────────────────────────
type KnownField = {
value: string
label: string
operators: string[]
valueType: 'select' | 'text' | 'number' | 'boolean'
placeholder?: string
}
const KNOWN_FIELDS: KnownField[] = [
{ value: 'competitionCategory', label: 'Category', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. STARTUP' },
{ value: 'oceanIssue', label: 'Ocean Issue', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. Pollution' },
{ value: 'country', label: 'Country', operators: ['is', 'is_not', 'is_one_of'], valueType: 'text', placeholder: 'e.g. France' },
{ value: 'geographicZone', label: 'Region', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. Mediterranean' },
{ value: 'foundedAt', label: 'Founded Year', operators: ['after', 'before'], valueType: 'number', placeholder: 'e.g. 2020' },
{ value: 'description', label: 'Has Description', operators: ['exists', 'min_length'], valueType: 'number', placeholder: 'Min chars' },
{ value: 'files', label: 'File Count', operators: ['greaterThan', 'lessThan'], valueType: 'number', placeholder: 'e.g. 1' },
{ value: 'wantsMentorship', label: 'Wants Mentorship', operators: ['equals'], valueType: 'boolean' },
]
const OPERATOR_LABELS: Record<string, string> = {
is: 'is',
is_not: 'is not',
is_one_of: 'is one of',
after: 'after',
before: 'before',
exists: 'exists',
min_length: 'min length',
greaterThan: 'greater than',
lessThan: 'less than',
equals: 'equals',
}
// ─── Human-readable preview for a rule ───────────────────────────────────────
function getRulePreview(rule: FilterRuleConfig): string {
const field = KNOWN_FIELDS.find((f) => f.value === rule.field)
const fieldLabel = field?.label ?? rule.field
const opLabel = OPERATOR_LABELS[rule.operator] ?? rule.operator
if (rule.operator === 'exists') {
return `Projects where ${fieldLabel} exists will pass`
}
const valueStr = typeof rule.value === 'boolean'
? (rule.value ? 'Yes' : 'No')
: String(rule.value)
return `Projects where ${fieldLabel} ${opLabel} ${valueStr} will pass`
}
// ─── AI Screening: Fields the AI Sees ────────────────────────────────────────
const AI_VISIBLE_FIELDS = [
'Project title',
'Description',
'Competition category',
'Ocean issue',
'Country & region',
'Tags',
'Founded year',
'Team size',
'File count',
]
// ─── Props ───────────────────────────────────────────────────────────────────
type FilteringSectionProps = {
config: FilterConfig
onChange: (config: FilterConfig) => void
isActive?: boolean
}
// ─── Component ───────────────────────────────────────────────────────────────
export function FilteringSection({ config, onChange, isActive }: FilteringSectionProps) {
const [rulesOpen, setRulesOpen] = useState(false)
const [aiFieldsOpen, setAiFieldsOpen] = useState(false)
const updateConfig = (updates: Partial<FilterConfig>) => {
onChange({ ...config, ...updates })
}
const rules = config.rules ?? []
const aiCriteriaText = config.aiCriteriaText ?? ''
const thresholds = config.aiConfidenceThresholds ?? { high: 0.85, medium: 0.6, low: 0.4 }
const updateRule = (index: number, updates: Partial<FilterRuleConfig>) => {
const updated = [...rules]
updated[index] = { ...updated[index], ...updates }
onChange({ ...config, rules: updated })
}
const addRule = () => {
onChange({
...config,
rules: [
...rules,
{ field: '', operator: 'is', value: '', weight: 1 },
],
})
}
const removeRule = (index: number) => {
onChange({ ...config, rules: rules.filter((_, i) => i !== index) })
}
const getFieldConfig = (fieldValue: string): KnownField | undefined => {
return KNOWN_FIELDS.find((f) => f.value === fieldValue)
}
const highPct = Math.round(thresholds.high * 100)
const medPct = Math.round(thresholds.medium * 100)
return (
<div className="space-y-6">
{/* ── AI Screening (Primary) ────────────────────────────────────── */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Brain className="h-4 w-4 text-primary" />
<Label>AI Screening</Label>
<InfoTooltip content="Uses AI to evaluate projects against your criteria in natural language. Results are suggestions, not final decisions." />
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Use AI to evaluate projects against your screening criteria
</p>
</div>
<Switch
checked={config.aiRubricEnabled}
onCheckedChange={(checked) => updateConfig({ aiRubricEnabled: checked })}
disabled={isActive}
/>
</div>
{config.aiRubricEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
{/* Criteria Textarea (THE KEY MISSING PIECE) */}
<div className="space-y-2">
<Label className="text-sm font-medium">Screening Criteria</Label>
<p className="text-xs text-muted-foreground">
Describe what makes a project eligible or ineligible in natural language.
The AI will evaluate each project against these criteria.
</p>
<Textarea
value={aiCriteriaText}
onChange={(e) => updateConfig({ aiCriteriaText: e.target.value })}
placeholder="e.g., Projects must demonstrate a clear ocean conservation impact. Reject projects that are purely commercial with no environmental benefit. Flag projects with vague descriptions for manual review."
rows={5}
className="resize-y"
disabled={isActive}
/>
{aiCriteriaText.length > 0 && (
<p className="text-xs text-muted-foreground text-right">
{aiCriteriaText.length} characters
</p>
)}
</div>
{/* "What the AI sees" Info Card */}
<Collapsible open={aiFieldsOpen} onOpenChange={setAiFieldsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
>
<Info className="h-3.5 w-3.5" />
<span>What the AI sees</span>
<ChevronDown className={`h-3.5 w-3.5 ml-auto transition-transform ${aiFieldsOpen ? 'rotate-180' : ''}`} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<Card className="mt-2 bg-muted/50 border-muted">
<CardContent className="pt-3 pb-3 px-4">
<p className="text-xs text-muted-foreground mb-2">
All data is anonymized before being sent to the AI. Only these fields are included:
</p>
<ul className="grid grid-cols-2 sm:grid-cols-3 gap-1">
{AI_VISIBLE_FIELDS.map((field) => (
<li key={field} className="text-xs text-muted-foreground flex items-center gap-1">
<span className="h-1 w-1 rounded-full bg-muted-foreground/50 shrink-0" />
{field}
</li>
))}
</ul>
<p className="text-xs text-muted-foreground/70 mt-2 italic">
No personal identifiers (names, emails, etc.) are sent to the AI.
</p>
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
{/* Confidence Thresholds */}
<div className="space-y-3">
<Label className="text-sm font-medium">Confidence Thresholds</Label>
<p className="text-xs text-muted-foreground">
Control how the AI's confidence score maps to outcomes.
</p>
{/* Visual range preview */}
<div className="flex items-center gap-1 text-[10px] font-medium">
<div className="flex-1 bg-emerald-100 dark:bg-emerald-950 border border-emerald-300 dark:border-emerald-800 rounded-l px-2 py-1 text-center text-emerald-700 dark:text-emerald-400">
Auto-approve above {highPct}%
</div>
<div className="flex-1 bg-amber-100 dark:bg-amber-950 border border-amber-300 dark:border-amber-800 px-2 py-1 text-center text-amber-700 dark:text-amber-400">
Review {medPct}%{'\u2013'}{highPct}%
</div>
<div className="flex-1 bg-red-100 dark:bg-red-950 border border-red-300 dark:border-red-800 rounded-r px-2 py-1 text-center text-red-700 dark:text-red-400">
Auto-reject below {medPct}%
</div>
</div>
{/* High threshold slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-emerald-500 shrink-0" />
<Label className="text-xs">Auto-approve threshold</Label>
</div>
<span className="text-xs font-mono font-medium">{highPct}%</span>
</div>
<Slider
value={[highPct]}
onValueChange={([v]) =>
updateConfig({
aiConfidenceThresholds: {
...thresholds,
high: v / 100,
},
})
}
min={50}
max={100}
step={5}
disabled={isActive}
/>
</div>
{/* Medium threshold slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-amber-500 shrink-0" />
<Label className="text-xs">Manual review threshold</Label>
</div>
<span className="text-xs font-mono font-medium">{medPct}%</span>
</div>
<Slider
value={[medPct]}
onValueChange={([v]) =>
updateConfig({
aiConfidenceThresholds: {
...thresholds,
medium: v / 100,
},
})
}
min={20}
max={80}
step={5}
disabled={isActive}
/>
</div>
</div>
</div>
)}
</div>
{/* ── Manual Review Queue ────────────────────────────────────────── */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Manual Review Queue</Label>
<InfoTooltip content="When enabled, projects that don't meet auto-processing thresholds are queued for admin review instead of being auto-rejected." />
</div>
<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 })}
disabled={isActive}
/>
</div>
{/* ── Eligibility Rules (Secondary, Collapsible) ─────────────────── */}
<Collapsible open={rulesOpen} onOpenChange={setRulesOpen}>
<div className="flex items-center justify-between">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<Shield className="h-4 w-4 text-muted-foreground" />
<Label className="cursor-pointer">Eligibility Rules</Label>
<span className="text-xs text-muted-foreground">
({rules.length} rule{rules.length !== 1 ? 's' : ''})
</span>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${rulesOpen ? 'rotate-180' : ''}`} />
</button>
</CollapsibleTrigger>
{rulesOpen && (
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
Deterministic rules that projects must pass. Applied before AI screening.
</p>
<CollapsibleContent>
<div className="space-y-3 mt-3">
{rules.map((rule, index) => {
const fieldConfig = getFieldConfig(rule.field)
const availableOperators = fieldConfig?.operators ?? Object.keys(OPERATOR_LABELS)
return (
<Card key={index}>
<CardContent className="pt-3 pb-3 px-4 space-y-2">
{/* Rule inputs */}
<div className="flex items-start gap-2">
<div className="flex-1 grid gap-2 sm:grid-cols-3">
{/* Field dropdown */}
<Select
value={rule.field}
onValueChange={(value) => {
const newFieldConfig = getFieldConfig(value)
const firstOp = newFieldConfig?.operators[0] ?? 'is'
updateRule(index, {
field: value,
operator: firstOp,
value: newFieldConfig?.valueType === 'boolean' ? true : '',
})
}}
disabled={isActive}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select field..." />
</SelectTrigger>
<SelectContent>
{KNOWN_FIELDS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Operator dropdown (filtered by field) */}
<Select
value={rule.operator}
onValueChange={(value) => updateRule(index, { operator: value })}
disabled={isActive || !rule.field}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableOperators.map((op) => (
<SelectItem key={op} value={op}>
{OPERATOR_LABELS[op] ?? op}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value input (adapted by field type) */}
{rule.operator === 'exists' ? (
<div className="h-8 flex items-center text-xs text-muted-foreground italic">
(no value needed)
</div>
) : fieldConfig?.valueType === 'boolean' ? (
<Select
value={String(rule.value)}
onValueChange={(v) => updateRule(index, { value: v === 'true' })}
disabled={isActive}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Yes</SelectItem>
<SelectItem value="false">No</SelectItem>
</SelectContent>
</Select>
) : fieldConfig?.valueType === 'number' ? (
<Input
type="number"
placeholder={fieldConfig.placeholder ?? 'Value'}
value={String(rule.value)}
className="h-8 text-sm"
disabled={isActive}
onChange={(e) => updateRule(index, { value: e.target.value ? Number(e.target.value) : '' })}
/>
) : (
<Input
placeholder={fieldConfig?.placeholder ?? 'Value'}
value={String(rule.value)}
className="h-8 text-sm"
disabled={isActive}
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 mt-0.5"
onClick={() => removeRule(index)}
disabled={isActive}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Human-readable preview */}
{rule.field && rule.operator && (
<p className="text-xs text-muted-foreground italic pl-1">
{getRulePreview(rule)}
</p>
)}
</CardContent>
</Card>
)
})}
{rules.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-3">
No eligibility rules configured. All projects will pass through to AI screening (if enabled).
</p>
)}
{!rulesOpen ? null : rules.length > 0 && (
<div className="flex justify-end">
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
)
}

View File

@@ -1,289 +1,289 @@
'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 { Badge } from '@/components/ui/badge'
import { Plus, Trash2, FileText } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { IntakeConfig, FileRequirementConfig } from '@/types/pipeline-wizard'
import {
FILE_TYPE_CATEGORIES,
getActiveCategoriesFromMimeTypes,
categoriesToMimeTypes,
} from '@/lib/file-type-categories'
type FileTypePickerProps = {
value: string[]
onChange: (mimeTypes: string[]) => void
}
function FileTypePicker({ value, onChange }: FileTypePickerProps) {
const activeCategories = getActiveCategoriesFromMimeTypes(value)
const toggleCategory = (categoryId: string) => {
const isActive = activeCategories.includes(categoryId)
const newCategories = isActive
? activeCategories.filter((id) => id !== categoryId)
: [...activeCategories, categoryId]
onChange(categoriesToMimeTypes(newCategories))
}
return (
<div className="space-y-2">
<Label className="text-xs">Accepted Types</Label>
<div className="flex flex-wrap gap-1.5">
{FILE_TYPE_CATEGORIES.map((cat) => {
const isActive = activeCategories.includes(cat.id)
return (
<Button
key={cat.id}
type="button"
variant={isActive ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => toggleCategory(cat.id)}
>
{cat.label}
</Button>
)
})}
</div>
<div className="flex flex-wrap gap-1">
{activeCategories.length === 0 ? (
<Badge variant="secondary" className="text-[10px]">All types</Badge>
) : (
activeCategories.map((catId) => {
const cat = FILE_TYPE_CATEGORIES.find((c) => c.id === catId)
return cat ? (
<Badge key={catId} variant="secondary" className="text-[10px]">
{cat.label}
</Badge>
) : null
})
)}
</div>
</div>
)
}
type IntakeSectionProps = {
config: IntakeConfig
onChange: (config: IntakeConfig) => void
isActive?: boolean
}
export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps) {
const updateConfig = (updates: Partial<IntakeConfig>) => {
onChange({ ...config, ...updates })
}
const fileRequirements = config.fileRequirements ?? []
const updateFileReq = (index: number, updates: Partial<FileRequirementConfig>) => {
const updated = [...fileRequirements]
updated[index] = { ...updated[index], ...updates }
onChange({ ...config, fileRequirements: updated })
}
const addFileReq = () => {
onChange({
...config,
fileRequirements: [
...fileRequirements,
{
name: '',
description: '',
acceptedMimeTypes: ['application/pdf'],
maxSizeMB: 50,
isRequired: false,
},
],
})
}
const removeFileReq = (index: number) => {
const updated = fileRequirements.filter((_, i) => i !== index)
onChange({ ...config, fileRequirements: updated })
}
return (
<div className="space-y-6">
{isActive && (
<p className="text-sm text-amber-600 bg-amber-50 rounded-md px-3 py-2">
Some settings are locked because this pipeline is active.
</p>
)}
{/* Submission Window */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Submission Window</Label>
<InfoTooltip content="When enabled, projects can only be submitted within the configured date range." />
</div>
<p className="text-xs text-muted-foreground">
Enable timed submission windows for project intake
</p>
</div>
<Switch
checked={config.submissionWindowEnabled ?? true}
onCheckedChange={(checked) =>
updateConfig({ submissionWindowEnabled: checked })
}
disabled={isActive}
/>
</div>
</div>
{/* Late Policy */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Late Submission Policy</Label>
<InfoTooltip content="Controls how submissions after the deadline are handled. 'Reject' blocks them, 'Flag' accepts but marks as late, 'Accept' treats them normally." />
</div>
<Select
value={config.lateSubmissionPolicy ?? 'flag'}
onValueChange={(value) =>
updateConfig({
lateSubmissionPolicy: value as IntakeConfig['lateSubmissionPolicy'],
})
}
disabled={isActive}
>
<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') === 'flag' && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Grace Period (hours)</Label>
<InfoTooltip content="Extra time after the deadline during which late submissions are still accepted but flagged." />
</div>
<Input
type="number"
min={0}
max={168}
value={config.lateGraceHours ?? 24}
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">
<div className="flex items-center gap-1.5">
<Label>File Requirements</Label>
<InfoTooltip content="Define what files applicants must upload. Each requirement can specify accepted formats and size limits." />
</div>
<Button type="button" variant="outline" size="sm" onClick={addFileReq} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Requirement
</Button>
</div>
{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>
)}
{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 className="sm:col-span-2">
<FileTypePicker
value={req.acceptedMimeTypes ?? []}
onChange={(mimeTypes) =>
updateFileReq(index, { acceptedMimeTypes: mimeTypes })
}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeFileReq(index)}
disabled={isActive}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
'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 { Badge } from '@/components/ui/badge'
import { Plus, Trash2, FileText } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { IntakeConfig, FileRequirementConfig } from '@/types/pipeline-wizard'
import {
FILE_TYPE_CATEGORIES,
getActiveCategoriesFromMimeTypes,
categoriesToMimeTypes,
} from '@/lib/file-type-categories'
type FileTypePickerProps = {
value: string[]
onChange: (mimeTypes: string[]) => void
}
function FileTypePicker({ value, onChange }: FileTypePickerProps) {
const activeCategories = getActiveCategoriesFromMimeTypes(value)
const toggleCategory = (categoryId: string) => {
const isActive = activeCategories.includes(categoryId)
const newCategories = isActive
? activeCategories.filter((id) => id !== categoryId)
: [...activeCategories, categoryId]
onChange(categoriesToMimeTypes(newCategories))
}
return (
<div className="space-y-2">
<Label className="text-xs">Accepted Types</Label>
<div className="flex flex-wrap gap-1.5">
{FILE_TYPE_CATEGORIES.map((cat) => {
const isActive = activeCategories.includes(cat.id)
return (
<Button
key={cat.id}
type="button"
variant={isActive ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => toggleCategory(cat.id)}
>
{cat.label}
</Button>
)
})}
</div>
<div className="flex flex-wrap gap-1">
{activeCategories.length === 0 ? (
<Badge variant="secondary" className="text-[10px]">All types</Badge>
) : (
activeCategories.map((catId) => {
const cat = FILE_TYPE_CATEGORIES.find((c) => c.id === catId)
return cat ? (
<Badge key={catId} variant="secondary" className="text-[10px]">
{cat.label}
</Badge>
) : null
})
)}
</div>
</div>
)
}
type IntakeSectionProps = {
config: IntakeConfig
onChange: (config: IntakeConfig) => void
isActive?: boolean
}
export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps) {
const updateConfig = (updates: Partial<IntakeConfig>) => {
onChange({ ...config, ...updates })
}
const fileRequirements = config.fileRequirements ?? []
const updateFileReq = (index: number, updates: Partial<FileRequirementConfig>) => {
const updated = [...fileRequirements]
updated[index] = { ...updated[index], ...updates }
onChange({ ...config, fileRequirements: updated })
}
const addFileReq = () => {
onChange({
...config,
fileRequirements: [
...fileRequirements,
{
name: '',
description: '',
acceptedMimeTypes: ['application/pdf'],
maxSizeMB: 50,
isRequired: false,
},
],
})
}
const removeFileReq = (index: number) => {
const updated = fileRequirements.filter((_, i) => i !== index)
onChange({ ...config, fileRequirements: updated })
}
return (
<div className="space-y-6">
{isActive && (
<p className="text-sm text-amber-600 bg-amber-50 rounded-md px-3 py-2">
Some settings are locked because this pipeline is active.
</p>
)}
{/* Submission Window */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Submission Window</Label>
<InfoTooltip content="When enabled, projects can only be submitted within the configured date range." />
</div>
<p className="text-xs text-muted-foreground">
Enable timed submission windows for project intake
</p>
</div>
<Switch
checked={config.submissionWindowEnabled ?? true}
onCheckedChange={(checked) =>
updateConfig({ submissionWindowEnabled: checked })
}
disabled={isActive}
/>
</div>
</div>
{/* Late Policy */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Late Submission Policy</Label>
<InfoTooltip content="Controls how submissions after the deadline are handled. 'Reject' blocks them, 'Flag' accepts but marks as late, 'Accept' treats them normally." />
</div>
<Select
value={config.lateSubmissionPolicy ?? 'flag'}
onValueChange={(value) =>
updateConfig({
lateSubmissionPolicy: value as IntakeConfig['lateSubmissionPolicy'],
})
}
disabled={isActive}
>
<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') === 'flag' && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Grace Period (hours)</Label>
<InfoTooltip content="Extra time after the deadline during which late submissions are still accepted but flagged." />
</div>
<Input
type="number"
min={0}
max={168}
value={config.lateGraceHours ?? 24}
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">
<div className="flex items-center gap-1.5">
<Label>File Requirements</Label>
<InfoTooltip content="Define what files applicants must upload. Each requirement can specify accepted formats and size limits." />
</div>
<Button type="button" variant="outline" size="sm" onClick={addFileReq} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Requirement
</Button>
</div>
{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>
)}
{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 className="sm:col-span-2">
<FileTypePicker
value={req.acceptedMimeTypes ?? []}
onChange={(mimeTypes) =>
updateFileReq(index, { acceptedMimeTypes: mimeTypes })
}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeFileReq(index)}
disabled={isActive}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,158 +1,158 @@
'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 { InfoTooltip } from '@/components/ui/info-tooltip'
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
type LiveFinalsSectionProps = {
config: LiveFinalConfig
onChange: (config: LiveFinalConfig) => void
isActive?: boolean
}
export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSectionProps) {
const updateConfig = (updates: Partial<LiveFinalConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Jury Voting</Label>
<InfoTooltip content="Enable jury members to cast votes during the live ceremony." />
</div>
<p className="text-xs text-muted-foreground">
Allow jury members to vote during the live finals event
</p>
</div>
<Switch
checked={config.juryVotingEnabled ?? true}
onCheckedChange={(checked) =>
updateConfig({ juryVotingEnabled: checked })
}
disabled={isActive}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Audience Voting</Label>
<InfoTooltip content="Allow audience members to participate in voting alongside the jury." />
</div>
<p className="text-xs text-muted-foreground">
Allow audience members to vote on projects
</p>
</div>
<Switch
checked={config.audienceVotingEnabled ?? false}
onCheckedChange={(checked) =>
updateConfig({ audienceVotingEnabled: checked })
}
disabled={isActive}
/>
</div>
{(config.audienceVotingEnabled ?? false) && (
<div className="pl-4 border-l-2 border-muted space-y-3">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Audience Vote Weight</Label>
<InfoTooltip content="Percentage weight of audience votes vs jury votes in the final score (e.g., 30 means 30% audience, 70% jury)." />
</div>
<div className="flex items-center gap-3">
<Slider
value={[(config.audienceVoteWeight ?? 0) * 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 ?? 0) * 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">
<div className="flex items-center gap-1.5">
<Label>Cohort Setup Mode</Label>
<InfoTooltip content="Auto: system assigns projects to presentation groups. Manual: admin defines cohorts." />
</div>
<Select
value={config.cohortSetupMode ?? 'manual'}
onValueChange={(value) =>
updateConfig({
cohortSetupMode: value as LiveFinalConfig['cohortSetupMode'],
})
}
disabled={isActive}
>
<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">
<div className="flex items-center gap-1.5">
<Label>Result Reveal Policy</Label>
<InfoTooltip content="Immediate: show results as votes come in. Delayed: reveal after all votes. Ceremony: reveal during a dedicated announcement." />
</div>
<Select
value={config.revealPolicy ?? 'ceremony'}
onValueChange={(value) =>
updateConfig({
revealPolicy: value as LiveFinalConfig['revealPolicy'],
})
}
disabled={isActive}
>
<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>
)
}
'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 { InfoTooltip } from '@/components/ui/info-tooltip'
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
type LiveFinalsSectionProps = {
config: LiveFinalConfig
onChange: (config: LiveFinalConfig) => void
isActive?: boolean
}
export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSectionProps) {
const updateConfig = (updates: Partial<LiveFinalConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Jury Voting</Label>
<InfoTooltip content="Enable jury members to cast votes during the live ceremony." />
</div>
<p className="text-xs text-muted-foreground">
Allow jury members to vote during the live finals event
</p>
</div>
<Switch
checked={config.juryVotingEnabled ?? true}
onCheckedChange={(checked) =>
updateConfig({ juryVotingEnabled: checked })
}
disabled={isActive}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Audience Voting</Label>
<InfoTooltip content="Allow audience members to participate in voting alongside the jury." />
</div>
<p className="text-xs text-muted-foreground">
Allow audience members to vote on projects
</p>
</div>
<Switch
checked={config.audienceVotingEnabled ?? false}
onCheckedChange={(checked) =>
updateConfig({ audienceVotingEnabled: checked })
}
disabled={isActive}
/>
</div>
{(config.audienceVotingEnabled ?? false) && (
<div className="pl-4 border-l-2 border-muted space-y-3">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Audience Vote Weight</Label>
<InfoTooltip content="Percentage weight of audience votes vs jury votes in the final score (e.g., 30 means 30% audience, 70% jury)." />
</div>
<div className="flex items-center gap-3">
<Slider
value={[(config.audienceVoteWeight ?? 0) * 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 ?? 0) * 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">
<div className="flex items-center gap-1.5">
<Label>Cohort Setup Mode</Label>
<InfoTooltip content="Auto: system assigns projects to presentation groups. Manual: admin defines cohorts." />
</div>
<Select
value={config.cohortSetupMode ?? 'manual'}
onValueChange={(value) =>
updateConfig({
cohortSetupMode: value as LiveFinalConfig['cohortSetupMode'],
})
}
disabled={isActive}
>
<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">
<div className="flex items-center gap-1.5">
<Label>Result Reveal Policy</Label>
<InfoTooltip content="Immediate: show results as votes come in. Delayed: reveal after all votes. Ceremony: reveal during a dedicated announcement." />
</div>
<Select
value={config.revealPolicy ?? 'ceremony'}
onValueChange={(value) =>
updateConfig({
revealPolicy: value as LiveFinalConfig['revealPolicy'],
})
}
disabled={isActive}
>
<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

@@ -1,228 +1,228 @@
'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 { InfoTooltip } from '@/components/ui/info-tooltip'
import type { WizardStageConfig } from '@/types/pipeline-wizard'
import type { StageType } from '@prisma/client'
type MainTrackSectionProps = {
stages: WizardStageConfig[]
onChange: (stages: WizardStageConfig[]) => void
isActive?: boolean
}
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, isActive }: 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>
<div className="flex items-center gap-1.5 mb-1">
<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>
<InfoTooltip
content="INTAKE: Collect project submissions. FILTER: Automated screening. EVALUATION: Jury review and scoring. SELECTION: Choose finalists. LIVE_FINAL: Live ceremony voting. RESULTS: Publish outcomes."
side="right"
/>
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={addStage} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Stage
</Button>
</div>
{isActive && (
<p className="text-sm text-amber-600 bg-amber-50 rounded-md px-3 py-2">
Stage structure is locked because this pipeline is active. Use the Advanced editor for config changes.
</p>
)}
<div className="space-y-2">
{stages.map((stage, index) => {
const typeInfo = STAGE_TYPE_OPTIONS.find((t) => t.value === stage.stageType)
const hasDuplicateSlug = stage.slug && stages.some((s, i) => i !== index && s.slug === stage.slug)
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={isActive || 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={isActive || 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={cn('h-8 text-sm', hasDuplicateSlug && 'border-destructive')}
disabled={isActive}
onChange={(e) => {
const name = e.target.value
updateStage(index, { name, slug: slugify(name) })
}}
/>
{hasDuplicateSlug && (
<p className="text-[10px] text-destructive mt-0.5">Duplicate name</p>
)}
</div>
{/* Stage type */}
<div className="w-36 shrink-0">
<Select
value={stage.stageType}
onValueChange={(value) =>
updateStage(index, { stageType: value as StageType })
}
disabled={isActive}
>
<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={isActive || 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>
)
}
'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 { InfoTooltip } from '@/components/ui/info-tooltip'
import type { WizardStageConfig } from '@/types/pipeline-wizard'
import type { StageType } from '@prisma/client'
type MainTrackSectionProps = {
stages: WizardStageConfig[]
onChange: (stages: WizardStageConfig[]) => void
isActive?: boolean
}
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, isActive }: 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>
<div className="flex items-center gap-1.5 mb-1">
<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>
<InfoTooltip
content="INTAKE: Collect project submissions. FILTER: Automated screening. EVALUATION: Jury review and scoring. SELECTION: Choose finalists. LIVE_FINAL: Live ceremony voting. RESULTS: Publish outcomes."
side="right"
/>
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={addStage} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Stage
</Button>
</div>
{isActive && (
<p className="text-sm text-amber-600 bg-amber-50 rounded-md px-3 py-2">
Stage structure is locked because this pipeline is active. Use the Advanced editor for config changes.
</p>
)}
<div className="space-y-2">
{stages.map((stage, index) => {
const typeInfo = STAGE_TYPE_OPTIONS.find((t) => t.value === stage.stageType)
const hasDuplicateSlug = stage.slug && stages.some((s, i) => i !== index && s.slug === stage.slug)
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={isActive || 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={isActive || 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={cn('h-8 text-sm', hasDuplicateSlug && 'border-destructive')}
disabled={isActive}
onChange={(e) => {
const name = e.target.value
updateStage(index, { name, slug: slugify(name) })
}}
/>
{hasDuplicateSlug && (
<p className="text-[10px] text-destructive mt-0.5">Duplicate name</p>
)}
</div>
{/* Stage type */}
<div className="w-36 shrink-0">
<Select
value={stage.stageType}
onValueChange={(value) =>
updateStage(index, { stageType: value as StageType })
}
disabled={isActive}
>
<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={isActive || 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

@@ -1,166 +1,166 @@
'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'
import { InfoTooltip } from '@/components/ui/info-tooltip'
type NotificationsSectionProps = {
config: Record<string, boolean>
onChange: (config: Record<string, boolean>) => void
overridePolicy: Record<string, unknown>
onOverridePolicyChange: (policy: Record<string, unknown>) => void
isActive?: boolean
}
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,
isActive,
}: NotificationsSectionProps) {
const toggleEvent = (key: string, enabled: boolean) => {
onChange({ ...config, [key]: enabled })
}
return (
<div className="space-y-6">
<div className="flex items-center gap-1.5">
<p className="text-sm text-muted-foreground">
Choose which pipeline events trigger notifications. All events are enabled by default.
</p>
<InfoTooltip content="Configure email notifications for pipeline events. Each event type can be individually enabled or disabled." />
</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)}
disabled={isActive}
/>
</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>
)
}
'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'
import { InfoTooltip } from '@/components/ui/info-tooltip'
type NotificationsSectionProps = {
config: Record<string, boolean>
onChange: (config: Record<string, boolean>) => void
overridePolicy: Record<string, unknown>
onOverridePolicyChange: (policy: Record<string, unknown>) => void
isActive?: boolean
}
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,
isActive,
}: NotificationsSectionProps) {
const toggleEvent = (key: string, enabled: boolean) => {
onChange({ ...config, [key]: enabled })
}
return (
<div className="space-y-6">
<div className="flex items-center gap-1.5">
<p className="text-sm text-muted-foreground">
Choose which pipeline events trigger notifications. All events are enabled by default.
</p>
<InfoTooltip content="Configure email notifications for pipeline events. Each event type can be individually enabled or disabled." />
</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)}
disabled={isActive}
/>
</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,93 @@
'use client'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ResultsConfig } from '@/types/pipeline-wizard'
type ResultsSectionProps = {
config: ResultsConfig
onChange: (config: ResultsConfig) => void
isActive?: boolean
}
export function ResultsSection({
config,
onChange,
isActive,
}: ResultsSectionProps) {
const updateConfig = (updates: Partial<ResultsConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Publication Mode</Label>
<InfoTooltip content="Manual publish requires explicit admin action. Auto publish triggers on stage close." />
</div>
<Select
value={config.publicationMode ?? 'manual'}
onValueChange={(value) =>
updateConfig({
publicationMode: value as ResultsConfig['publicationMode'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="auto_on_close">Auto on Close</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Show Detailed Scores</Label>
<InfoTooltip content="Expose detailed score breakdowns in published results." />
</div>
<p className="text-xs text-muted-foreground">
Controls score transparency in the results experience
</p>
</div>
<Switch
checked={config.showDetailedScores ?? false}
onCheckedChange={(checked) => updateConfig({ showDetailedScores: checked })}
disabled={isActive}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Show Rankings</Label>
<InfoTooltip content="Display ordered rankings in final results." />
</div>
<p className="text-xs text-muted-foreground">
Disable to show winners only without full ranking table
</p>
</div>
<Switch
checked={config.showRankings ?? true}
onCheckedChange={(checked) => updateConfig({ showRankings: checked })}
disabled={isActive}
/>
</div>
</div>
</div>
)
}

View File

@@ -2,11 +2,12 @@
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 { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight, ShieldCheck } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { cn } from '@/lib/utils'
import { validateAll } from '@/lib/pipeline-validation'
import type { WizardState, ValidationResult } from '@/types/pipeline-wizard'
import { normalizeStageConfig } from '@/lib/stage-config-schema'
import type { WizardState, ValidationResult, WizardStageConfig } from '@/types/pipeline-wizard'
type ReviewSectionProps = {
state: WizardState
@@ -52,12 +53,34 @@ function ValidationSection({
)
}
function stagePolicySummary(stage: WizardStageConfig): string {
const config = normalizeStageConfig(
stage.stageType,
stage.configJson as Record<string, unknown>
)
switch (stage.stageType) {
case 'INTAKE':
return `${String(config.lateSubmissionPolicy)} late policy, ${Array.isArray(config.fileRequirements) ? config.fileRequirements.length : 0} file reqs`
case 'FILTER':
return `${Array.isArray(config.rules) ? config.rules.length : 0} rules, AI ${config.aiRubricEnabled ? 'on' : 'off'}`
case 'EVALUATION':
return `${String(config.requiredReviews)} reviews, load ${String(config.minLoadPerJuror)}-${String(config.maxLoadPerJuror)}`
case 'SELECTION':
return `ranking ${String(config.rankingMethod)}, tie ${String(config.tieBreaker)}`
case 'LIVE_FINAL':
return `jury ${config.juryVotingEnabled ? 'on' : 'off'}, audience ${config.audienceVotingEnabled ? 'on' : 'off'}`
case 'RESULTS':
return `publication ${String(config.publicationMode)}, rankings ${config.showRankings ? 'shown' : 'hidden'}`
default:
return 'Configured'
}
}
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),
@@ -65,42 +88,107 @@ export function ReviewSection({ state }: ReviewSectionProps) {
)
const enabledNotifications = Object.values(state.notificationConfig).filter(Boolean).length
const blockers = [
...validation.sections.basics.errors,
...validation.sections.tracks.errors,
...validation.sections.notifications.errors,
]
const warnings = [
...validation.sections.basics.warnings,
...validation.sections.tracks.warnings,
...validation.sections.notifications.warnings,
]
const hasMainTrack = state.tracks.some((track) => track.kind === 'MAIN')
const hasStages = totalStages > 0
const hasNotificationDefaults = enabledNotifications > 0
const publishReady = validation.valid && hasMainTrack && hasStages
return (
<div className="space-y-6">
{/* Overall Status */}
<div
className={cn(
'rounded-lg border p-4',
validation.valid
publishReady
? '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>
</>
<div className="flex items-start gap-2">
{publishReady ? (
<CheckCircle2 className="h-5 w-5 text-emerald-600 mt-0.5" />
) : (
<>
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="font-medium text-destructive">
Pipeline has validation errors that must be fixed
</p>
</>
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
)}
<div>
<p className={cn('font-medium', publishReady ? 'text-emerald-800' : 'text-destructive')}>
{publishReady
? 'Pipeline is ready for publish'
: 'Pipeline has publish blockers'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Draft save can proceed with warnings. Publish should only proceed with zero blockers.
</p>
</div>
</div>
</div>
{/* Validation Checks */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-1.5">
<CardTitle className="text-sm">Validation Checks</CardTitle>
<InfoTooltip content="Automated checks that verify all required fields are filled and configuration is consistent before saving." />
<CardTitle className="text-sm">Readiness Checks</CardTitle>
<InfoTooltip content="Critical blockers prevent publish. Warnings indicate recommended fixes." />
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{blockers.length}</p>
<p className="text-xs text-muted-foreground">Blockers</p>
</div>
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{warnings.length}</p>
<p className="text-xs text-muted-foreground">Warnings</p>
</div>
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{totalTracks}</p>
<p className="text-xs text-muted-foreground">Tracks</p>
</div>
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{totalStages}</p>
<p className="text-xs text-muted-foreground">Stages</p>
</div>
</div>
{blockers.length > 0 && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
<p className="text-xs font-medium text-destructive mb-1">Publish Blockers</p>
{blockers.map((blocker, i) => (
<p key={i} className="text-xs text-destructive">
{blocker}
</p>
))}
</div>
)}
{warnings.length > 0 && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3">
<p className="text-xs font-medium text-amber-700 mb-1">Warnings</p>
{warnings.map((warn, i) => (
<p key={i} className="text-xs text-amber-700">
{warn}
</p>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-1.5">
<CardTitle className="text-sm">Validation Detail</CardTitle>
<InfoTooltip content="Automated checks per setup section." />
</div>
</CardHeader>
<CardContent className="divide-y">
@@ -110,15 +198,14 @@ export function ReviewSection({ state }: ReviewSectionProps) {
</CardContent>
</Card>
{/* Structure Summary */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-1.5">
<CardTitle className="text-sm">Structure Summary</CardTitle>
<InfoTooltip content="Overview of the pipeline structure showing total tracks, stages, transitions, and notification settings." />
<CardTitle className="text-sm">Structure and Policy Matrix</CardTitle>
<InfoTooltip content="Stage-by-stage policy preview used for final sanity check before creation." />
</div>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="text-center">
<p className="text-2xl font-bold">{totalTracks}</p>
@@ -147,34 +234,81 @@ export function ReviewSection({ state }: ReviewSectionProps) {
</div>
</div>
{/* Track breakdown */}
<div className="mt-4 space-y-2">
<div className="space-y-3">
{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 key={i} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<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 className="text-sm font-medium">{track.name || '(unnamed track)'}</span>
</div>
<span className="text-xs text-muted-foreground">{track.stages.length} stages</span>
</div>
<div className="space-y-1">
{track.stages.map((stage, stageIndex) => (
<div
key={stageIndex}
className="flex items-center justify-between text-xs border-b last:border-0 py-1.5"
>
<span className="font-medium">
{stageIndex + 1}. {stage.name || '(unnamed stage)'} ({stage.stageType})
</span>
<span className="text-muted-foreground">{stagePolicySummary(stage)}</span>
</div>
))}
</div>
<span className="text-muted-foreground text-xs">
{track.stages.length} stages
</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<ShieldCheck className="h-4 w-4" />
Publish Guardrails
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex items-center justify-between rounded-md border p-2">
<span>Main track present</span>
<Badge variant={hasMainTrack ? 'default' : 'destructive'}>
{hasMainTrack ? 'Pass' : 'Fail'}
</Badge>
</div>
<div className="flex items-center justify-between rounded-md border p-2">
<span>At least one stage configured</span>
<Badge variant={hasStages ? 'default' : 'destructive'}>
{hasStages ? 'Pass' : 'Fail'}
</Badge>
</div>
<div className="flex items-center justify-between rounded-md border p-2">
<span>Validation blockers cleared</span>
<Badge variant={blockers.length === 0 ? 'default' : 'destructive'}>
{blockers.length === 0 ? 'Pass' : 'Fail'}
</Badge>
</div>
<div className="flex items-center justify-between rounded-md border p-2">
<span>Notification policy configured</span>
<Badge variant={hasNotificationDefaults ? 'default' : 'secondary'}>
{hasNotificationDefaults ? 'Configured' : 'Optional'}
</Badge>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,108 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { SelectionConfig } from '@/types/pipeline-wizard'
type SelectionSectionProps = {
config: SelectionConfig
onChange: (config: SelectionConfig) => void
isActive?: boolean
}
export function SelectionSection({
config,
onChange,
isActive,
}: SelectionSectionProps) {
const updateConfig = (updates: Partial<SelectionConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Finalist Count</Label>
<InfoTooltip content="Optional fixed finalist target for this stage." />
</div>
<Input
type="number"
min={1}
max={500}
value={config.finalistCount ?? ''}
placeholder="e.g. 6"
disabled={isActive}
onChange={(e) =>
updateConfig({
finalistCount:
e.target.value.trim().length === 0
? undefined
: parseInt(e.target.value, 10) || undefined,
})
}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Ranking Method</Label>
<InfoTooltip content="How projects are ranked before finalist selection." />
</div>
<Select
value={config.rankingMethod ?? 'score_average'}
onValueChange={(value) =>
updateConfig({
rankingMethod: value as SelectionConfig['rankingMethod'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="score_average">Score Average</SelectItem>
<SelectItem value="weighted_criteria">Weighted Criteria</SelectItem>
<SelectItem value="binary_pass">Binary Pass</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Tie Breaker</Label>
<InfoTooltip content="Fallback policy used when projects tie in rank." />
</div>
<Select
value={config.tieBreaker ?? 'admin_decides'}
onValueChange={(value) =>
updateConfig({
tieBreaker: value as SelectionConfig['tieBreaker'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin_decides">Admin Decides</SelectItem>
<SelectItem value="highest_individual">Highest Individual Score</SelectItem>
<SelectItem value="revote">Re-vote</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -1,28 +1,32 @@
'use client'
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect } from 'react'
import { EditableCard } from '@/components/ui/editable-card'
import { Badge } from '@/components/ui/badge'
import {
Inbox,
Filter,
ClipboardCheck,
Trophy,
Tv,
BarChart3,
} from 'lucide-react'
import {
Inbox,
Filter,
ClipboardCheck,
Trophy,
Tv,
BarChart3,
} from 'lucide-react'
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
import { SelectionSection } from '@/components/admin/pipeline/sections/selection-section'
import { ResultsSection } from '@/components/admin/pipeline/sections/results-section'
import {
defaultIntakeConfig,
defaultFilterConfig,
defaultEvaluationConfig,
defaultLiveConfig,
defaultSelectionConfig,
defaultResultsConfig,
} from '@/lib/pipeline-defaults'
import type {
@@ -30,338 +34,356 @@ import type {
FilterConfig,
EvaluationConfig,
LiveFinalConfig,
SelectionConfig,
ResultsConfig,
} from '@/types/pipeline-wizard'
type StageConfigEditorProps = {
stageId: string
stageName: string
stageType: string
configJson: Record<string, unknown> | null
onSave: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
isSaving?: boolean
}
const stageIcons: Record<string, React.ReactNode> = {
INTAKE: <Inbox className="h-4 w-4" />,
FILTER: <Filter className="h-4 w-4" />,
EVALUATION: <ClipboardCheck className="h-4 w-4" />,
SELECTION: <Trophy className="h-4 w-4" />,
LIVE_FINAL: <Tv className="h-4 w-4" />,
RESULTS: <BarChart3 className="h-4 w-4" />,
}
function ConfigSummary({
stageType,
configJson,
}: {
stageType: string
configJson: Record<string, unknown> | null
}) {
if (!configJson) {
return (
<p className="text-sm text-muted-foreground italic">
No configuration set
</p>
)
}
switch (stageType) {
case 'INTAKE': {
const config = configJson as unknown as IntakeConfig
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Submission Window:</span>
<Badge variant="outline" className="text-[10px]">
{config.submissionWindowEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Late Policy:</span>
<span className="capitalize">{config.lateSubmissionPolicy ?? 'flag'}</span>
{(config.lateGraceHours ?? 0) > 0 && (
<span className="text-muted-foreground">
({config.lateGraceHours}h grace)
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">File Requirements:</span>
<span>{config.fileRequirements?.length ?? 0} configured</span>
</div>
</div>
)
}
case 'FILTER': {
const raw = configJson as Record<string, unknown>
const seedRules = (raw.deterministic as Record<string, unknown>)?.rules as unknown[] | undefined
const ruleCount = (raw.rules as unknown[])?.length ?? seedRules?.length ?? 0
const aiEnabled = (raw.aiRubricEnabled as boolean) ?? !!(raw.ai)
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Rules:</span>
<span>{ruleCount} eligibility rules</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">AI Screening:</span>
<Badge variant="outline" className="text-[10px]">
{aiEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Manual Queue:</span>
<Badge variant="outline" className="text-[10px]">
{(raw.manualQueueEnabled as boolean) ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</div>
)
}
case 'EVALUATION': {
const raw = configJson as Record<string, unknown>
const reviews = (raw.requiredReviews as number) ?? 3
const minLoad = (raw.minLoadPerJuror as number) ?? (raw.minAssignmentsPerJuror as number) ?? 5
const maxLoad = (raw.maxLoadPerJuror as number) ?? (raw.maxAssignmentsPerJuror as number) ?? 20
const overflow = (raw.overflowPolicy as string) ?? 'queue'
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Required Reviews:</span>
<span>{reviews}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Load per Juror:</span>
<span>
{minLoad} - {maxLoad}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Overflow Policy:</span>
<span className="capitalize">
{overflow.replace('_', ' ')}
</span>
</div>
</div>
)
}
case 'SELECTION': {
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Ranking Method:</span>
<span className="capitalize">
{((configJson.rankingMethod as string) ?? 'score_average').replace(
/_/g,
' '
)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Tie Breaker:</span>
<span className="capitalize">
{((configJson.tieBreaker as string) ?? 'admin_decides').replace(
/_/g,
' '
)}
</span>
</div>
{configJson.finalistCount != null && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Finalist Count:</span>
<span>{String(configJson.finalistCount)}</span>
</div>
)}
</div>
)
}
case 'LIVE_FINAL': {
const raw = configJson as Record<string, unknown>
const juryEnabled = (raw.juryVotingEnabled as boolean) ?? (raw.votingEnabled as boolean) ?? false
const audienceEnabled = (raw.audienceVotingEnabled as boolean) ?? (raw.audienceVoting as boolean) ?? false
const audienceWeight = (raw.audienceVoteWeight as number) ?? 0
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Jury Voting:</span>
<Badge variant="outline" className="text-[10px]">
{juryEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Audience Voting:</span>
<Badge variant="outline" className="text-[10px]">
{audienceEnabled ? 'Enabled' : 'Disabled'}
</Badge>
{audienceEnabled && (
<span className="text-muted-foreground">
({Math.round(audienceWeight * 100)}% weight)
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Reveal:</span>
<span className="capitalize">{(raw.revealPolicy as string) ?? 'ceremony'}</span>
</div>
</div>
)
}
case 'RESULTS': {
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Publication:</span>
<span className="capitalize">
{((configJson.publicationMode as string) ?? 'manual').replace(
/_/g,
' '
)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Show Scores:</span>
<Badge variant="outline" className="text-[10px]">
{configJson.showDetailedScores ? 'Yes' : 'No'}
</Badge>
</div>
</div>
)
}
default:
return (
<p className="text-sm text-muted-foreground italic">
Configuration view not available for this stage type
</p>
)
}
}
type StageConfigEditorProps = {
stageId: string
stageName: string
stageType: string
configJson: Record<string, unknown> | null
onSave: (stageId: string, configJson: Record<string, unknown>) => Promise<void>
isSaving?: boolean
}
const stageIcons: Record<string, React.ReactNode> = {
INTAKE: <Inbox className="h-4 w-4" />,
FILTER: <Filter className="h-4 w-4" />,
EVALUATION: <ClipboardCheck className="h-4 w-4" />,
SELECTION: <Trophy className="h-4 w-4" />,
LIVE_FINAL: <Tv className="h-4 w-4" />,
RESULTS: <BarChart3 className="h-4 w-4" />,
}
function ConfigSummary({
stageType,
configJson,
}: {
stageType: string
configJson: Record<string, unknown> | null
}) {
if (!configJson) {
return (
<p className="text-sm text-muted-foreground italic">
No configuration set
</p>
)
}
switch (stageType) {
case 'INTAKE': {
const config = configJson as unknown as IntakeConfig
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Submission Window:</span>
<Badge variant="outline" className="text-[10px]">
{config.submissionWindowEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Late Policy:</span>
<span className="capitalize">{config.lateSubmissionPolicy ?? 'flag'}</span>
{(config.lateGraceHours ?? 0) > 0 && (
<span className="text-muted-foreground">
({config.lateGraceHours}h grace)
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">File Requirements:</span>
<span>{config.fileRequirements?.length ?? 0} configured</span>
</div>
</div>
)
}
case 'FILTER': {
const raw = configJson as Record<string, unknown>
const seedRules = (raw.deterministic as Record<string, unknown>)?.rules as unknown[] | undefined
const ruleCount = (raw.rules as unknown[])?.length ?? seedRules?.length ?? 0
const aiEnabled = (raw.aiRubricEnabled as boolean) ?? !!(raw.ai)
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Rules:</span>
<span>{ruleCount} eligibility rules</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">AI Screening:</span>
<Badge variant="outline" className="text-[10px]">
{aiEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Manual Queue:</span>
<Badge variant="outline" className="text-[10px]">
{(raw.manualQueueEnabled as boolean) ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</div>
)
}
case 'EVALUATION': {
const raw = configJson as Record<string, unknown>
const reviews = (raw.requiredReviews as number) ?? 3
const minLoad = (raw.minLoadPerJuror as number) ?? (raw.minAssignmentsPerJuror as number) ?? 5
const maxLoad = (raw.maxLoadPerJuror as number) ?? (raw.maxAssignmentsPerJuror as number) ?? 20
const overflow = (raw.overflowPolicy as string) ?? 'queue'
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Required Reviews:</span>
<span>{reviews}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Load per Juror:</span>
<span>
{minLoad} - {maxLoad}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Overflow Policy:</span>
<span className="capitalize">
{overflow.replace('_', ' ')}
</span>
</div>
</div>
)
}
case 'SELECTION': {
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Ranking Method:</span>
<span className="capitalize">
{((configJson.rankingMethod as string) ?? 'score_average').replace(
/_/g,
' '
)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Tie Breaker:</span>
<span className="capitalize">
{((configJson.tieBreaker as string) ?? 'admin_decides').replace(
/_/g,
' '
)}
</span>
</div>
{configJson.finalistCount != null && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Finalist Count:</span>
<span>{String(configJson.finalistCount)}</span>
</div>
)}
</div>
)
}
case 'LIVE_FINAL': {
const raw = configJson as Record<string, unknown>
const juryEnabled = (raw.juryVotingEnabled as boolean) ?? (raw.votingEnabled as boolean) ?? false
const audienceEnabled = (raw.audienceVotingEnabled as boolean) ?? (raw.audienceVoting as boolean) ?? false
const audienceWeight = (raw.audienceVoteWeight as number) ?? 0
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Jury Voting:</span>
<Badge variant="outline" className="text-[10px]">
{juryEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Audience Voting:</span>
<Badge variant="outline" className="text-[10px]">
{audienceEnabled ? 'Enabled' : 'Disabled'}
</Badge>
{audienceEnabled && (
<span className="text-muted-foreground">
({Math.round(audienceWeight * 100)}% weight)
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Reveal:</span>
<span className="capitalize">{(raw.revealPolicy as string) ?? 'ceremony'}</span>
</div>
</div>
)
}
case 'RESULTS': {
return (
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Publication:</span>
<span className="capitalize">
{((configJson.publicationMode as string) ?? 'manual').replace(
/_/g,
' '
)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Show Scores:</span>
<Badge variant="outline" className="text-[10px]">
{configJson.showDetailedScores ? 'Yes' : 'No'}
</Badge>
</div>
</div>
)
}
default:
return (
<p className="text-sm text-muted-foreground italic">
Configuration view not available for this stage type
</p>
)
}
}
export function StageConfigEditor({
stageId,
stageName,
stageType,
configJson,
onSave,
isSaving = false,
}: StageConfigEditorProps) {
stageId,
stageName,
stageType,
configJson,
onSave,
isSaving = false,
}: StageConfigEditorProps) {
const [localConfig, setLocalConfig] = useState<Record<string, unknown>>(
() => configJson ?? {}
)
const handleSave = useCallback(async () => {
await onSave(stageId, localConfig)
}, [stageId, localConfig, onSave])
const renderEditor = () => {
switch (stageType) {
case 'INTAKE': {
const rawConfig = {
...defaultIntakeConfig(),
...(localConfig as object),
} as IntakeConfig
// Deep-normalize fileRequirements to handle DB shape mismatches
const config: IntakeConfig = {
...rawConfig,
fileRequirements: (rawConfig.fileRequirements ?? []).map((req) => ({
name: req.name ?? '',
description: req.description ?? '',
acceptedMimeTypes: req.acceptedMimeTypes ?? ['application/pdf'],
maxSizeMB: req.maxSizeMB ?? 50,
isRequired: req.isRequired ?? (req as Record<string, unknown>).required === true,
})),
}
return (
<IntakeSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'FILTER': {
const raw = localConfig as Record<string, unknown>
// Normalize seed data shape: deterministic.rules → rules, confidenceBands → aiConfidenceThresholds
const seedRules = (raw.deterministic as Record<string, unknown>)?.rules as FilterConfig['rules'] | undefined
const seedBands = raw.confidenceBands as Record<string, Record<string, number>> | undefined
const config: FilterConfig = {
...defaultFilterConfig(),
...raw,
rules: (raw.rules as FilterConfig['rules']) ?? seedRules ?? defaultFilterConfig().rules,
aiRubricEnabled: (raw.aiRubricEnabled as boolean | undefined) ?? !!raw.ai,
aiConfidenceThresholds: (raw.aiConfidenceThresholds as FilterConfig['aiConfidenceThresholds']) ?? (seedBands ? {
high: seedBands.high?.threshold ?? 0.85,
medium: seedBands.medium?.threshold ?? 0.6,
low: seedBands.low?.threshold ?? 0.4,
} : defaultFilterConfig().aiConfidenceThresholds),
}
return (
<FilteringSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'EVALUATION': {
const raw = localConfig as Record<string, unknown>
// Normalize seed data shape: minAssignmentsPerJuror → minLoadPerJuror, etc.
const config: EvaluationConfig = {
...defaultEvaluationConfig(),
...raw,
requiredReviews: (raw.requiredReviews as number) ?? defaultEvaluationConfig().requiredReviews,
minLoadPerJuror: (raw.minLoadPerJuror as number) ?? (raw.minAssignmentsPerJuror as number) ?? defaultEvaluationConfig().minLoadPerJuror,
maxLoadPerJuror: (raw.maxLoadPerJuror as number) ?? (raw.maxAssignmentsPerJuror as number) ?? defaultEvaluationConfig().maxLoadPerJuror,
}
return (
<AssignmentSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'LIVE_FINAL': {
const raw = localConfig as Record<string, unknown>
// Normalize seed data shape: votingEnabled → juryVotingEnabled, audienceVoting → audienceVotingEnabled
const config: LiveFinalConfig = {
...defaultLiveConfig(),
...raw,
juryVotingEnabled: (raw.juryVotingEnabled as boolean) ?? (raw.votingEnabled as boolean) ?? true,
audienceVotingEnabled: (raw.audienceVotingEnabled as boolean) ?? (raw.audienceVoting as boolean) ?? false,
audienceVoteWeight: (raw.audienceVoteWeight as number) ?? 0,
}
return (
<LiveFinalsSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
useEffect(() => {
setLocalConfig(configJson ?? {})
}, [stageId, configJson])
const handleSave = useCallback(async () => {
await onSave(stageId, localConfig)
}, [stageId, localConfig, onSave])
const renderEditor = () => {
switch (stageType) {
case 'INTAKE': {
const rawConfig = {
...defaultIntakeConfig(),
...(localConfig as object),
} as IntakeConfig
// Deep-normalize fileRequirements to handle DB shape mismatches
const config: IntakeConfig = {
...rawConfig,
fileRequirements: (rawConfig.fileRequirements ?? []).map((req) => ({
name: req.name ?? '',
description: req.description ?? '',
acceptedMimeTypes: req.acceptedMimeTypes ?? ['application/pdf'],
maxSizeMB: req.maxSizeMB ?? 50,
isRequired: req.isRequired ?? (req as Record<string, unknown>).required === true,
})),
}
return (
<IntakeSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'FILTER': {
const raw = localConfig as Record<string, unknown>
// Normalize seed data shape: deterministic.rules → rules, confidenceBands → aiConfidenceThresholds
const seedRules = (raw.deterministic as Record<string, unknown>)?.rules as FilterConfig['rules'] | undefined
const seedBands = raw.confidenceBands as Record<string, Record<string, number>> | undefined
const config: FilterConfig = {
...defaultFilterConfig(),
...raw,
rules: (raw.rules as FilterConfig['rules']) ?? seedRules ?? defaultFilterConfig().rules,
aiRubricEnabled: (raw.aiRubricEnabled as boolean | undefined) ?? !!raw.ai,
aiConfidenceThresholds: (raw.aiConfidenceThresholds as FilterConfig['aiConfidenceThresholds']) ?? (seedBands ? {
high: seedBands.high?.threshold ?? 0.85,
medium: seedBands.medium?.threshold ?? 0.6,
low: seedBands.low?.threshold ?? 0.4,
} : defaultFilterConfig().aiConfidenceThresholds),
}
return (
<FilteringSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'EVALUATION': {
const raw = localConfig as Record<string, unknown>
// Normalize seed data shape: minAssignmentsPerJuror → minLoadPerJuror, etc.
const config: EvaluationConfig = {
...defaultEvaluationConfig(),
...raw,
requiredReviews: (raw.requiredReviews as number) ?? defaultEvaluationConfig().requiredReviews,
minLoadPerJuror: (raw.minLoadPerJuror as number) ?? (raw.minAssignmentsPerJuror as number) ?? defaultEvaluationConfig().minLoadPerJuror,
maxLoadPerJuror: (raw.maxLoadPerJuror as number) ?? (raw.maxAssignmentsPerJuror as number) ?? defaultEvaluationConfig().maxLoadPerJuror,
}
return (
<AssignmentSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'LIVE_FINAL': {
const raw = localConfig as Record<string, unknown>
// Normalize seed data shape: votingEnabled → juryVotingEnabled, audienceVoting → audienceVotingEnabled
const config: LiveFinalConfig = {
...defaultLiveConfig(),
...raw,
juryVotingEnabled: (raw.juryVotingEnabled as boolean) ?? (raw.votingEnabled as boolean) ?? true,
audienceVotingEnabled: (raw.audienceVotingEnabled as boolean) ?? (raw.audienceVoting as boolean) ?? false,
audienceVoteWeight: (raw.audienceVoteWeight as number) ?? 0,
}
return (
<LiveFinalsSection
config={config}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
}
case 'SELECTION':
return (
<SelectionSection
config={{
...defaultSelectionConfig(),
...(localConfig as SelectionConfig),
}}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
case 'RESULTS':
return (
<div className="text-sm text-muted-foreground py-4 text-center">
Configuration for {stageType.replace('_', ' ')} stages is managed
through the stage settings.
</div>
<ResultsSection
config={{
...defaultResultsConfig(),
...(localConfig as ResultsConfig),
}}
onChange={(c) => setLocalConfig(c as unknown as Record<string, unknown>)}
/>
)
default:
return null
}
}
return (
<EditableCard
title={`${stageName} Configuration`}
icon={stageIcons[stageType]}
summary={<ConfigSummary stageType={stageType} configJson={configJson} />}
onSave={handleSave}
isSaving={isSaving}
>
{renderEditor()}
</EditableCard>
)
}
}
return (
<EditableCard
title={`${stageName} Configuration`}
icon={stageIcons[stageType]}
summary={<ConfigSummary stageType={stageType} configJson={configJson} />}
onSave={handleSave}
isSaving={isSaving}
>
{renderEditor()}
</EditableCard>
)
}

View File

@@ -1,136 +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>
)
}
'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

@@ -1,184 +1,184 @@
'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 space-y-1">
<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>
{config.aiCriteriaText && (
<p className="text-xs text-muted-foreground line-clamp-2">
Criteria: {config.aiCriteriaText}
</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>
)
}
'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 space-y-1">
<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>
{config.aiCriteriaText && (
<p className="text-xs text-muted-foreground line-clamp-2">
Criteria: {config.aiCriteriaText}
</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

@@ -1,135 +1,135 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
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) => (
<Link
key={ps.id}
href={`/admin/projects/${ps.project.id}` as Route}
className="block"
>
<div className="flex items-center justify-between text-sm py-1.5 border-b last:border-0 hover:bg-muted/50 cursor-pointer rounded-md px-1 transition-colors">
<span className="truncate">{ps.project.title}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
{ps.state}
</Badge>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}
'use client'
import Link from 'next/link'
import type { Route } from 'next'
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) => (
<Link
key={ps.id}
href={`/admin/projects/${ps.project.id}` as Route}
className="block"
>
<div className="flex items-center justify-between text-sm py-1.5 border-b last:border-0 hover:bg-muted/50 cursor-pointer rounded-md px-1 transition-colors">
<span className="truncate">{ps.project.title}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
{ps.state}
</Badge>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,173 +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>
)
}
'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

@@ -1,120 +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>
)
}
'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

@@ -1,166 +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>
)
}
'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,344 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Plus, Save, Trash2, Loader2 } from 'lucide-react'
type StageLite = {
id: string
name: string
sortOrder: number
}
type StageTransitionsEditorProps = {
trackId: string
stages: StageLite[]
}
type TransitionDraft = {
id: string
isDefault: boolean
guardText: string
}
export function StageTransitionsEditor({
trackId,
stages,
}: StageTransitionsEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, TransitionDraft>>({})
const [fromStageId, setFromStageId] = useState<string>('')
const [toStageId, setToStageId] = useState<string>('')
const [newIsDefault, setNewIsDefault] = useState<boolean>(false)
const [newGuardText, setNewGuardText] = useState<string>('{}')
const { data: transitions = [], isLoading } =
trpc.stage.listTransitions.useQuery({ trackId })
const createTransition = trpc.stage.createTransition.useMutation({
onSuccess: async () => {
await utils.stage.listTransitions.invalidate({ trackId })
toast.success('Transition created')
setNewGuardText('{}')
setNewIsDefault(false)
},
onError: (error) => toast.error(error.message),
})
const updateTransition = trpc.stage.updateTransition.useMutation({
onSuccess: async () => {
await utils.stage.listTransitions.invalidate({ trackId })
toast.success('Transition updated')
},
onError: (error) => toast.error(error.message),
})
const deleteTransition = trpc.stage.deleteTransition.useMutation({
onSuccess: async () => {
await utils.stage.listTransitions.invalidate({ trackId })
toast.success('Transition deleted')
},
onError: (error) => toast.error(error.message),
})
const orderedTransitions = useMemo(
() =>
[...transitions].sort((a, b) => {
const aFromOrder =
stages.find((stage) => stage.id === a.fromStageId)?.sortOrder ?? 0
const bFromOrder =
stages.find((stage) => stage.id === b.fromStageId)?.sortOrder ?? 0
if (aFromOrder !== bFromOrder) return aFromOrder - bFromOrder
const aToOrder =
stages.find((stage) => stage.id === a.toStageId)?.sortOrder ?? 0
const bToOrder =
stages.find((stage) => stage.id === b.toStageId)?.sortOrder ?? 0
return aToOrder - bToOrder
}),
[stages, transitions]
)
useEffect(() => {
if (!fromStageId && stages.length > 0) {
setFromStageId(stages[0].id)
}
if (!toStageId && stages.length > 1) {
setToStageId(stages[1].id)
}
}, [fromStageId, toStageId, stages])
useEffect(() => {
const nextDrafts: Record<string, TransitionDraft> = {}
for (const transition of orderedTransitions) {
nextDrafts[transition.id] = {
id: transition.id,
isDefault: transition.isDefault,
guardText: JSON.stringify(transition.guardJson ?? {}, null, 2),
}
}
setDrafts(nextDrafts)
}, [orderedTransitions])
const handleCreateTransition = async () => {
if (!fromStageId || !toStageId) {
toast.error('Select both from and to stages')
return
}
if (fromStageId === toStageId) {
toast.error('From and to stages must be different')
return
}
let guardJson: Record<string, unknown> | null = null
try {
const parsed = JSON.parse(newGuardText) as Record<string, unknown>
guardJson = Object.keys(parsed).length > 0 ? parsed : null
} catch {
toast.error('Guard JSON must be valid')
return
}
await createTransition.mutateAsync({
fromStageId,
toStageId,
isDefault: newIsDefault,
guardJson,
})
}
const handleSaveTransition = async (id: string) => {
const draft = drafts[id]
if (!draft) return
let guardJson: Record<string, unknown> | null = null
try {
const parsed = JSON.parse(draft.guardText) as Record<string, unknown>
guardJson = Object.keys(parsed).length > 0 ? parsed : null
} catch {
toast.error('Guard JSON must be valid')
return
}
await updateTransition.mutateAsync({
id,
isDefault: draft.isDefault,
guardJson,
})
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Stage Transitions</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Loading transitions...
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Stage Transitions</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border p-3 space-y-3">
<p className="text-sm font-medium">Create Transition</p>
<div className="grid gap-2 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">From Stage</Label>
<Select value={fromStageId} onValueChange={setFromStageId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{stages
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">To Stage</Label>
<Select value={toStageId} onValueChange={setToStageId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{stages
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end justify-start pb-2">
<div className="flex items-center gap-2">
<Switch
checked={newIsDefault}
onCheckedChange={setNewIsDefault}
/>
<Label className="text-xs">Default</Label>
</div>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Guard JSON (optional)</Label>
<Textarea
className="font-mono text-xs min-h-20"
value={newGuardText}
onChange={(e) => setNewGuardText(e.target.value)}
/>
</div>
<Button
type="button"
size="sm"
onClick={handleCreateTransition}
disabled={createTransition.isPending}
>
{createTransition.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="mr-1.5 h-3.5 w-3.5" />
)}
Add Transition
</Button>
</div>
{orderedTransitions.length === 0 && (
<p className="text-sm text-muted-foreground">
No transitions configured yet.
</p>
)}
{orderedTransitions.map((transition) => {
const fromName =
stages.find((stage) => stage.id === transition.fromStageId)?.name ??
transition.fromStage?.name ??
'Unknown'
const toName =
stages.find((stage) => stage.id === transition.toStageId)?.name ??
transition.toStage?.name ??
'Unknown'
const draft = drafts[transition.id]
if (!draft) return null
return (
<div key={transition.id} className="rounded-md border p-3 space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">
{fromName} {'->'} {toName}
</p>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<Switch
checked={draft.isDefault}
onCheckedChange={(checked) =>
setDrafts((prev) => ({
...prev,
[transition.id]: {
...draft,
isDefault: checked,
},
}))
}
/>
<Label className="text-xs">Default</Label>
</div>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Guard JSON</Label>
<Textarea
className="font-mono text-xs min-h-20"
value={draft.guardText}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[transition.id]: {
...draft,
guardText: e.target.value,
},
}))
}
/>
</div>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => handleSaveTransition(transition.id)}
disabled={updateTransition.isPending}
>
{updateTransition.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => deleteTransition.mutate({ id: transition.id })}
disabled={deleteTransition.isPending}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -1,303 +1,303 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { toast } from 'sonner'
import {
MoreHorizontal,
Mail,
UserCog,
Trash2,
Loader2,
Shield,
Check,
} from 'lucide-react'
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
const ROLE_LABELS: Record<Role, string> = {
SUPER_ADMIN: 'Super Admin',
PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
}
interface UserActionsProps {
userId: string
userEmail: string
userStatus: string
userRole: Role
currentUserRole?: Role
}
export function UserActions({ userId, userEmail, userStatus, userRole, currentUserRole }: UserActionsProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false)
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation()
const deleteUser = trpc.user.delete.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
},
})
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
toast.success('Role updated successfully')
},
onError: (error) => {
toast.error(error.message || 'Failed to update role')
},
})
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
// Determine which roles can be assigned
const getAvailableRoles = (): Role[] => {
if (isSuperAdmin) {
return ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']
}
// Program admins can only assign lower roles
return ['JURY_MEMBER', 'MENTOR', 'OBSERVER']
}
// Can this user's role be changed by the current user?
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const handleRoleChange = (newRole: Role) => {
if (newRole === userRole) return
updateUser.mutate({ id: userId, role: newRole })
}
const handleSendInvitation = async () => {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
return
}
setIsSending(true)
try {
await sendInvitation.mutateAsync({ userId })
toast.success(`Invitation sent to ${userEmail}`)
// Invalidate in case status changed
utils.user.list.invalidate()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
} finally {
setIsSending(false)
}
}
const handleDelete = async () => {
try {
await deleteUser.mutateAsync({ id: userId })
toast.success('User deleted successfully')
setShowDeleteDialog(false)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete user')
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/members/${userId}`}>
<UserCog className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{canChangeRole && (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={updateUser.isPending}>
<Shield className="mr-2 h-4 w-4" />
{updateUser.isPending ? 'Updating...' : 'Change Role'}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{getAvailableRoles().map((role) => (
<DropdownMenuItem
key={role}
onClick={() => handleRoleChange(role)}
disabled={role === userRole}
>
{role === userRole && <Check className="mr-2 h-4 w-4" />}
<span className={role === userRole ? 'font-medium' : role !== userRole ? 'ml-6' : ''}>
{ROLE_LABELS[role]}
</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem
onClick={handleSendInvitation}
disabled={(userStatus !== 'NONE' && userStatus !== 'INVITED') || isSending}
>
<Mail className="mr-2 h-4 w-4" />
{isSending ? 'Sending...' : 'Send Invite'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {userEmail}? This action cannot be
undone and will remove all their assignments and evaluations.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteUser.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
interface UserMobileActionsProps {
userId: string
userEmail: string
userStatus: string
userRole: Role
currentUserRole?: Role
}
export function UserMobileActions({
userId,
userEmail,
userStatus,
userRole,
currentUserRole,
}: UserMobileActionsProps) {
const [isSending, setIsSending] = useState(false)
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation()
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
toast.success('Role updated successfully')
},
onError: (error) => {
toast.error(error.message || 'Failed to update role')
},
})
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const handleSendInvitation = async () => {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
return
}
setIsSending(true)
try {
await sendInvitation.mutateAsync({ userId })
toast.success(`Invitation sent to ${userEmail}`)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
} finally {
setIsSending(false)
}
}
return (
<div className="space-y-2 pt-2">
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/members/${userId}`}>
<UserCog className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={handleSendInvitation}
disabled={(userStatus !== 'NONE' && userStatus !== 'INVITED') || isSending}
>
{isSending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Invite
</Button>
</div>
{canChangeRole && (
<select
value={userRole}
onChange={(e) => updateUser.mutate({ id: userId, role: e.target.value as Role })}
disabled={updateUser.isPending}
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
>
{(isSuperAdmin
? (['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
: (['JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
).map((role) => (
<option key={role} value={role}>
{ROLE_LABELS[role]}
</option>
))}
</select>
)}
</div>
)
}
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { toast } from 'sonner'
import {
MoreHorizontal,
Mail,
UserCog,
Trash2,
Loader2,
Shield,
Check,
} from 'lucide-react'
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
const ROLE_LABELS: Record<Role, string> = {
SUPER_ADMIN: 'Super Admin',
PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
}
interface UserActionsProps {
userId: string
userEmail: string
userStatus: string
userRole: Role
currentUserRole?: Role
}
export function UserActions({ userId, userEmail, userStatus, userRole, currentUserRole }: UserActionsProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false)
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation()
const deleteUser = trpc.user.delete.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
},
})
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
toast.success('Role updated successfully')
},
onError: (error) => {
toast.error(error.message || 'Failed to update role')
},
})
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
// Determine which roles can be assigned
const getAvailableRoles = (): Role[] => {
if (isSuperAdmin) {
return ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']
}
// Program admins can only assign lower roles
return ['JURY_MEMBER', 'MENTOR', 'OBSERVER']
}
// Can this user's role be changed by the current user?
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const handleRoleChange = (newRole: Role) => {
if (newRole === userRole) return
updateUser.mutate({ id: userId, role: newRole })
}
const handleSendInvitation = async () => {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
return
}
setIsSending(true)
try {
await sendInvitation.mutateAsync({ userId })
toast.success(`Invitation sent to ${userEmail}`)
// Invalidate in case status changed
utils.user.list.invalidate()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
} finally {
setIsSending(false)
}
}
const handleDelete = async () => {
try {
await deleteUser.mutateAsync({ id: userId })
toast.success('User deleted successfully')
setShowDeleteDialog(false)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete user')
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/members/${userId}`}>
<UserCog className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{canChangeRole && (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={updateUser.isPending}>
<Shield className="mr-2 h-4 w-4" />
{updateUser.isPending ? 'Updating...' : 'Change Role'}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{getAvailableRoles().map((role) => (
<DropdownMenuItem
key={role}
onClick={() => handleRoleChange(role)}
disabled={role === userRole}
>
{role === userRole && <Check className="mr-2 h-4 w-4" />}
<span className={role === userRole ? 'font-medium' : role !== userRole ? 'ml-6' : ''}>
{ROLE_LABELS[role]}
</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem
onClick={handleSendInvitation}
disabled={(userStatus !== 'NONE' && userStatus !== 'INVITED') || isSending}
>
<Mail className="mr-2 h-4 w-4" />
{isSending ? 'Sending...' : 'Send Invite'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {userEmail}? This action cannot be
undone and will remove all their assignments and evaluations.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteUser.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
interface UserMobileActionsProps {
userId: string
userEmail: string
userStatus: string
userRole: Role
currentUserRole?: Role
}
export function UserMobileActions({
userId,
userEmail,
userStatus,
userRole,
currentUserRole,
}: UserMobileActionsProps) {
const [isSending, setIsSending] = useState(false)
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation()
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
toast.success('Role updated successfully')
},
onError: (error) => {
toast.error(error.message || 'Failed to update role')
},
})
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const handleSendInvitation = async () => {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
return
}
setIsSending(true)
try {
await sendInvitation.mutateAsync({ userId })
toast.success(`Invitation sent to ${userEmail}`)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
} finally {
setIsSending(false)
}
}
return (
<div className="space-y-2 pt-2">
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/members/${userId}`}>
<UserCog className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={handleSendInvitation}
disabled={(userStatus !== 'NONE' && userStatus !== 'INVITED') || isSending}
>
{isSending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Invite
</Button>
</div>
{canChangeRole && (
<select
value={userRole}
onChange={(e) => updateUser.mutate({ id: userId, role: e.target.value as Role })}
disabled={updateUser.isPending}
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
>
{(isSuperAdmin
? (['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
: (['JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
).map((role) => (
<option key={role} value={role}>
{ROLE_LABELS[role]}
</option>
))}
</select>
)}
</div>
)
}