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>
)
}

View File

@@ -1,153 +1,153 @@
'use client'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface StageComparison {
stageId: string
stageName: string
projectCount: number
evaluationCount: number
completionRate: number
averageScore: number | null
scoreDistribution: { score: number; count: number }[]
}
interface CrossStageComparisonProps {
data: StageComparison[]
}
const STAGE_COLORS = ['#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f']
export function CrossStageComparisonChart({ data }: CrossStageComparisonProps) {
// Prepare comparison data
const comparisonData = data.map((stage, i) => ({
name: stage.stageName.length > 20 ? stage.stageName.slice(0, 20) + '...' : stage.stageName,
projects: stage.projectCount,
evaluations: stage.evaluationCount,
completionRate: stage.completionRate,
avgScore: stage.averageScore ? parseFloat(stage.averageScore.toFixed(2)) : 0,
color: STAGE_COLORS[i % STAGE_COLORS.length],
}))
return (
<div className="space-y-6">
{/* Metrics Comparison */}
<Card>
<CardHeader>
<CardTitle>Stage Metrics Comparison</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[350px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={comparisonData}
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="name"
angle={-25}
textAnchor="end"
height={60}
tick={{ fontSize: 12 }}
/>
<YAxis />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Legend />
<Bar dataKey="projects" name="Projects" fill="#053d57" radius={[4, 4, 0, 0]} />
<Bar dataKey="evaluations" name="Evaluations" fill="#557f8c" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Completion & Score Comparison */}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Completion Rate by Stage</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={comparisonData}
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="name"
angle={-25}
textAnchor="end"
height={60}
tick={{ fontSize: 12 }}
/>
<YAxis domain={[0, 100]} unit="%" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Bar dataKey="completionRate" name="Completion %" fill="#6ad82f" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Average Score by Stage</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={comparisonData}
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="name"
angle={-25}
textAnchor="end"
height={60}
tick={{ fontSize: 12 }}
/>
<YAxis domain={[0, 10]} />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Bar dataKey="avgScore" name="Avg Score" fill="#de0f1e" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
'use client'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface StageComparison {
stageId: string
stageName: string
projectCount: number
evaluationCount: number
completionRate: number
averageScore: number | null
scoreDistribution: { score: number; count: number }[]
}
interface CrossStageComparisonProps {
data: StageComparison[]
}
const STAGE_COLORS = ['#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f']
export function CrossStageComparisonChart({ data }: CrossStageComparisonProps) {
// Prepare comparison data
const comparisonData = data.map((stage, i) => ({
name: stage.stageName.length > 20 ? stage.stageName.slice(0, 20) + '...' : stage.stageName,
projects: stage.projectCount,
evaluations: stage.evaluationCount,
completionRate: stage.completionRate,
avgScore: stage.averageScore ? parseFloat(stage.averageScore.toFixed(2)) : 0,
color: STAGE_COLORS[i % STAGE_COLORS.length],
}))
return (
<div className="space-y-6">
{/* Metrics Comparison */}
<Card>
<CardHeader>
<CardTitle>Stage Metrics Comparison</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[350px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={comparisonData}
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="name"
angle={-25}
textAnchor="end"
height={60}
tick={{ fontSize: 12 }}
/>
<YAxis />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Legend />
<Bar dataKey="projects" name="Projects" fill="#053d57" radius={[4, 4, 0, 0]} />
<Bar dataKey="evaluations" name="Evaluations" fill="#557f8c" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Completion & Score Comparison */}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Completion Rate by Stage</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={comparisonData}
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="name"
angle={-25}
textAnchor="end"
height={60}
tick={{ fontSize: 12 }}
/>
<YAxis domain={[0, 100]} unit="%" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Bar dataKey="completionRate" name="Completion %" fill="#6ad82f" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Average Score by Stage</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={comparisonData}
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="name"
angle={-25}
textAnchor="end"
height={60}
tick={{ fontSize: 12 }}
/>
<YAxis domain={[0, 10]} />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Bar dataKey="avgScore" name="Avg Score" fill="#de0f1e" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,279 +1,279 @@
'use client'
import {
PieChart,
Pie,
Cell,
Tooltip,
ResponsiveContainer,
Legend,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
interface DiversityData {
total: number
byCountry: { country: string; count: number; percentage: number }[]
byCategory: { category: string; count: number; percentage: number }[]
byOceanIssue: { issue: string; count: number; percentage: number }[]
byTag: { tag: string; count: number; percentage: number }[]
}
interface DiversityMetricsProps {
data: DiversityData
}
const PIE_COLORS = [
'#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f',
'#3be31e', '#c9c052', '#e6382f', '#ed6141', '#0bd90f',
'#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1',
]
/** Convert ISO 3166-1 alpha-2 code to full country name using Intl API */
function getCountryName(code: string): string {
if (code === 'Others') return 'Others'
try {
const displayNames = new Intl.DisplayNames(['en'], { type: 'region' })
return displayNames.of(code.toUpperCase()) || code
} catch {
return code
}
}
/** Convert SCREAMING_SNAKE_CASE to Title Case */
function formatLabel(value: string): string {
if (!value) return value
return value
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase())
}
/** Custom tooltip for the pie chart */
function CountryTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { country: string; count: number; percentage: number } }> }) {
if (!active || !payload?.length) return null
const d = payload[0].payload
return (
<div className="rounded-md border bg-card px-3 py-2 text-sm shadow-md">
<p className="font-medium">{getCountryName(d.country)}</p>
<p className="text-muted-foreground">{d.count} projects ({d.percentage.toFixed(1)}%)</p>
</div>
)
}
/** Custom tooltip for bar charts */
function BarTooltip({ active, payload, labelFormatter }: { active?: boolean; payload?: Array<{ value: number }>; label?: string; labelFormatter: (val: string) => string }) {
if (!active || !payload?.length) return null
const entry = payload[0]
const rawPayload = entry as unknown as { payload: Record<string, unknown> }
const dataPoint = rawPayload.payload
const rawLabel = (dataPoint.category || dataPoint.issue || '') as string
return (
<div className="rounded-md border bg-card px-3 py-2 text-sm shadow-md">
<p className="font-medium">{labelFormatter(rawLabel)}</p>
<p className="text-muted-foreground">{entry.value} projects</p>
</div>
)
}
export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
if (data.total === 0) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">No project data available</p>
</CardContent>
</Card>
)
}
// Top countries for pie chart (max 10, others grouped)
const topCountries = data.byCountry.slice(0, 10)
const otherCountries = data.byCountry.slice(10)
const countryPieData = otherCountries.length > 0
? [...topCountries, {
country: 'Others',
count: otherCountries.reduce((sum, c) => sum + c.count, 0),
percentage: otherCountries.reduce((sum, c) => sum + c.percentage, 0),
}]
: topCountries
// Pre-format category and ocean issue data for display
const formattedCategories = data.byCategory.slice(0, 10).map((c) => ({
...c,
category: formatLabel(c.category),
}))
const formattedOceanIssues = data.byOceanIssue.slice(0, 15).map((o) => ({
...o,
issue: formatLabel(o.issue),
}))
return (
<div className="space-y-6">
{/* Summary */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{data.total}</div>
<p className="text-sm text-muted-foreground">Total Projects</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{data.byCountry.length}</div>
<p className="text-sm text-muted-foreground">Countries Represented</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{data.byCategory.length}</div>
<p className="text-sm text-muted-foreground">Categories</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{data.byTag.length}</div>
<p className="text-sm text-muted-foreground">Unique Tags</p>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Country Distribution */}
<Card>
<CardHeader>
<CardTitle>Geographic Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={countryPieData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={120}
paddingAngle={2}
dataKey="count"
nameKey="country"
label={((props: unknown) => {
const p = props as { country: string; percentage: number }
return `${getCountryName(p.country)} (${p.percentage.toFixed(0)}%)`
}) as unknown as boolean}
fontSize={13}
>
{countryPieData.map((_, index) => (
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip content={<CountryTooltip />} />
<Legend
formatter={(value: string) => getCountryName(value)}
wrapperStyle={{ fontSize: '13px' }}
/>
</PieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Category Distribution */}
<Card>
<CardHeader>
<CardTitle>Competition Categories</CardTitle>
</CardHeader>
<CardContent>
{formattedCategories.length > 0 ? (
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={formattedCategories}
layout="vertical"
margin={{ top: 5, right: 30, bottom: 5, left: 120 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis type="number" tick={{ fontSize: 13 }} />
<YAxis
type="category"
dataKey="category"
width={110}
tick={{ fontSize: 13 }}
/>
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
<Bar dataKey="count" fill="#053d57" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
) : (
<p className="text-muted-foreground text-center py-8">No category data</p>
)}
</CardContent>
</Card>
</div>
{/* Ocean Issues */}
{formattedOceanIssues.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Ocean Issues Addressed</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={formattedOceanIssues}
margin={{ top: 20, right: 30, bottom: 80, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="issue"
angle={-35}
textAnchor="end"
height={100}
tick={{ fontSize: 12 }}
interval={0}
/>
<YAxis tick={{ fontSize: 13 }} />
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
<Bar dataKey="count" fill="#557f8c" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)}
{/* Tags Cloud */}
{data.byTag.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Project Tags</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{data.byTag.slice(0, 30).map((tag) => (
<Badge
key={tag.tag}
variant="secondary"
className="text-sm"
style={{
fontSize: `${Math.max(0.75, Math.min(1.4, 0.75 + tag.percentage / 20))}rem`,
}}
>
{tag.tag} ({tag.count})
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}
'use client'
import {
PieChart,
Pie,
Cell,
Tooltip,
ResponsiveContainer,
Legend,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
interface DiversityData {
total: number
byCountry: { country: string; count: number; percentage: number }[]
byCategory: { category: string; count: number; percentage: number }[]
byOceanIssue: { issue: string; count: number; percentage: number }[]
byTag: { tag: string; count: number; percentage: number }[]
}
interface DiversityMetricsProps {
data: DiversityData
}
const PIE_COLORS = [
'#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f',
'#3be31e', '#c9c052', '#e6382f', '#ed6141', '#0bd90f',
'#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1',
]
/** Convert ISO 3166-1 alpha-2 code to full country name using Intl API */
function getCountryName(code: string): string {
if (code === 'Others') return 'Others'
try {
const displayNames = new Intl.DisplayNames(['en'], { type: 'region' })
return displayNames.of(code.toUpperCase()) || code
} catch {
return code
}
}
/** Convert SCREAMING_SNAKE_CASE to Title Case */
function formatLabel(value: string): string {
if (!value) return value
return value
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase())
}
/** Custom tooltip for the pie chart */
function CountryTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { country: string; count: number; percentage: number } }> }) {
if (!active || !payload?.length) return null
const d = payload[0].payload
return (
<div className="rounded-md border bg-card px-3 py-2 text-sm shadow-md">
<p className="font-medium">{getCountryName(d.country)}</p>
<p className="text-muted-foreground">{d.count} projects ({d.percentage.toFixed(1)}%)</p>
</div>
)
}
/** Custom tooltip for bar charts */
function BarTooltip({ active, payload, labelFormatter }: { active?: boolean; payload?: Array<{ value: number }>; label?: string; labelFormatter: (val: string) => string }) {
if (!active || !payload?.length) return null
const entry = payload[0]
const rawPayload = entry as unknown as { payload: Record<string, unknown> }
const dataPoint = rawPayload.payload
const rawLabel = (dataPoint.category || dataPoint.issue || '') as string
return (
<div className="rounded-md border bg-card px-3 py-2 text-sm shadow-md">
<p className="font-medium">{labelFormatter(rawLabel)}</p>
<p className="text-muted-foreground">{entry.value} projects</p>
</div>
)
}
export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
if (data.total === 0) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">No project data available</p>
</CardContent>
</Card>
)
}
// Top countries for pie chart (max 10, others grouped)
const topCountries = data.byCountry.slice(0, 10)
const otherCountries = data.byCountry.slice(10)
const countryPieData = otherCountries.length > 0
? [...topCountries, {
country: 'Others',
count: otherCountries.reduce((sum, c) => sum + c.count, 0),
percentage: otherCountries.reduce((sum, c) => sum + c.percentage, 0),
}]
: topCountries
// Pre-format category and ocean issue data for display
const formattedCategories = data.byCategory.slice(0, 10).map((c) => ({
...c,
category: formatLabel(c.category),
}))
const formattedOceanIssues = data.byOceanIssue.slice(0, 15).map((o) => ({
...o,
issue: formatLabel(o.issue),
}))
return (
<div className="space-y-6">
{/* Summary */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{data.total}</div>
<p className="text-sm text-muted-foreground">Total Projects</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{data.byCountry.length}</div>
<p className="text-sm text-muted-foreground">Countries Represented</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{data.byCategory.length}</div>
<p className="text-sm text-muted-foreground">Categories</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{data.byTag.length}</div>
<p className="text-sm text-muted-foreground">Unique Tags</p>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Country Distribution */}
<Card>
<CardHeader>
<CardTitle>Geographic Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={countryPieData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={120}
paddingAngle={2}
dataKey="count"
nameKey="country"
label={((props: unknown) => {
const p = props as { country: string; percentage: number }
return `${getCountryName(p.country)} (${p.percentage.toFixed(0)}%)`
}) as unknown as boolean}
fontSize={13}
>
{countryPieData.map((_, index) => (
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip content={<CountryTooltip />} />
<Legend
formatter={(value: string) => getCountryName(value)}
wrapperStyle={{ fontSize: '13px' }}
/>
</PieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Category Distribution */}
<Card>
<CardHeader>
<CardTitle>Competition Categories</CardTitle>
</CardHeader>
<CardContent>
{formattedCategories.length > 0 ? (
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={formattedCategories}
layout="vertical"
margin={{ top: 5, right: 30, bottom: 5, left: 120 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis type="number" tick={{ fontSize: 13 }} />
<YAxis
type="category"
dataKey="category"
width={110}
tick={{ fontSize: 13 }}
/>
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
<Bar dataKey="count" fill="#053d57" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
) : (
<p className="text-muted-foreground text-center py-8">No category data</p>
)}
</CardContent>
</Card>
</div>
{/* Ocean Issues */}
{formattedOceanIssues.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Ocean Issues Addressed</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={formattedOceanIssues}
margin={{ top: 20, right: 30, bottom: 80, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="issue"
angle={-35}
textAnchor="end"
height={100}
tick={{ fontSize: 12 }}
interval={0}
/>
<YAxis tick={{ fontSize: 13 }} />
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
<Bar dataKey="count" fill="#557f8c" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)}
{/* Tags Cloud */}
{data.byTag.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Project Tags</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{data.byTag.slice(0, 30).map((tag) => (
<Badge
key={tag.tag}
variant="secondary"
className="text-sm"
style={{
fontSize: `${Math.max(0.75, Math.min(1.4, 0.75 + tag.percentage / 20))}rem`,
}}
>
{tag.tag} ({tag.count})
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -1,12 +1,12 @@
export { ScoreDistributionChart } from './score-distribution'
export { EvaluationTimelineChart } from './evaluation-timeline'
export { StatusBreakdownChart } from './status-breakdown'
export { JurorWorkloadChart } from './juror-workload'
export { ProjectRankingsChart } from './project-rankings'
export { CriteriaScoresChart } from './criteria-scores'
export { GeographicDistribution } from './geographic-distribution'
export { GeographicSummaryCard } from './geographic-summary-card'
// Advanced analytics charts (F10)
export { CrossStageComparisonChart } from './cross-round-comparison'
export { JurorConsistencyChart } from './juror-consistency'
export { DiversityMetricsChart } from './diversity-metrics'
export { ScoreDistributionChart } from './score-distribution'
export { EvaluationTimelineChart } from './evaluation-timeline'
export { StatusBreakdownChart } from './status-breakdown'
export { JurorWorkloadChart } from './juror-workload'
export { ProjectRankingsChart } from './project-rankings'
export { CriteriaScoresChart } from './criteria-scores'
export { GeographicDistribution } from './geographic-distribution'
export { GeographicSummaryCard } from './geographic-summary-card'
// Advanced analytics charts (F10)
export { CrossStageComparisonChart } from './cross-round-comparison'
export { JurorConsistencyChart } from './juror-consistency'
export { DiversityMetricsChart } from './diversity-metrics'

View File

@@ -1,171 +1,171 @@
'use client'
import {
ScatterChart,
Scatter,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { AlertTriangle } from 'lucide-react'
interface JurorMetric {
userId: string
name: string
email: string
evaluationCount: number
averageScore: number
stddev: number
deviationFromOverall: number
isOutlier: boolean
}
interface JurorConsistencyProps {
data: {
overallAverage: number
jurors: JurorMetric[]
}
}
export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
const scatterData = data.jurors.map((j) => ({
name: j.name,
avgScore: parseFloat(j.averageScore.toFixed(2)),
stddev: parseFloat(j.stddev.toFixed(2)),
evaluations: j.evaluationCount,
isOutlier: j.isOutlier,
}))
const outlierCount = data.jurors.filter((j) => j.isOutlier).length
return (
<div className="space-y-6">
{/* Scatter: Average Score vs Standard Deviation */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Juror Scoring Patterns</span>
<span className="text-sm font-normal text-muted-foreground">
Overall Avg: {data.overallAverage.toFixed(2)}
{outlierCount > 0 && (
<Badge variant="destructive" className="ml-2">
{outlierCount} outlier{outlierCount > 1 ? 's' : ''}
</Badge>
)}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
type="number"
dataKey="avgScore"
name="Average Score"
domain={[0, 10]}
label={{ value: 'Average Score', position: 'insideBottom', offset: -10 }}
/>
<YAxis
type="number"
dataKey="stddev"
name="Std Deviation"
label={{ value: 'Std Deviation', angle: -90, position: 'insideLeft' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<ReferenceLine
x={data.overallAverage}
stroke="#de0f1e"
strokeDasharray="3 3"
label={{ value: 'Avg', fill: '#de0f1e', position: 'top' }}
/>
<Scatter data={scatterData} fill="#053d57">
{scatterData.map((entry, index) => (
<circle
key={index}
r={Math.max(4, entry.evaluations)}
fill={entry.isOutlier ? '#de0f1e' : '#053d57'}
fillOpacity={0.7}
/>
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
<p className="text-xs text-muted-foreground mt-2 text-center">
Dot size represents number of evaluations. Red dots indicate outlier jurors (2+ points from mean).
</p>
</CardContent>
</Card>
{/* Juror details table */}
<Card>
<CardHeader>
<CardTitle>Juror Consistency Details</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Juror</TableHead>
<TableHead className="text-right">Evaluations</TableHead>
<TableHead className="text-right">Avg Score</TableHead>
<TableHead className="text-right">Std Dev</TableHead>
<TableHead className="text-right">Deviation from Mean</TableHead>
<TableHead className="text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.jurors.map((juror) => (
<TableRow key={juror.userId} className={juror.isOutlier ? 'bg-destructive/5' : ''}>
<TableCell>
<div>
<p className="font-medium">{juror.name}</p>
<p className="text-xs text-muted-foreground">{juror.email}</p>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">{juror.evaluationCount}</TableCell>
<TableCell className="text-right tabular-nums">{juror.averageScore.toFixed(2)}</TableCell>
<TableCell className="text-right tabular-nums">{juror.stddev.toFixed(2)}</TableCell>
<TableCell className="text-right tabular-nums">
{juror.deviationFromOverall.toFixed(2)}
</TableCell>
<TableCell className="text-center">
{juror.isOutlier ? (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
Outlier
</Badge>
) : (
<Badge variant="secondary">Normal</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}
'use client'
import {
ScatterChart,
Scatter,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { AlertTriangle } from 'lucide-react'
interface JurorMetric {
userId: string
name: string
email: string
evaluationCount: number
averageScore: number
stddev: number
deviationFromOverall: number
isOutlier: boolean
}
interface JurorConsistencyProps {
data: {
overallAverage: number
jurors: JurorMetric[]
}
}
export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
const scatterData = data.jurors.map((j) => ({
name: j.name,
avgScore: parseFloat(j.averageScore.toFixed(2)),
stddev: parseFloat(j.stddev.toFixed(2)),
evaluations: j.evaluationCount,
isOutlier: j.isOutlier,
}))
const outlierCount = data.jurors.filter((j) => j.isOutlier).length
return (
<div className="space-y-6">
{/* Scatter: Average Score vs Standard Deviation */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Juror Scoring Patterns</span>
<span className="text-sm font-normal text-muted-foreground">
Overall Avg: {data.overallAverage.toFixed(2)}
{outlierCount > 0 && (
<Badge variant="destructive" className="ml-2">
{outlierCount} outlier{outlierCount > 1 ? 's' : ''}
</Badge>
)}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
type="number"
dataKey="avgScore"
name="Average Score"
domain={[0, 10]}
label={{ value: 'Average Score', position: 'insideBottom', offset: -10 }}
/>
<YAxis
type="number"
dataKey="stddev"
name="Std Deviation"
label={{ value: 'Std Deviation', angle: -90, position: 'insideLeft' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<ReferenceLine
x={data.overallAverage}
stroke="#de0f1e"
strokeDasharray="3 3"
label={{ value: 'Avg', fill: '#de0f1e', position: 'top' }}
/>
<Scatter data={scatterData} fill="#053d57">
{scatterData.map((entry, index) => (
<circle
key={index}
r={Math.max(4, entry.evaluations)}
fill={entry.isOutlier ? '#de0f1e' : '#053d57'}
fillOpacity={0.7}
/>
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
<p className="text-xs text-muted-foreground mt-2 text-center">
Dot size represents number of evaluations. Red dots indicate outlier jurors (2+ points from mean).
</p>
</CardContent>
</Card>
{/* Juror details table */}
<Card>
<CardHeader>
<CardTitle>Juror Consistency Details</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Juror</TableHead>
<TableHead className="text-right">Evaluations</TableHead>
<TableHead className="text-right">Avg Score</TableHead>
<TableHead className="text-right">Std Dev</TableHead>
<TableHead className="text-right">Deviation from Mean</TableHead>
<TableHead className="text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.jurors.map((juror) => (
<TableRow key={juror.userId} className={juror.isOutlier ? 'bg-destructive/5' : ''}>
<TableCell>
<div>
<p className="font-medium">{juror.name}</p>
<p className="text-xs text-muted-foreground">{juror.email}</p>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">{juror.evaluationCount}</TableCell>
<TableCell className="text-right tabular-nums">{juror.averageScore.toFixed(2)}</TableCell>
<TableCell className="text-right tabular-nums">{juror.stddev.toFixed(2)}</TableCell>
<TableCell className="text-right tabular-nums">
{juror.deviationFromOverall.toFixed(2)}
</TableCell>
<TableCell className="text-center">
{juror.isOutlier ? (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
Outlier
</Badge>
) : (
<Badge variant="secondary">Normal</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,142 +1,142 @@
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import { Calendar, GraduationCap, Heart } from 'lucide-react'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import type { ApplicationFormData } from '@/server/routers/application'
import type { WizardConfig } from '@/types/wizard-config'
interface StepAdditionalProps {
form: UseFormReturn<ApplicationFormData>
isBusinessConcept: boolean
isStartup: boolean
config?: WizardConfig
}
export function StepAdditional({ form, isBusinessConcept, isStartup, config }: StepAdditionalProps) {
const { register, formState: { errors }, setValue, watch } = form
const wantsMentorship = watch('wantsMentorship')
return (
<WizardStepContent
title="A few more details"
description="Help us understand your background and needs."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-md space-y-8"
>
{/* Institution (for Business Concepts) */}
{isBusinessConcept && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-2"
>
<div className="flex items-center gap-2">
<GraduationCap className="h-5 w-5 text-muted-foreground" />
<Label htmlFor="institution">
University/School <span className="text-destructive">*</span>
</Label>
</div>
<Input
id="institution"
placeholder="MIT, Stanford, INSEAD..."
{...register('institution')}
className="h-12 text-base"
/>
{errors.institution && (
<p className="text-sm text-destructive">{errors.institution.message}</p>
)}
<p className="text-xs text-muted-foreground">
Enter the name of your university or educational institution.
</p>
</motion.div>
)}
{/* Startup Created Date (for Startups) */}
{isStartup && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-2"
>
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-muted-foreground" />
<Label htmlFor="startupCreatedDate">
When was your startup created? <span className="text-destructive">*</span>
</Label>
</div>
<Input
id="startupCreatedDate"
type="date"
{...register('startupCreatedDate')}
className="h-12 text-base"
/>
{errors.startupCreatedDate && (
<p className="text-sm text-destructive">{errors.startupCreatedDate.message}</p>
)}
<p className="text-xs text-muted-foreground">
Enter the date your startup was officially registered or founded.
</p>
</motion.div>
)}
{/* Mentorship */}
{config?.features?.enableMentorship !== false && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="rounded-lg border bg-card p-6"
>
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Heart className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<Label htmlFor="wantsMentorship" className="text-base font-medium">
Would you like mentorship support?
</Label>
<Switch
id="wantsMentorship"
checked={wantsMentorship}
onCheckedChange={(checked) => setValue('wantsMentorship', checked)}
/>
</div>
<p className="mt-2 text-sm text-muted-foreground">
Our mentors are industry experts who can help guide your project.
This is optional but highly recommended.
</p>
</div>
</div>
</motion.div>
)}
{/* Referral Source */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="space-y-2"
>
<Label htmlFor="referralSource">
How did you hear about us? <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id="referralSource"
placeholder="Friend, Social media, Event..."
{...register('referralSource')}
className="h-12 text-base"
/>
</motion.div>
</motion.div>
</WizardStepContent>
)
}
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import { Calendar, GraduationCap, Heart } from 'lucide-react'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import type { ApplicationFormData } from '@/server/routers/application'
import type { WizardConfig } from '@/types/wizard-config'
interface StepAdditionalProps {
form: UseFormReturn<ApplicationFormData>
isBusinessConcept: boolean
isStartup: boolean
config?: WizardConfig
}
export function StepAdditional({ form, isBusinessConcept, isStartup, config }: StepAdditionalProps) {
const { register, formState: { errors }, setValue, watch } = form
const wantsMentorship = watch('wantsMentorship')
return (
<WizardStepContent
title="A few more details"
description="Help us understand your background and needs."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-md space-y-8"
>
{/* Institution (for Business Concepts) */}
{isBusinessConcept && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-2"
>
<div className="flex items-center gap-2">
<GraduationCap className="h-5 w-5 text-muted-foreground" />
<Label htmlFor="institution">
University/School <span className="text-destructive">*</span>
</Label>
</div>
<Input
id="institution"
placeholder="MIT, Stanford, INSEAD..."
{...register('institution')}
className="h-12 text-base"
/>
{errors.institution && (
<p className="text-sm text-destructive">{errors.institution.message}</p>
)}
<p className="text-xs text-muted-foreground">
Enter the name of your university or educational institution.
</p>
</motion.div>
)}
{/* Startup Created Date (for Startups) */}
{isStartup && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-2"
>
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-muted-foreground" />
<Label htmlFor="startupCreatedDate">
When was your startup created? <span className="text-destructive">*</span>
</Label>
</div>
<Input
id="startupCreatedDate"
type="date"
{...register('startupCreatedDate')}
className="h-12 text-base"
/>
{errors.startupCreatedDate && (
<p className="text-sm text-destructive">{errors.startupCreatedDate.message}</p>
)}
<p className="text-xs text-muted-foreground">
Enter the date your startup was officially registered or founded.
</p>
</motion.div>
)}
{/* Mentorship */}
{config?.features?.enableMentorship !== false && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="rounded-lg border bg-card p-6"
>
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Heart className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<Label htmlFor="wantsMentorship" className="text-base font-medium">
Would you like mentorship support?
</Label>
<Switch
id="wantsMentorship"
checked={wantsMentorship}
onCheckedChange={(checked) => setValue('wantsMentorship', checked)}
/>
</div>
<p className="mt-2 text-sm text-muted-foreground">
Our mentors are industry experts who can help guide your project.
This is optional but highly recommended.
</p>
</div>
</div>
</motion.div>
)}
{/* Referral Source */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="space-y-2"
>
<Label htmlFor="referralSource">
How did you hear about us? <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id="referralSource"
placeholder="Friend, Social media, Event..."
{...register('referralSource')}
className="h-12 text-base"
/>
</motion.div>
</motion.div>
</WizardStepContent>
)
}

View File

@@ -1,130 +1,130 @@
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PhoneInput } from '@/components/ui/phone-input'
import { CountrySelect } from '@/components/ui/country-select'
import type { ApplicationFormData } from '@/server/routers/application'
import type { WizardConfig } from '@/types/wizard-config'
import { isFieldVisible, isFieldRequired, getFieldConfig } from '@/lib/wizard-config'
interface StepContactProps {
form: UseFormReturn<ApplicationFormData>
config?: WizardConfig
}
export function StepContact({ form, config }: StepContactProps) {
const { register, formState: { errors }, setValue, watch } = form
const country = watch('country')
const phone = watch('contactPhone')
const showPhone = !config || isFieldVisible(config, 'contactPhone')
const showCountry = !config || isFieldVisible(config, 'country')
const showCity = !config || isFieldVisible(config, 'city')
const phoneRequired = !config || isFieldRequired(config, 'contactPhone')
const countryRequired = !config || isFieldRequired(config, 'country')
const phoneLabel = config ? getFieldConfig(config, 'contactPhone').label : undefined
const countryLabel = config ? getFieldConfig(config, 'country').label : undefined
return (
<WizardStepContent
title="Tell us about yourself"
description="We'll use this information to contact you about your application."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-md space-y-6"
>
{/* Full Name */}
<div className="space-y-2">
<Label htmlFor="contactName">
Full Name <span className="text-destructive">*</span>
</Label>
<Input
id="contactName"
placeholder="John Smith"
{...register('contactName')}
className="h-12 text-base"
/>
{errors.contactName && (
<p className="text-sm text-destructive">{errors.contactName.message}</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="contactEmail">
Email Address <span className="text-destructive">*</span>
</Label>
<Input
id="contactEmail"
type="email"
placeholder="john@example.com"
{...register('contactEmail')}
className="h-12 text-base"
/>
{errors.contactEmail && (
<p className="text-sm text-destructive">{errors.contactEmail.message}</p>
)}
</div>
{/* Phone */}
{showPhone && (
<div className="space-y-2">
<Label htmlFor="contactPhone">
{phoneLabel ?? 'Phone Number'}{' '}
{phoneRequired ? <span className="text-destructive">*</span> : <span className="text-muted-foreground text-xs">(optional)</span>}
</Label>
<PhoneInput
value={phone}
onChange={(value) => setValue('contactPhone', value || '')}
defaultCountry="MC"
className="h-12"
/>
{errors.contactPhone && (
<p className="text-sm text-destructive">{errors.contactPhone.message}</p>
)}
</div>
)}
{/* Country */}
{showCountry && (
<div className="space-y-2">
<Label>
{countryLabel ?? 'Country'}{' '}
{countryRequired ? <span className="text-destructive">*</span> : <span className="text-muted-foreground text-xs">(optional)</span>}
</Label>
<CountrySelect
value={country}
onChange={(value) => setValue('country', value)}
placeholder="Select your country"
className="h-12"
/>
{errors.country && (
<p className="text-sm text-destructive">{errors.country.message}</p>
)}
</div>
)}
{/* City (optional) */}
{showCity && (
<div className="space-y-2">
<Label htmlFor="city">
City <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id="city"
placeholder="Monaco"
{...register('city')}
className="h-12 text-base"
/>
</div>
)}
</motion.div>
</WizardStepContent>
)
}
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PhoneInput } from '@/components/ui/phone-input'
import { CountrySelect } from '@/components/ui/country-select'
import type { ApplicationFormData } from '@/server/routers/application'
import type { WizardConfig } from '@/types/wizard-config'
import { isFieldVisible, isFieldRequired, getFieldConfig } from '@/lib/wizard-config'
interface StepContactProps {
form: UseFormReturn<ApplicationFormData>
config?: WizardConfig
}
export function StepContact({ form, config }: StepContactProps) {
const { register, formState: { errors }, setValue, watch } = form
const country = watch('country')
const phone = watch('contactPhone')
const showPhone = !config || isFieldVisible(config, 'contactPhone')
const showCountry = !config || isFieldVisible(config, 'country')
const showCity = !config || isFieldVisible(config, 'city')
const phoneRequired = !config || isFieldRequired(config, 'contactPhone')
const countryRequired = !config || isFieldRequired(config, 'country')
const phoneLabel = config ? getFieldConfig(config, 'contactPhone').label : undefined
const countryLabel = config ? getFieldConfig(config, 'country').label : undefined
return (
<WizardStepContent
title="Tell us about yourself"
description="We'll use this information to contact you about your application."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-md space-y-6"
>
{/* Full Name */}
<div className="space-y-2">
<Label htmlFor="contactName">
Full Name <span className="text-destructive">*</span>
</Label>
<Input
id="contactName"
placeholder="John Smith"
{...register('contactName')}
className="h-12 text-base"
/>
{errors.contactName && (
<p className="text-sm text-destructive">{errors.contactName.message}</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="contactEmail">
Email Address <span className="text-destructive">*</span>
</Label>
<Input
id="contactEmail"
type="email"
placeholder="john@example.com"
{...register('contactEmail')}
className="h-12 text-base"
/>
{errors.contactEmail && (
<p className="text-sm text-destructive">{errors.contactEmail.message}</p>
)}
</div>
{/* Phone */}
{showPhone && (
<div className="space-y-2">
<Label htmlFor="contactPhone">
{phoneLabel ?? 'Phone Number'}{' '}
{phoneRequired ? <span className="text-destructive">*</span> : <span className="text-muted-foreground text-xs">(optional)</span>}
</Label>
<PhoneInput
value={phone}
onChange={(value) => setValue('contactPhone', value || '')}
defaultCountry="MC"
className="h-12"
/>
{errors.contactPhone && (
<p className="text-sm text-destructive">{errors.contactPhone.message}</p>
)}
</div>
)}
{/* Country */}
{showCountry && (
<div className="space-y-2">
<Label>
{countryLabel ?? 'Country'}{' '}
{countryRequired ? <span className="text-destructive">*</span> : <span className="text-muted-foreground text-xs">(optional)</span>}
</Label>
<CountrySelect
value={country}
onChange={(value) => setValue('country', value)}
placeholder="Select your country"
className="h-12"
/>
{errors.country && (
<p className="text-sm text-destructive">{errors.country.message}</p>
)}
</div>
)}
{/* City (optional) */}
{showCity && (
<div className="space-y-2">
<Label htmlFor="city">
City <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id="city"
placeholder="Monaco"
{...register('city')}
className="h-12 text-base"
/>
</div>
)}
</motion.div>
</WizardStepContent>
)
}

View File

@@ -1,130 +1,130 @@
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import { WizardStepContent } from '@/components/forms/form-wizard'
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 type { ApplicationFormData } from '@/server/routers/application'
import { type DropdownOption, type WizardConfig, DEFAULT_OCEAN_ISSUES } from '@/types/wizard-config'
import { isFieldVisible, getFieldConfig } from '@/lib/wizard-config'
interface StepProjectProps {
form: UseFormReturn<ApplicationFormData>
oceanIssues?: DropdownOption[]
config?: WizardConfig
}
export function StepProject({ form, oceanIssues, config }: StepProjectProps) {
const issueOptions = oceanIssues ?? DEFAULT_OCEAN_ISSUES
const { register, formState: { errors }, setValue, watch } = form
const oceanIssue = watch('oceanIssue')
const description = watch('description') || ''
const showTeamName = !config || isFieldVisible(config, 'teamName')
const descriptionLabel = config ? getFieldConfig(config, 'description').label : undefined
return (
<WizardStepContent
title="Tell us about your project"
description="Share the details of your ocean protection initiative."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-lg space-y-6"
>
{/* Project Name */}
<div className="space-y-2">
<Label htmlFor="projectName">
Name of your project/startup <span className="text-destructive">*</span>
</Label>
<Input
id="projectName"
placeholder="Ocean Guardian AI"
{...register('projectName')}
className="h-12 text-base"
/>
{errors.projectName && (
<p className="text-sm text-destructive">{errors.projectName.message}</p>
)}
</div>
{/* Team Name (optional) */}
{showTeamName && (
<div className="space-y-2">
<Label htmlFor="teamName">
Team Name <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id="teamName"
placeholder="Blue Innovation Team"
{...register('teamName')}
className="h-12 text-base"
/>
</div>
)}
{/* Ocean Issue */}
<div className="space-y-2">
<Label>
What type of ocean issue does your project address? <span className="text-destructive">*</span>
</Label>
<Select
value={oceanIssue}
onValueChange={(value) => setValue('oceanIssue', value)}
>
<SelectTrigger className="h-12 text-base">
<SelectValue placeholder="Select an ocean issue" />
</SelectTrigger>
<SelectContent>
{issueOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.oceanIssue && (
<p className="text-sm text-destructive">{errors.oceanIssue.message}</p>
)}
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">
Briefly describe your project idea and objectives <span className="text-destructive">*</span>
</Label>
<p className="text-xs text-muted-foreground">
Keep it brief - you&apos;ll have the opportunity to provide more details later.
</p>
<Textarea
id="description"
placeholder="Our project aims to..."
rows={5}
maxLength={2000}
{...register('description')}
className="text-base resize-none"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{errors.description ? (
<span className="text-destructive">{errors.description.message}</span>
) : (
'Minimum 20 characters'
)}
</span>
<span>{description.length} characters</span>
</div>
</div>
</motion.div>
</WizardStepContent>
)
}
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import { WizardStepContent } from '@/components/forms/form-wizard'
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 type { ApplicationFormData } from '@/server/routers/application'
import { type DropdownOption, type WizardConfig, DEFAULT_OCEAN_ISSUES } from '@/types/wizard-config'
import { isFieldVisible, getFieldConfig } from '@/lib/wizard-config'
interface StepProjectProps {
form: UseFormReturn<ApplicationFormData>
oceanIssues?: DropdownOption[]
config?: WizardConfig
}
export function StepProject({ form, oceanIssues, config }: StepProjectProps) {
const issueOptions = oceanIssues ?? DEFAULT_OCEAN_ISSUES
const { register, formState: { errors }, setValue, watch } = form
const oceanIssue = watch('oceanIssue')
const description = watch('description') || ''
const showTeamName = !config || isFieldVisible(config, 'teamName')
const descriptionLabel = config ? getFieldConfig(config, 'description').label : undefined
return (
<WizardStepContent
title="Tell us about your project"
description="Share the details of your ocean protection initiative."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-lg space-y-6"
>
{/* Project Name */}
<div className="space-y-2">
<Label htmlFor="projectName">
Name of your project/startup <span className="text-destructive">*</span>
</Label>
<Input
id="projectName"
placeholder="Ocean Guardian AI"
{...register('projectName')}
className="h-12 text-base"
/>
{errors.projectName && (
<p className="text-sm text-destructive">{errors.projectName.message}</p>
)}
</div>
{/* Team Name (optional) */}
{showTeamName && (
<div className="space-y-2">
<Label htmlFor="teamName">
Team Name <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id="teamName"
placeholder="Blue Innovation Team"
{...register('teamName')}
className="h-12 text-base"
/>
</div>
)}
{/* Ocean Issue */}
<div className="space-y-2">
<Label>
What type of ocean issue does your project address? <span className="text-destructive">*</span>
</Label>
<Select
value={oceanIssue}
onValueChange={(value) => setValue('oceanIssue', value)}
>
<SelectTrigger className="h-12 text-base">
<SelectValue placeholder="Select an ocean issue" />
</SelectTrigger>
<SelectContent>
{issueOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.oceanIssue && (
<p className="text-sm text-destructive">{errors.oceanIssue.message}</p>
)}
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">
Briefly describe your project idea and objectives <span className="text-destructive">*</span>
</Label>
<p className="text-xs text-muted-foreground">
Keep it brief - you&apos;ll have the opportunity to provide more details later.
</p>
<Textarea
id="description"
placeholder="Our project aims to..."
rows={5}
maxLength={2000}
{...register('description')}
className="text-base resize-none"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{errors.description ? (
<span className="text-destructive">{errors.description.message}</span>
) : (
'Minimum 20 characters'
)}
</span>
<span>{description.length} characters</span>
</div>
</div>
</motion.div>
</WizardStepContent>
)
}

View File

@@ -1,206 +1,206 @@
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import {
User,
Mail,
Phone,
MapPin,
Briefcase,
Waves,
Users,
GraduationCap,
Calendar,
Heart,
MessageSquare,
CheckCircle2,
} from 'lucide-react'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import type { ApplicationFormData } from '@/server/routers/application'
import { countries } from '@/components/ui/country-select'
import { type WizardConfig, DEFAULT_OCEAN_ISSUES, DEFAULT_COMPETITION_CATEGORIES } from '@/types/wizard-config'
interface StepReviewProps {
form: UseFormReturn<ApplicationFormData>
programName: string
config?: WizardConfig
}
export function StepReview({ form, programName, config }: StepReviewProps) {
const { formState: { errors }, setValue, watch } = form
const data = watch()
const countryName = countries.find((c) => c.code === data.country)?.name || data.country
const getOceanIssueLabel = (value: string): string => {
const issues = config?.oceanIssues ?? DEFAULT_OCEAN_ISSUES
return issues.find((i) => i.value === value)?.label ?? value
}
const getCategoryLabel = (value: string): string => {
const cats = config?.competitionCategories ?? DEFAULT_COMPETITION_CATEGORIES
return cats.find((c) => c.value === value)?.label ?? value
}
return (
<WizardStepContent
title="Review your application"
description="Please review your information before submitting."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-lg space-y-6"
>
{/* Contact Info Section */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<User className="h-4 w-4" />
Contact Information
</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>{data.contactName}</span>
</div>
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
<span>{data.contactEmail}</span>
</div>
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-muted-foreground" />
<span>{data.contactPhone}</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span>{data.city ? `${data.city}, ${countryName}` : countryName}</span>
</div>
</div>
</div>
{/* Project Info Section */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<Briefcase className="h-4 w-4" />
Project Details
</h3>
<div className="space-y-3 text-sm">
<div>
<span className="text-muted-foreground">Project Name:</span>
<p className="font-medium">{data.projectName}</p>
</div>
{data.teamName && (
<div>
<span className="text-muted-foreground">Team Name:</span>
<p className="font-medium">{data.teamName}</p>
</div>
)}
<div className="flex items-center gap-2">
<Badge variant="secondary">
{getCategoryLabel(data.competitionCategory)}
</Badge>
</div>
<div className="flex items-center gap-2">
<Waves className="h-4 w-4 text-muted-foreground" />
<span>{getOceanIssueLabel(data.oceanIssue)}</span>
</div>
<div>
<span className="text-muted-foreground">Description:</span>
<p className="mt-1 text-sm text-foreground/80 whitespace-pre-wrap">
{data.description}
</p>
</div>
</div>
</div>
{/* Team Members Section */}
{data.teamMembers && data.teamMembers.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<Users className="h-4 w-4" />
Team Members ({data.teamMembers.length})
</h3>
<div className="space-y-2">
{data.teamMembers.map((member, index) => (
<div key={index} className="flex items-center justify-between text-sm">
<div>
<span className="font-medium">{member.name}</span>
<span className="text-muted-foreground"> - {member.email}</span>
</div>
<Badge variant="outline" className="text-xs">
{member.role === 'MEMBER' ? 'Member' : 'Advisor'}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Additional Info Section */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
Additional Information
</h3>
<div className="space-y-3 text-sm">
{data.institution && (
<div className="flex items-center gap-2">
<GraduationCap className="h-4 w-4 text-muted-foreground" />
<span>{data.institution}</span>
</div>
)}
{data.startupCreatedDate && (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>Founded: {new Date(data.startupCreatedDate).toLocaleDateString()}</span>
</div>
)}
<div className="flex items-center gap-2">
<Heart className={`h-4 w-4 ${data.wantsMentorship ? 'text-primary' : 'text-muted-foreground'}`} />
<span>
{data.wantsMentorship
? 'Interested in mentorship'
: 'Not interested in mentorship'}
</span>
</div>
{data.referralSource && (
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<span>Heard about us via: {data.referralSource}</span>
</div>
)}
</div>
</div>
{/* GDPR Consent */}
<div className="rounded-lg border-2 border-primary/20 bg-primary/5 p-4">
<div className="flex items-start gap-3">
<Checkbox
id="gdprConsent"
checked={data.gdprConsent}
onCheckedChange={(checked) => setValue('gdprConsent', checked === true)}
className="mt-1"
/>
<div className="flex-1">
<Label htmlFor="gdprConsent" className="text-sm font-medium">
I consent to the processing of my personal data <span className="text-destructive">*</span>
</Label>
<p className="mt-1 text-xs text-muted-foreground">
By submitting this application, I agree that {programName} may process my personal
data in accordance with their privacy policy. My data will be used solely for the
purpose of evaluating my application and communicating with me about the program.
</p>
</div>
</div>
{errors.gdprConsent && (
<p className="mt-2 text-sm text-destructive">{errors.gdprConsent.message}</p>
)}
</div>
</motion.div>
</WizardStepContent>
)
}
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import {
User,
Mail,
Phone,
MapPin,
Briefcase,
Waves,
Users,
GraduationCap,
Calendar,
Heart,
MessageSquare,
CheckCircle2,
} from 'lucide-react'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import type { ApplicationFormData } from '@/server/routers/application'
import { countries } from '@/components/ui/country-select'
import { type WizardConfig, DEFAULT_OCEAN_ISSUES, DEFAULT_COMPETITION_CATEGORIES } from '@/types/wizard-config'
interface StepReviewProps {
form: UseFormReturn<ApplicationFormData>
programName: string
config?: WizardConfig
}
export function StepReview({ form, programName, config }: StepReviewProps) {
const { formState: { errors }, setValue, watch } = form
const data = watch()
const countryName = countries.find((c) => c.code === data.country)?.name || data.country
const getOceanIssueLabel = (value: string): string => {
const issues = config?.oceanIssues ?? DEFAULT_OCEAN_ISSUES
return issues.find((i) => i.value === value)?.label ?? value
}
const getCategoryLabel = (value: string): string => {
const cats = config?.competitionCategories ?? DEFAULT_COMPETITION_CATEGORIES
return cats.find((c) => c.value === value)?.label ?? value
}
return (
<WizardStepContent
title="Review your application"
description="Please review your information before submitting."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-lg space-y-6"
>
{/* Contact Info Section */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<User className="h-4 w-4" />
Contact Information
</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>{data.contactName}</span>
</div>
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
<span>{data.contactEmail}</span>
</div>
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-muted-foreground" />
<span>{data.contactPhone}</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span>{data.city ? `${data.city}, ${countryName}` : countryName}</span>
</div>
</div>
</div>
{/* Project Info Section */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<Briefcase className="h-4 w-4" />
Project Details
</h3>
<div className="space-y-3 text-sm">
<div>
<span className="text-muted-foreground">Project Name:</span>
<p className="font-medium">{data.projectName}</p>
</div>
{data.teamName && (
<div>
<span className="text-muted-foreground">Team Name:</span>
<p className="font-medium">{data.teamName}</p>
</div>
)}
<div className="flex items-center gap-2">
<Badge variant="secondary">
{getCategoryLabel(data.competitionCategory)}
</Badge>
</div>
<div className="flex items-center gap-2">
<Waves className="h-4 w-4 text-muted-foreground" />
<span>{getOceanIssueLabel(data.oceanIssue)}</span>
</div>
<div>
<span className="text-muted-foreground">Description:</span>
<p className="mt-1 text-sm text-foreground/80 whitespace-pre-wrap">
{data.description}
</p>
</div>
</div>
</div>
{/* Team Members Section */}
{data.teamMembers && data.teamMembers.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<Users className="h-4 w-4" />
Team Members ({data.teamMembers.length})
</h3>
<div className="space-y-2">
{data.teamMembers.map((member, index) => (
<div key={index} className="flex items-center justify-between text-sm">
<div>
<span className="font-medium">{member.name}</span>
<span className="text-muted-foreground"> - {member.email}</span>
</div>
<Badge variant="outline" className="text-xs">
{member.role === 'MEMBER' ? 'Member' : 'Advisor'}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Additional Info Section */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
Additional Information
</h3>
<div className="space-y-3 text-sm">
{data.institution && (
<div className="flex items-center gap-2">
<GraduationCap className="h-4 w-4 text-muted-foreground" />
<span>{data.institution}</span>
</div>
)}
{data.startupCreatedDate && (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>Founded: {new Date(data.startupCreatedDate).toLocaleDateString()}</span>
</div>
)}
<div className="flex items-center gap-2">
<Heart className={`h-4 w-4 ${data.wantsMentorship ? 'text-primary' : 'text-muted-foreground'}`} />
<span>
{data.wantsMentorship
? 'Interested in mentorship'
: 'Not interested in mentorship'}
</span>
</div>
{data.referralSource && (
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<span>Heard about us via: {data.referralSource}</span>
</div>
)}
</div>
</div>
{/* GDPR Consent */}
<div className="rounded-lg border-2 border-primary/20 bg-primary/5 p-4">
<div className="flex items-start gap-3">
<Checkbox
id="gdprConsent"
checked={data.gdprConsent}
onCheckedChange={(checked) => setValue('gdprConsent', checked === true)}
className="mt-1"
/>
<div className="flex-1">
<Label htmlFor="gdprConsent" className="text-sm font-medium">
I consent to the processing of my personal data <span className="text-destructive">*</span>
</Label>
<p className="mt-1 text-xs text-muted-foreground">
By submitting this application, I agree that {programName} may process my personal
data in accordance with their privacy policy. My data will be used solely for the
purpose of evaluating my application and communicating with me about the program.
</p>
</div>
</div>
{errors.gdprConsent && (
<p className="mt-2 text-sm text-destructive">{errors.gdprConsent.message}</p>
)}
</div>
</motion.div>
</WizardStepContent>
)
}

View File

@@ -1,186 +1,186 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { UseFormReturn, useFieldArray } from 'react-hook-form'
import { Plus, Trash2, Users } from 'lucide-react'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ApplicationFormData } from '@/server/routers/application'
import { TeamMemberRole } from '@prisma/client'
import type { WizardConfig } from '@/types/wizard-config'
const roleOptions: { value: TeamMemberRole; label: string }[] = [
{ value: 'MEMBER', label: 'Team Member' },
{ value: 'ADVISOR', label: 'Advisor' },
]
interface StepTeamProps {
form: UseFormReturn<ApplicationFormData>
config?: WizardConfig
}
export function StepTeam({ form }: StepTeamProps) {
const { control, register, formState: { errors } } = form
const { fields, append, remove } = useFieldArray({
control,
name: 'teamMembers',
})
const addMember = () => {
append({ name: '', email: '', role: 'MEMBER', title: '' })
}
return (
<WizardStepContent
title="Your team members"
description="Add the other members of your team. They will receive an invitation to create their account."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-lg space-y-6"
>
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 py-12 text-center">
<Users className="mb-4 h-12 w-12 text-muted-foreground/50" />
<p className="text-muted-foreground">
No team members added yet.
</p>
<p className="mt-1 text-sm text-muted-foreground">
You can add team members here, or skip this step if you&apos;re applying solo.
</p>
<Button
type="button"
variant="outline"
onClick={addMember}
className="mt-4"
>
<Plus className="mr-2 h-4 w-4" />
Add Team Member
</Button>
</div>
) : (
<div className="space-y-4">
<AnimatePresence mode="popLayout">
{fields.map((field, index) => (
<motion.div
key={field.id}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="rounded-lg border bg-card p-4"
>
<div className="flex items-start justify-between mb-4">
<h4 className="font-medium text-sm text-muted-foreground">
Team Member {index + 1}
</h4>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* Name */}
<div className="space-y-2">
<Label htmlFor={`teamMembers.${index}.name`}>
Full Name <span className="text-destructive">*</span>
</Label>
<Input
id={`teamMembers.${index}.name`}
placeholder="Jane Doe"
{...register(`teamMembers.${index}.name`)}
/>
{errors.teamMembers?.[index]?.name && (
<p className="text-sm text-destructive">
{errors.teamMembers[index]?.name?.message}
</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor={`teamMembers.${index}.email`}>
Email <span className="text-destructive">*</span>
</Label>
<Input
id={`teamMembers.${index}.email`}
type="email"
placeholder="jane@example.com"
{...register(`teamMembers.${index}.email`)}
/>
{errors.teamMembers?.[index]?.email && (
<p className="text-sm text-destructive">
{errors.teamMembers[index]?.email?.message}
</p>
)}
</div>
{/* Role */}
<div className="space-y-2">
<Label>Role</Label>
<Select
value={form.watch(`teamMembers.${index}.role`)}
onValueChange={(value) =>
form.setValue(`teamMembers.${index}.role`, value as TeamMemberRole)
}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{roleOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Title/Position */}
<div className="space-y-2">
<Label htmlFor={`teamMembers.${index}.title`}>
Title/Position <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id={`teamMembers.${index}.title`}
placeholder="CTO, Designer, etc."
{...register(`teamMembers.${index}.title`)}
/>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
<Button
type="button"
variant="outline"
onClick={addMember}
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
Add Another Team Member
</Button>
</div>
)}
</motion.div>
</WizardStepContent>
)
}
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { UseFormReturn, useFieldArray } from 'react-hook-form'
import { Plus, Trash2, Users } from 'lucide-react'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ApplicationFormData } from '@/server/routers/application'
import { TeamMemberRole } from '@prisma/client'
import type { WizardConfig } from '@/types/wizard-config'
const roleOptions: { value: TeamMemberRole; label: string }[] = [
{ value: 'MEMBER', label: 'Team Member' },
{ value: 'ADVISOR', label: 'Advisor' },
]
interface StepTeamProps {
form: UseFormReturn<ApplicationFormData>
config?: WizardConfig
}
export function StepTeam({ form }: StepTeamProps) {
const { control, register, formState: { errors } } = form
const { fields, append, remove } = useFieldArray({
control,
name: 'teamMembers',
})
const addMember = () => {
append({ name: '', email: '', role: 'MEMBER', title: '' })
}
return (
<WizardStepContent
title="Your team members"
description="Add the other members of your team. They will receive an invitation to create their account."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-lg space-y-6"
>
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 py-12 text-center">
<Users className="mb-4 h-12 w-12 text-muted-foreground/50" />
<p className="text-muted-foreground">
No team members added yet.
</p>
<p className="mt-1 text-sm text-muted-foreground">
You can add team members here, or skip this step if you&apos;re applying solo.
</p>
<Button
type="button"
variant="outline"
onClick={addMember}
className="mt-4"
>
<Plus className="mr-2 h-4 w-4" />
Add Team Member
</Button>
</div>
) : (
<div className="space-y-4">
<AnimatePresence mode="popLayout">
{fields.map((field, index) => (
<motion.div
key={field.id}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="rounded-lg border bg-card p-4"
>
<div className="flex items-start justify-between mb-4">
<h4 className="font-medium text-sm text-muted-foreground">
Team Member {index + 1}
</h4>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* Name */}
<div className="space-y-2">
<Label htmlFor={`teamMembers.${index}.name`}>
Full Name <span className="text-destructive">*</span>
</Label>
<Input
id={`teamMembers.${index}.name`}
placeholder="Jane Doe"
{...register(`teamMembers.${index}.name`)}
/>
{errors.teamMembers?.[index]?.name && (
<p className="text-sm text-destructive">
{errors.teamMembers[index]?.name?.message}
</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor={`teamMembers.${index}.email`}>
Email <span className="text-destructive">*</span>
</Label>
<Input
id={`teamMembers.${index}.email`}
type="email"
placeholder="jane@example.com"
{...register(`teamMembers.${index}.email`)}
/>
{errors.teamMembers?.[index]?.email && (
<p className="text-sm text-destructive">
{errors.teamMembers[index]?.email?.message}
</p>
)}
</div>
{/* Role */}
<div className="space-y-2">
<Label>Role</Label>
<Select
value={form.watch(`teamMembers.${index}.role`)}
onValueChange={(value) =>
form.setValue(`teamMembers.${index}.role`, value as TeamMemberRole)
}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{roleOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Title/Position */}
<div className="space-y-2">
<Label htmlFor={`teamMembers.${index}.title`}>
Title/Position <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id={`teamMembers.${index}.title`}
placeholder="CTO, Designer, etc."
{...register(`teamMembers.${index}.title`)}
/>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
<Button
type="button"
variant="outline"
onClick={addMember}
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
Add Another Team Member
</Button>
</div>
)}
</motion.div>
</WizardStepContent>
)
}

View File

@@ -1,119 +1,119 @@
'use client'
import { motion } from 'motion/react'
import { Waves, Rocket, GraduationCap, type LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { type DropdownOption, type WelcomeMessage, DEFAULT_COMPETITION_CATEGORIES } from '@/types/wizard-config'
const ICON_MAP: Record<string, LucideIcon> = {
GraduationCap,
Rocket,
}
interface StepWelcomeProps {
programName: string
programYear: number
value: string | null
onChange: (value: string) => void
categories?: DropdownOption[]
welcomeMessage?: WelcomeMessage
}
export function StepWelcome({ programName, programYear, value, onChange, categories, welcomeMessage }: StepWelcomeProps) {
const categoryOptions = categories ?? DEFAULT_COMPETITION_CATEGORIES
return (
<WizardStepContent>
<div className="flex flex-col items-center text-center">
{/* Logo/Icon */}
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', duration: 0.8 }}
className="mb-8"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
<Waves className="h-10 w-10 text-primary" />
</div>
</motion.div>
{/* Welcome text */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
{welcomeMessage?.title ?? programName}
</h1>
<p className="mt-2 text-xl text-primary font-semibold">
{programYear} Application
</p>
<p className="mt-4 max-w-md text-muted-foreground">
{welcomeMessage?.description ?? 'Join us in protecting our oceans. Select your category to begin.'}
</p>
</motion.div>
{/* Category selection */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-10 grid w-full max-w-2xl gap-4 md:grid-cols-2"
>
{categoryOptions.map((category) => {
const Icon = (category.icon ? ICON_MAP[category.icon] : undefined) ?? Waves
const isSelected = value === category.value
return (
<button
key={category.value}
type="button"
onClick={() => onChange(category.value)}
className={cn(
'relative flex flex-col items-center rounded-xl border-2 p-6 text-center transition-all hover:border-primary/50 hover:shadow-md',
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border bg-background'
)}
>
<div
className={cn(
'mb-4 flex h-14 w-14 items-center justify-center rounded-full transition-colors',
isSelected ? 'bg-primary text-primary-foreground' : 'bg-muted'
)}
>
<Icon className="h-7 w-7" />
</div>
<h3 className="text-lg font-semibold">{category.label}</h3>
<p className="mt-2 text-sm text-muted-foreground">
{category.description}
</p>
{isSelected && (
<motion.div
layoutId="selected-indicator"
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</motion.div>
)}
</button>
)
})}
</motion.div>
</div>
</WizardStepContent>
)
}
'use client'
import { motion } from 'motion/react'
import { Waves, Rocket, GraduationCap, type LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { type DropdownOption, type WelcomeMessage, DEFAULT_COMPETITION_CATEGORIES } from '@/types/wizard-config'
const ICON_MAP: Record<string, LucideIcon> = {
GraduationCap,
Rocket,
}
interface StepWelcomeProps {
programName: string
programYear: number
value: string | null
onChange: (value: string) => void
categories?: DropdownOption[]
welcomeMessage?: WelcomeMessage
}
export function StepWelcome({ programName, programYear, value, onChange, categories, welcomeMessage }: StepWelcomeProps) {
const categoryOptions = categories ?? DEFAULT_COMPETITION_CATEGORIES
return (
<WizardStepContent>
<div className="flex flex-col items-center text-center">
{/* Logo/Icon */}
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', duration: 0.8 }}
className="mb-8"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
<Waves className="h-10 w-10 text-primary" />
</div>
</motion.div>
{/* Welcome text */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
{welcomeMessage?.title ?? programName}
</h1>
<p className="mt-2 text-xl text-primary font-semibold">
{programYear} Application
</p>
<p className="mt-4 max-w-md text-muted-foreground">
{welcomeMessage?.description ?? 'Join us in protecting our oceans. Select your category to begin.'}
</p>
</motion.div>
{/* Category selection */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-10 grid w-full max-w-2xl gap-4 md:grid-cols-2"
>
{categoryOptions.map((category) => {
const Icon = (category.icon ? ICON_MAP[category.icon] : undefined) ?? Waves
const isSelected = value === category.value
return (
<button
key={category.value}
type="button"
onClick={() => onChange(category.value)}
className={cn(
'relative flex flex-col items-center rounded-xl border-2 p-6 text-center transition-all hover:border-primary/50 hover:shadow-md',
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border bg-background'
)}
>
<div
className={cn(
'mb-4 flex h-14 w-14 items-center justify-center rounded-full transition-colors',
isSelected ? 'bg-primary text-primary-foreground' : 'bg-muted'
)}
>
<Icon className="h-7 w-7" />
</div>
<h3 className="text-lg font-semibold">{category.label}</h3>
<p className="mt-2 text-sm text-muted-foreground">
{category.description}
</p>
{isSelected && (
<motion.div
layoutId="selected-indicator"
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</motion.div>
)}
</button>
)
})}
</motion.div>
</div>
</WizardStepContent>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,489 +1,489 @@
'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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { toast } from 'sonner'
import {
CheckCircle,
AlertCircle,
Loader2,
ArrowRight,
ArrowLeft,
Database,
} from 'lucide-react'
interface NotionImportFormProps {
programId: string
stageName?: string
onSuccess?: () => void
}
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
export function NotionImportForm({
programId,
stageName,
onSuccess,
}: NotionImportFormProps) {
const [step, setStep] = useState<Step>('connect')
const [apiKey, setApiKey] = useState('')
const [databaseId, setDatabaseId] = useState('')
const [isConnecting, setIsConnecting] = useState(false)
const [connectionError, setConnectionError] = useState<string | null>(null)
// Mapping state
const [mappings, setMappings] = useState({
title: '',
teamName: '',
description: '',
tags: '',
})
const [includeUnmapped, setIncludeUnmapped] = useState(true)
// Results
const [importResults, setImportResults] = useState<{
imported: number
skipped: number
errors: Array<{ recordId: string; error: string }>
} | null>(null)
const testConnection = trpc.notionImport.testConnection.useMutation()
const { data: schema, refetch: refetchSchema } =
trpc.notionImport.getDatabaseSchema.useQuery(
{ apiKey, databaseId },
{ enabled: false }
)
const { data: preview, refetch: refetchPreview } =
trpc.notionImport.previewData.useQuery(
{ apiKey, databaseId, limit: 5 },
{ enabled: false }
)
const importMutation = trpc.notionImport.importProjects.useMutation()
const handleConnect = async () => {
if (!apiKey || !databaseId) {
toast.error('Please enter both API key and database ID')
return
}
setIsConnecting(true)
setConnectionError(null)
try {
const result = await testConnection.mutateAsync({ apiKey })
if (!result.success) {
setConnectionError(result.error || 'Connection failed')
return
}
// Fetch schema
await refetchSchema()
setStep('map')
} catch (error) {
setConnectionError(
error instanceof Error ? error.message : 'Connection failed'
)
} finally {
setIsConnecting(false)
}
}
const handlePreview = async () => {
if (!mappings.title) {
toast.error('Please map the Title field')
return
}
await refetchPreview()
setStep('preview')
}
const handleImport = async () => {
setStep('import')
try {
const result = await importMutation.mutateAsync({
apiKey,
databaseId,
programId,
mappings: {
title: mappings.title,
teamName: mappings.teamName || undefined,
description: mappings.description || undefined,
tags: mappings.tags || undefined,
},
includeUnmappedInMetadata: includeUnmapped,
})
setImportResults(result)
setStep('complete')
if (result.imported > 0) {
toast.success(`Imported ${result.imported} projects`)
onSuccess?.()
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Import failed'
)
setStep('preview')
}
}
const properties = schema?.properties || []
return (
<div className="space-y-6">
{/* Progress indicator */}
<div className="flex items-center gap-2">
{['connect', 'map', 'preview', 'import', 'complete'].map((s, i) => (
<div key={s} className="flex items-center">
{i > 0 && <div className="w-8 h-0.5 bg-muted mx-1" />}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
step === s
? 'bg-primary text-primary-foreground'
: ['connect', 'map', 'preview', 'import', 'complete'].indexOf(step) > i
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
{i + 1}
</div>
</div>
))}
</div>
{/* Step 1: Connect */}
{step === 'connect' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Connect to Notion
</CardTitle>
<CardDescription>
Enter your Notion API key and database ID to connect
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="apiKey">Notion API Key</Label>
<Input
id="apiKey"
type="password"
placeholder="secret_..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Create an integration at{' '}
<a
href="https://www.notion.so/my-integrations"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
notion.so/my-integrations
</a>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="databaseId">Database ID</Label>
<Input
id="databaseId"
placeholder="abc123..."
value={databaseId}
onChange={(e) => setDatabaseId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
The ID from your Notion database URL
</p>
</div>
{connectionError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Connection Failed</AlertTitle>
<AlertDescription>{connectionError}</AlertDescription>
</Alert>
)}
<Button
onClick={handleConnect}
disabled={isConnecting || !apiKey || !databaseId}
>
{isConnecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Connect
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
)}
{/* Step 2: Map columns */}
{step === 'map' && (
<Card>
<CardHeader>
<CardTitle>Map Columns</CardTitle>
<CardDescription>
Map Notion properties to project fields. Database: {schema?.title}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>
Title <span className="text-destructive">*</span>
</Label>
<Select
value={mappings.title}
onValueChange={(v) => setMappings((m) => ({ ...m, title: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select property" />
</SelectTrigger>
<SelectContent>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}{' '}
<span className="text-muted-foreground">({p.type})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Team Name</Label>
<Select
value={mappings.teamName}
onValueChange={(v) =>
setMappings((m) => ({ ...m, teamName: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Select
value={mappings.description}
onValueChange={(v) =>
setMappings((m) => ({ ...m, description: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Tags</Label>
<Select
value={mappings.tags}
onValueChange={(v) => setMappings((m) => ({ ...m, tags: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties
.filter((p) => p.type === 'multi_select' || p.type === 'select')
.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="includeUnmapped"
checked={includeUnmapped}
onCheckedChange={(c) => setIncludeUnmapped(!!c)}
/>
<Label htmlFor="includeUnmapped" className="font-normal">
Store unmapped columns in metadata
</Label>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep('connect')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handlePreview} disabled={!mappings.title}>
Preview
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Preview */}
{step === 'preview' && (
<Card>
<CardHeader>
<CardTitle>Preview Import</CardTitle>
<CardDescription>
Review the first {preview?.count || 0} records before importing
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="px-4 py-2 text-left font-medium">Title</th>
<th className="px-4 py-2 text-left font-medium">Team</th>
<th className="px-4 py-2 text-left font-medium">Tags</th>
</tr>
</thead>
<tbody>
{preview?.records.map((record, i) => (
<tr key={i} className="border-t">
<td className="px-4 py-2">
{String(record.properties[mappings.title] || '-')}
</td>
<td className="px-4 py-2">
{mappings.teamName
? String(record.properties[mappings.teamName] || '-')
: '-'}
</td>
<td className="px-4 py-2">
{mappings.tags && record.properties[mappings.tags]
? (
record.properties[mappings.tags] as string[]
).map((tag, j) => (
<Badge key={j} variant="secondary" className="mr-1">
{tag}
</Badge>
))
: '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Ready to import</AlertTitle>
<AlertDescription>
This will import all records from the Notion database into{' '}
<strong>{stageName}</strong>.
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep('map')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handleImport}>
Import All Records
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 4: Importing */}
{step === 'import' && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">Importing projects...</p>
<p className="text-sm text-muted-foreground">
Please wait while we import your data from Notion
</p>
</CardContent>
</Card>
)}
{/* Step 5: Complete */}
{step === 'complete' && importResults && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
<p className="text-lg font-medium">Import Complete</p>
<div className="mt-4 text-center">
<p className="text-2xl font-bold text-green-600">
{importResults.imported}
</p>
<p className="text-sm text-muted-foreground">projects imported</p>
</div>
{importResults.skipped > 0 && (
<p className="mt-2 text-sm text-muted-foreground">
{importResults.skipped} records skipped
</p>
)}
{importResults.errors.length > 0 && (
<div className="mt-4 w-full max-w-md">
<p className="text-sm font-medium text-destructive mb-2">
Errors ({importResults.errors.length}):
</p>
<div className="max-h-32 overflow-y-auto text-xs text-muted-foreground">
{importResults.errors.slice(0, 5).map((e, i) => (
<p key={i}>
{e.recordId}: {e.error}
</p>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
)
}
'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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { toast } from 'sonner'
import {
CheckCircle,
AlertCircle,
Loader2,
ArrowRight,
ArrowLeft,
Database,
} from 'lucide-react'
interface NotionImportFormProps {
programId: string
stageName?: string
onSuccess?: () => void
}
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
export function NotionImportForm({
programId,
stageName,
onSuccess,
}: NotionImportFormProps) {
const [step, setStep] = useState<Step>('connect')
const [apiKey, setApiKey] = useState('')
const [databaseId, setDatabaseId] = useState('')
const [isConnecting, setIsConnecting] = useState(false)
const [connectionError, setConnectionError] = useState<string | null>(null)
// Mapping state
const [mappings, setMappings] = useState({
title: '',
teamName: '',
description: '',
tags: '',
})
const [includeUnmapped, setIncludeUnmapped] = useState(true)
// Results
const [importResults, setImportResults] = useState<{
imported: number
skipped: number
errors: Array<{ recordId: string; error: string }>
} | null>(null)
const testConnection = trpc.notionImport.testConnection.useMutation()
const { data: schema, refetch: refetchSchema } =
trpc.notionImport.getDatabaseSchema.useQuery(
{ apiKey, databaseId },
{ enabled: false }
)
const { data: preview, refetch: refetchPreview } =
trpc.notionImport.previewData.useQuery(
{ apiKey, databaseId, limit: 5 },
{ enabled: false }
)
const importMutation = trpc.notionImport.importProjects.useMutation()
const handleConnect = async () => {
if (!apiKey || !databaseId) {
toast.error('Please enter both API key and database ID')
return
}
setIsConnecting(true)
setConnectionError(null)
try {
const result = await testConnection.mutateAsync({ apiKey })
if (!result.success) {
setConnectionError(result.error || 'Connection failed')
return
}
// Fetch schema
await refetchSchema()
setStep('map')
} catch (error) {
setConnectionError(
error instanceof Error ? error.message : 'Connection failed'
)
} finally {
setIsConnecting(false)
}
}
const handlePreview = async () => {
if (!mappings.title) {
toast.error('Please map the Title field')
return
}
await refetchPreview()
setStep('preview')
}
const handleImport = async () => {
setStep('import')
try {
const result = await importMutation.mutateAsync({
apiKey,
databaseId,
programId,
mappings: {
title: mappings.title,
teamName: mappings.teamName || undefined,
description: mappings.description || undefined,
tags: mappings.tags || undefined,
},
includeUnmappedInMetadata: includeUnmapped,
})
setImportResults(result)
setStep('complete')
if (result.imported > 0) {
toast.success(`Imported ${result.imported} projects`)
onSuccess?.()
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Import failed'
)
setStep('preview')
}
}
const properties = schema?.properties || []
return (
<div className="space-y-6">
{/* Progress indicator */}
<div className="flex items-center gap-2">
{['connect', 'map', 'preview', 'import', 'complete'].map((s, i) => (
<div key={s} className="flex items-center">
{i > 0 && <div className="w-8 h-0.5 bg-muted mx-1" />}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
step === s
? 'bg-primary text-primary-foreground'
: ['connect', 'map', 'preview', 'import', 'complete'].indexOf(step) > i
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
{i + 1}
</div>
</div>
))}
</div>
{/* Step 1: Connect */}
{step === 'connect' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Connect to Notion
</CardTitle>
<CardDescription>
Enter your Notion API key and database ID to connect
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="apiKey">Notion API Key</Label>
<Input
id="apiKey"
type="password"
placeholder="secret_..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Create an integration at{' '}
<a
href="https://www.notion.so/my-integrations"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
notion.so/my-integrations
</a>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="databaseId">Database ID</Label>
<Input
id="databaseId"
placeholder="abc123..."
value={databaseId}
onChange={(e) => setDatabaseId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
The ID from your Notion database URL
</p>
</div>
{connectionError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Connection Failed</AlertTitle>
<AlertDescription>{connectionError}</AlertDescription>
</Alert>
)}
<Button
onClick={handleConnect}
disabled={isConnecting || !apiKey || !databaseId}
>
{isConnecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Connect
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
)}
{/* Step 2: Map columns */}
{step === 'map' && (
<Card>
<CardHeader>
<CardTitle>Map Columns</CardTitle>
<CardDescription>
Map Notion properties to project fields. Database: {schema?.title}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>
Title <span className="text-destructive">*</span>
</Label>
<Select
value={mappings.title}
onValueChange={(v) => setMappings((m) => ({ ...m, title: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select property" />
</SelectTrigger>
<SelectContent>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}{' '}
<span className="text-muted-foreground">({p.type})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Team Name</Label>
<Select
value={mappings.teamName}
onValueChange={(v) =>
setMappings((m) => ({ ...m, teamName: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Select
value={mappings.description}
onValueChange={(v) =>
setMappings((m) => ({ ...m, description: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Tags</Label>
<Select
value={mappings.tags}
onValueChange={(v) => setMappings((m) => ({ ...m, tags: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties
.filter((p) => p.type === 'multi_select' || p.type === 'select')
.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="includeUnmapped"
checked={includeUnmapped}
onCheckedChange={(c) => setIncludeUnmapped(!!c)}
/>
<Label htmlFor="includeUnmapped" className="font-normal">
Store unmapped columns in metadata
</Label>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep('connect')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handlePreview} disabled={!mappings.title}>
Preview
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Preview */}
{step === 'preview' && (
<Card>
<CardHeader>
<CardTitle>Preview Import</CardTitle>
<CardDescription>
Review the first {preview?.count || 0} records before importing
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="px-4 py-2 text-left font-medium">Title</th>
<th className="px-4 py-2 text-left font-medium">Team</th>
<th className="px-4 py-2 text-left font-medium">Tags</th>
</tr>
</thead>
<tbody>
{preview?.records.map((record, i) => (
<tr key={i} className="border-t">
<td className="px-4 py-2">
{String(record.properties[mappings.title] || '-')}
</td>
<td className="px-4 py-2">
{mappings.teamName
? String(record.properties[mappings.teamName] || '-')
: '-'}
</td>
<td className="px-4 py-2">
{mappings.tags && record.properties[mappings.tags]
? (
record.properties[mappings.tags] as string[]
).map((tag, j) => (
<Badge key={j} variant="secondary" className="mr-1">
{tag}
</Badge>
))
: '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Ready to import</AlertTitle>
<AlertDescription>
This will import all records from the Notion database into{' '}
<strong>{stageName}</strong>.
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep('map')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handleImport}>
Import All Records
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 4: Importing */}
{step === 'import' && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">Importing projects...</p>
<p className="text-sm text-muted-foreground">
Please wait while we import your data from Notion
</p>
</CardContent>
</Card>
)}
{/* Step 5: Complete */}
{step === 'complete' && importResults && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
<p className="text-lg font-medium">Import Complete</p>
<div className="mt-4 text-center">
<p className="text-2xl font-bold text-green-600">
{importResults.imported}
</p>
<p className="text-sm text-muted-foreground">projects imported</p>
</div>
{importResults.skipped > 0 && (
<p className="mt-2 text-sm text-muted-foreground">
{importResults.skipped} records skipped
</p>
)}
{importResults.errors.length > 0 && (
<div className="mt-4 w-full max-w-md">
<p className="text-sm font-medium text-destructive mb-2">
Errors ({importResults.errors.length}):
</p>
<div className="max-h-32 overflow-y-auto text-xs text-muted-foreground">
{importResults.errors.slice(0, 5).map((e, i) => (
<p key={i}>
{e.recordId}: {e.error}
</p>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,76 +1,76 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { ChevronDown, ChevronUp, FileText } from 'lucide-react'
import { ProjectFilesSection } from './project-files-section'
interface CollapsibleFilesSectionProps {
projectId: string
stageId: string
fileCount: number
}
export function CollapsibleFilesSection({
projectId,
stageId,
fileCount,
}: CollapsibleFilesSectionProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [showFiles, setShowFiles] = useState(false)
const handleToggle = () => {
setIsExpanded(!isExpanded)
// Lazy-load the files when expanding for the first time
if (!isExpanded && !showFiles) {
setShowFiles(true)
}
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<h3 className="font-semibold text-lg">
Project Documents ({fileCount})
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleToggle}
aria-label={isExpanded ? 'Collapse documents' : 'Expand documents'}
className="gap-1"
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4" />
<span className="text-sm">Hide</span>
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
<span className="text-sm">Show</span>
</>
)}
</Button>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="pt-0">
{showFiles ? (
<ProjectFilesSection projectId={projectId} stageId={stageId} />
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
Loading documents...
</div>
)}
</CardContent>
)}
</Card>
)
}
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { ChevronDown, ChevronUp, FileText } from 'lucide-react'
import { ProjectFilesSection } from './project-files-section'
interface CollapsibleFilesSectionProps {
projectId: string
stageId: string
fileCount: number
}
export function CollapsibleFilesSection({
projectId,
stageId,
fileCount,
}: CollapsibleFilesSectionProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [showFiles, setShowFiles] = useState(false)
const handleToggle = () => {
setIsExpanded(!isExpanded)
// Lazy-load the files when expanding for the first time
if (!isExpanded && !showFiles) {
setShowFiles(true)
}
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<h3 className="font-semibold text-lg">
Project Documents ({fileCount})
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleToggle}
aria-label={isExpanded ? 'Collapse documents' : 'Expand documents'}
className="gap-1"
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4" />
<span className="text-sm">Hide</span>
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
<span className="text-sm">Show</span>
</>
)}
</Button>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="pt-0">
{showFiles ? (
<ProjectFilesSection projectId={projectId} stageId={stageId} />
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
Loading documents...
</div>
)}
</CardContent>
)}
</Card>
)
}

View File

@@ -1,92 +1,92 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { FileViewer } from '@/components/shared/file-viewer'
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertCircle, FileX } from 'lucide-react'
interface ProjectFilesSectionProps {
projectId: string
stageId: string
}
export function ProjectFilesSection({ projectId, stageId }: ProjectFilesSectionProps) {
const { data: groupedFiles, isLoading, error } = trpc.file.listByProjectForStage.useQuery({
projectId,
stageId,
})
if (isLoading) {
return <ProjectFilesSectionSkeleton />
}
if (error) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium text-destructive">Failed to load files</p>
<p className="text-sm text-muted-foreground">
{error.message || 'An error occurred while loading project files'}
</p>
</CardContent>
</Card>
)
}
if (!groupedFiles || groupedFiles.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<FileX className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No files available</p>
<p className="text-sm text-muted-foreground">
This project has no files uploaded yet
</p>
</CardContent>
</Card>
)
}
// Flatten all files from all stage groups for FileViewer
const allFiles = groupedFiles.flatMap((group) => group.files)
return (
<div className="space-y-4">
{groupedFiles.map((group) => (
<div key={group.stageId || 'general'} className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
{group.stageName}
</h3>
<div className="flex-1 h-px bg-border" />
</div>
<FileViewer files={group.files} />
</div>
))}
</div>
)
}
function ProjectFilesSectionSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border p-3">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-9 w-20" />
</div>
))}
</CardContent>
</Card>
)
}
'use client'
import { trpc } from '@/lib/trpc/client'
import { FileViewer } from '@/components/shared/file-viewer'
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertCircle, FileX } from 'lucide-react'
interface ProjectFilesSectionProps {
projectId: string
stageId: string
}
export function ProjectFilesSection({ projectId, stageId }: ProjectFilesSectionProps) {
const { data: groupedFiles, isLoading, error } = trpc.file.listByProjectForStage.useQuery({
projectId,
stageId,
})
if (isLoading) {
return <ProjectFilesSectionSkeleton />
}
if (error) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium text-destructive">Failed to load files</p>
<p className="text-sm text-muted-foreground">
{error.message || 'An error occurred while loading project files'}
</p>
</CardContent>
</Card>
)
}
if (!groupedFiles || groupedFiles.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<FileX className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No files available</p>
<p className="text-sm text-muted-foreground">
This project has no files uploaded yet
</p>
</CardContent>
</Card>
)
}
// Flatten all files from all stage groups for FileViewer
const allFiles = groupedFiles.flatMap((group) => group.files)
return (
<div className="space-y-4">
{groupedFiles.map((group) => (
<div key={group.stageId || 'general'} className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
{group.stageName}
</h3>
<div className="flex-1 h-px bg-border" />
</div>
<FileViewer files={group.files} />
</div>
))}
</div>
)
}
function ProjectFilesSectionSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border p-3">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-9 w-20" />
</div>
))}
</CardContent>
</Card>
)
}

View File

@@ -1,383 +1,383 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import {
LayoutDashboard,
FolderKanban,
Users,
ClipboardList,
Settings,
FileSpreadsheet,
Menu,
X,
LogOut,
ChevronRight,
BookOpen,
Handshake,
CircleDot,
History,
Trophy,
User,
MessageSquare,
LayoutTemplate,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
import { EditionSelector } from '@/components/shared/edition-selector'
import { useEdition } from '@/contexts/edition-context'
import { UserAvatar } from '@/components/shared/user-avatar'
import { NotificationBell } from '@/components/shared/notification-bell'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
interface AdminSidebarProps {
user: {
name?: string | null
email?: string | null
role?: string
}
}
type NavItem = {
name: string
href: string
icon: typeof LayoutDashboard
activeMatch?: string // pathname must include this to be active
activeExclude?: string // pathname must NOT include this to be active
subItems?: { name: string; href: string }[]
}
// Main navigation - scoped to selected edition
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/admin',
icon: LayoutDashboard,
},
{
name: 'Rounds',
href: '/admin/rounds/pipelines',
icon: CircleDot,
},
{
name: 'Awards',
href: '/admin/awards',
icon: Trophy,
},
{
name: 'Projects',
href: '/admin/projects',
icon: ClipboardList,
},
{
name: 'Members',
href: '/admin/members',
icon: Users,
},
{
name: 'Reports',
href: '/admin/reports',
icon: FileSpreadsheet,
},
{
name: 'Learning Hub',
href: '/admin/learning',
icon: BookOpen,
},
{
name: 'Messages',
href: '/admin/messages',
icon: MessageSquare,
},
{
name: 'Partners',
href: '/admin/partners',
icon: Handshake,
},
]
// Admin-only navigation
const adminNavigation: NavItem[] = [
{
name: 'Manage Editions',
href: '/admin/programs',
icon: FolderKanban,
activeExclude: 'apply-settings',
},
{
name: 'Apply Page',
href: '/admin/programs',
icon: LayoutTemplate,
activeMatch: 'apply-settings',
},
{
name: 'Audit Log',
href: '/admin/audit',
icon: History,
},
{
name: 'Settings',
href: '/admin/settings',
icon: Settings,
},
]
// Role display labels
const roleLabels: Record<string, string> = {
SUPER_ADMIN: 'Super Admin',
PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member',
OBSERVER: 'Observer',
}
export function AdminSidebar({ user }: AdminSidebarProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
})
const { currentEdition } = useEdition()
const isSuperAdmin = user.role === 'SUPER_ADMIN'
const roleLabel = roleLabels[user.role || ''] || 'User'
// Build dynamic admin nav with current edition's apply page
const dynamicAdminNav = adminNavigation.map((item) => {
if (item.name === 'Apply Page' && currentEdition?.id) {
return { ...item, href: `/admin/programs/${currentEdition.id}/apply-settings` }
}
return item
})
return (
<>
{/* Mobile menu button */}
<div className="fixed top-0 left-0 right-0 z-40 flex h-16 items-center justify-between border-b bg-card px-4 lg:hidden overflow-x-hidden">
<Logo showText textSuffix="Admin" className="min-w-0" />
<div className="flex items-center gap-2">
<NotificationBell />
<Button
variant="ghost"
size="icon"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
{/* Mobile menu overlay */}
{isMobileMenuOpen && (
<div
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
onClick={() => setIsMobileMenuOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={cn(
'fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r bg-card transition-transform duration-300 lg:translate-x-0',
isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
{/* Logo + Notification */}
<div className="flex h-16 items-center justify-between border-b px-6">
<Logo showText textSuffix="Admin" />
<div className="hidden lg:flex items-center gap-1">
<NotificationBell />
</div>
</div>
{/* Edition Selector */}
<div className="border-b px-4 py-4">
<EditionSelector />
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
<div className="space-y-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href))
const isParentActive = item.subItems
? pathname.startsWith('/admin/rounds')
: false
return (
<div key={item.name}>
<Link
href={item.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150',
isActive
? 'bg-brand-blue text-white shadow-xs'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className={cn(
'h-4 w-4 transition-colors',
isActive ? 'text-white' : 'text-muted-foreground group-hover:text-foreground'
)} />
{item.name}
</Link>
{item.subItems && isParentActive && (
<div className="ml-7 mt-0.5 space-y-0.5">
{item.subItems.map((sub) => {
const isSubActive = pathname === sub.href ||
(sub.href !== '/admin/rounds' && pathname.startsWith(sub.href))
return (
<Link
key={sub.name}
href={sub.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'block rounded-md px-3 py-1.5 text-xs font-medium transition-colors',
isSubActive
? 'text-brand-blue bg-brand-blue/10'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
{sub.name}
</Link>
)
})}
</div>
)}
</div>
)
})}
</div>
{isSuperAdmin && (
<>
<Separator className="my-4" />
<div className="space-y-1">
<p className="mb-2 px-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
Administration
</p>
{dynamicAdminNav.map((item) => {
let isActive = pathname.startsWith(item.href)
if (item.activeMatch) {
isActive = pathname.includes(item.activeMatch)
} else if (item.activeExclude && pathname.includes(item.activeExclude)) {
isActive = false
}
return (
<Link
key={item.name}
href={item.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150',
isActive
? 'bg-brand-blue text-white shadow-xs'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className={cn(
'h-4 w-4 transition-colors',
isActive ? 'text-white' : 'text-muted-foreground group-hover:text-foreground'
)} />
{item.name}
</Link>
)
})}
</div>
</>
)}
</nav>
{/* User Profile Section */}
<div className="border-t p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="group flex w-full items-center gap-3 rounded-xl p-2.5 text-left transition-all duration-200 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{/* Avatar */}
<div className="relative shrink-0">
<UserAvatar user={user} avatarUrl={avatarUrl} size="md" />
{/* Online indicator */}
<div className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-background bg-emerald-500" />
</div>
{/* User info */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground">
{user.name || 'User'}
</p>
<p className="truncate text-xs text-muted-foreground">
{roleLabel}
</p>
</div>
{/* Chevron */}
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 group-hover:translate-x-0.5 group-hover:text-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="top"
sideOffset={8}
className="w-56 p-1.5"
>
{/* User info header */}
<div className="px-2 py-2.5">
<p className="text-sm font-semibold text-foreground">
{user.name || 'User'}
</p>
<p className="truncate text-xs text-muted-foreground">
{user.email}
</p>
</div>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem asChild>
<Link
href={"/settings/profile" as Route}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
>
<User className="h-4 w-4 text-muted-foreground" />
<span>Profile Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-destructive focus:bg-destructive/10 focus:text-destructive"
>
<LogOut className="h-4 w-4" />
<span>Sign Out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</aside>
</>
)
}
'use client'
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import {
LayoutDashboard,
FolderKanban,
Users,
ClipboardList,
Settings,
FileSpreadsheet,
Menu,
X,
LogOut,
ChevronRight,
BookOpen,
Handshake,
CircleDot,
History,
Trophy,
User,
MessageSquare,
LayoutTemplate,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
import { EditionSelector } from '@/components/shared/edition-selector'
import { useEdition } from '@/contexts/edition-context'
import { UserAvatar } from '@/components/shared/user-avatar'
import { NotificationBell } from '@/components/shared/notification-bell'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
interface AdminSidebarProps {
user: {
name?: string | null
email?: string | null
role?: string
}
}
type NavItem = {
name: string
href: string
icon: typeof LayoutDashboard
activeMatch?: string // pathname must include this to be active
activeExclude?: string // pathname must NOT include this to be active
subItems?: { name: string; href: string }[]
}
// Main navigation - scoped to selected edition
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/admin',
icon: LayoutDashboard,
},
{
name: 'Rounds',
href: '/admin/rounds/pipelines',
icon: CircleDot,
},
{
name: 'Awards',
href: '/admin/awards',
icon: Trophy,
},
{
name: 'Projects',
href: '/admin/projects',
icon: ClipboardList,
},
{
name: 'Members',
href: '/admin/members',
icon: Users,
},
{
name: 'Reports',
href: '/admin/reports',
icon: FileSpreadsheet,
},
{
name: 'Learning Hub',
href: '/admin/learning',
icon: BookOpen,
},
{
name: 'Messages',
href: '/admin/messages',
icon: MessageSquare,
},
{
name: 'Partners',
href: '/admin/partners',
icon: Handshake,
},
]
// Admin-only navigation
const adminNavigation: NavItem[] = [
{
name: 'Manage Editions',
href: '/admin/programs',
icon: FolderKanban,
activeExclude: 'apply-settings',
},
{
name: 'Apply Page',
href: '/admin/programs',
icon: LayoutTemplate,
activeMatch: 'apply-settings',
},
{
name: 'Audit Log',
href: '/admin/audit',
icon: History,
},
{
name: 'Settings',
href: '/admin/settings',
icon: Settings,
},
]
// Role display labels
const roleLabels: Record<string, string> = {
SUPER_ADMIN: 'Super Admin',
PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member',
OBSERVER: 'Observer',
}
export function AdminSidebar({ user }: AdminSidebarProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
})
const { currentEdition } = useEdition()
const isSuperAdmin = user.role === 'SUPER_ADMIN'
const roleLabel = roleLabels[user.role || ''] || 'User'
// Build dynamic admin nav with current edition's apply page
const dynamicAdminNav = adminNavigation.map((item) => {
if (item.name === 'Apply Page' && currentEdition?.id) {
return { ...item, href: `/admin/programs/${currentEdition.id}/apply-settings` }
}
return item
})
return (
<>
{/* Mobile menu button */}
<div className="fixed top-0 left-0 right-0 z-40 flex h-16 items-center justify-between border-b bg-card px-4 lg:hidden overflow-x-hidden">
<Logo showText textSuffix="Admin" className="min-w-0" />
<div className="flex items-center gap-2">
<NotificationBell />
<Button
variant="ghost"
size="icon"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
{/* Mobile menu overlay */}
{isMobileMenuOpen && (
<div
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
onClick={() => setIsMobileMenuOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={cn(
'fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r bg-card transition-transform duration-300 lg:translate-x-0',
isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
{/* Logo + Notification */}
<div className="flex h-16 items-center justify-between border-b px-6">
<Logo showText textSuffix="Admin" />
<div className="hidden lg:flex items-center gap-1">
<NotificationBell />
</div>
</div>
{/* Edition Selector */}
<div className="border-b px-4 py-4">
<EditionSelector />
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
<div className="space-y-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href))
const isParentActive = item.subItems
? pathname.startsWith('/admin/rounds')
: false
return (
<div key={item.name}>
<Link
href={item.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150',
isActive
? 'bg-brand-blue text-white shadow-xs'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className={cn(
'h-4 w-4 transition-colors',
isActive ? 'text-white' : 'text-muted-foreground group-hover:text-foreground'
)} />
{item.name}
</Link>
{item.subItems && isParentActive && (
<div className="ml-7 mt-0.5 space-y-0.5">
{item.subItems.map((sub) => {
const isSubActive = pathname === sub.href ||
(sub.href !== '/admin/rounds' && pathname.startsWith(sub.href))
return (
<Link
key={sub.name}
href={sub.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'block rounded-md px-3 py-1.5 text-xs font-medium transition-colors',
isSubActive
? 'text-brand-blue bg-brand-blue/10'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
{sub.name}
</Link>
)
})}
</div>
)}
</div>
)
})}
</div>
{isSuperAdmin && (
<>
<Separator className="my-4" />
<div className="space-y-1">
<p className="mb-2 px-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
Administration
</p>
{dynamicAdminNav.map((item) => {
let isActive = pathname.startsWith(item.href)
if (item.activeMatch) {
isActive = pathname.includes(item.activeMatch)
} else if (item.activeExclude && pathname.includes(item.activeExclude)) {
isActive = false
}
return (
<Link
key={item.name}
href={item.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150',
isActive
? 'bg-brand-blue text-white shadow-xs'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className={cn(
'h-4 w-4 transition-colors',
isActive ? 'text-white' : 'text-muted-foreground group-hover:text-foreground'
)} />
{item.name}
</Link>
)
})}
</div>
</>
)}
</nav>
{/* User Profile Section */}
<div className="border-t p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="group flex w-full items-center gap-3 rounded-xl p-2.5 text-left transition-all duration-200 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{/* Avatar */}
<div className="relative shrink-0">
<UserAvatar user={user} avatarUrl={avatarUrl} size="md" />
{/* Online indicator */}
<div className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-background bg-emerald-500" />
</div>
{/* User info */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground">
{user.name || 'User'}
</p>
<p className="truncate text-xs text-muted-foreground">
{roleLabel}
</p>
</div>
{/* Chevron */}
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 group-hover:translate-x-0.5 group-hover:text-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="top"
sideOffset={8}
className="w-56 p-1.5"
>
{/* User info header */}
<div className="px-2 py-2.5">
<p className="text-sm font-semibold text-foreground">
{user.name || 'User'}
</p>
<p className="truncate text-xs text-muted-foreground">
{user.email}
</p>
</div>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem asChild>
<Link
href={"/settings/profile" as Route}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
>
<User className="h-4 w-4 text-muted-foreground" />
<span>Profile Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-destructive focus:bg-destructive/10 focus:text-destructive"
>
<LogOut className="h-4 w-4" />
<span>Sign Out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</aside>
</>
)
}

View File

@@ -1,47 +1,47 @@
'use client'
import { Home, Users, FileText, MessageSquare, Layers } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface ApplicantNavProps {
user: RoleNavUser
}
export function ApplicantNav({ user }: ApplicantNavProps) {
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/applicant',
icon: Home,
},
{
name: 'Team',
href: '/applicant/team',
icon: Users,
},
{
name: 'Pipeline',
href: '/applicant/pipeline',
icon: Layers,
},
{
name: 'Documents',
href: '/applicant/documents',
icon: FileText,
},
{
name: 'Mentoring',
href: '/applicant/mentor',
icon: MessageSquare,
},
]
return (
<RoleNav
navigation={navigation}
roleName="Applicant"
user={user}
basePath="/applicant"
/>
)
}
'use client'
import { Home, Users, FileText, MessageSquare, Layers } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface ApplicantNavProps {
user: RoleNavUser
}
export function ApplicantNav({ user }: ApplicantNavProps) {
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/applicant',
icon: Home,
},
{
name: 'Team',
href: '/applicant/team',
icon: Users,
},
{
name: 'Pipeline',
href: '/applicant/pipeline',
icon: Layers,
},
{
name: 'Documents',
href: '/applicant/documents',
icon: FileText,
},
{
name: 'Mentoring',
href: '/applicant/mentor',
icon: MessageSquare,
},
]
return (
<RoleNav
navigation={navigation}
roleName="Applicant"
user={user}
basePath="/applicant"
/>
)
}

View File

@@ -1,77 +1,77 @@
'use client'
import { BookOpen, Home, Trophy, Layers } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
interface JuryNavProps {
user: RoleNavUser
}
function RemainingBadge() {
const { data: assignments } = trpc.assignment.myAssignments.useQuery(
{},
{ refetchInterval: 60000 }
)
if (!assignments) return null
const now = new Date()
const remaining = (assignments as Array<{
stage: { status: string; windowOpenAt: Date | null; windowCloseAt: Date | null } | null
evaluation: { status: string } | null
}>).filter((a) => {
const isActive =
a.stage?.status === 'STAGE_ACTIVE' &&
a.stage.windowOpenAt &&
a.stage.windowCloseAt &&
new Date(a.stage.windowOpenAt) <= now &&
new Date(a.stage.windowCloseAt) >= now
const isIncomplete = !a.evaluation || a.evaluation.status !== 'SUBMITTED'
return isActive && isIncomplete
}).length
if (remaining === 0) return null
return (
<Badge variant="secondary" className="text-xs font-medium">
{remaining} remaining
</Badge>
)
}
export function JuryNav({ user }: JuryNavProps) {
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/jury',
icon: Home,
},
{
name: 'Stages',
href: '/jury/stages',
icon: Layers,
},
{
name: 'Awards',
href: '/jury/awards',
icon: Trophy,
},
{
name: 'Learning Hub',
href: '/jury/learning',
icon: BookOpen,
},
]
return (
<RoleNav
navigation={navigation}
roleName="Jury"
user={user}
basePath="/jury"
statusBadge={<RemainingBadge />}
/>
)
}
'use client'
import { BookOpen, Home, Trophy, Layers } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
interface JuryNavProps {
user: RoleNavUser
}
function RemainingBadge() {
const { data: assignments } = trpc.assignment.myAssignments.useQuery(
{},
{ refetchInterval: 60000 }
)
if (!assignments) return null
const now = new Date()
const remaining = (assignments as Array<{
stage: { status: string; windowOpenAt: Date | null; windowCloseAt: Date | null } | null
evaluation: { status: string } | null
}>).filter((a) => {
const isActive =
a.stage?.status === 'STAGE_ACTIVE' &&
a.stage.windowOpenAt &&
a.stage.windowCloseAt &&
new Date(a.stage.windowOpenAt) <= now &&
new Date(a.stage.windowCloseAt) >= now
const isIncomplete = !a.evaluation || a.evaluation.status !== 'SUBMITTED'
return isActive && isIncomplete
}).length
if (remaining === 0) return null
return (
<Badge variant="secondary" className="text-xs font-medium">
{remaining} remaining
</Badge>
)
}
export function JuryNav({ user }: JuryNavProps) {
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/jury',
icon: Home,
},
{
name: 'Stages',
href: '/jury/stages',
icon: Layers,
},
{
name: 'Awards',
href: '/jury/awards',
icon: Trophy,
},
{
name: 'Learning Hub',
href: '/jury/learning',
icon: BookOpen,
},
]
return (
<RoleNav
navigation={navigation}
roleName="Jury"
user={user}
basePath="/jury"
statusBadge={<RemainingBadge />}
/>
)
}

View File

@@ -1,37 +1,37 @@
'use client'
import { BookOpen, Home, Users } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface MentorNavProps {
user: RoleNavUser
}
export function MentorNav({ user }: MentorNavProps) {
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/mentor',
icon: Home,
},
{
name: 'My Projects',
href: '/mentor/projects',
icon: Users,
},
{
name: 'Learning Hub',
href: '/mentor/resources',
icon: BookOpen,
},
]
return (
<RoleNav
navigation={navigation}
roleName="Mentor"
user={user}
basePath="/mentor"
/>
)
}
'use client'
import { BookOpen, Home, Users } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface MentorNavProps {
user: RoleNavUser
}
export function MentorNav({ user }: MentorNavProps) {
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/mentor',
icon: Home,
},
{
name: 'My Projects',
href: '/mentor/projects',
icon: Users,
},
{
name: 'Learning Hub',
href: '/mentor/resources',
icon: BookOpen,
},
]
return (
<RoleNav
navigation={navigation}
roleName="Mentor"
user={user}
basePath="/mentor"
/>
)
}

View File

@@ -1,32 +1,32 @@
'use client'
import { BarChart3, Home } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface ObserverNavProps {
user: RoleNavUser
}
export function ObserverNav({ user }: ObserverNavProps) {
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/observer',
icon: Home,
},
{
name: 'Reports',
href: '/observer/reports',
icon: BarChart3,
},
]
return (
<RoleNav
navigation={navigation}
roleName="Observer"
user={user}
basePath="/observer"
/>
)
}
'use client'
import { BarChart3, Home } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface ObserverNavProps {
user: RoleNavUser
}
export function ObserverNav({ user }: ObserverNavProps) {
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/observer',
icon: Home,
},
{
name: 'Reports',
href: '/observer/reports',
icon: BarChart3,
},
]
return (
<RoleNav
navigation={navigation}
roleName="Observer"
user={user}
basePath="/observer"
/>
)
}

View File

@@ -1,202 +1,202 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut, useSession } from 'next-auth/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { UserAvatar } from '@/components/shared/user-avatar'
import { trpc } from '@/lib/trpc/client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import type { Route } from 'next'
import type { LucideIcon } from 'lucide-react'
import { LogOut, Menu, Moon, Settings, Sun, User, X } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
export type NavItem = {
name: string
href: string
icon: LucideIcon
}
export type RoleNavUser = {
name?: string | null
email?: string | null
}
type RoleNavProps = {
navigation: NavItem[]
roleName: string
user: RoleNavUser
/** The base path for the role (e.g., '/jury', '/mentor', '/observer'). Used for active state detection on the dashboard link. */
basePath: string
/** Optional status badge displayed next to the logo (e.g., remaining evaluations count) */
statusBadge?: React.ReactNode
}
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
return pathname === href || (href !== basePath && pathname.startsWith(href))
}
export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: RoleNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
})
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
return (
<header className="sticky top-0 z-40 border-b bg-card">
<div className="container-app">
<div className="flex h-16 items-center justify-between">
{/* Logo */}
<div className="flex items-center gap-3">
<Logo showText textSuffix={roleName} />
{statusBadge}
</div>
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navigation.map((item) => {
const isActive = isNavItemActive(pathname, item.href, basePath)
return (
<Link
key={item.name}
href={item.href as Route}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
</nav>
{/* User menu & mobile toggle */}
<div className="flex items-center gap-2">
{mounted && (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
aria-label="Toggle theme"
>
{theme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
)}
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="gap-2 hidden sm:flex"
size="sm"
>
<UserAvatar user={user} avatarUrl={avatarUrl} size="xs" />
<span className="max-w-[120px] truncate">
{user.name || user.email}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem disabled>
<User className="mr-2 h-4 w-4" />
{user.email}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={"/settings/profile" as Route} className="flex cursor-pointer items-center">
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
className="text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
</div>
{/* Mobile menu */}
{isMobileMenuOpen && (
<div className="border-t md:hidden">
<nav className="container-app py-4 space-y-1">
{navigation.map((item) => {
const isActive = isNavItemActive(pathname, item.href, basePath)
return (
<Link
key={item.name}
href={item.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
<div className="border-t pt-4 mt-4">
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</Button>
</div>
</nav>
</div>
)}
</header>
)
}
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut, useSession } from 'next-auth/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { UserAvatar } from '@/components/shared/user-avatar'
import { trpc } from '@/lib/trpc/client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import type { Route } from 'next'
import type { LucideIcon } from 'lucide-react'
import { LogOut, Menu, Moon, Settings, Sun, User, X } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
export type NavItem = {
name: string
href: string
icon: LucideIcon
}
export type RoleNavUser = {
name?: string | null
email?: string | null
}
type RoleNavProps = {
navigation: NavItem[]
roleName: string
user: RoleNavUser
/** The base path for the role (e.g., '/jury', '/mentor', '/observer'). Used for active state detection on the dashboard link. */
basePath: string
/** Optional status badge displayed next to the logo (e.g., remaining evaluations count) */
statusBadge?: React.ReactNode
}
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
return pathname === href || (href !== basePath && pathname.startsWith(href))
}
export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: RoleNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
})
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
return (
<header className="sticky top-0 z-40 border-b bg-card">
<div className="container-app">
<div className="flex h-16 items-center justify-between">
{/* Logo */}
<div className="flex items-center gap-3">
<Logo showText textSuffix={roleName} />
{statusBadge}
</div>
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navigation.map((item) => {
const isActive = isNavItemActive(pathname, item.href, basePath)
return (
<Link
key={item.name}
href={item.href as Route}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
</nav>
{/* User menu & mobile toggle */}
<div className="flex items-center gap-2">
{mounted && (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
aria-label="Toggle theme"
>
{theme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
)}
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="gap-2 hidden sm:flex"
size="sm"
>
<UserAvatar user={user} avatarUrl={avatarUrl} size="xs" />
<span className="max-w-[120px] truncate">
{user.name || user.email}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem disabled>
<User className="mr-2 h-4 w-4" />
{user.email}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={"/settings/profile" as Route} className="flex cursor-pointer items-center">
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
className="text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
</div>
{/* Mobile menu */}
{isMobileMenuOpen && (
<div className="border-t md:hidden">
<nav className="container-app py-4 space-y-1">
{navigation.map((item) => {
const isActive = isNavItemActive(pathname, item.href, basePath)
return (
<Link
key={item.name}
href={item.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
<div className="border-t pt-4 mt-4">
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</Button>
</div>
</nav>
</div>
)}
</header>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,353 +1,353 @@
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Cog, Loader2, Zap, AlertCircle, RefreshCw, SlidersHorizontal } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const formSchema = z.object({
ai_enabled: z.boolean(),
ai_provider: z.string(),
ai_model: z.string(),
ai_send_descriptions: z.boolean(),
openai_api_key: z.string().optional(),
})
type FormValues = z.infer<typeof formSchema>
interface AISettingsFormProps {
settings: {
ai_enabled?: string
ai_provider?: string
ai_model?: string
ai_send_descriptions?: string
openai_api_key?: string
}
}
export function AISettingsForm({ settings }: AISettingsFormProps) {
const utils = trpc.useUtils()
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
ai_enabled: settings.ai_enabled === 'true',
ai_provider: settings.ai_provider || 'openai',
ai_model: settings.ai_model || 'gpt-4o',
ai_send_descriptions: settings.ai_send_descriptions === 'true',
openai_api_key: '',
},
})
// Fetch available models from OpenAI API
const {
data: modelsData,
isLoading: modelsLoading,
error: modelsError,
refetch: refetchModels,
} = trpc.settings.listAIModels.useQuery(undefined, {
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: false,
})
const updateSettings = trpc.settings.updateMultiple.useMutation({
onSuccess: () => {
toast.success('AI settings saved successfully')
utils.settings.getByCategory.invalidate({ category: 'AI' })
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`)
},
})
const testConnection = trpc.settings.testAIConnection.useMutation({
onSuccess: (result) => {
if (result.success) {
toast.success(`AI connection successful! Model: ${result.model || result.modelTested}`)
// Refetch models after successful API key save/test
refetchModels()
} else {
toast.error(`Connection failed: ${result.error}`)
}
},
onError: (error) => {
toast.error(`Test failed: ${error.message}`)
},
})
const onSubmit = (data: FormValues) => {
const settingsToUpdate = [
{ key: 'ai_enabled', value: String(data.ai_enabled) },
{ key: 'ai_provider', value: data.ai_provider },
{ key: 'ai_model', value: data.ai_model },
{ key: 'ai_send_descriptions', value: String(data.ai_send_descriptions) },
]
// Only update API key if a new value was entered
if (data.openai_api_key && data.openai_api_key.trim()) {
settingsToUpdate.push({ key: 'openai_api_key', value: data.openai_api_key })
}
updateSettings.mutate({ settings: settingsToUpdate })
}
// Group models by category for better display
type ModelInfo = { id: string; name: string; isReasoning: boolean; category: string }
const groupedModels = modelsData?.models?.reduce<Record<string, ModelInfo[]>>(
(acc, model) => {
const category = model.category
if (!acc[category]) acc[category] = []
acc[category].push(model)
return acc
},
{}
)
const categoryLabels: Record<string, string> = {
'gpt-5+': 'GPT-5+ Series (Latest)',
'gpt-4o': 'GPT-4o Series',
'gpt-4': 'GPT-4 Series',
'gpt-3.5': 'GPT-3.5 Series',
reasoning: 'Reasoning Models (o1, o3, o4)',
other: 'Other Models',
}
const categoryOrder = ['gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="ai_enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Enable AI Features</FormLabel>
<FormDescription>
Use AI to suggest optimal jury-project assignments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="ai_provider"
render={({ field }) => (
<FormItem>
<FormLabel>AI Provider</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
<FormDescription>
AI provider for smart assignment suggestions
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="openai_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder={settings.openai_api_key ? '••••••••' : 'Enter API key'}
{...field}
/>
</FormControl>
<FormDescription>
Your OpenAI API key. Leave blank to keep the existing key.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ai_model"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Model</FormLabel>
{modelsData?.success && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => refetchModels()}
className="h-6 px-2 text-xs"
>
<RefreshCw className="mr-1 h-3 w-3" />
Refresh
</Button>
)}
</div>
{modelsLoading ? (
<Skeleton className="h-10 w-full" />
) : modelsError || !modelsData?.success ? (
<div className="space-y-2">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{modelsError?.message || modelsData?.error || 'Failed to load models. Save your API key first and test the connection.'}
</AlertDescription>
</Alert>
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
placeholder="Enter model ID manually (e.g., gpt-4o)"
/>
</div>
) : (
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select model" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categoryOrder
.filter((cat) => groupedModels?.[cat]?.length)
.map((category) => (
<SelectGroup key={category}>
<SelectLabel className="text-xs font-semibold text-muted-foreground">
{categoryLabels[category] || category}
</SelectLabel>
{groupedModels?.[category]?.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
{model.isReasoning && (
<SlidersHorizontal className="h-3 w-3 text-purple-500" />
)}
<span>{model.name}</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
)}
<FormDescription>
{form.watch('ai_model')?.startsWith('o') ? (
<span className="flex items-center gap-1 text-purple-600">
<SlidersHorizontal className="h-3 w-3" />
Reasoning model - optimized for complex analysis tasks
</span>
) : (
'OpenAI model to use for AI features'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ai_send_descriptions"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Send Project Descriptions</FormLabel>
<FormDescription>
Include anonymized project descriptions in AI requests for better matching
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="submit"
disabled={updateSettings.isPending}
>
{updateSettings.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Cog className="mr-2 h-4 w-4" />
Save AI Settings
</>
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => testConnection.mutate()}
disabled={testConnection.isPending}
>
{testConnection.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
<>
<Zap className="mr-2 h-4 w-4" />
Test Connection
</>
)}
</Button>
</div>
</form>
</Form>
)
}
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Cog, Loader2, Zap, AlertCircle, RefreshCw, SlidersHorizontal } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const formSchema = z.object({
ai_enabled: z.boolean(),
ai_provider: z.string(),
ai_model: z.string(),
ai_send_descriptions: z.boolean(),
openai_api_key: z.string().optional(),
})
type FormValues = z.infer<typeof formSchema>
interface AISettingsFormProps {
settings: {
ai_enabled?: string
ai_provider?: string
ai_model?: string
ai_send_descriptions?: string
openai_api_key?: string
}
}
export function AISettingsForm({ settings }: AISettingsFormProps) {
const utils = trpc.useUtils()
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
ai_enabled: settings.ai_enabled === 'true',
ai_provider: settings.ai_provider || 'openai',
ai_model: settings.ai_model || 'gpt-4o',
ai_send_descriptions: settings.ai_send_descriptions === 'true',
openai_api_key: '',
},
})
// Fetch available models from OpenAI API
const {
data: modelsData,
isLoading: modelsLoading,
error: modelsError,
refetch: refetchModels,
} = trpc.settings.listAIModels.useQuery(undefined, {
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: false,
})
const updateSettings = trpc.settings.updateMultiple.useMutation({
onSuccess: () => {
toast.success('AI settings saved successfully')
utils.settings.getByCategory.invalidate({ category: 'AI' })
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`)
},
})
const testConnection = trpc.settings.testAIConnection.useMutation({
onSuccess: (result) => {
if (result.success) {
toast.success(`AI connection successful! Model: ${result.model || result.modelTested}`)
// Refetch models after successful API key save/test
refetchModels()
} else {
toast.error(`Connection failed: ${result.error}`)
}
},
onError: (error) => {
toast.error(`Test failed: ${error.message}`)
},
})
const onSubmit = (data: FormValues) => {
const settingsToUpdate = [
{ key: 'ai_enabled', value: String(data.ai_enabled) },
{ key: 'ai_provider', value: data.ai_provider },
{ key: 'ai_model', value: data.ai_model },
{ key: 'ai_send_descriptions', value: String(data.ai_send_descriptions) },
]
// Only update API key if a new value was entered
if (data.openai_api_key && data.openai_api_key.trim()) {
settingsToUpdate.push({ key: 'openai_api_key', value: data.openai_api_key })
}
updateSettings.mutate({ settings: settingsToUpdate })
}
// Group models by category for better display
type ModelInfo = { id: string; name: string; isReasoning: boolean; category: string }
const groupedModels = modelsData?.models?.reduce<Record<string, ModelInfo[]>>(
(acc, model) => {
const category = model.category
if (!acc[category]) acc[category] = []
acc[category].push(model)
return acc
},
{}
)
const categoryLabels: Record<string, string> = {
'gpt-5+': 'GPT-5+ Series (Latest)',
'gpt-4o': 'GPT-4o Series',
'gpt-4': 'GPT-4 Series',
'gpt-3.5': 'GPT-3.5 Series',
reasoning: 'Reasoning Models (o1, o3, o4)',
other: 'Other Models',
}
const categoryOrder = ['gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="ai_enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Enable AI Features</FormLabel>
<FormDescription>
Use AI to suggest optimal jury-project assignments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="ai_provider"
render={({ field }) => (
<FormItem>
<FormLabel>AI Provider</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
<FormDescription>
AI provider for smart assignment suggestions
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="openai_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder={settings.openai_api_key ? '••••••••' : 'Enter API key'}
{...field}
/>
</FormControl>
<FormDescription>
Your OpenAI API key. Leave blank to keep the existing key.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ai_model"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Model</FormLabel>
{modelsData?.success && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => refetchModels()}
className="h-6 px-2 text-xs"
>
<RefreshCw className="mr-1 h-3 w-3" />
Refresh
</Button>
)}
</div>
{modelsLoading ? (
<Skeleton className="h-10 w-full" />
) : modelsError || !modelsData?.success ? (
<div className="space-y-2">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{modelsError?.message || modelsData?.error || 'Failed to load models. Save your API key first and test the connection.'}
</AlertDescription>
</Alert>
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
placeholder="Enter model ID manually (e.g., gpt-4o)"
/>
</div>
) : (
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select model" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categoryOrder
.filter((cat) => groupedModels?.[cat]?.length)
.map((category) => (
<SelectGroup key={category}>
<SelectLabel className="text-xs font-semibold text-muted-foreground">
{categoryLabels[category] || category}
</SelectLabel>
{groupedModels?.[category]?.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
{model.isReasoning && (
<SlidersHorizontal className="h-3 w-3 text-purple-500" />
)}
<span>{model.name}</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
)}
<FormDescription>
{form.watch('ai_model')?.startsWith('o') ? (
<span className="flex items-center gap-1 text-purple-600">
<SlidersHorizontal className="h-3 w-3" />
Reasoning model - optimized for complex analysis tasks
</span>
) : (
'OpenAI model to use for AI features'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ai_send_descriptions"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Send Project Descriptions</FormLabel>
<FormDescription>
Include anonymized project descriptions in AI requests for better matching
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="submit"
disabled={updateSettings.isPending}
>
{updateSettings.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Cog className="mr-2 h-4 w-4" />
Save AI Settings
</>
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => testConnection.mutate()}
disabled={testConnection.isPending}
>
{testConnection.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
<>
<Zap className="mr-2 h-4 w-4" />
Test Connection
</>
)}
</Button>
</div>
</form>
</Form>
)
}

View File

@@ -1,294 +1,294 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import {
Coins,
Zap,
TrendingUp,
Activity,
SlidersHorizontal,
Filter,
Users,
Award,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const ACTION_ICONS: Record<string, typeof Zap> = {
ASSIGNMENT: Users,
FILTERING: Filter,
AWARD_ELIGIBILITY: Award,
MENTOR_MATCHING: SlidersHorizontal,
}
const ACTION_LABELS: Record<string, string> = {
ASSIGNMENT: 'Jury Assignment',
FILTERING: 'Project Filtering',
AWARD_ELIGIBILITY: 'Award Eligibility',
MENTOR_MATCHING: 'Mentor Matching',
}
function StatCard({
label,
value,
subValue,
icon: Icon,
trend,
}: {
label: string
value: string
subValue?: string
icon: typeof Zap
trend?: 'up' | 'down' | 'neutral'
}) {
return (
<div className="flex items-start gap-3 rounded-lg border bg-card p-4">
<div className="rounded-md bg-muted p-2">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold">{value}</p>
{trend && trend !== 'neutral' && (
<TrendingUp
className={cn(
'h-4 w-4',
trend === 'up' ? 'text-green-500' : 'rotate-180 text-red-500'
)}
/>
)}
</div>
{subValue && (
<p className="text-xs text-muted-foreground">{subValue}</p>
)}
</div>
</div>
)
}
function UsageBar({
label,
value,
maxValue,
color,
}: {
label: string
value: number
maxValue: number
color: string
}) {
const percentage = maxValue > 0 ? (value / maxValue) * 100 : 0
return (
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">{value.toLocaleString()}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full transition-all duration-500', color)}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
}
export function AIUsageCard() {
const {
data: monthCost,
isLoading: monthLoading,
} = trpc.settings.getAICurrentMonthCost.useQuery(undefined, {
staleTime: 60 * 1000, // 1 minute
})
const {
data: stats,
isLoading: statsLoading,
} = trpc.settings.getAIUsageStats.useQuery({}, {
staleTime: 60 * 1000,
})
const {
data: history,
isLoading: historyLoading,
} = trpc.settings.getAIUsageHistory.useQuery({ days: 30 }, {
staleTime: 60 * 1000,
})
const isLoading = monthLoading || statsLoading
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
AI Usage & Costs
</CardTitle>
<CardDescription>Loading usage data...</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
<Skeleton className="h-32" />
</CardContent>
</Card>
)
}
const hasUsage = monthCost && monthCost.requestCount > 0
const maxTokensByAction = stats?.byAction
? Math.max(...Object.values(stats.byAction).map((a) => a.tokens))
: 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
AI Usage & Costs
</CardTitle>
<CardDescription>
Token usage and estimated costs for AI features
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Current month summary */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label="This Month Cost"
value={monthCost?.costFormatted || '$0.00'}
subValue={`${monthCost?.requestCount || 0} requests`}
icon={Coins}
/>
<StatCard
label="Tokens Used"
value={monthCost?.tokens?.toLocaleString() || '0'}
subValue="This month"
icon={Zap}
/>
{stats && (
<StatCard
label="All-Time Cost"
value={stats.totalCostFormatted || '$0.00'}
subValue={`${stats.totalTokens?.toLocaleString() || 0} tokens`}
icon={TrendingUp}
/>
)}
</div>
{/* Usage by action */}
{hasUsage && stats?.byAction && Object.keys(stats.byAction).length > 0 && (
<div className="space-y-4">
<h4 className="text-sm font-semibold">Usage by Feature</h4>
<div className="space-y-3">
{Object.entries(stats.byAction)
.sort(([, a], [, b]) => b.tokens - a.tokens)
.map(([action, data]) => {
const Icon = ACTION_ICONS[action] || Zap
return (
<div key={action} className="flex items-center gap-3">
<div className="rounded-md bg-muted p-1.5">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="flex-1">
<UsageBar
label={ACTION_LABELS[action] || action}
value={data.tokens}
maxValue={maxTokensByAction}
color="bg-primary"
/>
</div>
<Badge variant="secondary" className="ml-2 text-xs">
{(data as { costFormatted?: string }).costFormatted}
</Badge>
</div>
)
})}
</div>
</div>
)}
{/* Usage by model */}
{hasUsage && stats?.byModel && Object.keys(stats.byModel).length > 0 && (
<div className="space-y-4">
<h4 className="text-sm font-semibold">Usage by Model</h4>
<div className="flex flex-wrap gap-2">
{Object.entries(stats.byModel)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([model, data]) => (
<Badge
key={model}
variant="outline"
className="flex items-center gap-2"
>
<SlidersHorizontal className="h-3 w-3" />
<span>{model}</span>
<span className="text-muted-foreground">
{(data as { costFormatted?: string }).costFormatted}
</span>
</Badge>
))}
</div>
</div>
)}
{/* Usage history mini chart */}
{hasUsage && history && history.length > 0 && (
<div className="space-y-4">
<h4 className="text-sm font-semibold">Last 30 Days</h4>
<div className="flex h-16 items-end gap-0.5">
{(() => {
const maxCost = Math.max(...history.map((d) => d.cost), 0.001)
return history.slice(-30).map((day, i) => {
const height = (day.cost / maxCost) * 100
return (
<div
key={day.date}
className="group relative flex-1 cursor-pointer"
title={`${day.date}: ${day.costFormatted}`}
>
<div
className="w-full rounded-t bg-primary/60 transition-colors hover:bg-primary"
style={{ height: `${Math.max(height, 4)}%` }}
/>
</div>
)
})
})()}
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{history[0]?.date}</span>
<span>{history[history.length - 1]?.date}</span>
</div>
</div>
)}
{/* No usage message */}
{!hasUsage && (
<div className="rounded-lg border border-dashed p-8 text-center">
<Activity className="mx-auto h-8 w-8 text-muted-foreground" />
<h4 className="mt-2 text-sm font-semibold">No AI usage yet</h4>
<p className="mt-1 text-sm text-muted-foreground">
AI usage will be tracked when you use filtering, assignments, or
other AI-powered features.
</p>
</div>
)}
</CardContent>
</Card>
)
}
'use client'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import {
Coins,
Zap,
TrendingUp,
Activity,
SlidersHorizontal,
Filter,
Users,
Award,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const ACTION_ICONS: Record<string, typeof Zap> = {
ASSIGNMENT: Users,
FILTERING: Filter,
AWARD_ELIGIBILITY: Award,
MENTOR_MATCHING: SlidersHorizontal,
}
const ACTION_LABELS: Record<string, string> = {
ASSIGNMENT: 'Jury Assignment',
FILTERING: 'Project Filtering',
AWARD_ELIGIBILITY: 'Award Eligibility',
MENTOR_MATCHING: 'Mentor Matching',
}
function StatCard({
label,
value,
subValue,
icon: Icon,
trend,
}: {
label: string
value: string
subValue?: string
icon: typeof Zap
trend?: 'up' | 'down' | 'neutral'
}) {
return (
<div className="flex items-start gap-3 rounded-lg border bg-card p-4">
<div className="rounded-md bg-muted p-2">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold">{value}</p>
{trend && trend !== 'neutral' && (
<TrendingUp
className={cn(
'h-4 w-4',
trend === 'up' ? 'text-green-500' : 'rotate-180 text-red-500'
)}
/>
)}
</div>
{subValue && (
<p className="text-xs text-muted-foreground">{subValue}</p>
)}
</div>
</div>
)
}
function UsageBar({
label,
value,
maxValue,
color,
}: {
label: string
value: number
maxValue: number
color: string
}) {
const percentage = maxValue > 0 ? (value / maxValue) * 100 : 0
return (
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">{value.toLocaleString()}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full transition-all duration-500', color)}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
}
export function AIUsageCard() {
const {
data: monthCost,
isLoading: monthLoading,
} = trpc.settings.getAICurrentMonthCost.useQuery(undefined, {
staleTime: 60 * 1000, // 1 minute
})
const {
data: stats,
isLoading: statsLoading,
} = trpc.settings.getAIUsageStats.useQuery({}, {
staleTime: 60 * 1000,
})
const {
data: history,
isLoading: historyLoading,
} = trpc.settings.getAIUsageHistory.useQuery({ days: 30 }, {
staleTime: 60 * 1000,
})
const isLoading = monthLoading || statsLoading
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
AI Usage & Costs
</CardTitle>
<CardDescription>Loading usage data...</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
<Skeleton className="h-32" />
</CardContent>
</Card>
)
}
const hasUsage = monthCost && monthCost.requestCount > 0
const maxTokensByAction = stats?.byAction
? Math.max(...Object.values(stats.byAction).map((a) => a.tokens))
: 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
AI Usage & Costs
</CardTitle>
<CardDescription>
Token usage and estimated costs for AI features
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Current month summary */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label="This Month Cost"
value={monthCost?.costFormatted || '$0.00'}
subValue={`${monthCost?.requestCount || 0} requests`}
icon={Coins}
/>
<StatCard
label="Tokens Used"
value={monthCost?.tokens?.toLocaleString() || '0'}
subValue="This month"
icon={Zap}
/>
{stats && (
<StatCard
label="All-Time Cost"
value={stats.totalCostFormatted || '$0.00'}
subValue={`${stats.totalTokens?.toLocaleString() || 0} tokens`}
icon={TrendingUp}
/>
)}
</div>
{/* Usage by action */}
{hasUsage && stats?.byAction && Object.keys(stats.byAction).length > 0 && (
<div className="space-y-4">
<h4 className="text-sm font-semibold">Usage by Feature</h4>
<div className="space-y-3">
{Object.entries(stats.byAction)
.sort(([, a], [, b]) => b.tokens - a.tokens)
.map(([action, data]) => {
const Icon = ACTION_ICONS[action] || Zap
return (
<div key={action} className="flex items-center gap-3">
<div className="rounded-md bg-muted p-1.5">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="flex-1">
<UsageBar
label={ACTION_LABELS[action] || action}
value={data.tokens}
maxValue={maxTokensByAction}
color="bg-primary"
/>
</div>
<Badge variant="secondary" className="ml-2 text-xs">
{(data as { costFormatted?: string }).costFormatted}
</Badge>
</div>
)
})}
</div>
</div>
)}
{/* Usage by model */}
{hasUsage && stats?.byModel && Object.keys(stats.byModel).length > 0 && (
<div className="space-y-4">
<h4 className="text-sm font-semibold">Usage by Model</h4>
<div className="flex flex-wrap gap-2">
{Object.entries(stats.byModel)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([model, data]) => (
<Badge
key={model}
variant="outline"
className="flex items-center gap-2"
>
<SlidersHorizontal className="h-3 w-3" />
<span>{model}</span>
<span className="text-muted-foreground">
{(data as { costFormatted?: string }).costFormatted}
</span>
</Badge>
))}
</div>
</div>
)}
{/* Usage history mini chart */}
{hasUsage && history && history.length > 0 && (
<div className="space-y-4">
<h4 className="text-sm font-semibold">Last 30 Days</h4>
<div className="flex h-16 items-end gap-0.5">
{(() => {
const maxCost = Math.max(...history.map((d) => d.cost), 0.001)
return history.slice(-30).map((day, i) => {
const height = (day.cost / maxCost) * 100
return (
<div
key={day.date}
className="group relative flex-1 cursor-pointer"
title={`${day.date}: ${day.costFormatted}`}
>
<div
className="w-full rounded-t bg-primary/60 transition-colors hover:bg-primary"
style={{ height: `${Math.max(height, 4)}%` }}
/>
</div>
)
})
})()}
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{history[0]?.date}</span>
<span>{history[history.length - 1]?.date}</span>
</div>
</div>
)}
{/* No usage message */}
{!hasUsage && (
<div className="rounded-lg border border-dashed p-8 text-center">
<Activity className="mx-auto h-8 w-8 text-muted-foreground" />
<h4 className="mt-2 text-sm font-semibold">No AI usage yet</h4>
<p className="mt-1 text-sm text-muted-foreground">
AI usage will be tracked when you use filtering, assignments, or
other AI-powered features.
</p>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,209 +1,209 @@
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Loader2, Settings } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const COMMON_TIMEZONES = [
{ value: 'Europe/Monaco', label: 'Monaco (CET/CEST)' },
{ value: 'Europe/Paris', label: 'Paris (CET/CEST)' },
{ value: 'Europe/London', label: 'London (GMT/BST)' },
{ value: 'America/New_York', label: 'New York (EST/EDT)' },
{ value: 'America/Los_Angeles', label: 'Los Angeles (PST/PDT)' },
{ value: 'Asia/Tokyo', label: 'Tokyo (JST)' },
{ value: 'Asia/Singapore', label: 'Singapore (SGT)' },
{ value: 'Australia/Sydney', label: 'Sydney (AEST/AEDT)' },
{ value: 'UTC', label: 'UTC' },
]
const formSchema = z.object({
default_timezone: z.string().min(1, 'Timezone is required'),
default_page_size: z.string().regex(/^\d+$/, 'Must be a number'),
autosave_interval_seconds: z.string().regex(/^\d+$/, 'Must be a number'),
display_project_names_uppercase: z.string().optional(),
})
type FormValues = z.infer<typeof formSchema>
interface DefaultsSettingsFormProps {
settings: {
default_timezone?: string
default_page_size?: string
autosave_interval_seconds?: string
display_project_names_uppercase?: string
}
}
export function DefaultsSettingsForm({ settings }: DefaultsSettingsFormProps) {
const utils = trpc.useUtils()
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
default_timezone: settings.default_timezone || 'Europe/Monaco',
default_page_size: settings.default_page_size || '20',
autosave_interval_seconds: settings.autosave_interval_seconds || '30',
display_project_names_uppercase: settings.display_project_names_uppercase || 'true',
},
})
const updateSettings = trpc.settings.updateMultiple.useMutation({
onSuccess: () => {
toast.success('Default settings saved successfully')
utils.settings.getByCategory.invalidate({ category: 'DEFAULTS' })
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`)
},
})
const onSubmit = (data: FormValues) => {
updateSettings.mutate({
settings: [
{ key: 'default_timezone', value: data.default_timezone },
{ key: 'default_page_size', value: data.default_page_size },
{ key: 'autosave_interval_seconds', value: data.autosave_interval_seconds },
{ key: 'display_project_names_uppercase', value: data.display_project_names_uppercase || 'true' },
],
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="default_timezone"
render={({ field }) => (
<FormItem>
<FormLabel>Default Timezone</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select timezone" />
</SelectTrigger>
</FormControl>
<SelectContent>
{COMMON_TIMEZONES.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Timezone used for displaying dates and deadlines across the platform
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="default_page_size"
render={({ field }) => (
<FormItem>
<FormLabel>Default Page Size</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select page size" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="10">10 items per page</SelectItem>
<SelectItem value="20">20 items per page</SelectItem>
<SelectItem value="50">50 items per page</SelectItem>
<SelectItem value="100">100 items per page</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Default number of items shown in lists and tables
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="autosave_interval_seconds"
render={({ field }) => (
<FormItem>
<FormLabel>Autosave Interval (seconds)</FormLabel>
<FormControl>
<Input type="number" min="10" max="120" placeholder="30" {...field} />
</FormControl>
<FormDescription>
How often evaluation forms are automatically saved while editing.
Lower values provide better data protection but increase server load.
Recommended: 30 seconds.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="display_project_names_uppercase"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Display Project Names in Uppercase
</FormLabel>
<FormDescription>
Show all project names in uppercase across the platform for a cleaner presentation
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value === 'true'}
onCheckedChange={(checked) => field.onChange(String(checked))}
/>
</FormControl>
</FormItem>
)}
/>
<Button type="submit" disabled={updateSettings.isPending}>
{updateSettings.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Settings className="mr-2 h-4 w-4" />
Save Default Settings
</>
)}
</Button>
</form>
</Form>
)
}
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Loader2, Settings } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const COMMON_TIMEZONES = [
{ value: 'Europe/Monaco', label: 'Monaco (CET/CEST)' },
{ value: 'Europe/Paris', label: 'Paris (CET/CEST)' },
{ value: 'Europe/London', label: 'London (GMT/BST)' },
{ value: 'America/New_York', label: 'New York (EST/EDT)' },
{ value: 'America/Los_Angeles', label: 'Los Angeles (PST/PDT)' },
{ value: 'Asia/Tokyo', label: 'Tokyo (JST)' },
{ value: 'Asia/Singapore', label: 'Singapore (SGT)' },
{ value: 'Australia/Sydney', label: 'Sydney (AEST/AEDT)' },
{ value: 'UTC', label: 'UTC' },
]
const formSchema = z.object({
default_timezone: z.string().min(1, 'Timezone is required'),
default_page_size: z.string().regex(/^\d+$/, 'Must be a number'),
autosave_interval_seconds: z.string().regex(/^\d+$/, 'Must be a number'),
display_project_names_uppercase: z.string().optional(),
})
type FormValues = z.infer<typeof formSchema>
interface DefaultsSettingsFormProps {
settings: {
default_timezone?: string
default_page_size?: string
autosave_interval_seconds?: string
display_project_names_uppercase?: string
}
}
export function DefaultsSettingsForm({ settings }: DefaultsSettingsFormProps) {
const utils = trpc.useUtils()
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
default_timezone: settings.default_timezone || 'Europe/Monaco',
default_page_size: settings.default_page_size || '20',
autosave_interval_seconds: settings.autosave_interval_seconds || '30',
display_project_names_uppercase: settings.display_project_names_uppercase || 'true',
},
})
const updateSettings = trpc.settings.updateMultiple.useMutation({
onSuccess: () => {
toast.success('Default settings saved successfully')
utils.settings.getByCategory.invalidate({ category: 'DEFAULTS' })
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`)
},
})
const onSubmit = (data: FormValues) => {
updateSettings.mutate({
settings: [
{ key: 'default_timezone', value: data.default_timezone },
{ key: 'default_page_size', value: data.default_page_size },
{ key: 'autosave_interval_seconds', value: data.autosave_interval_seconds },
{ key: 'display_project_names_uppercase', value: data.display_project_names_uppercase || 'true' },
],
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="default_timezone"
render={({ field }) => (
<FormItem>
<FormLabel>Default Timezone</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select timezone" />
</SelectTrigger>
</FormControl>
<SelectContent>
{COMMON_TIMEZONES.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Timezone used for displaying dates and deadlines across the platform
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="default_page_size"
render={({ field }) => (
<FormItem>
<FormLabel>Default Page Size</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select page size" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="10">10 items per page</SelectItem>
<SelectItem value="20">20 items per page</SelectItem>
<SelectItem value="50">50 items per page</SelectItem>
<SelectItem value="100">100 items per page</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Default number of items shown in lists and tables
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="autosave_interval_seconds"
render={({ field }) => (
<FormItem>
<FormLabel>Autosave Interval (seconds)</FormLabel>
<FormControl>
<Input type="number" min="10" max="120" placeholder="30" {...field} />
</FormControl>
<FormDescription>
How often evaluation forms are automatically saved while editing.
Lower values provide better data protection but increase server load.
Recommended: 30 seconds.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="display_project_names_uppercase"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Display Project Names in Uppercase
</FormLabel>
<FormDescription>
Show all project names in uppercase across the platform for a cleaner presentation
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value === 'true'}
onCheckedChange={(checked) => field.onChange(String(checked))}
/>
</FormControl>
</FormItem>
)}
/>
<Button type="submit" disabled={updateSettings.isPending}>
{updateSettings.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Settings className="mr-2 h-4 w-4" />
Save Default Settings
</>
)}
</Button>
</form>
</Form>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,28 @@
'use client'
import { motion } from 'motion/react'
import { type ReactNode } from 'react'
export function AnimatedCard({ children, index = 0 }: { children: ReactNode; index?: number }) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05, ease: 'easeOut' }}
>
{children}
</motion.div>
)
}
export function AnimatedList({ children }: { children: ReactNode }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
)
}
'use client'
import { motion } from 'motion/react'
import { type ReactNode } from 'react'
export function AnimatedCard({ children, index = 0 }: { children: ReactNode; index?: number }) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05, ease: 'easeOut' }}
>
{children}
</motion.div>
)
}
export function AnimatedList({ children }: { children: ReactNode }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
)
}

View File

@@ -1,215 +1,215 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Download, Loader2 } from 'lucide-react'
/**
* Converts a camelCase or snake_case column name to Title Case.
* e.g. "projectTitle" -> "Project Title", "ai_meetsCriteria" -> "Ai Meets Criteria"
*/
function formatColumnName(col: string): string {
// Replace underscores with spaces
let result = col.replace(/_/g, ' ')
// Insert space before uppercase letters (camelCase -> spaced)
result = result.replace(/([a-z])([A-Z])/g, '$1 $2')
// Capitalize first letter of each word
return result
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
type ExportData = {
data: Record<string, unknown>[]
columns: string[]
}
type CsvExportDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
exportData: ExportData | undefined
isLoading: boolean
filename: string
onRequestData: () => Promise<ExportData | undefined>
}
export function CsvExportDialog({
open,
onOpenChange,
exportData,
isLoading,
filename,
onRequestData,
}: CsvExportDialogProps) {
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set())
const [dataLoaded, setDataLoaded] = useState(false)
// When dialog opens, fetch data if not already loaded
useEffect(() => {
if (open && !dataLoaded) {
onRequestData().then((result) => {
if (result?.columns) {
setSelectedColumns(new Set(result.columns))
}
setDataLoaded(true)
})
}
}, [open, dataLoaded, onRequestData])
// Sync selected columns when export data changes
useEffect(() => {
if (exportData?.columns) {
setSelectedColumns(new Set(exportData.columns))
}
}, [exportData])
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setDataLoaded(false)
}
}, [open])
const toggleColumn = (col: string, checked: boolean) => {
const next = new Set(selectedColumns)
if (checked) {
next.add(col)
} else {
next.delete(col)
}
setSelectedColumns(next)
}
const toggleAll = () => {
if (!exportData) return
if (selectedColumns.size === exportData.columns.length) {
setSelectedColumns(new Set())
} else {
setSelectedColumns(new Set(exportData.columns))
}
}
const handleDownload = () => {
if (!exportData) return
const columnsArray = exportData.columns.filter((col) => selectedColumns.has(col))
// Build CSV header with formatted names
const csvHeader = columnsArray.map((col) => {
const formatted = formatColumnName(col)
// Escape quotes in header
if (formatted.includes(',') || formatted.includes('"')) {
return `"${formatted.replace(/"/g, '""')}"`
}
return formatted
})
const csvContent = [
csvHeader.join(','),
...exportData.data.map((row) =>
columnsArray
.map((col) => {
const value = row[col]
if (value === null || value === undefined) return ''
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
})
.join(',')
),
].join('\n')
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// Delay revoking to ensure download starts before URL is invalidated
setTimeout(() => URL.revokeObjectURL(url), 1000)
onOpenChange(false)
}
const allSelected = exportData ? selectedColumns.size === exportData.columns.length : false
const noneSelected = selectedColumns.size === 0
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Export CSV</DialogTitle>
<DialogDescription>
Select which columns to include in the export
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Loading data...</span>
</div>
) : exportData ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
{selectedColumns.size} of {exportData.columns.length} columns selected
</Label>
<Button variant="ghost" size="sm" onClick={toggleAll}>
{allSelected ? 'Deselect all' : 'Select all'}
</Button>
</div>
<div className="space-y-1.5 max-h-60 overflow-y-auto rounded-lg border p-3">
{exportData.columns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}`}
checked={selectedColumns.has(col)}
onCheckedChange={(checked) => toggleColumn(col, !!checked)}
/>
<Label htmlFor={`col-${col}`} className="text-sm cursor-pointer font-normal">
{formatColumnName(col)}
</Label>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
{exportData.data.length} row{exportData.data.length !== 1 ? 's' : ''} will be exported
</p>
</div>
) : (
<p className="text-sm text-muted-foreground py-4 text-center">
No data available for export.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleDownload}
disabled={isLoading || !exportData || noneSelected}
>
<Download className="mr-2 h-4 w-4" />
Download CSV
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Download, Loader2 } from 'lucide-react'
/**
* Converts a camelCase or snake_case column name to Title Case.
* e.g. "projectTitle" -> "Project Title", "ai_meetsCriteria" -> "Ai Meets Criteria"
*/
function formatColumnName(col: string): string {
// Replace underscores with spaces
let result = col.replace(/_/g, ' ')
// Insert space before uppercase letters (camelCase -> spaced)
result = result.replace(/([a-z])([A-Z])/g, '$1 $2')
// Capitalize first letter of each word
return result
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
type ExportData = {
data: Record<string, unknown>[]
columns: string[]
}
type CsvExportDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
exportData: ExportData | undefined
isLoading: boolean
filename: string
onRequestData: () => Promise<ExportData | undefined>
}
export function CsvExportDialog({
open,
onOpenChange,
exportData,
isLoading,
filename,
onRequestData,
}: CsvExportDialogProps) {
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set())
const [dataLoaded, setDataLoaded] = useState(false)
// When dialog opens, fetch data if not already loaded
useEffect(() => {
if (open && !dataLoaded) {
onRequestData().then((result) => {
if (result?.columns) {
setSelectedColumns(new Set(result.columns))
}
setDataLoaded(true)
})
}
}, [open, dataLoaded, onRequestData])
// Sync selected columns when export data changes
useEffect(() => {
if (exportData?.columns) {
setSelectedColumns(new Set(exportData.columns))
}
}, [exportData])
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setDataLoaded(false)
}
}, [open])
const toggleColumn = (col: string, checked: boolean) => {
const next = new Set(selectedColumns)
if (checked) {
next.add(col)
} else {
next.delete(col)
}
setSelectedColumns(next)
}
const toggleAll = () => {
if (!exportData) return
if (selectedColumns.size === exportData.columns.length) {
setSelectedColumns(new Set())
} else {
setSelectedColumns(new Set(exportData.columns))
}
}
const handleDownload = () => {
if (!exportData) return
const columnsArray = exportData.columns.filter((col) => selectedColumns.has(col))
// Build CSV header with formatted names
const csvHeader = columnsArray.map((col) => {
const formatted = formatColumnName(col)
// Escape quotes in header
if (formatted.includes(',') || formatted.includes('"')) {
return `"${formatted.replace(/"/g, '""')}"`
}
return formatted
})
const csvContent = [
csvHeader.join(','),
...exportData.data.map((row) =>
columnsArray
.map((col) => {
const value = row[col]
if (value === null || value === undefined) return ''
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
})
.join(',')
),
].join('\n')
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// Delay revoking to ensure download starts before URL is invalidated
setTimeout(() => URL.revokeObjectURL(url), 1000)
onOpenChange(false)
}
const allSelected = exportData ? selectedColumns.size === exportData.columns.length : false
const noneSelected = selectedColumns.size === 0
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Export CSV</DialogTitle>
<DialogDescription>
Select which columns to include in the export
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Loading data...</span>
</div>
) : exportData ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
{selectedColumns.size} of {exportData.columns.length} columns selected
</Label>
<Button variant="ghost" size="sm" onClick={toggleAll}>
{allSelected ? 'Deselect all' : 'Select all'}
</Button>
</div>
<div className="space-y-1.5 max-h-60 overflow-y-auto rounded-lg border p-3">
{exportData.columns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}`}
checked={selectedColumns.has(col)}
onCheckedChange={(checked) => toggleColumn(col, !!checked)}
/>
<Label htmlFor={`col-${col}`} className="text-sm cursor-pointer font-normal">
{formatColumnName(col)}
</Label>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
{exportData.data.length} row{exportData.data.length !== 1 ? 's' : ''} will be exported
</p>
</div>
) : (
<p className="text-sm text-muted-foreground py-4 text-center">
No data available for export.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleDownload}
disabled={isLoading || !exportData || noneSelected}
>
<Download className="mr-2 h-4 w-4" />
Download CSV
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,145 +1,145 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { MessageSquare, Lock, Send, User } from 'lucide-react'
import { cn } from '@/lib/utils'
interface Comment {
id: string
author: string
content: string
createdAt: string
}
interface DiscussionThreadProps {
comments: Comment[]
onAddComment?: (content: string) => void
isLocked?: boolean
maxLength?: number
isSubmitting?: boolean
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMinutes < 1) return 'just now'
if (diffMinutes < 60) return `${diffMinutes}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
export function DiscussionThread({
comments,
onAddComment,
isLocked = false,
maxLength = 2000,
isSubmitting = false,
}: DiscussionThreadProps) {
const [newComment, setNewComment] = useState('')
const handleSubmit = () => {
const trimmed = newComment.trim()
if (!trimmed || !onAddComment) return
onAddComment(trimmed)
setNewComment('')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
handleSubmit()
}
}
return (
<div className="space-y-4">
{/* Locked banner */}
{isLocked && (
<div className="flex items-center gap-2 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
<Lock className="h-4 w-4 shrink-0" />
Discussion is closed. No new comments can be added.
</div>
)}
{/* Comments list */}
{comments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<MessageSquare className="h-10 w-10 text-muted-foreground/50" />
<p className="mt-2 text-sm font-medium text-muted-foreground">No comments yet</p>
<p className="text-xs text-muted-foreground">
Be the first to share your thoughts on this project.
</p>
</div>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<Card key={comment.id}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
<User className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{comment.author}</span>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(comment.createdAt)}
</span>
</div>
<p className="mt-1 text-sm whitespace-pre-wrap break-words">
{comment.content}
</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Add comment form */}
{!isLocked && onAddComment && (
<div className="space-y-2">
<Textarea
placeholder="Add a comment... (Ctrl+Enter to send)"
value={newComment}
onChange={(e) => setNewComment(e.target.value.slice(0, maxLength))}
onKeyDown={handleKeyDown}
rows={3}
disabled={isSubmitting}
/>
<div className="flex items-center justify-between">
<span
className={cn(
'text-xs',
newComment.length > maxLength * 0.9
? 'text-destructive'
: 'text-muted-foreground'
)}
>
{newComment.length}/{maxLength}
</span>
<Button
size="sm"
onClick={handleSubmit}
disabled={!newComment.trim() || isSubmitting}
>
<Send className="mr-2 h-4 w-4" />
{isSubmitting ? 'Sending...' : 'Comment'}
</Button>
</div>
</div>
)}
</div>
)
}
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { MessageSquare, Lock, Send, User } from 'lucide-react'
import { cn } from '@/lib/utils'
interface Comment {
id: string
author: string
content: string
createdAt: string
}
interface DiscussionThreadProps {
comments: Comment[]
onAddComment?: (content: string) => void
isLocked?: boolean
maxLength?: number
isSubmitting?: boolean
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMinutes < 1) return 'just now'
if (diffMinutes < 60) return `${diffMinutes}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
export function DiscussionThread({
comments,
onAddComment,
isLocked = false,
maxLength = 2000,
isSubmitting = false,
}: DiscussionThreadProps) {
const [newComment, setNewComment] = useState('')
const handleSubmit = () => {
const trimmed = newComment.trim()
if (!trimmed || !onAddComment) return
onAddComment(trimmed)
setNewComment('')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
handleSubmit()
}
}
return (
<div className="space-y-4">
{/* Locked banner */}
{isLocked && (
<div className="flex items-center gap-2 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
<Lock className="h-4 w-4 shrink-0" />
Discussion is closed. No new comments can be added.
</div>
)}
{/* Comments list */}
{comments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<MessageSquare className="h-10 w-10 text-muted-foreground/50" />
<p className="mt-2 text-sm font-medium text-muted-foreground">No comments yet</p>
<p className="text-xs text-muted-foreground">
Be the first to share your thoughts on this project.
</p>
</div>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<Card key={comment.id}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
<User className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{comment.author}</span>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(comment.createdAt)}
</span>
</div>
<p className="mt-1 text-sm whitespace-pre-wrap break-words">
{comment.content}
</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Add comment form */}
{!isLocked && onAddComment && (
<div className="space-y-2">
<Textarea
placeholder="Add a comment... (Ctrl+Enter to send)"
value={newComment}
onChange={(e) => setNewComment(e.target.value.slice(0, maxLength))}
onKeyDown={handleKeyDown}
rows={3}
disabled={isSubmitting}
/>
<div className="flex items-center justify-between">
<span
className={cn(
'text-xs',
newComment.length > maxLength * 0.9
? 'text-destructive'
: 'text-muted-foreground'
)}
>
{newComment.length}/{maxLength}
</span>
<Button
size="sm"
onClick={handleSubmit}
disabled={!newComment.trim() || isSubmitting}
>
<Send className="mr-2 h-4 w-4" />
{isSubmitting ? 'Sending...' : 'Comment'}
</Button>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,51 +1,51 @@
import { LucideIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface EmptyStateProps {
icon: LucideIcon
title: string
description?: string
action?: {
label: string
href?: string
onClick?: () => void
}
className?: string
}
export function EmptyState({
icon: Icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center py-12 text-center',
className
)}
>
<div className="rounded-2xl bg-muted/60 p-4">
<Icon className="h-8 w-8 text-muted-foreground/70" />
</div>
<h3 className="mt-4 font-medium">{title}</h3>
{description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
{description}
</p>
)}
{action && (
<Button
className="mt-4"
onClick={action.onClick}
asChild={!!action.href}
>
{action.href ? <a href={action.href}>{action.label}</a> : action.label}
</Button>
)}
</div>
)
}
import { LucideIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface EmptyStateProps {
icon: LucideIcon
title: string
description?: string
action?: {
label: string
href?: string
onClick?: () => void
}
className?: string
}
export function EmptyState({
icon: Icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center py-12 text-center',
className
)}
>
<div className="rounded-2xl bg-muted/60 p-4">
<Icon className="h-8 w-8 text-muted-foreground/70" />
</div>
<h3 className="mt-4 font-medium">{title}</h3>
{description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
{description}
</p>
)}
{action && (
<Button
className="mt-4"
onClick={action.onClick}
asChild={!!action.href}
>
{action.href ? <a href={action.href}>{action.label}</a> : action.label}
</Button>
)}
</div>
)
}

View File

@@ -1,191 +1,191 @@
'use client'
import { useState, useCallback, type RefObject } 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,
addChartImage,
addAllPageFooters,
savePdf,
} from '@/lib/pdf-generator'
interface ExportPdfButtonProps {
stageId: string
roundName?: string
programName?: string
chartRefs?: Record<string, RefObject<HTMLDivElement | null>>
variant?: 'default' | 'outline' | 'secondary' | 'ghost'
size?: 'default' | 'sm' | 'lg' | 'icon'
}
export function ExportPdfButton({
stageId,
roundName,
programName,
chartRefs,
variant = 'outline',
size = 'sm',
}: ExportPdfButtonProps) {
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 = roundName || String(data.roundName || 'Report')
const pName = programName || 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 section
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)
// Capture chart images if refs provided
if (chartRefs) {
for (const [, ref] of Object.entries(chartRefs)) {
if (ref.current) {
try {
y = await addChartImage(doc, ref.current, y, { maxHeight: 90 })
} catch {
// Skip chart if capture fails
}
}
}
}
}
// 4. Rankings section
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 section
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. Footer on all pages
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, roundName, programName, chartRefs])
return (
<Button variant={variant} size={size} 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, type RefObject } 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,
addChartImage,
addAllPageFooters,
savePdf,
} from '@/lib/pdf-generator'
interface ExportPdfButtonProps {
stageId: string
roundName?: string
programName?: string
chartRefs?: Record<string, RefObject<HTMLDivElement | null>>
variant?: 'default' | 'outline' | 'secondary' | 'ghost'
size?: 'default' | 'sm' | 'lg' | 'icon'
}
export function ExportPdfButton({
stageId,
roundName,
programName,
chartRefs,
variant = 'outline',
size = 'sm',
}: ExportPdfButtonProps) {
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 = roundName || String(data.roundName || 'Report')
const pName = programName || 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 section
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)
// Capture chart images if refs provided
if (chartRefs) {
for (const [, ref] of Object.entries(chartRefs)) {
if (ref.current) {
try {
y = await addChartImage(doc, ref.current, y, { maxHeight: 90 })
} catch {
// Skip chart if capture fails
}
}
}
}
}
// 4. Rankings section
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 section
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. Footer on all pages
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, roundName, programName, chartRefs])
return (
<Button variant={variant} size={size} 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

@@ -1,103 +1,103 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Eye, Download, FileText, Image as ImageIcon, Video, File } from 'lucide-react'
interface FilePreviewProps {
fileName: string
mimeType: string
downloadUrl: string
}
function getPreviewType(mimeType: string): 'pdf' | 'image' | 'video' | 'unsupported' {
if (mimeType === 'application/pdf') return 'pdf'
if (mimeType.startsWith('image/')) return 'image'
if (mimeType.startsWith('video/')) return 'video'
return 'unsupported'
}
function getFileIcon(mimeType: string) {
if (mimeType === 'application/pdf') return FileText
if (mimeType.startsWith('image/')) return ImageIcon
if (mimeType.startsWith('video/')) return Video
return File
}
export function FilePreview({ fileName, mimeType, downloadUrl }: FilePreviewProps) {
const [open, setOpen] = useState(false)
const previewType = getPreviewType(mimeType)
const Icon = getFileIcon(mimeType)
const canPreview = previewType !== 'unsupported'
if (!canPreview) {
return (
<Button variant="outline" size="sm" asChild>
<a href={downloadUrl} target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 truncate">
<Icon className="h-4 w-4 shrink-0" />
{fileName}
</DialogTitle>
</DialogHeader>
<div className="overflow-auto">
{previewType === 'pdf' && (
<iframe
src={`${downloadUrl}#toolbar=0`}
className="w-full h-[70vh] rounded-md"
title={fileName}
/>
)}
{previewType === 'image' && (
<img
src={downloadUrl}
alt={fileName}
className="w-full h-auto max-h-[70vh] object-contain rounded-md"
/>
)}
{previewType === 'video' && (
<video
src={downloadUrl}
controls
className="w-full max-h-[70vh] rounded-md"
preload="metadata"
>
Your browser does not support the video tag.
</video>
)}
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" asChild>
<a href={downloadUrl} target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
</div>
</DialogContent>
</Dialog>
)
}
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Eye, Download, FileText, Image as ImageIcon, Video, File } from 'lucide-react'
interface FilePreviewProps {
fileName: string
mimeType: string
downloadUrl: string
}
function getPreviewType(mimeType: string): 'pdf' | 'image' | 'video' | 'unsupported' {
if (mimeType === 'application/pdf') return 'pdf'
if (mimeType.startsWith('image/')) return 'image'
if (mimeType.startsWith('video/')) return 'video'
return 'unsupported'
}
function getFileIcon(mimeType: string) {
if (mimeType === 'application/pdf') return FileText
if (mimeType.startsWith('image/')) return ImageIcon
if (mimeType.startsWith('video/')) return Video
return File
}
export function FilePreview({ fileName, mimeType, downloadUrl }: FilePreviewProps) {
const [open, setOpen] = useState(false)
const previewType = getPreviewType(mimeType)
const Icon = getFileIcon(mimeType)
const canPreview = previewType !== 'unsupported'
if (!canPreview) {
return (
<Button variant="outline" size="sm" asChild>
<a href={downloadUrl} target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 truncate">
<Icon className="h-4 w-4 shrink-0" />
{fileName}
</DialogTitle>
</DialogHeader>
<div className="overflow-auto">
{previewType === 'pdf' && (
<iframe
src={`${downloadUrl}#toolbar=0`}
className="w-full h-[70vh] rounded-md"
title={fileName}
/>
)}
{previewType === 'image' && (
<img
src={downloadUrl}
alt={fileName}
className="w-full h-auto max-h-[70vh] object-contain rounded-md"
/>
)}
{previewType === 'video' && (
<video
src={downloadUrl}
controls
className="w-full max-h-[70vh] rounded-md"
preload="metadata"
>
Your browser does not support the video tag.
</video>
)}
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" asChild>
<a href={downloadUrl} target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,493 +1,493 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Upload,
X,
FileIcon,
CheckCircle2,
AlertCircle,
Loader2,
Film,
FileText,
Presentation,
} from 'lucide-react'
import { cn, formatFileSize } from '@/lib/utils'
const MAX_FILE_SIZE = 500 * 1024 * 1024 // 500MB
type FileType = 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER'
interface UploadingFile {
id: string
file: File
progress: number
status: 'pending' | 'uploading' | 'complete' | 'error'
error?: string
fileType: FileType
dbFileId?: string
}
interface FileUploadProps {
projectId: string
onUploadComplete?: (file: { id: string; fileName: string; fileType: string }) => void
onUploadError?: (error: Error) => void
allowedTypes?: string[]
multiple?: boolean
className?: string
stageId?: string
availableStages?: Array<{ id: string; name: string }>
}
// Map MIME types to suggested file types
function suggestFileType(mimeType: string): FileType {
if (mimeType.startsWith('video/')) return 'VIDEO'
if (mimeType === 'application/pdf') return 'EXEC_SUMMARY'
if (
mimeType.includes('presentation') ||
mimeType.includes('powerpoint') ||
mimeType.includes('slides')
) {
return 'PRESENTATION'
}
return 'OTHER'
}
// Get icon for file type
function getFileTypeIcon(fileType: FileType) {
switch (fileType) {
case 'VIDEO':
return <Film className="h-4 w-4" />
case 'EXEC_SUMMARY':
return <FileText className="h-4 w-4" />
case 'PRESENTATION':
return <Presentation className="h-4 w-4" />
default:
return <FileIcon className="h-4 w-4" />
}
}
export function FileUpload({
projectId,
onUploadComplete,
onUploadError,
allowedTypes,
multiple = true,
className,
stageId,
availableStages,
}: FileUploadProps) {
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [selectedStageId, setSelectedStageId] = useState<string | null>(stageId ?? null)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.file.getUploadUrl.useMutation()
const confirmUpload = trpc.file.confirmUpload.useMutation()
const utils = trpc.useUtils()
// Validate file
const validateFile = useCallback(
(file: File): string | null => {
if (file.size > MAX_FILE_SIZE) {
return `File size exceeds ${formatFileSize(MAX_FILE_SIZE)} limit`
}
if (allowedTypes && !allowedTypes.includes(file.type)) {
return `File type ${file.type} is not allowed`
}
return null
},
[allowedTypes]
)
// Upload a single file
const uploadFile = useCallback(
async (uploadingFile: UploadingFile) => {
const { file, id, fileType } = uploadingFile
try {
// Update status to uploading
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, status: 'uploading' as const } : f))
)
// Get pre-signed upload URL
const { uploadUrl, file: dbFile } = await getUploadUrl.mutateAsync({
projectId,
fileName: file.name,
fileType,
mimeType: file.type || 'application/octet-stream',
size: file.size,
stageId: selectedStageId ?? undefined,
})
// Store the DB file ID
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, dbFileId: dbFile.id } : f))
)
// Upload to MinIO using XHR for progress tracking
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100)
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, progress } : f))
)
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'))
})
xhr.addEventListener('abort', () => {
reject(new Error('Upload aborted'))
})
xhr.open('PUT', uploadUrl)
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
xhr.send(file)
})
// Confirm upload
await confirmUpload.mutateAsync({ fileId: dbFile.id })
// Update status to complete
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === id ? { ...f, status: 'complete' as const, progress: 100 } : f
)
)
// Invalidate file list queries
utils.file.listByProject.invalidate({ projectId })
// Notify parent
onUploadComplete?.({
id: dbFile.id,
fileName: file.name,
fileType,
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Upload failed'
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === id ? { ...f, status: 'error' as const, error: errorMessage } : f
)
)
onUploadError?.(error instanceof Error ? error : new Error(errorMessage))
}
},
[projectId, getUploadUrl, confirmUpload, utils, onUploadComplete, onUploadError]
)
// Handle file selection
const handleFiles = useCallback(
(files: FileList | File[]) => {
const fileArray = Array.from(files)
const filesToUpload = multiple ? fileArray : [fileArray[0]].filter(Boolean)
const newUploadingFiles: UploadingFile[] = filesToUpload.map((file) => {
const validationError = validateFile(file)
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
file,
progress: 0,
status: validationError ? ('error' as const) : ('pending' as const),
error: validationError || undefined,
fileType: suggestFileType(file.type),
}
})
setUploadingFiles((prev) => [...prev, ...newUploadingFiles])
// Start uploading valid files
newUploadingFiles
.filter((f) => f.status === 'pending')
.forEach((f) => uploadFile(f))
},
[multiple, validateFile, uploadFile]
)
// Drag and drop handlers
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (e.dataTransfer.files?.length) {
handleFiles(e.dataTransfer.files)
}
},
[handleFiles]
)
// File input change handler
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) {
handleFiles(e.target.files)
}
// Reset input so same file can be selected again
e.target.value = ''
},
[handleFiles]
)
// Update file type for a pending file
const updateFileType = useCallback((fileId: string, fileType: FileType) => {
setUploadingFiles((prev) =>
prev.map((f) => (f.id === fileId ? { ...f, fileType } : f))
)
}, [])
// Remove a file from the queue
const removeFile = useCallback((fileId: string) => {
setUploadingFiles((prev) => prev.filter((f) => f.id !== fileId))
}, [])
// Retry a failed upload
const retryUpload = useCallback(
(fileId: string) => {
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === fileId
? { ...f, status: 'pending' as const, progress: 0, error: undefined }
: f
)
)
const file = uploadingFiles.find((f) => f.id === fileId)
if (file) {
uploadFile({ ...file, status: 'pending', progress: 0, error: undefined })
}
},
[uploadingFiles, uploadFile]
)
const hasActiveUploads = uploadingFiles.some(
(f) => f.status === 'pending' || f.status === 'uploading'
)
return (
<div className={cn('space-y-4', className)}>
{/* Stage selector */}
{availableStages && availableStages.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium">
Upload for Stage
</label>
<Select
value={selectedStageId ?? 'null'}
onValueChange={(value) => setSelectedStageId(value === 'null' ? null : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="null">General (no specific stage)</SelectItem>
{availableStages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Drop zone */}
<div
className={cn(
'relative border-2 border-dashed rounded-lg p-6 text-center transition-colors cursor-pointer',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50',
hasActiveUploads && 'opacity-50 pointer-events-none'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
multiple={multiple}
accept={allowedTypes?.join(',')}
onChange={handleFileInputChange}
className="hidden"
/>
<Upload className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="mt-2 font-medium">
{isDragging ? 'Drop files here' : 'Drag and drop files here'}
</p>
<p className="text-sm text-muted-foreground">
or click to browse (max {formatFileSize(MAX_FILE_SIZE)})
</p>
</div>
{/* Upload queue */}
{uploadingFiles.length > 0 && (
<div className="space-y-2">
{uploadingFiles.map((uploadingFile) => (
<div
key={uploadingFile.id}
className={cn(
'flex items-center gap-3 rounded-lg border p-3',
uploadingFile.status === 'error' && 'border-destructive/50 bg-destructive/5',
uploadingFile.status === 'complete' && 'border-green-500/50 bg-green-500/5'
)}
>
{/* File type icon */}
<div className="shrink-0 text-muted-foreground">
{getFileTypeIcon(uploadingFile.fileType)}
</div>
{/* File info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">
{uploadingFile.file.name}
</p>
<Badge variant="outline" className="shrink-0 text-xs">
{formatFileSize(uploadingFile.file.size)}
</Badge>
</div>
{/* Progress bar or error message */}
{uploadingFile.status === 'uploading' && (
<div className="mt-1 flex items-center gap-2">
<Progress value={uploadingFile.progress} className="h-1.5 flex-1" />
<span className="text-xs text-muted-foreground">
{uploadingFile.progress}%
</span>
</div>
)}
{uploadingFile.status === 'error' && (
<p className="mt-1 text-xs text-destructive">{uploadingFile.error}</p>
)}
{uploadingFile.status === 'pending' && (
<div className="mt-1 flex items-center gap-2">
<Select
value={uploadingFile.fileType}
onValueChange={(v) => updateFileType(uploadingFile.id, v as FileType)}
>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EXEC_SUMMARY">Executive Summary</SelectItem>
<SelectItem value="PRESENTATION">Presentation</SelectItem>
<SelectItem value="VIDEO">Video</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Status / Actions */}
<div className="shrink-0">
{uploadingFile.status === 'pending' && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{uploadingFile.status === 'uploading' && (
<Loader2 className="h-4 w-4 animate-spin text-primary" />
)}
{uploadingFile.status === 'complete' && (
<CheckCircle2 className="h-4 w-4 text-green-600" />
)}
{uploadingFile.status === 'error' && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation()
retryUpload(uploadingFile.id)
}}
>
Retry
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="Remove file"
onClick={(e) => {
e.stopPropagation()
removeFile(uploadingFile.id)
}}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
))}
{/* Clear completed */}
{uploadingFiles.some((f) => f.status === 'complete') && (
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() =>
setUploadingFiles((prev) => prev.filter((f) => f.status !== 'complete'))
}
>
Clear completed
</Button>
)}
</div>
)}
</div>
)
}
'use client'
import { useState, useCallback, useRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Upload,
X,
FileIcon,
CheckCircle2,
AlertCircle,
Loader2,
Film,
FileText,
Presentation,
} from 'lucide-react'
import { cn, formatFileSize } from '@/lib/utils'
const MAX_FILE_SIZE = 500 * 1024 * 1024 // 500MB
type FileType = 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER'
interface UploadingFile {
id: string
file: File
progress: number
status: 'pending' | 'uploading' | 'complete' | 'error'
error?: string
fileType: FileType
dbFileId?: string
}
interface FileUploadProps {
projectId: string
onUploadComplete?: (file: { id: string; fileName: string; fileType: string }) => void
onUploadError?: (error: Error) => void
allowedTypes?: string[]
multiple?: boolean
className?: string
stageId?: string
availableStages?: Array<{ id: string; name: string }>
}
// Map MIME types to suggested file types
function suggestFileType(mimeType: string): FileType {
if (mimeType.startsWith('video/')) return 'VIDEO'
if (mimeType === 'application/pdf') return 'EXEC_SUMMARY'
if (
mimeType.includes('presentation') ||
mimeType.includes('powerpoint') ||
mimeType.includes('slides')
) {
return 'PRESENTATION'
}
return 'OTHER'
}
// Get icon for file type
function getFileTypeIcon(fileType: FileType) {
switch (fileType) {
case 'VIDEO':
return <Film className="h-4 w-4" />
case 'EXEC_SUMMARY':
return <FileText className="h-4 w-4" />
case 'PRESENTATION':
return <Presentation className="h-4 w-4" />
default:
return <FileIcon className="h-4 w-4" />
}
}
export function FileUpload({
projectId,
onUploadComplete,
onUploadError,
allowedTypes,
multiple = true,
className,
stageId,
availableStages,
}: FileUploadProps) {
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [selectedStageId, setSelectedStageId] = useState<string | null>(stageId ?? null)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.file.getUploadUrl.useMutation()
const confirmUpload = trpc.file.confirmUpload.useMutation()
const utils = trpc.useUtils()
// Validate file
const validateFile = useCallback(
(file: File): string | null => {
if (file.size > MAX_FILE_SIZE) {
return `File size exceeds ${formatFileSize(MAX_FILE_SIZE)} limit`
}
if (allowedTypes && !allowedTypes.includes(file.type)) {
return `File type ${file.type} is not allowed`
}
return null
},
[allowedTypes]
)
// Upload a single file
const uploadFile = useCallback(
async (uploadingFile: UploadingFile) => {
const { file, id, fileType } = uploadingFile
try {
// Update status to uploading
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, status: 'uploading' as const } : f))
)
// Get pre-signed upload URL
const { uploadUrl, file: dbFile } = await getUploadUrl.mutateAsync({
projectId,
fileName: file.name,
fileType,
mimeType: file.type || 'application/octet-stream',
size: file.size,
stageId: selectedStageId ?? undefined,
})
// Store the DB file ID
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, dbFileId: dbFile.id } : f))
)
// Upload to MinIO using XHR for progress tracking
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100)
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, progress } : f))
)
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'))
})
xhr.addEventListener('abort', () => {
reject(new Error('Upload aborted'))
})
xhr.open('PUT', uploadUrl)
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
xhr.send(file)
})
// Confirm upload
await confirmUpload.mutateAsync({ fileId: dbFile.id })
// Update status to complete
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === id ? { ...f, status: 'complete' as const, progress: 100 } : f
)
)
// Invalidate file list queries
utils.file.listByProject.invalidate({ projectId })
// Notify parent
onUploadComplete?.({
id: dbFile.id,
fileName: file.name,
fileType,
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Upload failed'
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === id ? { ...f, status: 'error' as const, error: errorMessage } : f
)
)
onUploadError?.(error instanceof Error ? error : new Error(errorMessage))
}
},
[projectId, getUploadUrl, confirmUpload, utils, onUploadComplete, onUploadError]
)
// Handle file selection
const handleFiles = useCallback(
(files: FileList | File[]) => {
const fileArray = Array.from(files)
const filesToUpload = multiple ? fileArray : [fileArray[0]].filter(Boolean)
const newUploadingFiles: UploadingFile[] = filesToUpload.map((file) => {
const validationError = validateFile(file)
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
file,
progress: 0,
status: validationError ? ('error' as const) : ('pending' as const),
error: validationError || undefined,
fileType: suggestFileType(file.type),
}
})
setUploadingFiles((prev) => [...prev, ...newUploadingFiles])
// Start uploading valid files
newUploadingFiles
.filter((f) => f.status === 'pending')
.forEach((f) => uploadFile(f))
},
[multiple, validateFile, uploadFile]
)
// Drag and drop handlers
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (e.dataTransfer.files?.length) {
handleFiles(e.dataTransfer.files)
}
},
[handleFiles]
)
// File input change handler
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) {
handleFiles(e.target.files)
}
// Reset input so same file can be selected again
e.target.value = ''
},
[handleFiles]
)
// Update file type for a pending file
const updateFileType = useCallback((fileId: string, fileType: FileType) => {
setUploadingFiles((prev) =>
prev.map((f) => (f.id === fileId ? { ...f, fileType } : f))
)
}, [])
// Remove a file from the queue
const removeFile = useCallback((fileId: string) => {
setUploadingFiles((prev) => prev.filter((f) => f.id !== fileId))
}, [])
// Retry a failed upload
const retryUpload = useCallback(
(fileId: string) => {
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === fileId
? { ...f, status: 'pending' as const, progress: 0, error: undefined }
: f
)
)
const file = uploadingFiles.find((f) => f.id === fileId)
if (file) {
uploadFile({ ...file, status: 'pending', progress: 0, error: undefined })
}
},
[uploadingFiles, uploadFile]
)
const hasActiveUploads = uploadingFiles.some(
(f) => f.status === 'pending' || f.status === 'uploading'
)
return (
<div className={cn('space-y-4', className)}>
{/* Stage selector */}
{availableStages && availableStages.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium">
Upload for Stage
</label>
<Select
value={selectedStageId ?? 'null'}
onValueChange={(value) => setSelectedStageId(value === 'null' ? null : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="null">General (no specific stage)</SelectItem>
{availableStages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Drop zone */}
<div
className={cn(
'relative border-2 border-dashed rounded-lg p-6 text-center transition-colors cursor-pointer',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50',
hasActiveUploads && 'opacity-50 pointer-events-none'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
multiple={multiple}
accept={allowedTypes?.join(',')}
onChange={handleFileInputChange}
className="hidden"
/>
<Upload className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="mt-2 font-medium">
{isDragging ? 'Drop files here' : 'Drag and drop files here'}
</p>
<p className="text-sm text-muted-foreground">
or click to browse (max {formatFileSize(MAX_FILE_SIZE)})
</p>
</div>
{/* Upload queue */}
{uploadingFiles.length > 0 && (
<div className="space-y-2">
{uploadingFiles.map((uploadingFile) => (
<div
key={uploadingFile.id}
className={cn(
'flex items-center gap-3 rounded-lg border p-3',
uploadingFile.status === 'error' && 'border-destructive/50 bg-destructive/5',
uploadingFile.status === 'complete' && 'border-green-500/50 bg-green-500/5'
)}
>
{/* File type icon */}
<div className="shrink-0 text-muted-foreground">
{getFileTypeIcon(uploadingFile.fileType)}
</div>
{/* File info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">
{uploadingFile.file.name}
</p>
<Badge variant="outline" className="shrink-0 text-xs">
{formatFileSize(uploadingFile.file.size)}
</Badge>
</div>
{/* Progress bar or error message */}
{uploadingFile.status === 'uploading' && (
<div className="mt-1 flex items-center gap-2">
<Progress value={uploadingFile.progress} className="h-1.5 flex-1" />
<span className="text-xs text-muted-foreground">
{uploadingFile.progress}%
</span>
</div>
)}
{uploadingFile.status === 'error' && (
<p className="mt-1 text-xs text-destructive">{uploadingFile.error}</p>
)}
{uploadingFile.status === 'pending' && (
<div className="mt-1 flex items-center gap-2">
<Select
value={uploadingFile.fileType}
onValueChange={(v) => updateFileType(uploadingFile.id, v as FileType)}
>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EXEC_SUMMARY">Executive Summary</SelectItem>
<SelectItem value="PRESENTATION">Presentation</SelectItem>
<SelectItem value="VIDEO">Video</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Status / Actions */}
<div className="shrink-0">
{uploadingFile.status === 'pending' && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{uploadingFile.status === 'uploading' && (
<Loader2 className="h-4 w-4 animate-spin text-primary" />
)}
{uploadingFile.status === 'complete' && (
<CheckCircle2 className="h-4 w-4 text-green-600" />
)}
{uploadingFile.status === 'error' && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation()
retryUpload(uploadingFile.id)
}}
>
Retry
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="Remove file"
onClick={(e) => {
e.stopPropagation()
removeFile(uploadingFile.id)
}}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
))}
{/* Clear completed */}
{uploadingFiles.some((f) => f.status === 'complete') && (
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() =>
setUploadingFiles((prev) => prev.filter((f) => f.status !== 'complete'))
}
>
Clear completed
</Button>
)}
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,173 +1,173 @@
'use client'
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from '@/lib/utils'
interface LiveScoreAnimationProps {
score: number | null
maxScore: number
label: string
animate?: boolean
theme?: 'dark' | 'light' | 'branded'
}
function getScoreColor(score: number, maxScore: number): string {
const ratio = score / maxScore
if (ratio >= 0.75) return 'text-green-500'
if (ratio >= 0.5) return 'text-yellow-500'
if (ratio >= 0.25) return 'text-orange-500'
return 'text-red-500'
}
function getProgressColor(score: number, maxScore: number): string {
const ratio = score / maxScore
if (ratio >= 0.75) return 'stroke-green-500'
if (ratio >= 0.5) return 'stroke-yellow-500'
if (ratio >= 0.25) return 'stroke-orange-500'
return 'stroke-red-500'
}
function getThemeClasses(theme: 'dark' | 'light' | 'branded') {
switch (theme) {
case 'dark':
return {
bg: 'bg-gray-900',
text: 'text-white',
label: 'text-gray-400',
ring: 'stroke-gray-700',
}
case 'light':
return {
bg: 'bg-white',
text: 'text-gray-900',
label: 'text-gray-500',
ring: 'stroke-gray-200',
}
case 'branded':
return {
bg: 'bg-[#053d57]',
text: 'text-white',
label: 'text-[#557f8c]',
ring: 'stroke-[#053d57]/30',
}
}
}
export function LiveScoreAnimation({
score,
maxScore,
label,
animate = true,
theme = 'branded',
}: LiveScoreAnimationProps) {
const [displayScore, setDisplayScore] = useState(0)
const themeClasses = getThemeClasses(theme)
const radius = 40
const circumference = 2 * Math.PI * radius
const targetScore = score ?? 0
const progress = maxScore > 0 ? targetScore / maxScore : 0
const offset = circumference - progress * circumference
useEffect(() => {
if (!animate || score === null) {
setDisplayScore(targetScore)
return
}
let frame: number
const duration = 1200
const startTime = performance.now()
const startScore = 0
const step = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3)
const current = startScore + (targetScore - startScore) * eased
setDisplayScore(Math.round(current * 10) / 10)
if (progress < 1) {
frame = requestAnimationFrame(step)
}
}
frame = requestAnimationFrame(step)
return () => cancelAnimationFrame(frame)
}, [targetScore, animate, score])
if (score === null) {
return (
<div className={cn('flex flex-col items-center gap-2 rounded-xl p-4', themeClasses.bg)}>
<div className="relative h-24 w-24">
<svg className="h-24 w-24 -rotate-90" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r={radius}
fill="none"
className={themeClasses.ring}
strokeWidth="6"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className={cn('text-lg font-medium', themeClasses.label)}>--</span>
</div>
</div>
<span className={cn('text-xs font-medium', themeClasses.label)}>{label}</span>
</div>
)
}
return (
<AnimatePresence>
<motion.div
initial={animate ? { opacity: 0, scale: 0.8 } : false}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className={cn('flex flex-col items-center gap-2 rounded-xl p-4', themeClasses.bg)}
>
<div className="relative h-24 w-24">
<svg className="h-24 w-24 -rotate-90" viewBox="0 0 100 100">
{/* Background ring */}
<circle
cx="50"
cy="50"
r={radius}
fill="none"
className={themeClasses.ring}
strokeWidth="6"
/>
{/* Progress ring */}
<motion.circle
cx="50"
cy="50"
r={radius}
fill="none"
className={getProgressColor(targetScore, maxScore)}
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={circumference}
initial={animate ? { strokeDashoffset: circumference } : { strokeDashoffset: offset }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 1.2, ease: 'easeOut' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span
className={cn(
'text-2xl font-bold tabular-nums',
themeClasses.text,
getScoreColor(targetScore, maxScore)
)}
>
{displayScore.toFixed(maxScore % 1 !== 0 ? 1 : 0)}
</span>
</div>
</div>
<span className={cn('text-xs font-medium text-center', themeClasses.label)}>{label}</span>
</motion.div>
</AnimatePresence>
)
}
'use client'
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from '@/lib/utils'
interface LiveScoreAnimationProps {
score: number | null
maxScore: number
label: string
animate?: boolean
theme?: 'dark' | 'light' | 'branded'
}
function getScoreColor(score: number, maxScore: number): string {
const ratio = score / maxScore
if (ratio >= 0.75) return 'text-green-500'
if (ratio >= 0.5) return 'text-yellow-500'
if (ratio >= 0.25) return 'text-orange-500'
return 'text-red-500'
}
function getProgressColor(score: number, maxScore: number): string {
const ratio = score / maxScore
if (ratio >= 0.75) return 'stroke-green-500'
if (ratio >= 0.5) return 'stroke-yellow-500'
if (ratio >= 0.25) return 'stroke-orange-500'
return 'stroke-red-500'
}
function getThemeClasses(theme: 'dark' | 'light' | 'branded') {
switch (theme) {
case 'dark':
return {
bg: 'bg-gray-900',
text: 'text-white',
label: 'text-gray-400',
ring: 'stroke-gray-700',
}
case 'light':
return {
bg: 'bg-white',
text: 'text-gray-900',
label: 'text-gray-500',
ring: 'stroke-gray-200',
}
case 'branded':
return {
bg: 'bg-[#053d57]',
text: 'text-white',
label: 'text-[#557f8c]',
ring: 'stroke-[#053d57]/30',
}
}
}
export function LiveScoreAnimation({
score,
maxScore,
label,
animate = true,
theme = 'branded',
}: LiveScoreAnimationProps) {
const [displayScore, setDisplayScore] = useState(0)
const themeClasses = getThemeClasses(theme)
const radius = 40
const circumference = 2 * Math.PI * radius
const targetScore = score ?? 0
const progress = maxScore > 0 ? targetScore / maxScore : 0
const offset = circumference - progress * circumference
useEffect(() => {
if (!animate || score === null) {
setDisplayScore(targetScore)
return
}
let frame: number
const duration = 1200
const startTime = performance.now()
const startScore = 0
const step = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3)
const current = startScore + (targetScore - startScore) * eased
setDisplayScore(Math.round(current * 10) / 10)
if (progress < 1) {
frame = requestAnimationFrame(step)
}
}
frame = requestAnimationFrame(step)
return () => cancelAnimationFrame(frame)
}, [targetScore, animate, score])
if (score === null) {
return (
<div className={cn('flex flex-col items-center gap-2 rounded-xl p-4', themeClasses.bg)}>
<div className="relative h-24 w-24">
<svg className="h-24 w-24 -rotate-90" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r={radius}
fill="none"
className={themeClasses.ring}
strokeWidth="6"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className={cn('text-lg font-medium', themeClasses.label)}>--</span>
</div>
</div>
<span className={cn('text-xs font-medium', themeClasses.label)}>{label}</span>
</div>
)
}
return (
<AnimatePresence>
<motion.div
initial={animate ? { opacity: 0, scale: 0.8 } : false}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className={cn('flex flex-col items-center gap-2 rounded-xl p-4', themeClasses.bg)}
>
<div className="relative h-24 w-24">
<svg className="h-24 w-24 -rotate-90" viewBox="0 0 100 100">
{/* Background ring */}
<circle
cx="50"
cy="50"
r={radius}
fill="none"
className={themeClasses.ring}
strokeWidth="6"
/>
{/* Progress ring */}
<motion.circle
cx="50"
cy="50"
r={radius}
fill="none"
className={getProgressColor(targetScore, maxScore)}
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={circumference}
initial={animate ? { strokeDashoffset: circumference } : { strokeDashoffset: offset }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 1.2, ease: 'easeOut' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span
className={cn(
'text-2xl font-bold tabular-nums',
themeClasses.text,
getScoreColor(targetScore, maxScore)
)}
>
{displayScore.toFixed(maxScore % 1 !== 0 ? 1 : 0)}
</span>
</div>
</div>
<span className={cn('text-xs font-medium text-center', themeClasses.label)}>{label}</span>
</motion.div>
</AnimatePresence>
)
}

View File

@@ -1,335 +1,335 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import Cropper from 'react-easy-crop'
import type { Area } from 'react-easy-crop'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { ProjectLogo } from './project-logo'
import { Upload, Loader2, Trash2, ImagePlus, ZoomIn } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
type LogoUploadProps = {
project: {
id: string
title: string
logoKey?: string | null
}
currentLogoUrl?: string | null
onUploadComplete?: () => void
children?: React.ReactNode
}
const MAX_SIZE_MB = 5
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
/**
* Crop an image client-side using canvas and return a Blob.
*/
async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<Blob> {
const image = new Image()
image.crossOrigin = 'anonymous'
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve()
image.onerror = reject
image.src = imageSrc
})
const canvas = document.createElement('canvas')
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
)
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob)
else reject(new Error('Canvas toBlob failed'))
},
'image/png',
0.9
)
})
}
export function LogoUpload({
project,
currentLogoUrl,
onUploadComplete,
children,
}: LogoUploadProps) {
const [open, setOpen] = useState(false)
const [imageSrc, setImageSrc] = useState<string | null>(null)
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const utils = trpc.useUtils()
const getUploadUrl = trpc.logo.getUploadUrl.useMutation()
const confirmUpload = trpc.logo.confirmUpload.useMutation()
const deleteLogo = trpc.logo.delete.useMutation()
const onCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
setCroppedAreaPixels(croppedPixels)
}, [])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Validate type
if (!ALLOWED_TYPES.includes(file.type)) {
toast.error('Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.')
return
}
// Validate size
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
toast.error(`File too large. Maximum size is ${MAX_SIZE_MB}MB.`)
return
}
const reader = new FileReader()
reader.onload = (ev) => {
setImageSrc(ev.target?.result as string)
setCrop({ x: 0, y: 0 })
setZoom(1)
}
reader.readAsDataURL(file)
}, [])
const handleUpload = async () => {
if (!imageSrc || !croppedAreaPixels) return
setIsUploading(true)
try {
// Crop the image client-side
const croppedBlob = await getCroppedImg(imageSrc, croppedAreaPixels)
// Get pre-signed upload URL
const { uploadUrl, key, providerType } = await getUploadUrl.mutateAsync({
projectId: project.id,
fileName: 'logo.png',
contentType: 'image/png',
})
// Upload cropped blob directly to storage
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: croppedBlob,
headers: {
'Content-Type': 'image/png',
},
})
if (!uploadResponse.ok) {
throw new Error('Failed to upload file')
}
// Confirm upload with the provider type that was used
await confirmUpload.mutateAsync({ projectId: project.id, key, providerType })
// Invalidate logo query
utils.logo.getUrl.invalidate({ projectId: project.id })
toast.success('Logo updated successfully')
setOpen(false)
resetState()
onUploadComplete?.()
} catch (error) {
console.error('Upload error:', error)
toast.error('Failed to upload logo. Please try again.')
} finally {
setIsUploading(false)
}
}
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteLogo.mutateAsync({ projectId: project.id })
utils.logo.getUrl.invalidate({ projectId: project.id })
toast.success('Logo removed')
setOpen(false)
onUploadComplete?.()
} catch (error) {
console.error('Delete error:', error)
toast.error('Failed to remove logo')
} finally {
setIsDeleting(false)
}
}
const resetState = () => {
setImageSrc(null)
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedAreaPixels(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleCancel = () => {
resetState()
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children || (
<Button variant="outline" size="sm" className="gap-2">
<ImagePlus className="h-4 w-4" />
{currentLogoUrl ? 'Change Logo' : 'Add Logo'}
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Update Project Logo</DialogTitle>
<DialogDescription>
{imageSrc
? 'Drag to reposition and use the slider to zoom. The logo will be cropped to a square.'
: `Upload a logo for "${project.title}". Allowed formats: JPEG, PNG, GIF, WebP. Max size: ${MAX_SIZE_MB}MB.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{imageSrc ? (
<>
{/* Cropper */}
<div className="relative w-full h-64 bg-muted rounded-lg overflow-hidden">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="rect"
showGrid
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
</div>
{/* Zoom slider */}
<div className="flex items-center gap-3 px-1">
<ZoomIn className="h-4 w-4 text-muted-foreground shrink-0" />
<Slider
value={[zoom]}
min={1}
max={3}
step={0.1}
onValueChange={([val]) => setZoom(val)}
className="flex-1"
/>
</div>
{/* Change image button */}
<Button
variant="ghost"
size="sm"
onClick={() => {
resetState()
fileInputRef.current?.click()
}}
className="w-full"
>
Choose a different image
</Button>
</>
) : (
<>
{/* Current logo preview */}
<div className="flex justify-center">
<ProjectLogo
project={project}
logoUrl={currentLogoUrl}
size="lg"
/>
</div>
{/* File input */}
<div className="space-y-2">
<Label htmlFor="logo">Select image</Label>
<Input
ref={fileInputRef}
id="logo"
type="file"
accept={ALLOWED_TYPES.join(',')}
onChange={handleFileSelect}
className="cursor-pointer"
/>
</div>
</>
)}
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row">
{currentLogoUrl && !imageSrc && (
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
className="w-full sm:w-auto"
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Remove
</Button>
)}
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCancel} className="flex-1">
Cancel
</Button>
{imageSrc && (
<Button
onClick={handleUpload}
disabled={!croppedAreaPixels || isUploading}
className="flex-1"
>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
'use client'
import { useState, useRef, useCallback } from 'react'
import Cropper from 'react-easy-crop'
import type { Area } from 'react-easy-crop'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { ProjectLogo } from './project-logo'
import { Upload, Loader2, Trash2, ImagePlus, ZoomIn } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
type LogoUploadProps = {
project: {
id: string
title: string
logoKey?: string | null
}
currentLogoUrl?: string | null
onUploadComplete?: () => void
children?: React.ReactNode
}
const MAX_SIZE_MB = 5
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
/**
* Crop an image client-side using canvas and return a Blob.
*/
async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<Blob> {
const image = new Image()
image.crossOrigin = 'anonymous'
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve()
image.onerror = reject
image.src = imageSrc
})
const canvas = document.createElement('canvas')
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
)
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob)
else reject(new Error('Canvas toBlob failed'))
},
'image/png',
0.9
)
})
}
export function LogoUpload({
project,
currentLogoUrl,
onUploadComplete,
children,
}: LogoUploadProps) {
const [open, setOpen] = useState(false)
const [imageSrc, setImageSrc] = useState<string | null>(null)
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const utils = trpc.useUtils()
const getUploadUrl = trpc.logo.getUploadUrl.useMutation()
const confirmUpload = trpc.logo.confirmUpload.useMutation()
const deleteLogo = trpc.logo.delete.useMutation()
const onCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
setCroppedAreaPixels(croppedPixels)
}, [])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Validate type
if (!ALLOWED_TYPES.includes(file.type)) {
toast.error('Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.')
return
}
// Validate size
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
toast.error(`File too large. Maximum size is ${MAX_SIZE_MB}MB.`)
return
}
const reader = new FileReader()
reader.onload = (ev) => {
setImageSrc(ev.target?.result as string)
setCrop({ x: 0, y: 0 })
setZoom(1)
}
reader.readAsDataURL(file)
}, [])
const handleUpload = async () => {
if (!imageSrc || !croppedAreaPixels) return
setIsUploading(true)
try {
// Crop the image client-side
const croppedBlob = await getCroppedImg(imageSrc, croppedAreaPixels)
// Get pre-signed upload URL
const { uploadUrl, key, providerType } = await getUploadUrl.mutateAsync({
projectId: project.id,
fileName: 'logo.png',
contentType: 'image/png',
})
// Upload cropped blob directly to storage
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: croppedBlob,
headers: {
'Content-Type': 'image/png',
},
})
if (!uploadResponse.ok) {
throw new Error('Failed to upload file')
}
// Confirm upload with the provider type that was used
await confirmUpload.mutateAsync({ projectId: project.id, key, providerType })
// Invalidate logo query
utils.logo.getUrl.invalidate({ projectId: project.id })
toast.success('Logo updated successfully')
setOpen(false)
resetState()
onUploadComplete?.()
} catch (error) {
console.error('Upload error:', error)
toast.error('Failed to upload logo. Please try again.')
} finally {
setIsUploading(false)
}
}
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteLogo.mutateAsync({ projectId: project.id })
utils.logo.getUrl.invalidate({ projectId: project.id })
toast.success('Logo removed')
setOpen(false)
onUploadComplete?.()
} catch (error) {
console.error('Delete error:', error)
toast.error('Failed to remove logo')
} finally {
setIsDeleting(false)
}
}
const resetState = () => {
setImageSrc(null)
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedAreaPixels(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleCancel = () => {
resetState()
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children || (
<Button variant="outline" size="sm" className="gap-2">
<ImagePlus className="h-4 w-4" />
{currentLogoUrl ? 'Change Logo' : 'Add Logo'}
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Update Project Logo</DialogTitle>
<DialogDescription>
{imageSrc
? 'Drag to reposition and use the slider to zoom. The logo will be cropped to a square.'
: `Upload a logo for "${project.title}". Allowed formats: JPEG, PNG, GIF, WebP. Max size: ${MAX_SIZE_MB}MB.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{imageSrc ? (
<>
{/* Cropper */}
<div className="relative w-full h-64 bg-muted rounded-lg overflow-hidden">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="rect"
showGrid
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
</div>
{/* Zoom slider */}
<div className="flex items-center gap-3 px-1">
<ZoomIn className="h-4 w-4 text-muted-foreground shrink-0" />
<Slider
value={[zoom]}
min={1}
max={3}
step={0.1}
onValueChange={([val]) => setZoom(val)}
className="flex-1"
/>
</div>
{/* Change image button */}
<Button
variant="ghost"
size="sm"
onClick={() => {
resetState()
fileInputRef.current?.click()
}}
className="w-full"
>
Choose a different image
</Button>
</>
) : (
<>
{/* Current logo preview */}
<div className="flex justify-center">
<ProjectLogo
project={project}
logoUrl={currentLogoUrl}
size="lg"
/>
</div>
{/* File input */}
<div className="space-y-2">
<Label htmlFor="logo">Select image</Label>
<Input
ref={fileInputRef}
id="logo"
type="file"
accept={ALLOWED_TYPES.join(',')}
onChange={handleFileSelect}
className="cursor-pointer"
/>
</div>
</>
)}
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row">
{currentLogoUrl && !imageSrc && (
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
className="w-full sm:w-auto"
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Remove
</Button>
)}
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCancel} className="flex-1">
Cancel
</Button>
{imageSrc && (
<Button
onClick={handleUpload}
disabled={!croppedAreaPixels || isUploading}
className="flex-1"
>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,55 +1,55 @@
import Image from 'next/image'
import { cn } from '@/lib/utils'
interface LogoProps {
variant?: 'small' | 'long'
className?: string
showText?: boolean
textSuffix?: string
}
export function Logo({
variant = 'small',
className,
showText = false,
textSuffix,
}: LogoProps) {
if (variant === 'long') {
return (
<div className={cn('flex items-center gap-2', className)}>
<Image
src="/images/MOPC-blue-long.png"
alt="MOPC Logo"
width={120}
height={40}
className="h-8 w-auto"
priority
/>
{textSuffix && (
<span className="text-xs text-muted-foreground">{textSuffix}</span>
)}
</div>
)
}
return (
<div className={cn('flex items-center gap-3', className)}>
<Image
src="/images/MOPC-blue-small.png"
alt="MOPC Logo"
width={32}
height={32}
className="h-8 w-8"
priority
/>
{showText && (
<div className="flex items-center gap-1 min-w-0">
<span className="font-semibold">MOPC</span>
{textSuffix && (
<span className="text-xs text-muted-foreground truncate">{textSuffix}</span>
)}
</div>
)}
</div>
)
}
import Image from 'next/image'
import { cn } from '@/lib/utils'
interface LogoProps {
variant?: 'small' | 'long'
className?: string
showText?: boolean
textSuffix?: string
}
export function Logo({
variant = 'small',
className,
showText = false,
textSuffix,
}: LogoProps) {
if (variant === 'long') {
return (
<div className={cn('flex items-center gap-2', className)}>
<Image
src="/images/MOPC-blue-long.png"
alt="MOPC Logo"
width={120}
height={40}
className="h-8 w-auto"
priority
/>
{textSuffix && (
<span className="text-xs text-muted-foreground">{textSuffix}</span>
)}
</div>
)
}
return (
<div className={cn('flex items-center gap-3', className)}>
<Image
src="/images/MOPC-blue-small.png"
alt="MOPC Logo"
width={32}
height={32}
className="h-8 w-8"
priority
/>
{showText && (
<div className="flex items-center gap-1 min-w-0">
<span className="font-semibold">MOPC</span>
{textSuffix && (
<span className="text-xs text-muted-foreground truncate">{textSuffix}</span>
)}
</div>
)}
</div>
)
}

View File

@@ -1,425 +1,425 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { usePathname } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import { cn, formatRelativeTime } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Bell,
CheckCheck,
Settings,
AlertTriangle,
FileText,
Files,
Upload,
ClipboardList,
PlayCircle,
Clock,
AlertCircle,
Lock,
Users,
TrendingUp,
Trophy,
CheckCircle,
Star,
GraduationCap,
Vote,
Brain,
Download,
AlertOctagon,
RefreshCw,
CalendarPlus,
Heart,
BarChart,
Award,
UserPlus,
UserCheck,
UserMinus,
FileCheck,
Eye,
MessageSquare,
MessageCircle,
Info,
Calendar,
Newspaper,
UserX,
Lightbulb,
BookOpen,
XCircle,
Edit,
FileUp,
} from 'lucide-react'
// Icon mapping for notification types
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
AlertTriangle,
FileText,
Files,
Upload,
ClipboardList,
PlayCircle,
Clock,
AlertCircle,
Lock,
Users,
TrendingUp,
Trophy,
CheckCircle,
Star,
GraduationCap,
Vote,
Download,
AlertOctagon,
RefreshCw,
CalendarPlus,
Heart,
BarChart,
Award,
UserPlus,
UserCheck,
UserMinus,
FileCheck,
Eye,
MessageSquare,
MessageCircle,
Info,
Calendar,
Newspaper,
UserX,
Lightbulb,
BookOpen,
XCircle,
Edit,
FileUp,
Bell,
}
// Priority styles
const PRIORITY_STYLES = {
low: {
iconBg: 'bg-slate-100 dark:bg-slate-800',
iconColor: 'text-slate-500',
},
normal: {
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
iconColor: 'text-blue-600 dark:text-blue-400',
},
high: {
iconBg: 'bg-amber-100 dark:bg-amber-900/30',
iconColor: 'text-amber-600 dark:text-amber-400',
},
urgent: {
iconBg: 'bg-red-100 dark:bg-red-900/30',
iconColor: 'text-red-600 dark:text-red-400',
},
}
type Notification = {
id: string
type: string
priority: string
icon: string | null
title: string
message: string
linkUrl: string | null
linkLabel: string | null
isRead: boolean
createdAt: Date
}
function NotificationItem({
notification,
onRead,
observeRef,
}: {
notification: Notification
onRead: () => void
observeRef?: (el: HTMLDivElement | null) => void
}) {
const IconComponent = ICON_MAP[notification.icon || 'Bell'] || Bell
const priorityStyle =
PRIORITY_STYLES[notification.priority as keyof typeof PRIORITY_STYLES] ||
PRIORITY_STYLES.normal
const content = (
<div
ref={observeRef}
data-notification-id={notification.id}
className={cn(
'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer',
!notification.isRead && 'bg-blue-50/50 dark:bg-blue-950/20'
)}
onClick={onRead}
>
{/* Icon with colored background */}
<div
className={cn(
'shrink-0 w-10 h-10 rounded-full flex items-center justify-center',
priorityStyle.iconBg
)}
>
<IconComponent className={cn('h-5 w-5', priorityStyle.iconColor)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className={cn('text-sm', !notification.isRead && 'font-medium')}>
{notification.title}
</p>
<p className="text-sm text-muted-foreground line-clamp-2">
{notification.message}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">
{formatRelativeTime(notification.createdAt)}
</span>
{notification.linkLabel && (
<span className="text-xs text-primary font-medium">
{notification.linkLabel} &rarr;
</span>
)}
</div>
</div>
{/* Unread dot */}
{!notification.isRead && (
<div className="shrink-0 w-2 h-2 rounded-full bg-primary mt-2" />
)}
</div>
)
if (notification.linkUrl) {
return (
<Link href={notification.linkUrl as Route} className="block">
{content}
</Link>
)
}
return content
}
export function NotificationBell() {
const [open, setOpen] = useState(false)
const pathname = usePathname()
const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
// Derive the role-based path prefix from the current route
const pathPrefix = pathname.startsWith('/admin')
? '/admin'
: pathname.startsWith('/jury')
? '/jury'
: pathname.startsWith('/mentor')
? '/mentor'
: pathname.startsWith('/observer')
? '/observer'
: pathname.startsWith('/applicant')
? '/applicant'
: ''
const { data: countData } = trpc.notification.getUnreadCount.useQuery(
undefined,
{
enabled: isAuthenticated,
refetchInterval: 30000, // Refetch every 30 seconds
}
)
const { data: hasUrgent } = trpc.notification.hasUrgent.useQuery(undefined, {
enabled: isAuthenticated,
refetchInterval: 30000,
})
const { data: notificationData, refetch } = trpc.notification.list.useQuery(
{
unreadOnly: false,
limit: 20,
},
{
enabled: open && isAuthenticated, // Only fetch when popover is open and authenticated
}
)
const markAsReadMutation = trpc.notification.markAsRead.useMutation({
onSuccess: () => refetch(),
})
const markAllAsReadMutation = trpc.notification.markAllAsRead.useMutation({
onSuccess: () => refetch(),
})
const markBatchAsReadMutation = trpc.notification.markBatchAsRead.useMutation({
onSuccess: () => refetch(),
})
const unreadCount = countData ?? 0
const notifications = notificationData?.notifications ?? []
// Track unread notification IDs that have become visible
const pendingReadIds = useRef<Set<string>>(new Set())
const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// Flush pending read IDs in a batch
const flushPendingReads = useCallback(() => {
if (pendingReadIds.current.size === 0) return
const ids = Array.from(pendingReadIds.current)
pendingReadIds.current.clear()
markBatchAsReadMutation.mutate({ ids })
}, [markBatchAsReadMutation])
// Set up IntersectionObserver when popover opens
useEffect(() => {
if (!open) {
// Flush any remaining on close
if (flushTimer.current) clearTimeout(flushTimer.current)
flushPendingReads()
observerRef.current?.disconnect()
observerRef.current = null
return
}
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = (entry.target as HTMLElement).dataset.notificationId
if (id) {
// Check if this notification is unread
const notif = notifications.find((n) => n.id === id)
if (notif && !notif.isRead) {
pendingReadIds.current.add(id)
}
}
}
}
// Debounce the batch call
if (pendingReadIds.current.size > 0) {
if (flushTimer.current) clearTimeout(flushTimer.current)
flushTimer.current = setTimeout(flushPendingReads, 500)
}
},
{ threshold: 0.5 }
)
// Observe all currently tracked items
itemRefs.current.forEach((el) => observerRef.current?.observe(el))
return () => {
observerRef.current?.disconnect()
if (flushTimer.current) clearTimeout(flushTimer.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, notifications])
// Ref callback for each notification item
const getItemRef = useCallback(
(id: string) => (el: HTMLDivElement | null) => {
if (el) {
itemRefs.current.set(id, el)
observerRef.current?.observe(el)
} else {
const prev = itemRefs.current.get(id)
if (prev) observerRef.current?.unobserve(prev)
itemRefs.current.delete(id)
}
},
[]
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell
className={cn('h-5 w-5', hasUrgent && 'animate-pulse text-red-500')}
/>
{unreadCount > 0 && (
<span
className={cn(
'absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center',
hasUrgent ? 'bg-red-500' : 'bg-primary'
)}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
<span className="sr-only">Notifications</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="end">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b">
<h3 className="font-semibold">Notifications</h3>
<div className="flex gap-1">
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
>
<CheckCheck className="h-4 w-4 mr-1" />
Mark all read
</Button>
)}
<Button variant="ghost" size="icon" asChild>
<Link href={`${pathPrefix}/settings` as Route}>
<Settings className="h-4 w-4" />
<span className="sr-only">Notification settings</span>
</Link>
</Button>
</div>
</div>
{/* Notification list */}
<ScrollArea className="h-[400px]">
<div className="divide-y">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
observeRef={!notification.isRead ? getItemRef(notification.id) : undefined}
onRead={() => {
if (!notification.isRead) {
markAsReadMutation.mutate({ id: notification.id })
}
}}
/>
))}
{notifications.length === 0 && (
<div className="p-8 text-center">
<Bell className="h-10 w-10 mx-auto text-muted-foreground/30" />
<p className="mt-2 text-muted-foreground">
No notifications yet
</p>
</div>
)}
</div>
</ScrollArea>
{/* Footer */}
{notifications.length > 0 && (
<div className="p-2 border-t bg-muted/30">
<Button variant="ghost" className="w-full" asChild>
<Link href={`${pathPrefix}/notifications` as Route}>View all notifications</Link>
</Button>
</div>
)}
</PopoverContent>
</Popover>
)
}
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { usePathname } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import { cn, formatRelativeTime } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Bell,
CheckCheck,
Settings,
AlertTriangle,
FileText,
Files,
Upload,
ClipboardList,
PlayCircle,
Clock,
AlertCircle,
Lock,
Users,
TrendingUp,
Trophy,
CheckCircle,
Star,
GraduationCap,
Vote,
Brain,
Download,
AlertOctagon,
RefreshCw,
CalendarPlus,
Heart,
BarChart,
Award,
UserPlus,
UserCheck,
UserMinus,
FileCheck,
Eye,
MessageSquare,
MessageCircle,
Info,
Calendar,
Newspaper,
UserX,
Lightbulb,
BookOpen,
XCircle,
Edit,
FileUp,
} from 'lucide-react'
// Icon mapping for notification types
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
AlertTriangle,
FileText,
Files,
Upload,
ClipboardList,
PlayCircle,
Clock,
AlertCircle,
Lock,
Users,
TrendingUp,
Trophy,
CheckCircle,
Star,
GraduationCap,
Vote,
Download,
AlertOctagon,
RefreshCw,
CalendarPlus,
Heart,
BarChart,
Award,
UserPlus,
UserCheck,
UserMinus,
FileCheck,
Eye,
MessageSquare,
MessageCircle,
Info,
Calendar,
Newspaper,
UserX,
Lightbulb,
BookOpen,
XCircle,
Edit,
FileUp,
Bell,
}
// Priority styles
const PRIORITY_STYLES = {
low: {
iconBg: 'bg-slate-100 dark:bg-slate-800',
iconColor: 'text-slate-500',
},
normal: {
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
iconColor: 'text-blue-600 dark:text-blue-400',
},
high: {
iconBg: 'bg-amber-100 dark:bg-amber-900/30',
iconColor: 'text-amber-600 dark:text-amber-400',
},
urgent: {
iconBg: 'bg-red-100 dark:bg-red-900/30',
iconColor: 'text-red-600 dark:text-red-400',
},
}
type Notification = {
id: string
type: string
priority: string
icon: string | null
title: string
message: string
linkUrl: string | null
linkLabel: string | null
isRead: boolean
createdAt: Date
}
function NotificationItem({
notification,
onRead,
observeRef,
}: {
notification: Notification
onRead: () => void
observeRef?: (el: HTMLDivElement | null) => void
}) {
const IconComponent = ICON_MAP[notification.icon || 'Bell'] || Bell
const priorityStyle =
PRIORITY_STYLES[notification.priority as keyof typeof PRIORITY_STYLES] ||
PRIORITY_STYLES.normal
const content = (
<div
ref={observeRef}
data-notification-id={notification.id}
className={cn(
'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer',
!notification.isRead && 'bg-blue-50/50 dark:bg-blue-950/20'
)}
onClick={onRead}
>
{/* Icon with colored background */}
<div
className={cn(
'shrink-0 w-10 h-10 rounded-full flex items-center justify-center',
priorityStyle.iconBg
)}
>
<IconComponent className={cn('h-5 w-5', priorityStyle.iconColor)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className={cn('text-sm', !notification.isRead && 'font-medium')}>
{notification.title}
</p>
<p className="text-sm text-muted-foreground line-clamp-2">
{notification.message}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">
{formatRelativeTime(notification.createdAt)}
</span>
{notification.linkLabel && (
<span className="text-xs text-primary font-medium">
{notification.linkLabel} &rarr;
</span>
)}
</div>
</div>
{/* Unread dot */}
{!notification.isRead && (
<div className="shrink-0 w-2 h-2 rounded-full bg-primary mt-2" />
)}
</div>
)
if (notification.linkUrl) {
return (
<Link href={notification.linkUrl as Route} className="block">
{content}
</Link>
)
}
return content
}
export function NotificationBell() {
const [open, setOpen] = useState(false)
const pathname = usePathname()
const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
// Derive the role-based path prefix from the current route
const pathPrefix = pathname.startsWith('/admin')
? '/admin'
: pathname.startsWith('/jury')
? '/jury'
: pathname.startsWith('/mentor')
? '/mentor'
: pathname.startsWith('/observer')
? '/observer'
: pathname.startsWith('/applicant')
? '/applicant'
: ''
const { data: countData } = trpc.notification.getUnreadCount.useQuery(
undefined,
{
enabled: isAuthenticated,
refetchInterval: 30000, // Refetch every 30 seconds
}
)
const { data: hasUrgent } = trpc.notification.hasUrgent.useQuery(undefined, {
enabled: isAuthenticated,
refetchInterval: 30000,
})
const { data: notificationData, refetch } = trpc.notification.list.useQuery(
{
unreadOnly: false,
limit: 20,
},
{
enabled: open && isAuthenticated, // Only fetch when popover is open and authenticated
}
)
const markAsReadMutation = trpc.notification.markAsRead.useMutation({
onSuccess: () => refetch(),
})
const markAllAsReadMutation = trpc.notification.markAllAsRead.useMutation({
onSuccess: () => refetch(),
})
const markBatchAsReadMutation = trpc.notification.markBatchAsRead.useMutation({
onSuccess: () => refetch(),
})
const unreadCount = countData ?? 0
const notifications = notificationData?.notifications ?? []
// Track unread notification IDs that have become visible
const pendingReadIds = useRef<Set<string>>(new Set())
const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// Flush pending read IDs in a batch
const flushPendingReads = useCallback(() => {
if (pendingReadIds.current.size === 0) return
const ids = Array.from(pendingReadIds.current)
pendingReadIds.current.clear()
markBatchAsReadMutation.mutate({ ids })
}, [markBatchAsReadMutation])
// Set up IntersectionObserver when popover opens
useEffect(() => {
if (!open) {
// Flush any remaining on close
if (flushTimer.current) clearTimeout(flushTimer.current)
flushPendingReads()
observerRef.current?.disconnect()
observerRef.current = null
return
}
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = (entry.target as HTMLElement).dataset.notificationId
if (id) {
// Check if this notification is unread
const notif = notifications.find((n) => n.id === id)
if (notif && !notif.isRead) {
pendingReadIds.current.add(id)
}
}
}
}
// Debounce the batch call
if (pendingReadIds.current.size > 0) {
if (flushTimer.current) clearTimeout(flushTimer.current)
flushTimer.current = setTimeout(flushPendingReads, 500)
}
},
{ threshold: 0.5 }
)
// Observe all currently tracked items
itemRefs.current.forEach((el) => observerRef.current?.observe(el))
return () => {
observerRef.current?.disconnect()
if (flushTimer.current) clearTimeout(flushTimer.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, notifications])
// Ref callback for each notification item
const getItemRef = useCallback(
(id: string) => (el: HTMLDivElement | null) => {
if (el) {
itemRefs.current.set(id, el)
observerRef.current?.observe(el)
} else {
const prev = itemRefs.current.get(id)
if (prev) observerRef.current?.unobserve(prev)
itemRefs.current.delete(id)
}
},
[]
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell
className={cn('h-5 w-5', hasUrgent && 'animate-pulse text-red-500')}
/>
{unreadCount > 0 && (
<span
className={cn(
'absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center',
hasUrgent ? 'bg-red-500' : 'bg-primary'
)}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
<span className="sr-only">Notifications</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="end">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b">
<h3 className="font-semibold">Notifications</h3>
<div className="flex gap-1">
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
>
<CheckCheck className="h-4 w-4 mr-1" />
Mark all read
</Button>
)}
<Button variant="ghost" size="icon" asChild>
<Link href={`${pathPrefix}/settings` as Route}>
<Settings className="h-4 w-4" />
<span className="sr-only">Notification settings</span>
</Link>
</Button>
</div>
</div>
{/* Notification list */}
<ScrollArea className="h-[400px]">
<div className="divide-y">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
observeRef={!notification.isRead ? getItemRef(notification.id) : undefined}
onRead={() => {
if (!notification.isRead) {
markAsReadMutation.mutate({ id: notification.id })
}
}}
/>
))}
{notifications.length === 0 && (
<div className="p-8 text-center">
<Bell className="h-10 w-10 mx-auto text-muted-foreground/30" />
<p className="mt-2 text-muted-foreground">
No notifications yet
</p>
</div>
)}
</div>
</ScrollArea>
{/* Footer */}
{notifications.length > 0 && (
<div className="p-2 border-t bg-muted/30">
<Button variant="ghost" className="w-full" asChild>
<Link href={`${pathPrefix}/notifications` as Route}>View all notifications</Link>
</Button>
</div>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -1,86 +1,86 @@
'use client'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface PaginationProps {
page: number
totalPages: number
total: number
perPage: number
onPageChange: (page: number) => void
onPerPageChange?: (perPage: number) => void
}
export function Pagination({
page,
totalPages,
total,
perPage,
onPageChange,
onPerPageChange,
}: PaginationProps) {
if (totalPages <= 1 && !onPerPageChange) return null
const from = (page - 1) * perPage + 1
const to = Math.min(page * perPage, total)
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing {from} to {to} of {total} results
</p>
{onPerPageChange && (
<Select
value={String(perPage)}
onValueChange={(v) => onPerPageChange(Number(v))}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[10, 20, 50, 100].map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page - 1)}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<span className="text-sm">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
)
}
'use client'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface PaginationProps {
page: number
totalPages: number
total: number
perPage: number
onPageChange: (page: number) => void
onPerPageChange?: (perPage: number) => void
}
export function Pagination({
page,
totalPages,
total,
perPage,
onPageChange,
onPerPageChange,
}: PaginationProps) {
if (totalPages <= 1 && !onPerPageChange) return null
const from = (page - 1) * perPage + 1
const to = Math.min(page * perPage, total)
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing {from} to {to} of {total} results
</p>
{onPerPageChange && (
<Select
value={String(perPage)}
onValueChange={(v) => onPerPageChange(Number(v))}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[10, 20, 50, 100].map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page - 1)}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<span className="text-sm">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
)
}

