Comprehensive platform audit: security, UX, performance, and visual polish
Phase 1: Security - status transition validation, Zod tightening, DB indexes, transactions Phase 2: Admin UX - search/filter for awards, learning, partners pages Phase 3: Dashboard - Recent Activity feed, Pending Actions card, quick actions Phase 4: Jury - assignments progress/urgency, autosave indicator, divergence highlighting Phase 5: Portals - observer charts, mentor search, login/onboarding polish Phase 6: Messages preview dialog, CsvExportDialog with column selection Phase 7: Performance - query optimizations, loading skeletons, useDebounce hook Phase 8: Visual - AnimatedCard, hover effects, StatusBadge, empty state CTAs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import { use, useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
@@ -53,9 +53,21 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { Pagination } from '@/components/shared/pagination'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Trophy,
|
||||
@@ -73,6 +85,9 @@ import {
|
||||
Trash2,
|
||||
Plus,
|
||||
Search,
|
||||
Vote,
|
||||
ChevronDown,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
@@ -83,6 +98,41 @@ const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'o
|
||||
ARCHIVED: 'secondary',
|
||||
}
|
||||
|
||||
// Status workflow steps for the step indicator
|
||||
const WORKFLOW_STEPS = [
|
||||
{ key: 'DRAFT', label: 'Draft' },
|
||||
{ key: 'NOMINATIONS_OPEN', label: 'Nominations' },
|
||||
{ key: 'VOTING_OPEN', label: 'Voting' },
|
||||
{ key: 'CLOSED', label: 'Closed' },
|
||||
] as const
|
||||
|
||||
function getStepIndex(status: string): number {
|
||||
const idx = WORKFLOW_STEPS.findIndex((s) => s.key === status)
|
||||
return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0)
|
||||
}
|
||||
|
||||
function ConfidenceBadge({ confidence }: { confidence: number }) {
|
||||
if (confidence > 0.8) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs tabular-nums">
|
||||
{Math.round(confidence * 100)}%
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (confidence >= 0.5) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-400 text-xs tabular-nums">
|
||||
{Math.round(confidence * 100)}%
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950/30 dark:text-red-400 text-xs tabular-nums">
|
||||
{Math.round(confidence * 100)}%
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AwardDetailPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -111,6 +161,53 @@ export default function AwardDetailPage({
|
||||
{ enabled: !!award?.programId }
|
||||
)
|
||||
|
||||
const [isPollingJob, setIsPollingJob] = useState(false)
|
||||
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Eligibility job polling
|
||||
const { data: jobStatus, refetch: refetchJobStatus } =
|
||||
trpc.specialAward.getEligibilityJobStatus.useQuery(
|
||||
{ awardId },
|
||||
{ enabled: isPollingJob }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPollingJob) return
|
||||
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
refetchJobStatus()
|
||||
}, 2000)
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current)
|
||||
pollingIntervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isPollingJob, refetchJobStatus])
|
||||
|
||||
// React to job status changes
|
||||
useEffect(() => {
|
||||
if (!jobStatus || !isPollingJob) return
|
||||
|
||||
if (jobStatus.eligibilityJobStatus === 'COMPLETED') {
|
||||
setIsPollingJob(false)
|
||||
toast.success('Eligibility processing completed')
|
||||
refetchEligibility()
|
||||
refetch()
|
||||
} else if (jobStatus.eligibilityJobStatus === 'FAILED') {
|
||||
setIsPollingJob(false)
|
||||
toast.error(jobStatus.eligibilityJobError || 'Eligibility processing failed')
|
||||
}
|
||||
}, [jobStatus, isPollingJob, refetchEligibility, refetch])
|
||||
|
||||
// Check on mount if there's an ongoing job
|
||||
useEffect(() => {
|
||||
if (award?.eligibilityJobStatus === 'PROCESSING' || award?.eligibilityJobStatus === 'PENDING') {
|
||||
setIsPollingJob(true)
|
||||
}
|
||||
}, [award?.eligibilityJobStatus])
|
||||
|
||||
const updateStatus = trpc.specialAward.updateStatus.useMutation()
|
||||
const runEligibility = trpc.specialAward.runEligibility.useMutation()
|
||||
const setEligibility = trpc.specialAward.setEligibility.useMutation()
|
||||
@@ -123,6 +220,7 @@ export default function AwardDetailPage({
|
||||
const [includeSubmitted, setIncludeSubmitted] = useState(true)
|
||||
const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false)
|
||||
const [projectSearchQuery, setProjectSearchQuery] = useState('')
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleStatusChange = async (
|
||||
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
|
||||
@@ -140,15 +238,12 @@ export default function AwardDetailPage({
|
||||
|
||||
const handleRunEligibility = async () => {
|
||||
try {
|
||||
const result = await runEligibility.mutateAsync({ awardId, includeSubmitted })
|
||||
toast.success(
|
||||
`Eligibility run: ${result.eligible} eligible, ${result.ineligible} ineligible`
|
||||
)
|
||||
refetchEligibility()
|
||||
refetch()
|
||||
await runEligibility.mutateAsync({ awardId, includeSubmitted })
|
||||
toast.success('Eligibility processing started')
|
||||
setIsPollingJob(true)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to run eligibility'
|
||||
error instanceof Error ? error.message : 'Failed to start eligibility'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -369,6 +464,110 @@ export default function AwardDetailPage({
|
||||
<p className="text-muted-foreground">{award.description}</p>
|
||||
)}
|
||||
|
||||
{/* Status Workflow Step Indicator */}
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between">
|
||||
{WORKFLOW_STEPS.map((step, i) => {
|
||||
const currentIdx = getStepIndex(award.status)
|
||||
const isComplete = i < currentIdx
|
||||
const isCurrent = i === currentIdx
|
||||
return (
|
||||
<div key={step.key} className="flex flex-1 items-center">
|
||||
<div className="flex flex-col items-center gap-1.5 relative z-10">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold transition-colors ${
|
||||
isCurrent
|
||||
? 'bg-brand-blue text-white ring-2 ring-brand-blue/20 ring-offset-2 ring-offset-background'
|
||||
: isComplete
|
||||
? 'bg-brand-blue/90 text-white'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isComplete ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
i + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap ${
|
||||
isCurrent ? 'text-foreground' : isComplete ? 'text-muted-foreground' : 'text-muted-foreground/60'
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < WORKFLOW_STEPS.length - 1 && (
|
||||
<div className="flex-1 mx-2 mt-[-18px]">
|
||||
<div
|
||||
className={`h-0.5 w-full rounded-full transition-colors ${
|
||||
i < currentIdx ? 'bg-brand-blue/70' : 'bg-muted'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<Card className="border-l-4 border-l-emerald-500">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Eligible</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{award.eligibleCount}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/40">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{award._count.eligibilities}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
|
||||
<Brain className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-violet-500">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Jurors</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{award._count.jurors}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-950/40">
|
||||
<Users className="h-5 w-5 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Votes</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{award._count.votes}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-950/40">
|
||||
<Vote className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="eligibility">
|
||||
<TabsList>
|
||||
@@ -407,27 +606,27 @@ export default function AwardDetailPage({
|
||||
{award.useAiEligibility ? (
|
||||
<Button
|
||||
onClick={handleRunEligibility}
|
||||
disabled={runEligibility.isPending}
|
||||
disabled={runEligibility.isPending || isPollingJob}
|
||||
>
|
||||
{runEligibility.isPending ? (
|
||||
{runEligibility.isPending || isPollingJob ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Brain className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Run AI Eligibility
|
||||
{isPollingJob ? 'Processing...' : 'Run AI Eligibility'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleRunEligibility}
|
||||
disabled={runEligibility.isPending}
|
||||
disabled={runEligibility.isPending || isPollingJob}
|
||||
variant="outline"
|
||||
>
|
||||
{runEligibility.isPending ? (
|
||||
{runEligibility.isPending || isPollingJob ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Load All Projects
|
||||
{isPollingJob ? 'Processing...' : 'Load All Projects'}
|
||||
</Button>
|
||||
)}
|
||||
<Dialog open={addProjectDialogOpen} onOpenChange={setAddProjectDialogOpen}>
|
||||
@@ -527,6 +726,59 @@ export default function AwardDetailPage({
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
{/* Eligibility job progress */}
|
||||
{isPollingJob && jobStatus && (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">
|
||||
{jobStatus.eligibilityJobStatus === 'PENDING'
|
||||
? 'Preparing...'
|
||||
: `Processing... ${jobStatus.eligibilityJobDone ?? 0} of ${jobStatus.eligibilityJobTotal ?? '?'} projects`}
|
||||
</span>
|
||||
{jobStatus.eligibilityJobTotal && jobStatus.eligibilityJobTotal > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{Math.round(
|
||||
((jobStatus.eligibilityJobDone ?? 0) / jobStatus.eligibilityJobTotal) * 100
|
||||
)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
jobStatus.eligibilityJobTotal && jobStatus.eligibilityJobTotal > 0
|
||||
? ((jobStatus.eligibilityJobDone ?? 0) / jobStatus.eligibilityJobTotal) * 100
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Failed job notice */}
|
||||
{!isPollingJob && award.eligibilityJobStatus === 'FAILED' && (
|
||||
<Card className="border-destructive/50">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
Last eligibility run failed: {award.eligibilityJobError || 'Unknown error'}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleRunEligibility} disabled={runEligibility.isPending}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!award.useAiEligibility && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
AI eligibility is off for this award. Projects are loaded for manual selection.
|
||||
@@ -542,67 +794,146 @@ export default function AwardDetailPage({
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
{award.useAiEligibility && <TableHead>AI Confidence</TableHead>}
|
||||
<TableHead>Eligible</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{eligibilityData.eligibilities.map((e) => (
|
||||
<TableRow key={e.id} className={!e.eligible ? 'opacity-50' : ''}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{e.project.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{e.project.teamName}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{e.project.competitionCategory ? (
|
||||
<Badge variant="outline">
|
||||
{e.project.competitionCategory.replace('_', ' ')}
|
||||
</Badge>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{e.project.country || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs">
|
||||
{e.method === 'MANUAL' ? 'Manual' : 'Auto'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={e.eligible}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggleEligibility(e.projectId, checked)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveFromEligibility(e.projectId)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{eligibilityData.eligibilities.map((e) => {
|
||||
const aiReasoning = e.aiReasoningJson as { confidence?: number; reasoning?: string } | null
|
||||
const hasReasoning = !!aiReasoning?.reasoning
|
||||
const isExpanded = expandedRows.has(e.id)
|
||||
|
||||
return (
|
||||
<Collapsible key={e.id} open={isExpanded} onOpenChange={(open) => {
|
||||
setExpandedRows((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (open) next.add(e.id)
|
||||
else next.delete(e.id)
|
||||
return next
|
||||
})
|
||||
}} asChild>
|
||||
<>
|
||||
<TableRow className={`${!e.eligible ? 'opacity-50' : ''} ${hasReasoning ? 'cursor-pointer' : ''}`}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasReasoning && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="flex-shrink-0 p-0.5 rounded hover:bg-muted transition-colors">
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{e.project.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{e.project.teamName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{e.project.competitionCategory ? (
|
||||
<Badge variant="outline">
|
||||
{e.project.competitionCategory.replace('_', ' ')}
|
||||
</Badge>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{e.project.country || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs">
|
||||
{e.method === 'MANUAL' ? 'Manual' : 'Auto'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
{award.useAiEligibility && (
|
||||
<TableCell>
|
||||
{aiReasoning?.confidence != null ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ConfidenceBadge confidence={aiReasoning.confidence} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
AI confidence: {Math.round(aiReasoning.confidence * 100)}%
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={e.eligible}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggleEligibility(e.projectId, checked)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveFromEligibility(e.projectId)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{hasReasoning && (
|
||||
<CollapsibleContent asChild>
|
||||
<tr>
|
||||
<td colSpan={award.useAiEligibility ? 7 : 6} className="p-0">
|
||||
<div className="border-t bg-muted/30 px-6 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Brain className="h-4 w-4 text-brand-teal mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">AI Reasoning</p>
|
||||
<p className="text-sm leading-relaxed">{aiReasoning?.reasoning}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</>
|
||||
</Collapsible>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Brain className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No eligibility data</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run AI eligibility to evaluate projects or manually add projects
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
|
||||
<Brain className="h-8 w-8 text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="text-lg font-medium">No eligibility data yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
|
||||
{award.useAiEligibility
|
||||
? 'Run AI eligibility to automatically evaluate projects against this award\'s criteria, or manually add projects.'
|
||||
: 'Load all eligible projects into the evaluation list, or manually add specific projects.'}
|
||||
</p>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button onClick={handleRunEligibility} disabled={runEligibility.isPending || isPollingJob} size="sm">
|
||||
{award.useAiEligibility ? (
|
||||
<><Brain className="mr-2 h-4 w-4" />Run AI Eligibility</>
|
||||
) : (
|
||||
<><CheckCircle2 className="mr-2 h-4 w-4" />Load Projects</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setAddProjectDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Manually
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -680,11 +1011,13 @@ export default function AwardDetailPage({
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Users className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No jurors assigned</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add members as jurors for this award
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
|
||||
<Users className="h-8 w-8 text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="text-lg font-medium">No jurors assigned</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
|
||||
Add jury members who will vote on eligible projects for this award. Select from existing jury members above.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -693,84 +1026,134 @@ export default function AwardDetailPage({
|
||||
|
||||
{/* Results Tab */}
|
||||
<TabsContent value="results" className="space-y-4">
|
||||
{voteResults && voteResults.results.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{voteResults.votedJurorCount} of {voteResults.jurorCount}{' '}
|
||||
jurors voted
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{voteResults.scoringMode.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{voteResults && voteResults.results.length > 0 ? (() => {
|
||||
const maxPoints = Math.max(...voteResults.results.map((r) => r.points), 1)
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{voteResults.votedJurorCount} of {voteResults.jurorCount}{' '}
|
||||
jurors voted
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{voteResults.scoringMode.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Votes</TableHead>
|
||||
<TableHead>Points</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{voteResults.results.map((r, i) => (
|
||||
<TableRow
|
||||
key={r.project.id}
|
||||
className={
|
||||
r.project.id === voteResults.winnerId
|
||||
? 'bg-amber-50 dark:bg-amber-950/20'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<TableCell className="font-bold">{i + 1}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{r.project.id === voteResults.winnerId && (
|
||||
<Crown className="h-4 w-4 text-amber-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{r.project.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{r.project.teamName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{r.votes}</TableCell>
|
||||
<TableCell className="font-semibold">
|
||||
{r.points}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{r.project.id !== voteResults.winnerId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSetWinner(r.project.id)}
|
||||
disabled={setWinner.isPending}
|
||||
>
|
||||
<Crown className="mr-1 h-3 w-3" />
|
||||
Set Winner
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Votes</TableHead>
|
||||
<TableHead className="min-w-[200px]">Score</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{voteResults.results.map((r, i) => {
|
||||
const isWinner = r.project.id === voteResults.winnerId
|
||||
const barPercent = (r.points / maxPoints) * 100
|
||||
return (
|
||||
<TableRow
|
||||
key={r.project.id}
|
||||
className={isWinner ? 'bg-amber-50/80 dark:bg-amber-950/20' : ''}
|
||||
>
|
||||
<TableCell>
|
||||
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${
|
||||
i === 0
|
||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
|
||||
: i === 1
|
||||
? 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-300'
|
||||
: i === 2
|
||||
? 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300'
|
||||
: 'text-muted-foreground'
|
||||
}`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{isWinner && (
|
||||
<Crown className="h-4 w-4 text-amber-500 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{r.project.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{r.project.teamName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="tabular-nums">{r.votes}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-2.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
isWinner
|
||||
? 'bg-gradient-to-r from-amber-400 to-amber-500'
|
||||
: i === 0
|
||||
? 'bg-gradient-to-r from-brand-blue to-brand-teal'
|
||||
: 'bg-brand-teal/60'
|
||||
}`}
|
||||
style={{ width: `${barPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tabular-nums w-10 text-right">
|
||||
{r.points}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{!isWinner && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSetWinner(r.project.id)}
|
||||
disabled={setWinner.isPending}
|
||||
>
|
||||
<Crown className="mr-1 h-3 w-3" />
|
||||
Set Winner
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
})() : (
|
||||
<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">No votes yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Votes will appear here once jurors submit their selections
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
|
||||
<BarChart3 className="h-8 w-8 text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="text-lg font-medium">No votes yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
|
||||
{award._count.jurors === 0
|
||||
? 'Assign jurors to this award first, then open voting to collect their selections.'
|
||||
: award.status === 'DRAFT' || award.status === 'NOMINATIONS_OPEN'
|
||||
? 'Open voting to allow jurors to submit their selections for this award.'
|
||||
: 'Votes will appear here as jurors submit their selections.'}
|
||||
</p>
|
||||
{award.status === 'NOMINATIONS_OPEN' && (
|
||||
<Button
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange('VOTING_OPEN')}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Open Voting
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -12,7 +14,15 @@ import {
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Plus, Trophy, Users, CheckCircle2 } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trophy, Users, CheckCircle2, Search } from 'lucide-react'
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
DRAFT: 'secondary',
|
||||
@@ -31,16 +41,46 @@ const SCORING_LABELS: Record<string, string> = {
|
||||
export default function AwardsListPage() {
|
||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({})
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [scoringFilter, setScoringFilter] = useState('all')
|
||||
|
||||
const filteredAwards = useMemo(() => {
|
||||
if (!awards) return []
|
||||
return awards.filter((award) => {
|
||||
const matchesSearch =
|
||||
!debouncedSearch ||
|
||||
award.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
award.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
const matchesStatus = statusFilter === 'all' || award.status === statusFilter
|
||||
const matchesScoring = scoringFilter === 'all' || award.scoringMode === scoringFilter
|
||||
return matchesSearch && matchesStatus && matchesScoring
|
||||
})
|
||||
}, [awards, debouncedSearch, statusFilter, scoringFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Skeleton className="h-10 flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-[180px]" />
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Cards skeleton */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-48" />
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,12 +107,58 @@ export default function AwardsListPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search awards..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="DRAFT">Draft</SelectItem>
|
||||
<SelectItem value="NOMINATIONS_OPEN">Nominations Open</SelectItem>
|
||||
<SelectItem value="VOTING_OPEN">Voting Open</SelectItem>
|
||||
<SelectItem value="CLOSED">Closed</SelectItem>
|
||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={scoringFilter} onValueChange={setScoringFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All scoring" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All scoring</SelectItem>
|
||||
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
|
||||
<SelectItem value="RANKED">Ranked</SelectItem>
|
||||
<SelectItem value="SCORED">Scored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{awards && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredAwards.length} of {awards.length} awards
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Awards Grid */}
|
||||
{awards && awards.length > 0 ? (
|
||||
{filteredAwards.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{awards.map((award) => (
|
||||
{filteredAwards.map((award) => (
|
||||
<Link key={award.id} href={`/admin/awards/${award.id}`}>
|
||||
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
|
||||
<Card className="transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md cursor-pointer h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
@@ -118,13 +204,22 @@ export default function AwardsListPage() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : awards && awards.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No awards match your filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No awards yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create special awards for outstanding projects
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/40" />
|
||||
<h3 className="mt-3 text-lg font-medium">No awards yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
Create special awards with eligibility criteria and jury voting for outstanding projects.
|
||||
</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/admin/awards/new">
|
||||
|
||||
Reference in New Issue
Block a user