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

@@ -67,6 +67,8 @@ import {
AlertCircle,
Layers,
FolderOpen,
X,
AlertTriangle,
} from 'lucide-react'
import {
Select,
@@ -75,6 +77,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo'
@@ -346,6 +349,77 @@ export default function ProjectsPage() {
? Math.round((jobStatus.processedCount / jobStatus.totalProjects) * 100)
: 0
// Bulk selection state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [bulkStatus, setBulkStatus] = useState<string>('')
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({
onSuccess: (result) => {
toast.success(`${result.updated} project${result.updated !== 1 ? 's' : ''} updated successfully`)
setSelectedIds(new Set())
setBulkStatus('')
setBulkConfirmOpen(false)
utils.project.list.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Failed to update projects')
},
})
const handleToggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const handleSelectAll = () => {
if (!data) return
const allVisible = data.projects.map((p) => p.id)
const allSelected = allVisible.every((id) => selectedIds.has(id))
if (allSelected) {
setSelectedIds((prev) => {
const next = new Set(prev)
allVisible.forEach((id) => next.delete(id))
return next
})
} else {
setSelectedIds((prev) => {
const next = new Set(prev)
allVisible.forEach((id) => next.add(id))
return next
})
}
}
const handleBulkApply = () => {
if (!bulkStatus || selectedIds.size === 0) return
setBulkConfirmOpen(true)
}
const handleBulkConfirm = () => {
if (!bulkStatus || selectedIds.size === 0 || !filters.roundId) return
bulkUpdateStatus.mutate({
ids: Array.from(selectedIds),
roundId: filters.roundId,
status: bulkStatus as 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED',
})
}
// Determine if all visible projects are selected
const allVisibleSelected = data
? data.projects.length > 0 && data.projects.every((p) => selectedIds.has(p.id))
: false
const someVisibleSelected = data
? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected
: false
const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => {
toast.success('Project deleted successfully')
@@ -468,6 +542,15 @@ export default function ProjectsPage() {
<Table>
<TableHeader>
<TableRow>
{filters.roundId && (
<TableHead className="w-10">
<Checkbox
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
onCheckedChange={handleSelectAll}
aria-label="Select all projects"
/>
</TableHead>
)}
<TableHead>Project</TableHead>
<TableHead>Round</TableHead>
<TableHead>Files</TableHead>
@@ -484,6 +567,16 @@ export default function ProjectsPage() {
key={project.id}
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
>
{filters.roundId && (
<TableCell className="relative z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
)}
<TableCell>
<Link
href={`/admin/projects/${project.id}`}
@@ -577,14 +670,23 @@ export default function ProjectsPage() {
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{data.projects.map((project) => (
<div key={project.id} className="relative">
{filters.roundId && (
<div className="absolute left-3 top-4 z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
/>
</div>
)}
<Link
key={project.id}
href={`/admin/projects/${project.id}`}
className="block"
>
<Card className="transition-colors hover:bg-muted/50">
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
<div className={`flex items-start gap-3 ${filters.roundId ? 'pl-8' : ''}`}>
<ProjectLogo
project={project}
size="md"
@@ -627,6 +729,7 @@ export default function ProjectsPage() {
</CardContent>
</Card>
</Link>
</div>
))}
</div>
@@ -641,6 +744,93 @@ export default function ProjectsPage() {
</>
) : null}
{/* Bulk Action Floating Toolbar */}
{selectedIds.size > 0 && filters.roundId && (
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2">
<Card className="border-2 shadow-lg">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<Badge variant="secondary" className="shrink-0 text-sm">
{selectedIds.size} selected
</Badge>
<Select value={bulkStatus} onValueChange={setBulkStatus}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Set status..." />
</SelectTrigger>
<SelectContent>
{['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].map((s) => (
<SelectItem key={s} value={s}>
{s.replace('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleBulkApply}
disabled={!bulkStatus || bulkUpdateStatus.isPending}
>
{bulkUpdateStatus.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Apply
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedIds(new Set())
setBulkStatus('')
}}
>
<X className="mr-1 h-4 w-4" />
Clear
</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* Bulk Status Update Confirmation Dialog */}
<AlertDialog open={bulkConfirmOpen} onOpenChange={setBulkConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Update Project Status</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
You are about to change the status of{' '}
<strong>{selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''}</strong>{' '}
to <Badge variant={statusColors[bulkStatus] || 'secondary'}>{bulkStatus.replace('_', ' ')}</Badge>.
</p>
{bulkStatus === 'REJECTED' && (
<div className="flex items-start gap-2 rounded-md bg-destructive/10 p-3 text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm">
Warning: Rejected projects will be marked as eliminated. This will send notifications to the project teams.
</p>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkUpdateStatus.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleBulkConfirm}
className={bulkStatus === 'REJECTED' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
disabled={bulkUpdateStatus.isPending}
>
{bulkUpdateStatus.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Update {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>