View File

@@ -1,135 +1,135 @@
'use client'
import { useEffect, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Copy, QrCode } from 'lucide-react'
import { toast } from 'sonner'
interface QRCodeDisplayProps {
url: string
title?: string
size?: number
}
/**
* Generates a simple QR code using Canvas API.
* Uses a basic QR encoding approach for URLs.
*/
function generateQRMatrix(data: string): boolean[][] {
// Simple QR-like grid pattern based on data hash
// For production, use a library like 'qrcode', but this is a lightweight visual
const size = 25
const matrix: boolean[][] = Array.from({ length: size }, () =>
Array(size).fill(false)
)
// Add finder patterns (top-left, top-right, bottom-left)
const addFinderPattern = (row: number, col: number) => {
for (let r = 0; r < 7; r++) {
for (let c = 0; c < 7; c++) {
if (
r === 0 || r === 6 || c === 0 || c === 6 || // outer border
(r >= 2 && r <= 4 && c >= 2 && c <= 4) // inner block
) {
if (row + r < size && col + c < size) {
matrix[row + r][col + c] = true
}
}
}
}
}
addFinderPattern(0, 0)
addFinderPattern(0, size - 7)
addFinderPattern(size - 7, 0)
// Fill data area with a hash-based pattern
let hash = 0
for (let i = 0; i < data.length; i++) {
hash = ((hash << 5) - hash + data.charCodeAt(i)) | 0
}
for (let r = 8; r < size - 8; r++) {
for (let c = 8; c < size - 8; c++) {
hash = ((hash << 5) - hash + r * size + c) | 0
matrix[r][c] = (hash & 1) === 1
}
}
// Timing patterns
for (let i = 8; i < size - 8; i++) {
matrix[6][i] = i % 2 === 0
matrix[i][6] = i % 2 === 0
}
return matrix
}
function drawQR(canvas: HTMLCanvasElement, data: string, pixelSize: number) {
const matrix = generateQRMatrix(data)
const size = matrix.length
const totalSize = size * pixelSize
canvas.width = totalSize
canvas.height = totalSize
const ctx = canvas.getContext('2d')
if (!ctx) return
// White background
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, totalSize, totalSize)
// Draw modules
ctx.fillStyle = '#053d57'
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (matrix[r][c]) {
ctx.fillRect(c * pixelSize, r * pixelSize, pixelSize, pixelSize)
}
}
}
}
export function QRCodeDisplay({ url, title = 'Scan to Vote', size = 200 }: QRCodeDisplayProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const pixelSize = Math.floor(size / 25)
useEffect(() => {
if (canvasRef.current) {
drawQR(canvasRef.current, url, pixelSize)
}
}, [url, pixelSize])
const handleCopyUrl = () => {
navigator.clipboard.writeText(url).then(() => {
toast.success('URL copied to clipboard')
})
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<QrCode className="h-4 w-4" />
{title}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-3">
<canvas
ref={canvasRef}
className="border rounded-lg"
style={{ width: size, height: size }}
/>
<div className="flex items-center gap-2 w-full">
<code className="flex-1 text-xs bg-muted p-2 rounded truncate">
{url}
</code>
<Button variant="ghost" size="sm" onClick={handleCopyUrl}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
)
}
'use client'
import { useEffect, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Copy, QrCode } from 'lucide-react'
import { toast } from 'sonner'
interface QRCodeDisplayProps {
url: string
title?: string
size?: number
}
/**
* Generates a simple QR code using Canvas API.
* Uses a basic QR encoding approach for URLs.
*/
function generateQRMatrix(data: string): boolean[][] {
// Simple QR-like grid pattern based on data hash
// For production, use a library like 'qrcode', but this is a lightweight visual
const size = 25
const matrix: boolean[][] = Array.from({ length: size }, () =>
Array(size).fill(false)
)
// Add finder patterns (top-left, top-right, bottom-left)
const addFinderPattern = (row: number, col: number) => {
for (let r = 0; r < 7; r++) {
for (let c = 0; c < 7; c++) {
if (
r === 0 || r === 6 || c === 0 || c === 6 || // outer border
(r >= 2 && r <= 4 && c >= 2 && c <= 4) // inner block
) {
if (row + r < size && col + c < size) {
matrix[row + r][col + c] = true
}
}
}
}
}
addFinderPattern(0, 0)
addFinderPattern(0, size - 7)
addFinderPattern(size - 7, 0)
// Fill data area with a hash-based pattern
let hash = 0
for (let i = 0; i < data.length; i++) {
hash = ((hash << 5) - hash + data.charCodeAt(i)) | 0
}
for (let r = 8; r < size - 8; r++) {
for (let c = 8; c < size - 8; c++) {
hash = ((hash << 5) - hash + r * size + c) | 0
matrix[r][c] = (hash & 1) === 1
}
}
// Timing patterns
for (let i = 8; i < size - 8; i++) {
matrix[6][i] = i % 2 === 0
matrix[i][6] = i % 2 === 0
}
return matrix
}
function drawQR(canvas: HTMLCanvasElement, data: string, pixelSize: number) {
const matrix = generateQRMatrix(data)
const size = matrix.length
const totalSize = size * pixelSize
canvas.width = totalSize
canvas.height = totalSize
const ctx = canvas.getContext('2d')
if (!ctx) return
// White background
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, totalSize, totalSize)
// Draw modules
ctx.fillStyle = '#053d57'
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (matrix[r][c]) {
ctx.fillRect(c * pixelSize, r * pixelSize, pixelSize, pixelSize)
}
}
}
}
export function QRCodeDisplay({ url, title = 'Scan to Vote', size = 200 }: QRCodeDisplayProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const pixelSize = Math.floor(size / 25)
useEffect(() => {
if (canvasRef.current) {
drawQR(canvasRef.current, url, pixelSize)
}
}, [url, pixelSize])
const handleCopyUrl = () => {
navigator.clipboard.writeText(url).then(() => {
toast.success('URL copied to clipboard')
})
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<QrCode className="h-4 w-4" />
{title}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-3">
<canvas
ref={canvasRef}
className="border rounded-lg"
style={{ width: size, height: size }}
/>
<div className="flex items-center gap-2 w-full">
<code className="flex-1 text-xs bg-muted p-2 rounded truncate">
{url}
</code>
<Button variant="ghost" size="sm" onClick={handleCopyUrl}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,364 +1,364 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import {
Upload,
FileIcon,
CheckCircle2,
AlertCircle,
Loader2,
Trash2,
RefreshCw,
} from 'lucide-react'
import { cn, formatFileSize } from '@/lib/utils'
import { toast } from 'sonner'
function getMimeLabel(mime: string): string {
if (mime === 'application/pdf') return 'PDF'
if (mime.startsWith('image/')) return 'Images'
if (mime.startsWith('video/')) return 'Video'
if (mime.includes('wordprocessingml')) return 'Word'
if (mime.includes('spreadsheetml')) return 'Excel'
if (mime.includes('presentationml')) return 'PowerPoint'
if (mime.endsWith('/*')) return mime.replace('/*', '')
return mime
}
interface FileRequirement {
id: string
name: string
description?: string | null
acceptedMimeTypes: string[]
maxSizeMB?: number | null
isRequired: boolean
}
interface UploadedFile {
id: string
fileName: string
mimeType: string
size: number
createdAt: string | Date
requirementId?: string | null
}
interface RequirementUploadSlotProps {
requirement: FileRequirement
existingFile?: UploadedFile | null
projectId: string
stageId: string
onFileChange?: () => void
disabled?: boolean
}
export function RequirementUploadSlot({
requirement,
existingFile,
projectId,
stageId,
onFileChange,
disabled = false,
}: RequirementUploadSlotProps) {
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [deleting, setDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.applicant.getUploadUrl.useMutation()
const saveFileMetadata = trpc.applicant.saveFileMetadata.useMutation()
const deleteFile = trpc.applicant.deleteFile.useMutation()
const acceptsMime = useCallback(
(mimeType: string) => {
if (requirement.acceptedMimeTypes.length === 0) return true
return requirement.acceptedMimeTypes.some((pattern) => {
if (pattern.endsWith('/*')) {
return mimeType.startsWith(pattern.replace('/*', '/'))
}
return mimeType === pattern
})
},
[requirement.acceptedMimeTypes]
)
const handleFileSelect = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Reset input
if (fileInputRef.current) fileInputRef.current.value = ''
// Validate mime type
if (!acceptsMime(file.type)) {
toast.error(`File type ${file.type} is not accepted for this requirement`)
return
}
// Validate size
if (requirement.maxSizeMB && file.size > requirement.maxSizeMB * 1024 * 1024) {
toast.error(`File exceeds maximum size of ${requirement.maxSizeMB}MB`)
return
}
setUploading(true)
setProgress(0)
try {
// Get presigned URL
const { url, bucket, objectKey, isLate, stageId: uploadStageId } =
await getUploadUrl.mutateAsync({
projectId,
fileName: file.name,
mimeType: file.type,
fileType: 'OTHER',
stageId,
requirementId: requirement.id,
})
// Upload file with progress tracking
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
setProgress(Math.round((event.loaded / event.total) * 100))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => reject(new Error('Upload failed')))
xhr.open('PUT', url)
xhr.setRequestHeader('Content-Type', file.type)
xhr.send(file)
})
// Save metadata
await saveFileMetadata.mutateAsync({
projectId,
fileName: file.name,
mimeType: file.type,
size: file.size,
fileType: 'OTHER',
bucket,
objectKey,
stageId: uploadStageId || stageId,
isLate: isLate || false,
requirementId: requirement.id,
})
toast.success(`${requirement.name} uploaded successfully`)
onFileChange?.()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Upload failed')
} finally {
setUploading(false)
setProgress(0)
}
},
[projectId, stageId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange]
)
const handleDelete = useCallback(async () => {
if (!existingFile) return
setDeleting(true)
try {
await deleteFile.mutateAsync({ fileId: existingFile.id })
toast.success('File deleted')
onFileChange?.()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed')
} finally {
setDeleting(false)
}
}, [existingFile, deleteFile, onFileChange])
const isFulfilled = !!existingFile
const statusColor = isFulfilled
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
: requirement.isRequired
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950'
: 'border-muted'
// Build accept string for file input
const acceptStr =
requirement.acceptedMimeTypes.length > 0
? requirement.acceptedMimeTypes.join(',')
: undefined
return (
<div className={cn('rounded-lg border p-4 transition-colors', statusColor)}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{isFulfilled ? (
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
) : requirement.isRequired ? (
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
) : (
<FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className="font-medium text-sm">{requirement.name}</span>
<Badge
variant={requirement.isRequired ? 'destructive' : 'secondary'}
className="text-xs shrink-0"
>
{requirement.isRequired ? 'Required' : 'Optional'}
</Badge>
</div>
{requirement.description && (
<p className="text-xs text-muted-foreground ml-6 mb-2">
{requirement.description}
</p>
)}
<div className="flex flex-wrap gap-1 ml-6 mb-2">
{requirement.acceptedMimeTypes.map((mime) => (
<Badge key={mime} variant="outline" className="text-xs">
{getMimeLabel(mime)}
</Badge>
))}
{requirement.maxSizeMB && (
<Badge variant="outline" className="text-xs">
Max {requirement.maxSizeMB}MB
</Badge>
)}
</div>
{existingFile && (
<div className="ml-6 flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon className="h-3 w-3" />
<span className="truncate">{existingFile.fileName}</span>
<span>({formatFileSize(existingFile.size)})</span>
</div>
)}
{uploading && (
<div className="ml-6 mt-2">
<Progress value={progress} className="h-1.5" />
<p className="text-xs text-muted-foreground mt-1">Uploading... {progress}%</p>
</div>
)}
</div>
{!disabled && (
<div className="flex items-center gap-1 shrink-0">
{existingFile ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
<RefreshCw className="mr-1 h-3 w-3" />
Replace
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Upload className="mr-1 h-3 w-3" />
)}
Upload
</Button>
)}
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={acceptStr}
onChange={handleFileSelect}
/>
</div>
)
}
interface RequirementUploadListProps {
projectId: string
stageId: string
disabled?: boolean
}
export function RequirementUploadList({ projectId, stageId, disabled }: RequirementUploadListProps) {
const utils = trpc.useUtils()
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({
stageId,
})
const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, stageId })
if (requirements.length === 0) return null
const handleFileChange = () => {
utils.file.listByProject.invalidate({ projectId, stageId })
}
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Required Documents
</h3>
{requirements.map((req) => {
const existing = files.find(
(f) => (f as { requirementId?: string | null }).requirementId === req.id
)
return (
<RequirementUploadSlot
key={req.id}
requirement={req}
existingFile={
existing
? {
id: existing.id,
fileName: existing.fileName,
mimeType: existing.mimeType,
size: existing.size,
createdAt: existing.createdAt,
requirementId: (existing as { requirementId?: string | null }).requirementId,
}
: null
}
projectId={projectId}
stageId={stageId}
onFileChange={handleFileChange}
disabled={disabled}
/>
)
})}
</div>
)
}
'use client'
import { useState, useCallback, useRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import {
Upload,
FileIcon,
CheckCircle2,
AlertCircle,
Loader2,
Trash2,
RefreshCw,
} from 'lucide-react'
import { cn, formatFileSize } from '@/lib/utils'
import { toast } from 'sonner'
function getMimeLabel(mime: string): string {
if (mime === 'application/pdf') return 'PDF'
if (mime.startsWith('image/')) return 'Images'
if (mime.startsWith('video/')) return 'Video'
if (mime.includes('wordprocessingml')) return 'Word'
if (mime.includes('spreadsheetml')) return 'Excel'
if (mime.includes('presentationml')) return 'PowerPoint'
if (mime.endsWith('/*')) return mime.replace('/*', '')
return mime
}
interface FileRequirement {
id: string
name: string
description?: string | null
acceptedMimeTypes: string[]
maxSizeMB?: number | null
isRequired: boolean
}
interface UploadedFile {
id: string
fileName: string
mimeType: string
size: number
createdAt: string | Date
requirementId?: string | null
}
interface RequirementUploadSlotProps {
requirement: FileRequirement
existingFile?: UploadedFile | null
projectId: string
stageId: string
onFileChange?: () => void
disabled?: boolean
}
export function RequirementUploadSlot({
requirement,
existingFile,
projectId,
stageId,
onFileChange,
disabled = false,
}: RequirementUploadSlotProps) {
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [deleting, setDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.applicant.getUploadUrl.useMutation()
const saveFileMetadata = trpc.applicant.saveFileMetadata.useMutation()
const deleteFile = trpc.applicant.deleteFile.useMutation()
const acceptsMime = useCallback(
(mimeType: string) => {
if (requirement.acceptedMimeTypes.length === 0) return true
return requirement.acceptedMimeTypes.some((pattern) => {
if (pattern.endsWith('/*')) {
return mimeType.startsWith(pattern.replace('/*', '/'))
}
return mimeType === pattern
})
},
[requirement.acceptedMimeTypes]
)
const handleFileSelect = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Reset input
if (fileInputRef.current) fileInputRef.current.value = ''
// Validate mime type
if (!acceptsMime(file.type)) {
toast.error(`File type ${file.type} is not accepted for this requirement`)
return
}
// Validate size
if (requirement.maxSizeMB && file.size > requirement.maxSizeMB * 1024 * 1024) {
toast.error(`File exceeds maximum size of ${requirement.maxSizeMB}MB`)
return
}
setUploading(true)
setProgress(0)
try {
// Get presigned URL
const { url, bucket, objectKey, isLate, stageId: uploadStageId } =
await getUploadUrl.mutateAsync({
projectId,
fileName: file.name,
mimeType: file.type,
fileType: 'OTHER',
stageId,
requirementId: requirement.id,
})
// Upload file with progress tracking
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
setProgress(Math.round((event.loaded / event.total) * 100))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => reject(new Error('Upload failed')))
xhr.open('PUT', url)
xhr.setRequestHeader('Content-Type', file.type)
xhr.send(file)
})
// Save metadata
await saveFileMetadata.mutateAsync({
projectId,
fileName: file.name,
mimeType: file.type,
size: file.size,
fileType: 'OTHER',
bucket,
objectKey,
stageId: uploadStageId || stageId,
isLate: isLate || false,
requirementId: requirement.id,
})
toast.success(`${requirement.name} uploaded successfully`)
onFileChange?.()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Upload failed')
} finally {
setUploading(false)
setProgress(0)
}
},
[projectId, stageId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange]
)
const handleDelete = useCallback(async () => {
if (!existingFile) return
setDeleting(true)
try {
await deleteFile.mutateAsync({ fileId: existingFile.id })
toast.success('File deleted')
onFileChange?.()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed')
} finally {
setDeleting(false)
}
}, [existingFile, deleteFile, onFileChange])
const isFulfilled = !!existingFile
const statusColor = isFulfilled
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
: requirement.isRequired
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950'
: 'border-muted'
// Build accept string for file input
const acceptStr =
requirement.acceptedMimeTypes.length > 0
? requirement.acceptedMimeTypes.join(',')
: undefined
return (
<div className={cn('rounded-lg border p-4 transition-colors', statusColor)}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{isFulfilled ? (
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
) : requirement.isRequired ? (
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
) : (
<FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className="font-medium text-sm">{requirement.name}</span>
<Badge
variant={requirement.isRequired ? 'destructive' : 'secondary'}
className="text-xs shrink-0"
>
{requirement.isRequired ? 'Required' : 'Optional'}
</Badge>
</div>
{requirement.description && (
<p className="text-xs text-muted-foreground ml-6 mb-2">
{requirement.description}
</p>
)}
<div className="flex flex-wrap gap-1 ml-6 mb-2">
{requirement.acceptedMimeTypes.map((mime) => (
<Badge key={mime} variant="outline" className="text-xs">
{getMimeLabel(mime)}
</Badge>
))}
{requirement.maxSizeMB && (
<Badge variant="outline" className="text-xs">
Max {requirement.maxSizeMB}MB
</Badge>
)}
</div>
{existingFile && (
<div className="ml-6 flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon className="h-3 w-3" />
<span className="truncate">{existingFile.fileName}</span>
<span>({formatFileSize(existingFile.size)})</span>
</div>
)}
{uploading && (
<div className="ml-6 mt-2">
<Progress value={progress} className="h-1.5" />
<p className="text-xs text-muted-foreground mt-1">Uploading... {progress}%</p>
</div>
)}
</div>
{!disabled && (
<div className="flex items-center gap-1 shrink-0">
{existingFile ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
<RefreshCw className="mr-1 h-3 w-3" />
Replace
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Upload className="mr-1 h-3 w-3" />
)}
Upload
</Button>
)}
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={acceptStr}
onChange={handleFileSelect}
/>
</div>
)
}
interface RequirementUploadListProps {
projectId: string
stageId: string
disabled?: boolean
}
export function RequirementUploadList({ projectId, stageId, disabled }: RequirementUploadListProps) {
const utils = trpc.useUtils()
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({
stageId,
})
const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, stageId })
if (requirements.length === 0) return null
const handleFileChange = () => {
utils.file.listByProject.invalidate({ projectId, stageId })
}
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Required Documents
</h3>
{requirements.map((req) => {
const existing = files.find(
(f) => (f as { requirementId?: string | null }).requirementId === req.id
)
return (
<RequirementUploadSlot
key={req.id}
requirement={req}
existingFile={
existing
? {
id: existing.id,
fileName: existing.fileName,
mimeType: existing.mimeType,
size: existing.size,
createdAt: existing.createdAt,
requirementId: (existing as { requirementId?: string | null }).requirementId,
}
: null
}
projectId={projectId}
stageId={stageId}
onFileChange={handleFileChange}
disabled={disabled}
/>
)
})}
</div>
)
}

