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:
@@ -27,6 +27,7 @@ import { FileViewer } from '@/components/shared/file-viewer'
|
||||
import { FileUpload } from '@/components/shared/file-upload'
|
||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
@@ -635,6 +636,14 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* AI Evaluation Summary */}
|
||||
{project.roundId && stats && stats.totalEvaluations > 0 && (
|
||||
<EvaluationSummaryCard
|
||||
projectId={projectId}
|
||||
roundId={project.roundId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
404
src/app/(admin)/admin/rounds/[id]/coi/page.tsx
Normal file
404
src/app/(admin)/admin/rounds/[id]/coi/page.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
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 { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ShieldAlert,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
MoreHorizontal,
|
||||
ShieldCheck,
|
||||
UserX,
|
||||
StickyNote,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
function COIManagementContent({ roundId }: { roundId: string }) {
|
||||
const [conflictsOnly, setConflictsOnly] = useState(false)
|
||||
|
||||
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
|
||||
const { data: coiList, isLoading: loadingCOI } = trpc.evaluation.listCOIByRound.useQuery({
|
||||
roundId,
|
||||
hasConflictOnly: conflictsOnly || undefined,
|
||||
})
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const reviewCOI = trpc.evaluation.reviewCOI.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.evaluation.listCOIByRound.invalidate({ roundId })
|
||||
toast.success(`COI marked as "${data.reviewAction}"`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to review COI')
|
||||
},
|
||||
})
|
||||
|
||||
if (loadingRound || loadingCOI) {
|
||||
return <COISkeleton />
|
||||
}
|
||||
|
||||
if (!round) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Round Not Found</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/rounds">Back to Rounds</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const conflictCount = coiList?.filter((c) => c.hasConflict).length ?? 0
|
||||
const totalCount = coiList?.length ?? 0
|
||||
const reviewedCount = coiList?.filter((c) => c.reviewAction).length ?? 0
|
||||
|
||||
const getReviewBadge = (reviewAction: string | null) => {
|
||||
switch (reviewAction) {
|
||||
case 'cleared':
|
||||
return (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<ShieldCheck className="mr-1 h-3 w-3" />
|
||||
Cleared
|
||||
</Badge>
|
||||
)
|
||||
case 'reassigned':
|
||||
return (
|
||||
<Badge variant="default" className="bg-blue-600">
|
||||
<UserX className="mr-1 h-3 w-3" />
|
||||
Reassigned
|
||||
</Badge>
|
||||
)
|
||||
case 'noted':
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<StickyNote className="mr-1 h-3 w-3" />
|
||||
Noted
|
||||
</Badge>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Badge variant="outline" className="text-amber-600 border-amber-300">
|
||||
Pending Review
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getConflictTypeBadge = (type: string | null) => {
|
||||
switch (type) {
|
||||
case 'financial':
|
||||
return <Badge variant="destructive">Financial</Badge>
|
||||
case 'personal':
|
||||
return <Badge variant="secondary">Personal</Badge>
|
||||
case 'organizational':
|
||||
return <Badge variant="outline">Organizational</Badge>
|
||||
case 'other':
|
||||
return <Badge variant="outline">Other</Badge>
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/rounds/${roundId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Round
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
|
||||
{round.program.name}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/admin/rounds/${roundId}`} className="hover:underline">
|
||||
{round.name}
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<ShieldAlert className="h-6 w-6" />
|
||||
Conflict of Interest Declarations
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Declarations</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Conflicts Declared</CardTitle>
|
||||
<AlertCircle className="h-4 w-4 text-amber-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-amber-600">{conflictCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Reviewed</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{reviewedCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* COI Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">Declarations</CardTitle>
|
||||
<CardDescription>
|
||||
Review and manage conflict of interest declarations from jury members
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="conflicts-only"
|
||||
checked={conflictsOnly}
|
||||
onCheckedChange={setConflictsOnly}
|
||||
/>
|
||||
<Label htmlFor="conflicts-only" className="text-sm">
|
||||
Conflicts only
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{coiList && coiList.length > 0 ? (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Juror</TableHead>
|
||||
<TableHead>Conflict</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-12">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{coiList.map((coi) => (
|
||||
<TableRow key={coi.id}>
|
||||
<TableCell className="font-medium max-w-[200px] truncate">
|
||||
{coi.assignment.project.title}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{coi.user.name || coi.user.email}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{coi.hasConflict ? (
|
||||
<Badge variant="destructive">Yes</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-green-600 border-green-300">
|
||||
No
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{coi.hasConflict ? getConflictTypeBadge(coi.conflictType) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px]">
|
||||
{coi.description ? (
|
||||
<span className="text-sm text-muted-foreground truncate block">
|
||||
{coi.description}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{coi.hasConflict ? (
|
||||
<div className="space-y-1">
|
||||
{getReviewBadge(coi.reviewAction)}
|
||||
{coi.reviewedBy && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
by {coi.reviewedBy.name || coi.reviewedBy.email}
|
||||
{coi.reviewedAt && (
|
||||
<> {formatDistanceToNow(new Date(coi.reviewedAt), { addSuffix: true })}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{coi.hasConflict && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={reviewCOI.isPending}
|
||||
>
|
||||
{reviewCOI.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
reviewCOI.mutate({
|
||||
id: coi.id,
|
||||
reviewAction: 'cleared',
|
||||
})
|
||||
}
|
||||
>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
Clear
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
reviewCOI.mutate({
|
||||
id: coi.id,
|
||||
reviewAction: 'reassigned',
|
||||
})
|
||||
}
|
||||
>
|
||||
<UserX className="mr-2 h-4 w-4" />
|
||||
Reassign
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
reviewCOI.mutate({
|
||||
id: coi.id,
|
||||
reviewAction: 'noted',
|
||||
})
|
||||
}
|
||||
>
|
||||
<StickyNote className="mr-2 h-4 w-4" />
|
||||
Note
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ShieldAlert className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Declarations Yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{conflictsOnly
|
||||
? 'No conflicts of interest have been declared for this round'
|
||||
: 'No jury members have submitted COI declarations for this round yet'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function COISkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-8 w-80" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function COIManagementPage({ params }: PageProps) {
|
||||
const { id } = use(params)
|
||||
|
||||
return (
|
||||
<Suspense fallback={<COISkeleton />}>
|
||||
<COIManagementContent roundId={id} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -418,7 +418,45 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Notification - removed from schema, feature not implemented */}
|
||||
{/* Upload Deadline Policy */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Upload Deadline Policy</CardTitle>
|
||||
<CardDescription>
|
||||
Control how file uploads are handled after the round starts
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Select
|
||||
value={(roundSettings.uploadDeadlinePolicy as string) || ''}
|
||||
onValueChange={(value) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
uploadDeadlinePolicy: value || undefined,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Default (no restriction)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="NONE">
|
||||
Default - No restriction
|
||||
</SelectItem>
|
||||
<SelectItem value="BLOCK">
|
||||
Block uploads after round starts
|
||||
</SelectItem>
|
||||
<SelectItem value="ALLOW_LATE">
|
||||
Allow late uploads (marked as late)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When set to “Block”, applicants cannot upload files after the voting start date.
|
||||
When set to “Allow late”, uploads are accepted but flagged as late submissions.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Evaluation Criteria */}
|
||||
<Card>
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
Download,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -109,6 +110,41 @@ export default function FilteringResultsPage({
|
||||
const overrideResult = trpc.filtering.overrideResult.useMutation()
|
||||
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
|
||||
|
||||
const exportResults = trpc.export.filteringResults.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: false }
|
||||
)
|
||||
|
||||
const handleExport = async () => {
|
||||
const result = await exportResults.refetch()
|
||||
if (result.data) {
|
||||
const { data: rows, columns } = result.data
|
||||
|
||||
const csvContent = [
|
||||
columns.join(','),
|
||||
...rows.map((row) =>
|
||||
columns
|
||||
.map((col) => {
|
||||
const value = row[col as keyof typeof row]
|
||||
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
return value ?? ''
|
||||
})
|
||||
.join(',')
|
||||
),
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `filtering-results-${new Date().toISOString().split('T')[0]}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRow = (id: string) => {
|
||||
const next = new Set(expandedRows)
|
||||
if (next.has(id)) next.delete(id)
|
||||
@@ -166,13 +202,27 @@ export default function FilteringResultsPage({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Filtering Results
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Review and override filtering outcomes
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Filtering Results
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Review and override filtering outcomes
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
disabled={exportResults.isFetching}
|
||||
>
|
||||
{exportResults.isFetching ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Outcome Filter Tabs */}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
AlertTriangle,
|
||||
ListChecks,
|
||||
ClipboardCheck,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||
@@ -125,6 +126,22 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
const startJob = trpc.filtering.startJob.useMutation()
|
||||
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
|
||||
|
||||
// AI summary bulk generation
|
||||
const bulkSummaries = trpc.evaluation.generateBulkSummaries.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.errors.length > 0) {
|
||||
toast.warning(
|
||||
`Generated ${data.generated} of ${data.total} summaries. ${data.errors.length} failed.`
|
||||
)
|
||||
} else {
|
||||
toast.success(`Generated ${data.generated} AI summaries successfully`)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to generate AI summaries')
|
||||
},
|
||||
})
|
||||
|
||||
// Set active job from latest job on load
|
||||
useEffect(() => {
|
||||
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
|
||||
@@ -764,6 +781,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
Jury Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => bulkSummaries.mutate({ roundId: round.id })}
|
||||
disabled={bulkSummaries.isPending}
|
||||
>
|
||||
{bulkSummaries.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ArrowRight,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||
|
||||
async function JuryDashboardContent() {
|
||||
const session = await auth()
|
||||
@@ -105,6 +106,27 @@ async function JuryDashboardContent() {
|
||||
{} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }>
|
||||
)
|
||||
|
||||
// Get grace periods for this user
|
||||
const gracePeriods = await prisma.gracePeriod.findMany({
|
||||
where: {
|
||||
userId,
|
||||
extendedUntil: { gte: new Date() },
|
||||
},
|
||||
select: {
|
||||
roundId: true,
|
||||
extendedUntil: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Build a map of roundId -> latest extendedUntil
|
||||
const graceByRound = new Map<string, Date>()
|
||||
for (const gp of gracePeriods) {
|
||||
const existing = graceByRound.get(gp.roundId)
|
||||
if (!existing || gp.extendedUntil > existing) {
|
||||
graceByRound.set(gp.roundId, gp.extendedUntil)
|
||||
}
|
||||
}
|
||||
|
||||
// Get active rounds (voting window is open)
|
||||
const now = new Date()
|
||||
const activeRounds = Object.values(assignmentsByRound).filter(
|
||||
@@ -221,9 +243,15 @@ async function JuryDashboardContent() {
|
||||
</div>
|
||||
|
||||
{round.votingEndAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deadline: {formatDateOnly(round.votingEndAt)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<CountdownTimer
|
||||
deadline={graceByRound.get(round.id) ?? new Date(round.votingEndAt)}
|
||||
label="Deadline:"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({formatDateOnly(round.votingEndAt)})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button asChild size="sm" className="w-full sm:w-auto">
|
||||
|
||||
@@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { EvaluationForm } from '@/components/forms/evaluation-form'
|
||||
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
|
||||
import { ArrowLeft, AlertCircle, Clock, FileText, Users } from 'lucide-react'
|
||||
import { isFuture, isPast } from 'date-fns'
|
||||
|
||||
@@ -21,9 +21,20 @@ interface Criterion {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
scale: number
|
||||
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
|
||||
}
|
||||
|
||||
async function EvaluateContent({ projectId }: { projectId: string }) {
|
||||
@@ -133,6 +144,14 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
|
||||
redirect(`/jury/projects/${projectId}/evaluation`)
|
||||
}
|
||||
|
||||
// Check COI status
|
||||
const coiRecord = await prisma.conflictOfInterest.findUnique({
|
||||
where: { assignmentId: assignment.id },
|
||||
})
|
||||
const coiStatus = coiRecord
|
||||
? { hasConflict: coiRecord.hasConflict, declared: true }
|
||||
: { hasConflict: false, declared: false }
|
||||
|
||||
// Get evaluation form criteria
|
||||
const evaluationForm = round.evaluationForms[0]
|
||||
if (!evaluationForm) {
|
||||
@@ -247,8 +266,8 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Evaluation Form */}
|
||||
<EvaluationForm
|
||||
{/* Evaluation Form with COI Gate */}
|
||||
<EvaluationFormWithCOI
|
||||
assignmentId={assignment.id}
|
||||
evaluationId={evaluation?.id || null}
|
||||
projectTitle={project.title}
|
||||
@@ -258,7 +277,7 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
|
||||
? {
|
||||
criterionScoresJson: evaluation.criterionScoresJson as Record<
|
||||
string,
|
||||
number
|
||||
number | string | boolean
|
||||
> | null,
|
||||
globalScore: evaluation.globalScore,
|
||||
binaryDecision: evaluation.binaryDecision,
|
||||
@@ -269,6 +288,7 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
|
||||
}
|
||||
isVotingOpen={effectiveVotingOpen}
|
||||
deadline={round.votingEndAt}
|
||||
coiStatus={coiStatus}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { FileViewer } from '@/components/shared/file-viewer'
|
||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
Calendar,
|
||||
FileText,
|
||||
ExternalLink,
|
||||
MessageSquare,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly, getInitials } from '@/lib/utils'
|
||||
|
||||
@@ -52,6 +54,17 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
projectId,
|
||||
})
|
||||
|
||||
const { data: mentorMessages, isLoading: messagesLoading } = trpc.mentor.getMessages.useQuery({
|
||||
projectId,
|
||||
})
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const sendMessage = trpc.mentor.sendMessage.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.getMessages.invalidate({ projectId })
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <ProjectDetailSkeleton />
|
||||
}
|
||||
@@ -363,6 +376,30 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Messaging Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Messages
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Communicate with the project team
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MentorChat
|
||||
messages={mentorMessages || []}
|
||||
currentUserId={project.mentorAssignment?.mentor?.id || ''}
|
||||
onSendMessage={async (message) => {
|
||||
await sendMessage.mutateAsync({ projectId, message })
|
||||
}}
|
||||
isLoading={messagesLoading}
|
||||
isSending={sendMessage.isPending}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Suspense } from 'react'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
'use client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -20,80 +20,72 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { FileSpreadsheet, BarChart3, Users, ClipboardList } from 'lucide-react'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
FileSpreadsheet,
|
||||
BarChart3,
|
||||
Users,
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import {
|
||||
ScoreDistributionChart,
|
||||
EvaluationTimelineChart,
|
||||
StatusBreakdownChart,
|
||||
JurorWorkloadChart,
|
||||
ProjectRankingsChart,
|
||||
CriteriaScoresChart,
|
||||
} from '@/components/charts'
|
||||
|
||||
async function ReportsContent() {
|
||||
// Get rounds with evaluation stats
|
||||
const rounds = await prisma.round.findMany({
|
||||
include: {
|
||||
program: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
projects: true,
|
||||
assignments: true,
|
||||
},
|
||||
},
|
||||
assignments: {
|
||||
select: {
|
||||
id: true,
|
||||
evaluation: {
|
||||
select: { id: true, status: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
|
||||
// Calculate completion stats for each round
|
||||
const roundStats = rounds.map((round) => {
|
||||
const totalAssignments = round._count.assignments
|
||||
const completedEvaluations = round.assignments.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
).length
|
||||
const completionRate =
|
||||
totalAssignments > 0
|
||||
? Math.round((completedEvaluations / totalAssignments) * 100)
|
||||
: 0
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({
|
||||
...r,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
|
||||
return {
|
||||
...round,
|
||||
totalAssignments,
|
||||
completedEvaluations,
|
||||
completionRate,
|
||||
}
|
||||
})
|
||||
const { data: overviewStats, isLoading: statsLoading } =
|
||||
trpc.analytics.getOverviewStats.useQuery(
|
||||
{ roundId: selectedRoundId! },
|
||||
{ enabled: !!selectedRoundId }
|
||||
)
|
||||
|
||||
// Calculate totals
|
||||
const totalProjects = roundStats.reduce((acc, r) => acc + r._count.projects, 0)
|
||||
const totalAssignments = roundStats.reduce(
|
||||
(acc, r) => acc + r.totalAssignments,
|
||||
0
|
||||
)
|
||||
const totalEvaluations = roundStats.reduce(
|
||||
(acc, r) => acc + r.completedEvaluations,
|
||||
0
|
||||
)
|
||||
|
||||
if (rounds.length === 0) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileSpreadsheet className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No data to report</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reports will appear here once rounds are created
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-3 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalProjects = rounds.reduce((acc, r) => acc + (r._count?.projects || 0), 0)
|
||||
const activeRounds = rounds.filter((r) => r.status === 'ACTIVE').length
|
||||
const totalPrograms = programs?.length || 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Quick Stats */}
|
||||
@@ -106,7 +98,7 @@ async function ReportsContent() {
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{rounds.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{rounds.filter((r) => r.status === 'ACTIVE').length} active
|
||||
{activeRounds} active
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -124,27 +116,99 @@ async function ReportsContent() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">Active Rounds</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalAssignments}</div>
|
||||
<p className="text-xs text-muted-foreground">Total assignments</p>
|
||||
<div className="text-2xl font-bold">{activeRounds}</div>
|
||||
<p className="text-xs text-muted-foreground">Currently active</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
|
||||
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium">Programs</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalEvaluations}</div>
|
||||
<p className="text-xs text-muted-foreground">Completed</p>
|
||||
<div className="text-2xl font-bold">{totalPrograms}</div>
|
||||
<p className="text-xs text-muted-foreground">Total programs</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Round-specific overview stats */}
|
||||
{selectedRoundId && (
|
||||
<>
|
||||
{statsLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-3 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : overviewStats ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Selected Round Details</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{overviewStats.projectCount}</div>
|
||||
<p className="text-xs text-muted-foreground">In this round</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{overviewStats.assignmentCount}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{overviewStats.jurorCount} jurors
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
|
||||
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{overviewStats.evaluationCount}</div>
|
||||
<p className="text-xs text-muted-foreground">Submitted</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completion</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{overviewStats.completionRate}%</div>
|
||||
<Progress value={overviewStats.completionRate} className="mt-2 h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Rounds Table - Desktop */}
|
||||
<Card className="hidden md:block">
|
||||
<CardHeader>
|
||||
@@ -158,12 +222,11 @@ async function ReportsContent() {
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead>Projects</TableHead>
|
||||
<TableHead>Progress</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roundStats.map((round) => (
|
||||
{rounds.map((round) => (
|
||||
<TableRow key={round.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
@@ -175,21 +238,8 @@ async function ReportsContent() {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{round.program.name}</TableCell>
|
||||
<TableCell>{round._count.projects}</TableCell>
|
||||
<TableCell>
|
||||
<div className="min-w-[120px] space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>
|
||||
{round.completedEvaluations}/{round.totalAssignments}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{round.completionRate}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={round.completionRate} className="h-2" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{round.programName}</TableCell>
|
||||
<TableCell>{round._count?.projects || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
@@ -213,7 +263,7 @@ async function ReportsContent() {
|
||||
{/* Rounds Cards - Mobile */}
|
||||
<div className="space-y-4 md:hidden">
|
||||
<h2 className="text-lg font-semibold">Round Reports</h2>
|
||||
{roundStats.map((round) => (
|
||||
{rounds.map((round) => (
|
||||
<Card key={round.id}>
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -230,24 +280,14 @@ async function ReportsContent() {
|
||||
{round.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{round.program.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{round.programName}</p>
|
||||
{round.votingEndAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ends: {formatDateOnly(round.votingEndAt)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{round._count.projects} projects</span>
|
||||
<span className="text-muted-foreground">
|
||||
{round.completedEvaluations}/{round.totalAssignments} evaluations
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Progress</span>
|
||||
<span className="text-muted-foreground">{round.completionRate}%</span>
|
||||
</div>
|
||||
<Progress value={round.completionRate} className="h-2" />
|
||||
<div className="text-sm">
|
||||
<span>{round._count?.projects || 0} projects</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -257,40 +297,136 @@ async function ReportsContent() {
|
||||
)
|
||||
}
|
||||
|
||||
function ReportsSkeleton() {
|
||||
function AnalyticsTab({ selectedRoundId }: { selectedRoundId: string }) {
|
||||
const { data: scoreDistribution, isLoading: scoreLoading } =
|
||||
trpc.analytics.getScoreDistribution.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
)
|
||||
|
||||
const { data: timeline, isLoading: timelineLoading } =
|
||||
trpc.analytics.getEvaluationTimeline.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
)
|
||||
|
||||
const { data: statusBreakdown, isLoading: statusLoading } =
|
||||
trpc.analytics.getStatusBreakdown.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
)
|
||||
|
||||
const { data: jurorWorkload, isLoading: workloadLoading } =
|
||||
trpc.analytics.getJurorWorkload.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
)
|
||||
|
||||
const { data: projectRankings, isLoading: rankingsLoading } =
|
||||
trpc.analytics.getProjectRankings.useQuery(
|
||||
{ roundId: selectedRoundId, limit: 15 },
|
||||
{ enabled: !!selectedRoundId }
|
||||
)
|
||||
|
||||
const { data: criteriaScores, isLoading: criteriaLoading } =
|
||||
trpc.analytics.getCriteriaScores.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-3 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{/* Row 1: Score Distribution & Status Breakdown */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{scoreLoading ? (
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : scoreDistribution ? (
|
||||
<ScoreDistributionChart
|
||||
data={scoreDistribution.distribution}
|
||||
averageScore={scoreDistribution.averageScore}
|
||||
totalScores={scoreDistribution.totalScores}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{statusLoading ? (
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : statusBreakdown ? (
|
||||
<StatusBreakdownChart data={statusBreakdown} />
|
||||
) : null}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Row 2: Evaluation Timeline */}
|
||||
{timelineLoading ? (
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : timeline?.length ? (
|
||||
<EvaluationTimelineChart data={timeline} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
No evaluation data available yet
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Row 3: Criteria Scores */}
|
||||
{criteriaLoading ? (
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : criteriaScores?.length ? (
|
||||
<CriteriaScoresChart data={criteriaScores} />
|
||||
) : null}
|
||||
|
||||
{/* Row 4: Juror Workload */}
|
||||
{workloadLoading ? (
|
||||
<Skeleton className="h-[450px]" />
|
||||
) : jurorWorkload?.length ? (
|
||||
<JurorWorkloadChart data={jurorWorkload} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
No juror assignments yet
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Row 5: Project Rankings */}
|
||||
{rankingsLoading ? (
|
||||
<Skeleton className="h-[550px]" />
|
||||
) : projectRankings?.length ? (
|
||||
<ProjectRankingsChart data={projectRankings} limit={15} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
No project scores available yet
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ObserverReportsPage() {
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({
|
||||
...r,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
|
||||
// Set default selected round
|
||||
if (rounds.length && !selectedRoundId) {
|
||||
setSelectedRoundId(rounds[0].id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -301,10 +437,62 @@ export default function ObserverReportsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Suspense fallback={<ReportsSkeleton />}>
|
||||
<ReportsContent />
|
||||
</Suspense>
|
||||
{/* Round Selector */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||
<label className="text-sm font-medium">Select Round:</label>
|
||||
{roundsLoading ? (
|
||||
<Skeleton className="h-10 w-full sm:w-[300px]" />
|
||||
) : rounds.length > 0 ? (
|
||||
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
|
||||
<SelectTrigger className="w-full sm:w-[300px]">
|
||||
<SelectValue placeholder="Select a round" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No rounds available</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview" className="gap-2">
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics" className="gap-2" disabled={!selectedRoundId}>
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab selectedRoundId={selectedRoundId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics">
|
||||
{selectedRoundId ? (
|
||||
<AnalyticsTab selectedRoundId={selectedRoundId} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">Select a round</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a round from the dropdown above to view analytics
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
@@ -16,17 +17,21 @@ import {
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Download,
|
||||
Video,
|
||||
File,
|
||||
Users,
|
||||
Crown,
|
||||
MessageSquare,
|
||||
} from 'lucide-react'
|
||||
|
||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||
@@ -64,12 +69,25 @@ export function SubmissionDetailClient() {
|
||||
const params = useParams()
|
||||
const { data: session } = useSession()
|
||||
const projectId = params.id as string
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
const { data: statusData, isLoading, error } = trpc.applicant.getSubmissionStatus.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: !!session?.user }
|
||||
)
|
||||
|
||||
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: !!session?.user && activeTab === 'mentor' }
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.applicant.getMentorMessages.invalidate({ projectId })
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
@@ -148,43 +166,161 @@ export function SubmissionDetailClient() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Project details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{project.teamName && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Team/Organization</p>
|
||||
<p>{project.teamName}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.description && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Description</p>
|
||||
<p className="whitespace-pre-wrap">{project.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
<TabsTrigger value="documents">Documents</TabsTrigger>
|
||||
<TabsTrigger value="mentor" className="gap-1.5">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Mentor
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Files */}
|
||||
{/* Details Tab */}
|
||||
<TabsContent value="details">
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Project details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{project.teamName && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Team/Organization</p>
|
||||
<p>{project.teamName}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.description && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Description</p>
|
||||
<p className="whitespace-pre-wrap">{project.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Metadata */}
|
||||
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Additional Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="space-y-3">
|
||||
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<dt className="text-sm font-medium text-muted-foreground capitalize">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</dt>
|
||||
<dd className="text-sm">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Status timeline */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusTracker
|
||||
timeline={timeline}
|
||||
currentStatus={currentStatus}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dates */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key Dates</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{project.submittedAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Submitted</span>
|
||||
<span>{new Date(project.submittedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Last Updated</span>
|
||||
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Members */}
|
||||
{'teamMembers' in project && project.teamMembers && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Team
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/my-submission/${projectId}/team` as Route}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(project.teamMembers as Array<{ id: string; role: string; user: { name: string | null; email: string } }>).map((member) => (
|
||||
<div key={member.id} className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
|
||||
{member.role === 'LEAD' ? (
|
||||
<Crown className="h-4 w-4 text-yellow-500" />
|
||||
) : (
|
||||
<span className="text-xs font-medium">
|
||||
{member.user.name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{member.user.name || member.user.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Documents Tab */}
|
||||
<TabsContent value="documents">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Uploaded Documents</CardTitle>
|
||||
@@ -201,6 +337,7 @@ export function SubmissionDetailClient() {
|
||||
<div className="space-y-2">
|
||||
{project.files.map((file) => {
|
||||
const Icon = fileTypeIcons[file.fileType] || File
|
||||
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -210,7 +347,15 @@ export function SubmissionDetailClient() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">{file.fileName}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{file.fileName}</p>
|
||||
{fileRecord.isLate && (
|
||||
<Badge variant="warning" className="text-xs gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Submitted late
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{fileTypeLabels[file.fileType] || file.fileType}
|
||||
</p>
|
||||
@@ -226,110 +371,34 @@ export function SubmissionDetailClient() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Metadata */}
|
||||
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Additional Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="space-y-3">
|
||||
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<dt className="text-sm font-medium text-muted-foreground capitalize">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</dt>
|
||||
<dd className="text-sm">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Status timeline */}
|
||||
{/* Mentor Tab */}
|
||||
<TabsContent value="mentor">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status Timeline</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Mentor Communication
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Chat with your assigned mentor
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusTracker
|
||||
timeline={timeline}
|
||||
currentStatus={currentStatus}
|
||||
<MentorChat
|
||||
messages={mentorMessages || []}
|
||||
currentUserId={session?.user?.id || ''}
|
||||
onSendMessage={async (message) => {
|
||||
await sendMessage.mutateAsync({ projectId, message })
|
||||
}}
|
||||
isLoading={messagesLoading}
|
||||
isSending={sendMessage.isPending}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dates */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key Dates</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{project.submittedAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Submitted</span>
|
||||
<span>{new Date(project.submittedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Last Updated</span>
|
||||
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Members */}
|
||||
{'teamMembers' in project && project.teamMembers && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Team
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/my-submission/${projectId}/team` as Route}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(project.teamMembers as Array<{ id: string; role: string; user: { name: string | null; email: string } }>).map((member) => (
|
||||
<div key={member.id} className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
|
||||
{member.role === 'LEAD' ? (
|
||||
<Crown className="h-4 w-4 text-yellow-500" />
|
||||
) : (
|
||||
<span className="text-xs font-medium">
|
||||
{member.user.name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{member.user.name || member.user.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
28
src/app/api/cron/reminders/route.ts
Normal file
28
src/app/api/cron/reminders/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { processEvaluationReminders } from '@/server/services/evaluation-reminders'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await processEvaluationReminders()
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
sent: result.sent,
|
||||
errors: result.errors,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Cron reminder processing failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user