Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
358
src/components/admin/pipeline/award-governance-editor.tsx
Normal file
358
src/components/admin/pipeline/award-governance-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
379
src/components/admin/pipeline/filtering-rules-editor.tsx
Normal file
379
src/components/admin/pipeline/filtering-rules-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
464
src/components/admin/pipeline/routing-rules-editor.tsx
Normal file
464
src/components/admin/pipeline/routing-rules-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 "{track.name}" 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 "{track.name}" 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 "Add Stage" 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 "Add Stage" to begin.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
93
src/components/admin/pipeline/sections/results-section.tsx
Normal file
93
src/components/admin/pipeline/sections/results-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
108
src/components/admin/pipeline/sections/selection-section.tsx
Normal file
108
src/components/admin/pipeline/sections/selection-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
344
src/components/admin/pipeline/stage-transitions-editor.tsx
Normal file
344
src/components/admin/pipeline/stage-transitions-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'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'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'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'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} →
|
||||
</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} →
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user