View File

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

View File

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

View File

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

View File

@@ -1,56 +1,56 @@
import { Badge, type BadgeProps } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?: string }> = {
// Round statuses
DRAFT: { variant: 'secondary' },
ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
EVALUATION: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
// Project statuses
SUBMITTED: { variant: 'secondary', className: 'bg-indigo-500/10 text-indigo-700 border-indigo-200 dark:text-indigo-400' },
ELIGIBLE: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
ASSIGNED: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
UNDER_REVIEW: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
SHORTLISTED: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' },
SEMIFINALIST: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' },
FINALIST: { variant: 'default', className: 'bg-orange-500/10 text-orange-700 border-orange-200 dark:text-orange-400' },
WINNER: { variant: 'default', className: 'bg-yellow-500/10 text-yellow-800 border-yellow-300 dark:text-yellow-400' },
REJECTED: { variant: 'destructive' },
WITHDRAWN: { variant: 'secondary' },
// Evaluation statuses
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
// User statuses
NONE: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-500 border-slate-200 dark:text-slate-400' },
INVITED: { variant: 'secondary', className: 'bg-sky-500/10 text-sky-700 border-sky-200 dark:text-sky-400' },
INACTIVE: { variant: 'secondary' },
SUSPENDED: { variant: 'destructive' },
}
type StatusBadgeProps = {
status: string
className?: string
size?: 'sm' | 'default'
}
export function StatusBadge({ status, className, size = 'default' }: StatusBadgeProps) {
const style = STATUS_STYLES[status] || { variant: 'secondary' as const }
const label = status === 'NONE' ? 'NOT INVITED' : status.replace(/_/g, ' ')
return (
<Badge
variant={style.variant}
className={cn(
style.className,
size === 'sm' && 'text-[10px] px-1.5 py-0',
className,
)}
>
{label}
</Badge>
)
}
import { Badge, type BadgeProps } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?: string }> = {
// Round statuses
DRAFT: { variant: 'secondary' },
ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
EVALUATION: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
// Project statuses
SUBMITTED: { variant: 'secondary', className: 'bg-indigo-500/10 text-indigo-700 border-indigo-200 dark:text-indigo-400' },
ELIGIBLE: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
ASSIGNED: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
UNDER_REVIEW: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
SHORTLISTED: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' },
SEMIFINALIST: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' },
FINALIST: { variant: 'default', className: 'bg-orange-500/10 text-orange-700 border-orange-200 dark:text-orange-400' },
WINNER: { variant: 'default', className: 'bg-yellow-500/10 text-yellow-800 border-yellow-300 dark:text-yellow-400' },
REJECTED: { variant: 'destructive' },
WITHDRAWN: { variant: 'secondary' },
// Evaluation statuses
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
// User statuses
NONE: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-500 border-slate-200 dark:text-slate-400' },
INVITED: { variant: 'secondary', className: 'bg-sky-500/10 text-sky-700 border-sky-200 dark:text-sky-400' },
INACTIVE: { variant: 'secondary' },
SUSPENDED: { variant: 'destructive' },
}
type StatusBadgeProps = {
status: string
className?: string
size?: 'sm' | 'default'
}
export function StatusBadge({ status, className, size = 'default' }: StatusBadgeProps) {
const style = STATUS_STYLES[status] || { variant: 'secondary' as const }
const label = status === 'NONE' ? 'NOT INVITED' : status.replace(/_/g, ' ')
return (
<Badge
variant={style.variant}
className={cn(
style.className,
size === 'sm' && 'text-[10px] px-1.5 py-0',
className,
)}
>
{label}
</Badge>
)
}

