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:
2026-02-05 21:58:27 +01:00
parent 002a9dbfc3
commit 699248e40b
38 changed files with 5437 additions and 533 deletions

View 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 &ldquo;{projectTitle}&rdquo;, 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>
)
}

View File

@@ -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">

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

View File

@@ -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 &middot; {percentage}%
</span>
</span>
</div>
)
}
// Autosave indicator component
function AutosaveIndicator({
status,