Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins: - F1: Evaluation progress indicator with touch tracking in sticky status bar - F2: Export filtering results as CSV with dynamic AI column flattening - F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure) Batch 2 - Jury Experience: - F4: Countdown timer component with urgency colors + email reminder service with cron endpoint - F5: Conflict of interest declaration system (dialog, admin management, review workflow) Batch 3 - Admin & AI Enhancements: - F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording - F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns - F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking) Batch 4 - Form Flexibility & Applicant Portal: - F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility) - F10: Applicant portal (status timeline, per-round documents, mentor messaging) Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
335
src/components/admin/evaluation-summary-card.tsx
Normal file
335
src/components/admin/evaluation-summary-card.tsx
Normal file
@@ -0,0 +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 {
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Users,
|
||||
Target,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface EvaluationSummaryCardProps {
|
||||
projectId: string
|
||||
roundId: 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,
|
||||
roundId,
|
||||
}: EvaluationSummaryCardProps) {
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
const {
|
||||
data: summary,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = trpc.evaluation.getSummary.useQuery({ projectId, roundId })
|
||||
|
||||
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, roundId })
|
||||
}
|
||||
|
||||
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">
|
||||
<Sparkles 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">
|
||||
<Sparkles 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" />
|
||||
) : (
|
||||
<Sparkles 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">
|
||||
<Sparkles 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>
|
||||
)
|
||||
}
|
||||
162
src/components/forms/coi-declaration-dialog.tsx
Normal file
162
src/components/forms/coi-declaration-dialog.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Loader2, ShieldAlert } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface COIDeclarationDialogProps {
|
||||
open: boolean
|
||||
assignmentId: string
|
||||
projectTitle: string
|
||||
onComplete: (hasConflict: boolean) => void
|
||||
}
|
||||
|
||||
export function COIDeclarationDialog({
|
||||
open,
|
||||
assignmentId,
|
||||
projectTitle,
|
||||
onComplete,
|
||||
}: COIDeclarationDialogProps) {
|
||||
const [hasConflict, setHasConflict] = useState<boolean | null>(null)
|
||||
const [conflictType, setConflictType] = useState<string>('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
const declareCOI = trpc.evaluation.declareCOI.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.hasConflict) {
|
||||
toast.info('Conflict of interest recorded. An admin will review your declaration.')
|
||||
}
|
||||
onComplete(data.hasConflict)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to submit COI declaration')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (hasConflict === null) return
|
||||
|
||||
declareCOI.mutate({
|
||||
assignmentId,
|
||||
hasConflict,
|
||||
conflictType: hasConflict ? conflictType : undefined,
|
||||
description: hasConflict ? description : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
hasConflict !== null &&
|
||||
(!hasConflict || (hasConflict && conflictType)) &&
|
||||
!declareCOI.isPending
|
||||
|
||||
return (
|
||||
<AlertDialog open={open}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-5 w-5 text-amber-500" />
|
||||
Conflict of Interest Declaration
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Before evaluating “{projectTitle}”, please declare whether
|
||||
you have any conflict of interest with this project.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">
|
||||
Do you have a conflict of interest with this project?
|
||||
</Label>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasConflict === false ? 'default' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => setHasConflict(false)}
|
||||
>
|
||||
No Conflict
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasConflict === true ? 'destructive' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => setHasConflict(true)}
|
||||
>
|
||||
Yes, I Have a Conflict
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasConflict && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conflict-type">Type of Conflict</Label>
|
||||
<Select value={conflictType} onValueChange={setConflictType}>
|
||||
<SelectTrigger id="conflict-type">
|
||||
<SelectValue placeholder="Select conflict type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="financial">Financial Interest</SelectItem>
|
||||
<SelectItem value="personal">Personal Relationship</SelectItem>
|
||||
<SelectItem value="organizational">Organizational Affiliation</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conflict-description">
|
||||
Description <span className="text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="conflict-description"
|
||||
placeholder="Briefly describe the nature of your conflict..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{declareCOI.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{hasConflict === null
|
||||
? 'Select an option'
|
||||
: hasConflict
|
||||
? 'Submit Declaration'
|
||||
: 'Confirm No Conflict'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -45,16 +45,42 @@ import {
|
||||
GripVertical,
|
||||
Check,
|
||||
X,
|
||||
Type,
|
||||
ToggleLeft,
|
||||
Hash,
|
||||
Heading,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||
|
||||
export interface CriterionCondition {
|
||||
criterionId: string
|
||||
operator: 'equals' | 'greaterThan' | 'lessThan'
|
||||
value: number | string | boolean
|
||||
}
|
||||
|
||||
export interface Criterion {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
scale: number // 5 or 10
|
||||
type?: CriterionType // defaults to 'numeric'
|
||||
// Numeric fields
|
||||
scale?: number // 5 or 10
|
||||
weight?: number
|
||||
required: boolean
|
||||
required?: boolean
|
||||
// Text fields
|
||||
maxLength?: number
|
||||
placeholder?: string
|
||||
// Boolean fields
|
||||
trueLabel?: string
|
||||
falseLabel?: string
|
||||
// Conditional visibility
|
||||
condition?: CriterionCondition
|
||||
// Section grouping
|
||||
sectionId?: string
|
||||
}
|
||||
|
||||
interface EvaluationFormBuilderProps {
|
||||
@@ -67,17 +93,34 @@ function generateId(): string {
|
||||
return `criterion-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
function createDefaultCriterion(): Criterion {
|
||||
return {
|
||||
function createDefaultCriterion(type: CriterionType = 'numeric'): Criterion {
|
||||
const base: Criterion = {
|
||||
id: generateId(),
|
||||
label: '',
|
||||
description: '',
|
||||
scale: 5,
|
||||
weight: 1,
|
||||
required: true,
|
||||
type,
|
||||
}
|
||||
switch (type) {
|
||||
case 'numeric':
|
||||
return { ...base, scale: 5, weight: 1, required: true }
|
||||
case 'text':
|
||||
return { ...base, maxLength: 1000, placeholder: '', required: true }
|
||||
case 'boolean':
|
||||
return { ...base, trueLabel: 'Yes', falseLabel: 'No', required: true }
|
||||
case 'section_header':
|
||||
return { ...base, required: false }
|
||||
default:
|
||||
return { ...base, scale: 5, weight: 1, required: true }
|
||||
}
|
||||
}
|
||||
|
||||
const CRITERION_TYPE_OPTIONS: { value: CriterionType; label: string; icon: typeof Hash }[] = [
|
||||
{ value: 'numeric', label: 'Numeric Score', icon: Hash },
|
||||
{ value: 'text', label: 'Text Response', icon: Type },
|
||||
{ value: 'boolean', label: 'Yes / No', icon: ToggleLeft },
|
||||
{ value: 'section_header', label: 'Section Header', icon: Heading },
|
||||
]
|
||||
|
||||
export function EvaluationFormBuilder({
|
||||
initialCriteria = [],
|
||||
onChange,
|
||||
@@ -97,8 +140,8 @@ export function EvaluationFormBuilder({
|
||||
)
|
||||
|
||||
// Add new criterion
|
||||
const addCriterion = useCallback(() => {
|
||||
const newCriterion = createDefaultCriterion()
|
||||
const addCriterion = useCallback((type: CriterionType = 'numeric') => {
|
||||
const newCriterion = createDefaultCriterion(type)
|
||||
const newCriteria = [...criteria, newCriterion]
|
||||
updateCriteria(newCriteria)
|
||||
setEditingId(newCriterion.id)
|
||||
@@ -190,13 +233,24 @@ export function EvaluationFormBuilder({
|
||||
{isEditing && editDraft ? (
|
||||
// Edit mode
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Type indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{CRITERION_TYPE_OPTIONS.find((t) => t.value === (editDraft.type || 'numeric'))?.label ?? 'Numeric Score'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`label-${criterion.id}`}>Label *</Label>
|
||||
<Input
|
||||
id={`label-${criterion.id}`}
|
||||
value={editDraft.label}
|
||||
onChange={(e) => updateDraft({ label: e.target.value })}
|
||||
placeholder="e.g., Innovation"
|
||||
placeholder={
|
||||
(editDraft.type || 'numeric') === 'section_header'
|
||||
? 'e.g., Technical Assessment'
|
||||
: 'e.g., Innovation'
|
||||
}
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -217,64 +271,246 @@ export function EvaluationFormBuilder({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`scale-${criterion.id}`}>Scale</Label>
|
||||
<Select
|
||||
value={String(editDraft.scale)}
|
||||
onValueChange={(v) => updateDraft({ scale: parseInt(v) })}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id={`scale-${criterion.id}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">1-5</SelectItem>
|
||||
<SelectItem value="10">1-10</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`weight-${criterion.id}`}>
|
||||
Weight: {editDraft.weight ?? 1}x
|
||||
</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground w-4">0.5</span>
|
||||
<Slider
|
||||
id={`weight-${criterion.id}`}
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.5}
|
||||
value={[editDraft.weight ?? 1]}
|
||||
onValueChange={(v) => updateDraft({ weight: v[0] })}
|
||||
{/* Type-specific fields */}
|
||||
{(editDraft.type || 'numeric') === 'numeric' && (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`scale-${criterion.id}`}>Scale</Label>
|
||||
<Select
|
||||
value={String(editDraft.scale ?? 5)}
|
||||
onValueChange={(v) => updateDraft({ scale: parseInt(v) })}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-4">3</span>
|
||||
>
|
||||
<SelectTrigger id={`scale-${criterion.id}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">1-5</SelectItem>
|
||||
<SelectItem value="10">1-10</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(editDraft.weight ?? 1) === 1
|
||||
? 'Normal importance'
|
||||
: (editDraft.weight ?? 1) < 1
|
||||
? 'Lower importance'
|
||||
: 'Higher importance'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Required</Label>
|
||||
<div className="flex items-center h-10">
|
||||
<Switch
|
||||
checked={editDraft.required}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDraft({ required: checked })
|
||||
}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`weight-${criterion.id}`}>
|
||||
Weight: {editDraft.weight ?? 1}x
|
||||
</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground w-4">0.5</span>
|
||||
<Slider
|
||||
id={`weight-${criterion.id}`}
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.5}
|
||||
value={[editDraft.weight ?? 1]}
|
||||
onValueChange={(v) => updateDraft({ weight: v[0] })}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-4">3</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(editDraft.weight ?? 1) === 1
|
||||
? 'Normal importance'
|
||||
: (editDraft.weight ?? 1) < 1
|
||||
? 'Lower importance'
|
||||
: 'Higher importance'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Required</Label>
|
||||
<div className="flex items-center h-10">
|
||||
<Switch
|
||||
checked={editDraft.required ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDraft({ required: checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(editDraft.type || 'numeric') === 'text' && (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`maxLength-${criterion.id}`}>Max Length</Label>
|
||||
<Input
|
||||
id={`maxLength-${criterion.id}`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={editDraft.maxLength ?? 1000}
|
||||
onChange={(e) => updateDraft({ maxLength: parseInt(e.target.value) || 1000 })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`placeholder-${criterion.id}`}>Placeholder</Label>
|
||||
<Input
|
||||
id={`placeholder-${criterion.id}`}
|
||||
value={editDraft.placeholder || ''}
|
||||
onChange={(e) => updateDraft({ placeholder: e.target.value })}
|
||||
placeholder="Enter placeholder text..."
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Required</Label>
|
||||
<div className="flex items-center h-10">
|
||||
<Switch
|
||||
checked={editDraft.required ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDraft({ required: checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(editDraft.type || 'numeric') === 'boolean' && (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`trueLabel-${criterion.id}`}>Yes Label</Label>
|
||||
<Input
|
||||
id={`trueLabel-${criterion.id}`}
|
||||
value={editDraft.trueLabel || 'Yes'}
|
||||
onChange={(e) => updateDraft({ trueLabel: e.target.value })}
|
||||
placeholder="Yes"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`falseLabel-${criterion.id}`}>No Label</Label>
|
||||
<Input
|
||||
id={`falseLabel-${criterion.id}`}
|
||||
value={editDraft.falseLabel || 'No'}
|
||||
onChange={(e) => updateDraft({ falseLabel: e.target.value })}
|
||||
placeholder="No"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Required</Label>
|
||||
<div className="flex items-center h-10">
|
||||
<Switch
|
||||
checked={editDraft.required ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDraft({ required: checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Condition builder - available for all types except section_header */}
|
||||
{(editDraft.type || 'numeric') !== 'section_header' && criteria.filter((c) => c.id !== editDraft.id).length > 0 && (
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Conditional Visibility</Label>
|
||||
{editDraft.condition ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => updateDraft({ condition: undefined })}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Remove
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateDraft({
|
||||
condition: {
|
||||
criterionId: criteria.filter((c) => c.id !== editDraft.id)[0]?.id ?? '',
|
||||
operator: 'equals',
|
||||
value: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
Add Condition
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editDraft.condition && (
|
||||
<div className="grid gap-2 sm:grid-cols-3 p-3 rounded-md bg-muted/50">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">When criterion</Label>
|
||||
<Select
|
||||
value={editDraft.condition.criterionId}
|
||||
onValueChange={(v) =>
|
||||
updateDraft({ condition: { ...editDraft.condition!, criterionId: v } })
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{criteria
|
||||
.filter((c) => c.id !== editDraft.id && (c.type || 'numeric') !== 'section_header')
|
||||
.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.label || '(Untitled)'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Operator</Label>
|
||||
<Select
|
||||
value={editDraft.condition.operator}
|
||||
onValueChange={(v) =>
|
||||
updateDraft({ condition: { ...editDraft.condition!, operator: v as 'equals' | 'greaterThan' | 'lessThan' } })
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="equals">equals</SelectItem>
|
||||
<SelectItem value="greaterThan">greater than</SelectItem>
|
||||
<SelectItem value="lessThan">less than</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Value</Label>
|
||||
<Input
|
||||
value={String(editDraft.condition.value)}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
const parsed = Number(raw)
|
||||
updateDraft({
|
||||
condition: {
|
||||
...editDraft.condition!,
|
||||
value: isNaN(parsed) ? (raw === 'true' ? true : raw === 'false' ? false : raw) : parsed,
|
||||
},
|
||||
})
|
||||
}}
|
||||
disabled={disabled}
|
||||
placeholder="Value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
@@ -310,22 +546,37 @@ export function EvaluationFormBuilder({
|
||||
{/* Criterion info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium truncate">
|
||||
<span className={cn(
|
||||
'font-medium truncate',
|
||||
(criterion.type || 'numeric') === 'section_header' && 'text-base font-semibold'
|
||||
)}>
|
||||
{criterion.label || '(Untitled)'}
|
||||
</span>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
1-{criterion.scale}
|
||||
</Badge>
|
||||
{criterion.weight && criterion.weight !== 1 && (
|
||||
{(() => {
|
||||
const type = criterion.type || 'numeric'
|
||||
const TypeIcon = CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.icon ?? Hash
|
||||
return (
|
||||
<Badge variant="secondary" className="shrink-0 text-xs gap-1">
|
||||
<TypeIcon className="h-3 w-3" />
|
||||
{type === 'numeric' ? `1-${criterion.scale ?? 5}` : CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.label}
|
||||
</Badge>
|
||||
)
|
||||
})()}
|
||||
{criterion.weight && criterion.weight !== 1 && (criterion.type || 'numeric') === 'numeric' && (
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
{criterion.weight}x
|
||||
</Badge>
|
||||
)}
|
||||
{criterion.required && (
|
||||
{criterion.required && (criterion.type || 'numeric') !== 'section_header' && (
|
||||
<Badge variant="default" className="shrink-0 text-xs">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
{criterion.condition && (
|
||||
<Badge variant="outline" className="shrink-0 text-xs text-amber-600 border-amber-300">
|
||||
Conditional
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground truncate mt-0.5">
|
||||
@@ -418,17 +669,20 @@ export function EvaluationFormBuilder({
|
||||
|
||||
{/* Actions */}
|
||||
{!disabled && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addCriterion}
|
||||
disabled={editingId !== null}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add Criterion
|
||||
</Button>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{CRITERION_TYPE_OPTIONS.map(({ value, label, icon: Icon }) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addCriterion(value)}
|
||||
disabled={editingId !== null}
|
||||
>
|
||||
<Icon className="mr-1 h-4 w-4" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{criteria.length > 0 && (
|
||||
<PreviewDialog criteria={criteria} />
|
||||
@@ -458,57 +712,94 @@ function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{criteria.map((criterion) => (
|
||||
<Card key={criterion.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{criterion.label}
|
||||
{criterion.required && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{criterion.description && (
|
||||
<CardDescription>{criterion.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-4">1</span>
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary/30 rounded-full"
|
||||
style={{ width: '50%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-4">
|
||||
{criterion.scale}
|
||||
</span>
|
||||
</div>
|
||||
{criteria.map((criterion) => {
|
||||
const type = criterion.type || 'numeric'
|
||||
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map(
|
||||
(num) => (
|
||||
<div
|
||||
key={num}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-md text-sm font-medium flex items-center justify-center',
|
||||
num <= Math.ceil(criterion.scale / 2)
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted'
|
||||
)}
|
||||
>
|
||||
{num}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
if (type === 'section_header') {
|
||||
return (
|
||||
<div key={criterion.id} className="border-b pb-2 pt-4">
|
||||
<h3 className="font-semibold text-lg">{criterion.label}</h3>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card key={criterion.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{criterion.label}
|
||||
{criterion.required && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{criterion.description && (
|
||||
<CardDescription>{criterion.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{type === 'numeric' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-4">1</span>
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary/30 rounded-full"
|
||||
style={{ width: '50%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-4">
|
||||
{criterion.scale ?? 5}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Array.from({ length: criterion.scale ?? 5 }, (_, i) => i + 1).map(
|
||||
(num) => (
|
||||
<div
|
||||
key={num}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-md text-sm font-medium flex items-center justify-center',
|
||||
num <= Math.ceil((criterion.scale ?? 5) / 2)
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted'
|
||||
)}
|
||||
>
|
||||
{num}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{type === 'text' && (
|
||||
<Textarea
|
||||
placeholder={criterion.placeholder || 'Enter your response...'}
|
||||
rows={3}
|
||||
maxLength={criterion.maxLength ?? 1000}
|
||||
disabled
|
||||
className="opacity-60"
|
||||
/>
|
||||
)}
|
||||
{type === 'boolean' && (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 h-12 rounded-md border flex items-center justify-center text-sm font-medium bg-muted/50">
|
||||
<ThumbsUp className="mr-2 h-4 w-4" />
|
||||
{criterion.trueLabel || 'Yes'}
|
||||
</div>
|
||||
<div className="flex-1 h-12 rounded-md border flex items-center justify-center text-sm font-medium bg-muted/50">
|
||||
<ThumbsDown className="mr-2 h-4 w-4" />
|
||||
{criterion.falseLabel || 'No'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{criteria.length === 0 && (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
|
||||
112
src/components/forms/evaluation-form-with-coi.tsx
Normal file
112
src/components/forms/evaluation-form-with-coi.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { EvaluationForm } from './evaluation-form'
|
||||
import { COIDeclarationDialog } from './coi-declaration-dialog'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { ShieldAlert } from 'lucide-react'
|
||||
|
||||
interface Criterion {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
type?: 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||
scale?: number
|
||||
weight?: number
|
||||
required?: boolean
|
||||
maxLength?: number
|
||||
placeholder?: string
|
||||
trueLabel?: string
|
||||
falseLabel?: string
|
||||
condition?: {
|
||||
criterionId: string
|
||||
operator: 'equals' | 'greaterThan' | 'lessThan'
|
||||
value: number | string | boolean
|
||||
}
|
||||
sectionId?: string
|
||||
}
|
||||
|
||||
interface EvaluationFormWithCOIProps {
|
||||
assignmentId: string
|
||||
evaluationId: string | null
|
||||
projectTitle: string
|
||||
criteria: Criterion[]
|
||||
initialData?: {
|
||||
criterionScoresJson: Record<string, number | string | boolean> | null
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
feedbackText: string | null
|
||||
status: string
|
||||
}
|
||||
isVotingOpen: boolean
|
||||
deadline?: Date | null
|
||||
coiStatus: {
|
||||
hasConflict: boolean
|
||||
declared: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function EvaluationFormWithCOI({
|
||||
assignmentId,
|
||||
evaluationId,
|
||||
projectTitle,
|
||||
criteria,
|
||||
initialData,
|
||||
isVotingOpen,
|
||||
deadline,
|
||||
coiStatus,
|
||||
}: EvaluationFormWithCOIProps) {
|
||||
const [coiDeclared, setCOIDeclared] = useState(coiStatus.declared)
|
||||
const [hasConflict, setHasConflict] = useState(coiStatus.hasConflict)
|
||||
|
||||
const handleCOIComplete = (conflictDeclared: boolean) => {
|
||||
setCOIDeclared(true)
|
||||
setHasConflict(conflictDeclared)
|
||||
}
|
||||
|
||||
// Show COI dialog if not yet declared
|
||||
if (!coiDeclared) {
|
||||
return (
|
||||
<COIDeclarationDialog
|
||||
open={true}
|
||||
assignmentId={assignmentId}
|
||||
projectTitle={projectTitle}
|
||||
onComplete={handleCOIComplete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Show warning banner if conflict was declared
|
||||
if (hasConflict) {
|
||||
return (
|
||||
<Card className="border-amber-500 bg-amber-500/5">
|
||||
<CardContent className="flex items-center gap-3 py-6">
|
||||
<ShieldAlert className="h-6 w-6 text-amber-600 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-800 dark:text-amber-200">
|
||||
Conflict of Interest Declared
|
||||
</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||
You declared a conflict of interest for this project. An admin will
|
||||
review your declaration. You cannot evaluate this project while the
|
||||
conflict is under review.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// No conflict - show the evaluation form
|
||||
return (
|
||||
<EvaluationForm
|
||||
assignmentId={assignmentId}
|
||||
evaluationId={evaluationId}
|
||||
projectTitle={projectTitle}
|
||||
criteria={criteria}
|
||||
initialData={initialData}
|
||||
isVotingOpen={isVotingOpen}
|
||||
deadline={deadline}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useTransition } from 'react'
|
||||
import { useState, useEffect, useCallback, useTransition, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -14,6 +14,7 @@ import { Textarea } from '@/components/ui/textarea'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -38,15 +39,36 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||
|
||||
// Define criterion type from the evaluation form JSON
|
||||
type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||
|
||||
interface CriterionCondition {
|
||||
criterionId: string
|
||||
operator: 'equals' | 'greaterThan' | 'lessThan'
|
||||
value: number | string | boolean
|
||||
}
|
||||
|
||||
interface Criterion {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
scale: number // max value (e.g., 5 or 10)
|
||||
type?: CriterionType // defaults to 'numeric'
|
||||
// Numeric
|
||||
scale?: number // max value (e.g., 5 or 10)
|
||||
weight?: number
|
||||
required?: boolean
|
||||
// Text
|
||||
maxLength?: number
|
||||
placeholder?: string
|
||||
// Boolean
|
||||
trueLabel?: string
|
||||
falseLabel?: string
|
||||
// Conditional visibility
|
||||
condition?: CriterionCondition
|
||||
// Section grouping
|
||||
sectionId?: string
|
||||
}
|
||||
|
||||
interface EvaluationFormProps {
|
||||
@@ -55,7 +77,7 @@ interface EvaluationFormProps {
|
||||
projectTitle: string
|
||||
criteria: Criterion[]
|
||||
initialData?: {
|
||||
criterionScoresJson: Record<string, number> | null
|
||||
criterionScoresJson: Record<string, number | string | boolean> | null
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
feedbackText: string | null
|
||||
@@ -65,15 +87,52 @@ interface EvaluationFormProps {
|
||||
deadline?: Date | null
|
||||
}
|
||||
|
||||
const createEvaluationSchema = (criteria: Criterion[]) =>
|
||||
z.object({
|
||||
criterionScores: z.record(z.number()),
|
||||
const createEvaluationSchema = (criteria: Criterion[]) => {
|
||||
const criterionFields: Record<string, z.ZodTypeAny> = {}
|
||||
for (const c of criteria) {
|
||||
const type = c.type || 'numeric'
|
||||
if (type === 'section_header') continue
|
||||
if (type === 'numeric') {
|
||||
criterionFields[c.id] = z.number()
|
||||
} else if (type === 'text') {
|
||||
criterionFields[c.id] = z.string()
|
||||
} else if (type === 'boolean') {
|
||||
criterionFields[c.id] = z.boolean()
|
||||
}
|
||||
}
|
||||
return z.object({
|
||||
criterionScores: z.object(criterionFields).passthrough(),
|
||||
globalScore: z.number().int().min(1).max(10),
|
||||
binaryDecision: z.boolean(),
|
||||
feedbackText: z.string().min(10, 'Please provide at least 10 characters of feedback'),
|
||||
})
|
||||
}
|
||||
|
||||
type EvaluationFormData = z.infer<ReturnType<typeof createEvaluationSchema>>
|
||||
type EvaluationFormData = {
|
||||
criterionScores: Record<string, number | string | boolean>
|
||||
globalScore: number
|
||||
binaryDecision: boolean
|
||||
feedbackText: string
|
||||
}
|
||||
|
||||
/** Evaluate whether a condition is met based on current form values */
|
||||
function evaluateCondition(
|
||||
condition: CriterionCondition,
|
||||
scores: Record<string, number | string | boolean>
|
||||
): boolean {
|
||||
const val = scores[condition.criterionId]
|
||||
if (val === undefined) return false
|
||||
switch (condition.operator) {
|
||||
case 'equals':
|
||||
return val === condition.value
|
||||
case 'greaterThan':
|
||||
return typeof val === 'number' && typeof condition.value === 'number' && val > condition.value
|
||||
case 'lessThan':
|
||||
return typeof val === 'number' && typeof condition.value === 'number' && val < condition.value
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function EvaluationForm({
|
||||
assignmentId,
|
||||
@@ -89,10 +148,41 @@ export function EvaluationForm({
|
||||
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null)
|
||||
|
||||
// Progress tracking state
|
||||
const [touchedCriteria, setTouchedCriteria] = useState<Set<string>>(() => {
|
||||
const initial = new Set<string>()
|
||||
if (initialData?.criterionScoresJson) {
|
||||
for (const key of Object.keys(initialData.criterionScoresJson)) {
|
||||
initial.add(key)
|
||||
}
|
||||
}
|
||||
return initial
|
||||
})
|
||||
const [globalScoreTouched, setGlobalScoreTouched] = useState(
|
||||
() => initialData?.globalScore != null
|
||||
)
|
||||
const [decisionTouched, setDecisionTouched] = useState(
|
||||
() => initialData?.binaryDecision != null
|
||||
)
|
||||
|
||||
// Compute which criteria are scorable (not section headers)
|
||||
const scorableCriteria = useMemo(
|
||||
() => criteria.filter((c) => (c.type || 'numeric') !== 'section_header'),
|
||||
[criteria]
|
||||
)
|
||||
|
||||
// Initialize criterion scores with existing data or defaults
|
||||
const defaultCriterionScores: Record<string, number> = {}
|
||||
criteria.forEach((c) => {
|
||||
defaultCriterionScores[c.id] = initialData?.criterionScoresJson?.[c.id] ?? Math.ceil(c.scale / 2)
|
||||
const defaultCriterionScores: Record<string, number | string | boolean> = {}
|
||||
scorableCriteria.forEach((c) => {
|
||||
const type = c.type || 'numeric'
|
||||
const existing = initialData?.criterionScoresJson?.[c.id]
|
||||
if (type === 'numeric') {
|
||||
defaultCriterionScores[c.id] = typeof existing === 'number' ? existing : Math.ceil((c.scale ?? 5) / 2)
|
||||
} else if (type === 'text') {
|
||||
defaultCriterionScores[c.id] = typeof existing === 'string' ? existing : ''
|
||||
} else if (type === 'boolean') {
|
||||
defaultCriterionScores[c.id] = typeof existing === 'boolean' ? existing : false
|
||||
}
|
||||
})
|
||||
|
||||
const form = useForm<EvaluationFormData>({
|
||||
@@ -109,6 +199,46 @@ export function EvaluationForm({
|
||||
const { watch, handleSubmit, control, formState } = form
|
||||
const { errors, isValid, isDirty } = formState
|
||||
|
||||
// Progress tracking callbacks
|
||||
const onCriterionTouch = useCallback((criterionId: string) => {
|
||||
setTouchedCriteria((prev) => {
|
||||
if (prev.has(criterionId)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(criterionId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Compute progress - section_headers count as always complete (skip them)
|
||||
const feedbackValue = watch('feedbackText')
|
||||
const watchedScores = watch('criterionScores')
|
||||
const progress = useMemo(() => {
|
||||
// Only count scorable criteria (not section headers)
|
||||
const totalItems = scorableCriteria.length + 3
|
||||
|
||||
// Count completed criteria
|
||||
let criteriaDone = 0
|
||||
for (const c of scorableCriteria) {
|
||||
const type = c.type || 'numeric'
|
||||
if (type === 'numeric') {
|
||||
if (touchedCriteria.has(c.id)) criteriaDone++
|
||||
} else if (type === 'text') {
|
||||
const val = watchedScores?.[c.id]
|
||||
if (typeof val === 'string' && val.length > 0) criteriaDone++
|
||||
} else if (type === 'boolean') {
|
||||
if (touchedCriteria.has(c.id)) criteriaDone++
|
||||
}
|
||||
}
|
||||
|
||||
const completedItems =
|
||||
criteriaDone +
|
||||
(globalScoreTouched ? 1 : 0) +
|
||||
(decisionTouched ? 1 : 0) +
|
||||
(feedbackValue.length >= 10 ? 1 : 0)
|
||||
const percentage = Math.round((completedItems / totalItems) * 100)
|
||||
return { totalItems, completedItems, percentage, criteriaDone, criteriaTotal: scorableCriteria.length }
|
||||
}, [scorableCriteria, touchedCriteria, watchedScores, globalScoreTouched, decisionTouched, feedbackValue])
|
||||
|
||||
// tRPC mutations
|
||||
const startEvaluation = trpc.evaluation.start.useMutation()
|
||||
const autosave = trpc.evaluation.autosave.useMutation()
|
||||
@@ -206,10 +336,20 @@ export function EvaluationForm({
|
||||
{/* Status bar */}
|
||||
<div className="sticky top-0 z-10 -mx-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<h2 className="font-semibold truncate max-w-[200px] sm:max-w-none">
|
||||
{projectTitle}
|
||||
</h2>
|
||||
{!isReadOnly && deadline && (
|
||||
<CountdownTimer deadline={new Date(deadline)} />
|
||||
)}
|
||||
{!isReadOnly && (
|
||||
<ProgressIndicator
|
||||
percentage={progress.percentage}
|
||||
criteriaDone={progress.criteriaDone}
|
||||
criteriaTotal={progress.criteriaTotal}
|
||||
/>
|
||||
)}
|
||||
<AutosaveIndicator status={autosaveStatus} lastSaved={lastSaved} />
|
||||
</div>
|
||||
|
||||
@@ -273,14 +413,61 @@ export function EvaluationForm({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{criteria.map((criterion) => (
|
||||
<CriterionField
|
||||
key={criterion.id}
|
||||
criterion={criterion}
|
||||
control={control}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
{criteria.map((criterion) => {
|
||||
const type = criterion.type || 'numeric'
|
||||
|
||||
// Evaluate conditional visibility
|
||||
if (criterion.condition) {
|
||||
const conditionMet = evaluateCondition(criterion.condition, watchedScores ?? {})
|
||||
if (!conditionMet) return null
|
||||
}
|
||||
|
||||
// Section header
|
||||
if (type === 'section_header') {
|
||||
return <SectionHeaderField key={criterion.id} criterion={criterion} />
|
||||
}
|
||||
|
||||
// Numeric (default)
|
||||
if (type === 'numeric') {
|
||||
return (
|
||||
<NumericCriterionField
|
||||
key={criterion.id}
|
||||
criterion={criterion}
|
||||
control={control}
|
||||
disabled={isReadOnly}
|
||||
onTouch={onCriterionTouch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Text
|
||||
if (type === 'text') {
|
||||
return (
|
||||
<TextCriterionField
|
||||
key={criterion.id}
|
||||
criterion={criterion}
|
||||
control={control}
|
||||
disabled={isReadOnly}
|
||||
onTouch={onCriterionTouch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (type === 'boolean') {
|
||||
return (
|
||||
<BooleanCriterionField
|
||||
key={criterion.id}
|
||||
criterion={criterion}
|
||||
control={control}
|
||||
disabled={isReadOnly}
|
||||
onTouch={onCriterionTouch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -309,7 +496,10 @@ export function EvaluationForm({
|
||||
max={10}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={(v) => field.onChange(v[0])}
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v[0])
|
||||
setGlobalScoreTouched(true)
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
className="py-4"
|
||||
/>
|
||||
@@ -318,7 +508,12 @@ export function EvaluationForm({
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
onClick={() => !isReadOnly && field.onChange(num)}
|
||||
onClick={() => {
|
||||
if (!isReadOnly) {
|
||||
field.onChange(num)
|
||||
setGlobalScoreTouched(true)
|
||||
}
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full text-sm font-medium transition-colors',
|
||||
@@ -359,7 +554,12 @@ export function EvaluationForm({
|
||||
'flex-1 h-20',
|
||||
field.value && 'bg-green-600 hover:bg-green-700'
|
||||
)}
|
||||
onClick={() => !isReadOnly && field.onChange(true)}
|
||||
onClick={() => {
|
||||
if (!isReadOnly) {
|
||||
field.onChange(true)
|
||||
setDecisionTouched(true)
|
||||
}
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-6 w-6" />
|
||||
@@ -372,7 +572,12 @@ export function EvaluationForm({
|
||||
'flex-1 h-20',
|
||||
!field.value && 'bg-red-600 hover:bg-red-700'
|
||||
)}
|
||||
onClick={() => !isReadOnly && field.onChange(false)}
|
||||
onClick={() => {
|
||||
if (!isReadOnly) {
|
||||
field.onChange(false)
|
||||
setDecisionTouched(true)
|
||||
}
|
||||
}}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-6 w-6" />
|
||||
@@ -484,16 +689,31 @@ export function EvaluationForm({
|
||||
)
|
||||
}
|
||||
|
||||
// Criterion field component
|
||||
function CriterionField({
|
||||
// Section header component (no input)
|
||||
function SectionHeaderField({ criterion }: { criterion: Criterion }) {
|
||||
return (
|
||||
<div className="border-b pb-2 pt-4 first:pt-0">
|
||||
<h3 className="font-semibold text-lg">{criterion.label}</h3>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Numeric criterion field component (original behavior)
|
||||
function NumericCriterionField({
|
||||
criterion,
|
||||
control,
|
||||
disabled,
|
||||
onTouch,
|
||||
}: {
|
||||
criterion: Criterion
|
||||
control: any
|
||||
disabled: boolean
|
||||
onTouch: (criterionId: string) => void
|
||||
}) {
|
||||
const scale = criterion.scale ?? 5
|
||||
return (
|
||||
<Controller
|
||||
name={`criterionScores.${criterion.id}`}
|
||||
@@ -510,7 +730,7 @@ function CriterionField({
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{field.value}/{criterion.scale}
|
||||
{field.value}/{scale}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -518,23 +738,31 @@ function CriterionField({
|
||||
<span className="text-xs text-muted-foreground w-4">1</span>
|
||||
<Slider
|
||||
min={1}
|
||||
max={criterion.scale}
|
||||
max={scale}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={(v) => field.onChange(v[0])}
|
||||
value={[typeof field.value === 'number' ? field.value : Math.ceil(scale / 2)]}
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v[0])
|
||||
onTouch(criterion.id)
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-4">{criterion.scale}</span>
|
||||
<span className="text-xs text-muted-foreground w-4">{scale}</span>
|
||||
</div>
|
||||
|
||||
{/* Visual rating buttons */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map((num) => (
|
||||
{Array.from({ length: scale }, (_, i) => i + 1).map((num) => (
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
onClick={() => !disabled && field.onChange(num)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
field.onChange(num)
|
||||
onTouch(criterion.id)
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||
@@ -556,6 +784,159 @@ function CriterionField({
|
||||
)
|
||||
}
|
||||
|
||||
// Text criterion field component
|
||||
function TextCriterionField({
|
||||
criterion,
|
||||
control,
|
||||
disabled,
|
||||
onTouch,
|
||||
}: {
|
||||
criterion: Criterion
|
||||
control: any
|
||||
disabled: boolean
|
||||
onTouch: (criterionId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Controller
|
||||
name={`criterionScores.${criterion.id}`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base font-medium">{criterion.label}</Label>
|
||||
{criterion.required && (
|
||||
<Badge variant="destructive" className="text-xs">Required</Badge>
|
||||
)}
|
||||
</div>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{criterion.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
value={typeof field.value === 'string' ? field.value : ''}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value)
|
||||
onTouch(criterion.id)
|
||||
}}
|
||||
placeholder={criterion.placeholder || 'Enter your response...'}
|
||||
rows={3}
|
||||
maxLength={criterion.maxLength ?? 1000}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{typeof field.value === 'string' ? field.value.length : 0}
|
||||
{criterion.maxLength ? ` / ${criterion.maxLength}` : ''} characters
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Boolean criterion field component
|
||||
function BooleanCriterionField({
|
||||
criterion,
|
||||
control,
|
||||
disabled,
|
||||
onTouch,
|
||||
}: {
|
||||
criterion: Criterion
|
||||
control: any
|
||||
disabled: boolean
|
||||
onTouch: (criterionId: string) => void
|
||||
}) {
|
||||
const trueLabel = criterion.trueLabel || 'Yes'
|
||||
const falseLabel = criterion.falseLabel || 'No'
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`criterionScores.${criterion.id}`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base font-medium">{criterion.label}</Label>
|
||||
{criterion.required && (
|
||||
<Badge variant="destructive" className="text-xs">Required</Badge>
|
||||
)}
|
||||
</div>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{criterion.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.value === true ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'flex-1 h-12',
|
||||
field.value === true && 'bg-green-600 hover:bg-green-700'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
field.onChange(true)
|
||||
onTouch(criterion.id)
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-4 w-4" />
|
||||
{trueLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.value === false ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'flex-1 h-12',
|
||||
field.value === false && 'bg-red-600 hover:bg-red-700'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
field.onChange(false)
|
||||
onTouch(criterion.id)
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-4 w-4" />
|
||||
{falseLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Progress indicator component
|
||||
function ProgressIndicator({
|
||||
percentage,
|
||||
criteriaDone,
|
||||
criteriaTotal,
|
||||
}: {
|
||||
percentage: number
|
||||
criteriaDone: number
|
||||
criteriaTotal: number
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Progress value={percentage} className="w-16 sm:w-24 h-2" />
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
<span className="sm:hidden">{percentage}%</span>
|
||||
<span className="hidden sm:inline">
|
||||
{criteriaDone} of {criteriaTotal} criteria scored · {percentage}%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Autosave indicator component
|
||||
function AutosaveIndicator({
|
||||
status,
|
||||
|
||||
105
src/components/shared/countdown-timer.tsx
Normal file
105
src/components/shared/countdown-timer.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Clock, AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface CountdownTimerProps {
|
||||
deadline: Date
|
||||
label?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface TimeRemaining {
|
||||
days: number
|
||||
hours: number
|
||||
minutes: number
|
||||
seconds: number
|
||||
totalMs: number
|
||||
}
|
||||
|
||||
function getTimeRemaining(deadline: Date): TimeRemaining {
|
||||
const totalMs = deadline.getTime() - Date.now()
|
||||
if (totalMs <= 0) {
|
||||
return { days: 0, hours: 0, minutes: 0, seconds: 0, totalMs: 0 }
|
||||
}
|
||||
|
||||
const seconds = Math.floor((totalMs / 1000) % 60)
|
||||
const minutes = Math.floor((totalMs / 1000 / 60) % 60)
|
||||
const hours = Math.floor((totalMs / (1000 * 60 * 60)) % 24)
|
||||
const days = Math.floor(totalMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
return { days, hours, minutes, seconds, totalMs }
|
||||
}
|
||||
|
||||
function formatCountdown(time: TimeRemaining): string {
|
||||
if (time.totalMs <= 0) return 'Deadline passed'
|
||||
|
||||
const { days, hours, minutes, seconds } = time
|
||||
|
||||
// Less than 1 hour: show minutes and seconds
|
||||
if (days === 0 && hours === 0) {
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
|
||||
// Less than 24 hours: show hours and minutes
|
||||
if (days === 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`
|
||||
}
|
||||
|
||||
// More than 24 hours: show days, hours, minutes
|
||||
return `${days}d ${hours}h ${minutes}m`
|
||||
}
|
||||
|
||||
type Urgency = 'expired' | 'critical' | 'warning' | 'normal'
|
||||
|
||||
function getUrgency(totalMs: number): Urgency {
|
||||
if (totalMs <= 0) return 'expired'
|
||||
if (totalMs < 60 * 60 * 1000) return 'critical' // < 1 hour
|
||||
if (totalMs < 24 * 60 * 60 * 1000) return 'warning' // < 24 hours
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
const urgencyStyles: Record<Urgency, string> = {
|
||||
expired: 'text-muted-foreground bg-muted',
|
||||
critical: 'text-red-700 bg-red-50 border-red-200 dark:text-red-400 dark:bg-red-950/50 dark:border-red-900',
|
||||
warning: 'text-amber-700 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/50 dark:border-amber-900',
|
||||
normal: 'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
|
||||
}
|
||||
|
||||
export function CountdownTimer({ deadline, label, className }: CountdownTimerProps) {
|
||||
const [time, setTime] = useState<TimeRemaining>(() => getTimeRemaining(deadline))
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
const remaining = getTimeRemaining(deadline)
|
||||
setTime(remaining)
|
||||
if (remaining.totalMs <= 0) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [deadline])
|
||||
|
||||
const urgency = getUrgency(time.totalMs)
|
||||
const displayText = formatCountdown(time)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
urgencyStyles[urgency],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{urgency === 'critical' ? (
|
||||
<AlertTriangle className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<Clock className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
{label && <span className="hidden sm:inline">{label}</span>}
|
||||
<span className="tabular-nums">{displayText}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
177
src/components/shared/mentor-chat.tsx
Normal file
177
src/components/shared/mentor-chat.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Send, MessageSquare } from 'lucide-react'
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
message: string
|
||||
createdAt: Date | string
|
||||
isRead: boolean
|
||||
sender: {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string
|
||||
role?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MentorChatProps {
|
||||
messages: Message[]
|
||||
currentUserId: string
|
||||
onSendMessage: (message: string) => Promise<void>
|
||||
isLoading?: boolean
|
||||
isSending?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MentorChat({
|
||||
messages,
|
||||
currentUserId,
|
||||
onSendMessage,
|
||||
isLoading,
|
||||
isSending,
|
||||
className,
|
||||
}: MentorChatProps) {
|
||||
const [newMessage, setNewMessage] = useState('')
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = newMessage.trim()
|
||||
if (!text || isSending) return
|
||||
setNewMessage('')
|
||||
await onSendMessage(text)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-3', className)}>
|
||||
<Skeleton className="h-16 w-3/4" />
|
||||
<Skeleton className="h-16 w-3/4 ml-auto" />
|
||||
<Skeleton className="h-16 w-3/4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[400px] space-y-3 p-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<MessageSquare className="h-10 w-10 mb-3 opacity-50" />
|
||||
<p className="text-sm font-medium">No messages yet</p>
|
||||
<p className="text-xs mt-1">Send a message to start the conversation</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => {
|
||||
const isOwn = msg.sender.id === currentUserId
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
'flex',
|
||||
isOwn ? 'justify-end' : 'justify-start'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%] rounded-lg px-4 py-2.5',
|
||||
isOwn
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted'
|
||||
)}
|
||||
>
|
||||
{!isOwn && (
|
||||
<p className={cn(
|
||||
'text-xs font-medium mb-1',
|
||||
isOwn ? 'text-primary-foreground/70' : 'text-foreground/70'
|
||||
)}>
|
||||
{msg.sender.name || msg.sender.email}
|
||||
{msg.sender.role === 'MENTOR' && (
|
||||
<span className="ml-1.5 text-[10px] font-normal opacity-70">
|
||||
Mentor
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm whitespace-pre-wrap break-words">
|
||||
{msg.message}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'text-[10px] mt-1',
|
||||
isOwn
|
||||
? 'text-primary-foreground/60'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{formatTime(msg.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t p-3">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
className="min-h-[40px] max-h-[120px] resize-none"
|
||||
rows={1}
|
||||
disabled={isSending}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
disabled={!newMessage.trim() || isSending}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1.5">
|
||||
Press Enter to send, Shift+Enter for new line
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CheckCircle, Circle, Clock } from 'lucide-react'
|
||||
import { CheckCircle, Circle, Clock, XCircle, Trophy } from 'lucide-react'
|
||||
|
||||
interface TimelineItem {
|
||||
status: string
|
||||
label: string
|
||||
date: Date | string | null
|
||||
completed: boolean
|
||||
isTerminal?: boolean
|
||||
}
|
||||
|
||||
interface StatusTrackerProps {
|
||||
@@ -39,6 +40,8 @@ export function StatusTracker({
|
||||
const isCurrent =
|
||||
isCompleted && !timeline[index + 1]?.completed
|
||||
const isPending = !isCompleted
|
||||
const isRejected = item.status === 'REJECTED' && item.isTerminal
|
||||
const isWinner = item.status === 'WINNER' && isCompleted
|
||||
|
||||
return (
|
||||
<div key={item.status} className="relative flex gap-4">
|
||||
@@ -47,14 +50,26 @@ export function StatusTracker({
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-[15px] top-[32px] h-full w-0.5',
|
||||
isCompleted ? 'bg-primary' : 'bg-muted'
|
||||
isRejected
|
||||
? 'bg-destructive/30'
|
||||
: isCompleted
|
||||
? 'bg-primary'
|
||||
: 'bg-muted'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
{isCompleted ? (
|
||||
{isRejected ? (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground">
|
||||
<XCircle className="h-4 w-4" />
|
||||
</div>
|
||||
) : isWinner ? (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-yellow-500 text-white">
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
) : isCompleted ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-full',
|
||||
@@ -82,23 +97,35 @@ export function StatusTracker({
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium',
|
||||
isPending && 'text-muted-foreground'
|
||||
isRejected && 'text-destructive',
|
||||
isWinner && 'text-yellow-600',
|
||||
isPending && !isRejected && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</p>
|
||||
{isCurrent && (
|
||||
{isCurrent && !isRejected && !isWinner && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
{isRejected && (
|
||||
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded-full">
|
||||
Final
|
||||
</span>
|
||||
)}
|
||||
{isWinner && (
|
||||
<span className="text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full">
|
||||
Winner
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.date && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(item.date)}
|
||||
</p>
|
||||
)}
|
||||
{isPending && !isCurrent && (
|
||||
{isPending && !isCurrent && !isRejected && (
|
||||
<p className="text-sm text-muted-foreground">Pending</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user