View File

@@ -1,118 +1,118 @@
'use client'
import * as React from 'react'
import { CheckIcon, ChevronsUpDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { COUNTRIES } from '@/lib/countries'
// Build sorted country list from the canonical COUNTRIES source
const countryList = Object.entries(COUNTRIES)
.map(([code, info]) => ({ code, name: info.name }))
.sort((a, b) => a.name.localeCompare(b.name))
/** Renders a country flag image from flagcdn.com CDN */
function CountryFlagImg({ code, size = 20, className }: { code: string; size?: number; className?: string }) {
return (
<img
src={`https://flagcdn.com/w${size}/${code.toLowerCase()}.png`}
srcSet={`https://flagcdn.com/w${size * 2}/${code.toLowerCase()}.png 2x`}
width={size}
height={Math.round(size * 0.75)}
alt={code}
className={cn('inline-block rounded-[2px]', className)}
loading="lazy"
/>
)
}
export type Country = { code: string; name: string }
interface CountrySelectProps {
value?: string
onChange?: (value: string) => void
placeholder?: string
disabled?: boolean
className?: string
}
const CountrySelect = React.forwardRef<HTMLButtonElement, CountrySelectProps>(
({ value, onChange, placeholder = 'Select country...', disabled, className }, ref) => {
const [open, setOpen] = React.useState(false)
const selectedCountry = countryList.find((c) => c.code === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={ref}
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('w-full justify-between font-normal', className)}
disabled={disabled}
>
{selectedCountry ? (
<span className="flex items-center gap-2">
<CountryFlagImg code={selectedCountry.code} size={20} />
<span>{selectedCountry.name}</span>
</span>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<ScrollArea className="h-72">
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countryList.map((country) => (
<CommandItem
key={country.code}
value={country.name}
onSelect={() => {
onChange?.(country.code)
setOpen(false)
}}
className="gap-2"
>
<CountryFlagImg code={country.code} size={20} />
<span className="flex-1">{country.name}</span>
<CheckIcon
className={cn(
'ml-auto h-4 w-4',
value === country.code ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
)
CountrySelect.displayName = 'CountrySelect'
export { CountrySelect, countryList as countries, CountryFlagImg }
'use client'
import * as React from 'react'
import { CheckIcon, ChevronsUpDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { COUNTRIES } from '@/lib/countries'
// Build sorted country list from the canonical COUNTRIES source
const countryList = Object.entries(COUNTRIES)
.map(([code, info]) => ({ code, name: info.name }))
.sort((a, b) => a.name.localeCompare(b.name))
/** Renders a country flag image from flagcdn.com CDN */
function CountryFlagImg({ code, size = 20, className }: { code: string; size?: number; className?: string }) {
return (
<img
src={`https://flagcdn.com/w${size}/${code.toLowerCase()}.png`}
srcSet={`https://flagcdn.com/w${size * 2}/${code.toLowerCase()}.png 2x`}
width={size}
height={Math.round(size * 0.75)}
alt={code}
className={cn('inline-block rounded-[2px]', className)}
loading="lazy"
/>
)
}
export type Country = { code: string; name: string }
interface CountrySelectProps {
value?: string
onChange?: (value: string) => void
placeholder?: string
disabled?: boolean
className?: string
}
const CountrySelect = React.forwardRef<HTMLButtonElement, CountrySelectProps>(
({ value, onChange, placeholder = 'Select country...', disabled, className }, ref) => {
const [open, setOpen] = React.useState(false)
const selectedCountry = countryList.find((c) => c.code === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={ref}
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('w-full justify-between font-normal', className)}
disabled={disabled}
>
{selectedCountry ? (
<span className="flex items-center gap-2">
<CountryFlagImg code={selectedCountry.code} size={20} />
<span>{selectedCountry.name}</span>
</span>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<ScrollArea className="h-72">
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countryList.map((country) => (
<CommandItem
key={country.code}
value={country.name}
onSelect={() => {
onChange?.(country.code)
setOpen(false)
}}
className="gap-2"
>
<CountryFlagImg code={country.code} size={20} />
<span className="flex-1">{country.name}</span>
<CheckIcon
className={cn(
'ml-auto h-4 w-4',
value === country.code ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
)
CountrySelect.displayName = 'CountrySelect'
export { CountrySelect, countryList as countries, CountryFlagImg }

View File

@@ -1,121 +1,121 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Pencil, X, Loader2 } from 'lucide-react'
type EditableCardProps = {
title: string
icon?: React.ReactNode
summary: React.ReactNode
children: React.ReactNode
onSave?: () => void | Promise<void>
isSaving?: boolean
alwaysShowEdit?: boolean
defaultEditing?: boolean
className?: string
}
export function EditableCard({
title,
icon,
summary,
children,
onSave,
isSaving = false,
alwaysShowEdit = false,
defaultEditing = false,
className,
}: EditableCardProps) {
const [isEditing, setIsEditing] = useState(defaultEditing)
const handleSave = async () => {
if (onSave) {
await onSave()
}
setIsEditing(false)
}
return (
<Card className={cn('group relative overflow-hidden', className)}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon && (
<span className="text-muted-foreground">{icon}</span>
)}
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
</div>
{!isEditing && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsEditing(true)}
className={cn(
'h-7 gap-1.5 text-xs',
!alwaysShowEdit && 'sm:opacity-0 sm:group-hover:opacity-100 transition-opacity'
)}
>
<Pencil className="h-3 w-3" />
Edit
</Button>
)}
{isEditing && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsEditing(false)}
disabled={isSaving}
className="h-7 gap-1.5 text-xs"
>
<X className="h-3 w-3" />
Cancel
</Button>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
<AnimatePresence mode="wait" initial={false}>
{isEditing ? (
<motion.div
key="edit"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<div className="space-y-4">
{children}
{onSave && (
<div className="flex justify-end pt-2 border-t">
<Button
size="sm"
onClick={handleSave}
disabled={isSaving}
>
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
Save Changes
</Button>
</div>
)}
</div>
</motion.div>
) : (
<motion.div
key="view"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{summary}
</motion.div>
)}
</AnimatePresence>
</CardContent>
</Card>
)
}
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Pencil, X, Loader2 } from 'lucide-react'
type EditableCardProps = {
title: string
icon?: React.ReactNode
summary: React.ReactNode
children: React.ReactNode
onSave?: () => void | Promise<void>
isSaving?: boolean
alwaysShowEdit?: boolean
defaultEditing?: boolean
className?: string
}
export function EditableCard({
title,
icon,
summary,
children,
onSave,
isSaving = false,
alwaysShowEdit = false,
defaultEditing = false,
className,
}: EditableCardProps) {
const [isEditing, setIsEditing] = useState(defaultEditing)
const handleSave = async () => {
if (onSave) {
await onSave()
}
setIsEditing(false)
}
return (
<Card className={cn('group relative overflow-hidden', className)}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon && (
<span className="text-muted-foreground">{icon}</span>
)}
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
</div>
{!isEditing && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsEditing(true)}
className={cn(
'h-7 gap-1.5 text-xs',
!alwaysShowEdit && 'sm:opacity-0 sm:group-hover:opacity-100 transition-opacity'
)}
>
<Pencil className="h-3 w-3" />
Edit
</Button>
)}
{isEditing && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsEditing(false)}
disabled={isSaving}
className="h-7 gap-1.5 text-xs"
>
<X className="h-3 w-3" />
Cancel
</Button>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
<AnimatePresence mode="wait" initial={false}>
{isEditing ? (
<motion.div
key="edit"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<div className="space-y-4">
{children}
{onSave && (
<div className="flex justify-end pt-2 border-t">
<Button
size="sm"
onClick={handleSave}
disabled={isSaving}
>
{isSaving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
Save Changes
</Button>
</div>
)}
</div>
</motion.div>
) : (
<motion.div
key="view"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{summary}
</motion.div>
)}
</AnimatePresence>
</CardContent>
</Card>
)
}

View File

@@ -1,36 +1,36 @@
'use client'
import { Info } from 'lucide-react'
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from '@/components/ui/tooltip'
type InfoTooltipProps = {
content: string
side?: 'top' | 'right' | 'bottom' | 'left'
}
export function InfoTooltip({ content, side = 'top' }: InfoTooltipProps) {
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
tabIndex={0}
className="inline-flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
>
<Info className="h-4 w-4" />
<span className="sr-only">More info</span>
</button>
</TooltipTrigger>
<TooltipContent side={side} className="max-w-xs text-sm">
{content}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
'use client'
import { Info } from 'lucide-react'
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from '@/components/ui/tooltip'
type InfoTooltipProps = {
content: string
side?: 'top' | 'right' | 'bottom' | 'left'
}
export function InfoTooltip({ content, side = 'top' }: InfoTooltipProps) {
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
tabIndex={0}
className="inline-flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
>
<Info className="h-4 w-4" />
<span className="sr-only">More info</span>
</button>
</TooltipTrigger>
<TooltipContent side={side} className="max-w-xs text-sm">
{content}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -1,179 +1,179 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { Pencil, Check, X } from 'lucide-react'
type InlineEditableTextVariant = 'h1' | 'h2' | 'body' | 'mono'
type InlineEditableTextProps = {
value: string
onSave: (newValue: string) => void | Promise<void>
variant?: InlineEditableTextVariant
placeholder?: string
multiline?: boolean
disabled?: boolean
className?: string
}
const variantStyles: Record<InlineEditableTextVariant, string> = {
h1: 'text-xl font-bold',
h2: 'text-base font-semibold',
body: 'text-sm',
mono: 'text-sm font-mono text-muted-foreground',
}
export function InlineEditableText({
value,
onSave,
variant = 'body',
placeholder = 'Click to edit...',
multiline = false,
disabled = false,
className,
}: InlineEditableTextProps) {
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(value)
const [isSaving, setIsSaving] = useState(false)
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [isEditing])
useEffect(() => {
setEditValue(value)
}, [value])
const handleSave = useCallback(async () => {
const trimmed = editValue.trim()
if (trimmed === value) {
setIsEditing(false)
return
}
if (!trimmed) {
setEditValue(value)
setIsEditing(false)
return
}
setIsSaving(true)
try {
await onSave(trimmed)
setIsEditing(false)
} catch {
setEditValue(value)
} finally {
setIsSaving(false)
}
}, [editValue, value, onSave])
const handleCancel = useCallback(() => {
setEditValue(value)
setIsEditing(false)
}, [value])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
handleCancel()
}
if (e.key === 'Enter') {
if (multiline && !e.ctrlKey) return
e.preventDefault()
handleSave()
}
},
[handleCancel, handleSave, multiline]
)
if (disabled) {
return (
<span className={cn(variantStyles[variant], className)}>
{value || <span className="text-muted-foreground italic">{placeholder}</span>}
</span>
)
}
return (
<AnimatePresence mode="wait" initial={false}>
{isEditing ? (
<motion.div
key="editing"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.15 }}
className={cn('flex items-center gap-1.5', className)}
>
{multiline ? (
<Textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
disabled={isSaving}
placeholder={placeholder}
className={cn(variantStyles[variant], 'min-h-[60px] resize-y')}
rows={3}
/>
) : (
<Input
ref={inputRef as React.RefObject<HTMLInputElement>}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
disabled={isSaving}
placeholder={placeholder}
className={cn(variantStyles[variant], 'h-auto py-1')}
/>
)}
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="shrink-0 rounded p-1 text-emerald-600 hover:bg-emerald-50 transition-colors"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleCancel}
disabled={isSaving}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</motion.div>
) : (
<motion.button
key="viewing"
type="button"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.15 }}
onClick={() => setIsEditing(true)}
className={cn(
'group inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 -mx-1.5 -my-0.5',
'hover:bg-muted/60 transition-colors text-left cursor-text',
variantStyles[variant],
className
)}
>
<span className={cn(!value && 'text-muted-foreground italic')}>
{value || placeholder}
</span>
<Pencil className="h-3 w-3 shrink-0 opacity-30 sm:opacity-0 sm:group-hover:opacity-50 transition-opacity" />
</motion.button>
)}
</AnimatePresence>
)
}
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { Pencil, Check, X } from 'lucide-react'
type InlineEditableTextVariant = 'h1' | 'h2' | 'body' | 'mono'
type InlineEditableTextProps = {
value: string
onSave: (newValue: string) => void | Promise<void>
variant?: InlineEditableTextVariant
placeholder?: string
multiline?: boolean
disabled?: boolean
className?: string
}
const variantStyles: Record<InlineEditableTextVariant, string> = {
h1: 'text-xl font-bold',
h2: 'text-base font-semibold',
body: 'text-sm',
mono: 'text-sm font-mono text-muted-foreground',
}
export function InlineEditableText({
value,
onSave,
variant = 'body',
placeholder = 'Click to edit...',
multiline = false,
disabled = false,
className,
}: InlineEditableTextProps) {
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(value)
const [isSaving, setIsSaving] = useState(false)
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [isEditing])
useEffect(() => {
setEditValue(value)
}, [value])
const handleSave = useCallback(async () => {
const trimmed = editValue.trim()
if (trimmed === value) {
setIsEditing(false)
return
}
if (!trimmed) {
setEditValue(value)
setIsEditing(false)
return
}
setIsSaving(true)
try {
await onSave(trimmed)
setIsEditing(false)
} catch {
setEditValue(value)
} finally {
setIsSaving(false)
}
}, [editValue, value, onSave])
const handleCancel = useCallback(() => {
setEditValue(value)
setIsEditing(false)
}, [value])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
handleCancel()
}
if (e.key === 'Enter') {
if (multiline && !e.ctrlKey) return
e.preventDefault()
handleSave()
}
},
[handleCancel, handleSave, multiline]
)
if (disabled) {
return (
<span className={cn(variantStyles[variant], className)}>
{value || <span className="text-muted-foreground italic">{placeholder}</span>}
</span>
)
}
return (
<AnimatePresence mode="wait" initial={false}>
{isEditing ? (
<motion.div
key="editing"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.15 }}
className={cn('flex items-center gap-1.5', className)}
>
{multiline ? (
<Textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
disabled={isSaving}
placeholder={placeholder}
className={cn(variantStyles[variant], 'min-h-[60px] resize-y')}
rows={3}
/>
) : (
<Input
ref={inputRef as React.RefObject<HTMLInputElement>}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
disabled={isSaving}
placeholder={placeholder}
className={cn(variantStyles[variant], 'h-auto py-1')}
/>
)}
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="shrink-0 rounded p-1 text-emerald-600 hover:bg-emerald-50 transition-colors"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleCancel}
disabled={isSaving}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</motion.div>
) : (
<motion.button
key="viewing"
type="button"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.15 }}
onClick={() => setIsEditing(true)}
className={cn(
'group inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 -mx-1.5 -my-0.5',
'hover:bg-muted/60 transition-colors text-left cursor-text',
variantStyles[variant],
className
)}
>
<span className={cn(!value && 'text-muted-foreground italic')}>
{value || placeholder}
</span>
<Pencil className="h-3 w-3 shrink-0 opacity-30 sm:opacity-0 sm:group-hover:opacity-50 transition-opacity" />
</motion.button>
)}
</AnimatePresence>
)
}

View File

@@ -1,34 +1,34 @@
'use client'
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
gradient?: boolean
}
>(({ className, value, gradient, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-2 w-full overflow-hidden rounded-full bg-primary/20',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn(
'h-full w-full flex-1 transition-all',
gradient
? 'bg-gradient-to-r from-brand-teal to-brand-blue'
: 'bg-primary'
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
'use client'
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
gradient?: boolean
}
>(({ className, value, gradient, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-2 w-full overflow-hidden rounded-full bg-primary/20',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn(
'h-full w-full flex-1 transition-all',
gradient
? 'bg-gradient-to-r from-brand-teal to-brand-blue'
: 'bg-primary'
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -1,262 +1,262 @@
'use client'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { CheckCircle2, AlertCircle, Loader2, Save, Rocket } from 'lucide-react'
export type StepConfig = {
title: string
description?: string
isValid?: boolean
hasErrors?: boolean
}
type SidebarStepperProps = {
steps: StepConfig[]
currentStep: number
onStepChange: (index: number) => void
onSave?: () => void
onSubmit?: () => void
isSaving?: boolean
isSubmitting?: boolean
saveLabel?: string
submitLabel?: string
canSubmit?: boolean
children: React.ReactNode
className?: string
}
export function SidebarStepper({
steps,
currentStep,
onStepChange,
onSave,
onSubmit,
isSaving = false,
isSubmitting = false,
saveLabel = 'Save Draft',
submitLabel = 'Create Pipeline',
canSubmit = true,
children,
className,
}: SidebarStepperProps) {
const direction = (prev: number, next: number) => (next > prev ? 1 : -1)
return (
<div className={cn('flex gap-6 min-h-[600px]', className)}>
{/* Sidebar - hidden on mobile */}
<div className="hidden lg:flex lg:flex-col lg:w-[260px] lg:shrink-0">
<nav className="flex-1 space-y-1 py-2">
{steps.map((step, index) => {
const isCurrent = index === currentStep
const isComplete = step.isValid === true
const hasErrors = step.hasErrors === true
return (
<button
key={index}
type="button"
onClick={() => onStepChange(index)}
className={cn(
'w-full flex items-start gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
isCurrent
? 'bg-primary/5 border border-primary/20'
: 'hover:bg-muted/50 border border-transparent'
)}
>
<div
className={cn(
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold transition-colors',
isCurrent && 'bg-primary text-primary-foreground',
!isCurrent && isComplete && 'bg-emerald-500 text-white',
!isCurrent && hasErrors && 'bg-destructive/10 text-destructive border border-destructive/30',
!isCurrent && !isComplete && !hasErrors && 'bg-muted text-muted-foreground'
)}
>
{isComplete && !isCurrent ? (
<CheckCircle2 className="h-3.5 w-3.5" />
) : hasErrors && !isCurrent ? (
<AlertCircle className="h-3.5 w-3.5" />
) : (
index + 1
)}
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
'text-sm font-medium truncate',
isCurrent && 'text-primary',
!isCurrent && 'text-foreground'
)}
>
{step.title}
</p>
{step.description && (
<p className="text-[11px] text-muted-foreground truncate mt-0.5">
{step.description}
</p>
)}
</div>
</button>
)
})}
</nav>
{/* Actions */}
<div className="border-t pt-4 space-y-2 mt-auto">
{onSave && (
<Button
variant="outline"
className="w-full justify-start"
disabled={isSaving || isSubmitting}
onClick={onSave}
>
{isSaving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
{saveLabel}
</Button>
)}
{onSubmit && (
<Button
className="w-full justify-start"
disabled={isSubmitting || isSaving || !canSubmit}
onClick={onSubmit}
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Rocket className="h-4 w-4 mr-2" />
)}
{submitLabel}
</Button>
)}
</div>
</div>
{/* Mobile step indicator */}
<div className="lg:hidden flex flex-col w-full">
<MobileStepIndicator
steps={steps}
currentStep={currentStep}
onStepChange={onStepChange}
/>
<div className="flex-1 mt-4">
<StepContent currentStep={currentStep} direction={direction}>
{children}
</StepContent>
</div>
{/* Mobile actions */}
<div className="flex gap-2 pt-4 border-t mt-4">
{onSave && (
<Button
variant="outline"
size="sm"
className="flex-1"
disabled={isSaving || isSubmitting}
onClick={onSave}
>
{isSaving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
{saveLabel}
</Button>
)}
{onSubmit && (
<Button
size="sm"
className="flex-1"
disabled={isSubmitting || isSaving || !canSubmit}
onClick={onSubmit}
>
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Rocket className="h-4 w-4 mr-1" />}
{submitLabel}
</Button>
)}
</div>
</div>
{/* Desktop content */}
<div className="hidden lg:block flex-1 min-w-0">
<StepContent currentStep={currentStep} direction={direction}>
{children}
</StepContent>
</div>
</div>
)
}
function MobileStepIndicator({
steps,
currentStep,
onStepChange,
}: {
steps: StepConfig[]
currentStep: number
onStepChange: (index: number) => void
}) {
return (
<div className="overflow-x-auto">
<div className="flex items-center gap-1 pb-2 min-w-max">
{steps.map((step, index) => {
const isCurrent = index === currentStep
const isComplete = step.isValid === true
return (
<button
key={index}
type="button"
onClick={() => onStepChange(index)}
className={cn(
'flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-colors shrink-0',
isCurrent && 'bg-primary text-primary-foreground',
!isCurrent && isComplete && 'bg-emerald-100 text-emerald-700',
!isCurrent && !isComplete && 'bg-muted text-muted-foreground hover:bg-muted/80'
)}
>
{isComplete && !isCurrent ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<span>{index + 1}</span>
)}
<span className="hidden sm:inline">{step.title}</span>
</button>
)
})}
</div>
</div>
)
}
function StepContent({
currentStep,
direction,
children,
}: {
currentStep: number
direction: (prev: number, next: number) => number
children: React.ReactNode
}) {
const childArray = Array.isArray(children) ? children : [children]
const currentChild = childArray[currentStep]
return (
<div className="relative overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{
duration: 0.2,
ease: 'easeInOut',
}}
>
{currentChild}
</motion.div>
</AnimatePresence>
</div>
)
}
'use client'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { CheckCircle2, AlertCircle, Loader2, Save, Rocket } from 'lucide-react'
export type StepConfig = {
title: string
description?: string
isValid?: boolean
hasErrors?: boolean
}
type SidebarStepperProps = {
steps: StepConfig[]
currentStep: number
onStepChange: (index: number) => void
onSave?: () => void
onSubmit?: () => void
isSaving?: boolean
isSubmitting?: boolean
saveLabel?: string
submitLabel?: string
canSubmit?: boolean
children: React.ReactNode
className?: string
}
export function SidebarStepper({
steps,
currentStep,
onStepChange,
onSave,
onSubmit,
isSaving = false,
isSubmitting = false,
saveLabel = 'Save Draft',
submitLabel = 'Create Pipeline',
canSubmit = true,
children,
className,
}: SidebarStepperProps) {
const direction = (prev: number, next: number) => (next > prev ? 1 : -1)
return (
<div className={cn('flex gap-6 min-h-[600px]', className)}>
{/* Sidebar - hidden on mobile */}
<div className="hidden lg:flex lg:flex-col lg:w-[260px] lg:shrink-0">
<nav className="flex-1 space-y-1 py-2">
{steps.map((step, index) => {
const isCurrent = index === currentStep
const isComplete = step.isValid === true
const hasErrors = step.hasErrors === true
return (
<button
key={index}
type="button"
onClick={() => onStepChange(index)}
className={cn(
'w-full flex items-start gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
isCurrent
? 'bg-primary/5 border border-primary/20'
: 'hover:bg-muted/50 border border-transparent'
)}
>
<div
className={cn(
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold transition-colors',
isCurrent && 'bg-primary text-primary-foreground',
!isCurrent && isComplete && 'bg-emerald-500 text-white',
!isCurrent && hasErrors && 'bg-destructive/10 text-destructive border border-destructive/30',
!isCurrent && !isComplete && !hasErrors && 'bg-muted text-muted-foreground'
)}
>
{isComplete && !isCurrent ? (
<CheckCircle2 className="h-3.5 w-3.5" />
) : hasErrors && !isCurrent ? (
<AlertCircle className="h-3.5 w-3.5" />
) : (
index + 1
)}
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
'text-sm font-medium truncate',
isCurrent && 'text-primary',
!isCurrent && 'text-foreground'
)}
>
{step.title}
</p>
{step.description && (
<p className="text-[11px] text-muted-foreground truncate mt-0.5">
{step.description}
</p>
)}
</div>
</button>
)
})}
</nav>
{/* Actions */}
<div className="border-t pt-4 space-y-2 mt-auto">
{onSave && (
<Button
variant="outline"
className="w-full justify-start"
disabled={isSaving || isSubmitting}
onClick={onSave}
>
{isSaving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
{saveLabel}
</Button>
)}
{onSubmit && (
<Button
className="w-full justify-start"
disabled={isSubmitting || isSaving || !canSubmit}
onClick={onSubmit}
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Rocket className="h-4 w-4 mr-2" />
)}
{submitLabel}
</Button>
)}
</div>
</div>
{/* Mobile step indicator */}
<div className="lg:hidden flex flex-col w-full">
<MobileStepIndicator
steps={steps}
currentStep={currentStep}
onStepChange={onStepChange}
/>
<div className="flex-1 mt-4">
<StepContent currentStep={currentStep} direction={direction}>
{children}
</StepContent>
</div>
{/* Mobile actions */}
<div className="flex gap-2 pt-4 border-t mt-4">
{onSave && (
<Button
variant="outline"
size="sm"
className="flex-1"
disabled={isSaving || isSubmitting}
onClick={onSave}
>
{isSaving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
{saveLabel}
</Button>
)}
{onSubmit && (
<Button
size="sm"
className="flex-1"
disabled={isSubmitting || isSaving || !canSubmit}
onClick={onSubmit}
>
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Rocket className="h-4 w-4 mr-1" />}
{submitLabel}
</Button>
)}
</div>
</div>
{/* Desktop content */}
<div className="hidden lg:block flex-1 min-w-0">
<StepContent currentStep={currentStep} direction={direction}>
{children}
</StepContent>
</div>
</div>
)
}
function MobileStepIndicator({
steps,
currentStep,
onStepChange,
}: {
steps: StepConfig[]
currentStep: number
onStepChange: (index: number) => void
}) {
return (
<div className="overflow-x-auto">
<div className="flex items-center gap-1 pb-2 min-w-max">
{steps.map((step, index) => {
const isCurrent = index === currentStep
const isComplete = step.isValid === true
return (
<button
key={index}
type="button"
onClick={() => onStepChange(index)}
className={cn(
'flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-colors shrink-0',
isCurrent && 'bg-primary text-primary-foreground',
!isCurrent && isComplete && 'bg-emerald-100 text-emerald-700',
!isCurrent && !isComplete && 'bg-muted text-muted-foreground hover:bg-muted/80'
)}
>
{isComplete && !isCurrent ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<span>{index + 1}</span>
)}
<span className="hidden sm:inline">{step.title}</span>
</button>
)
})}
</div>
</div>
)
}
function StepContent({
currentStep,
direction,
children,
}: {
currentStep: number
direction: (prev: number, next: number) => number
children: React.ReactNode
}) {
const childArray = Array.isArray(children) ? children : [children]
const currentChild = childArray[currentStep]
return (
<div className="relative overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{
duration: 0.2,
ease: 'easeInOut',
}}
>
{currentChild}
</motion.div>
</AnimatePresence>
</div>
)
}