Jury dashboard compact layout, assignment redesign, auth fixes
Build and Push Docker Image / build (push) Successful in 10m25s Details

- Jury dashboard: collapse zero-assignment state into single welcome card
  with inline quick actions; merge completion bar into stats row; tighten spacing
- Manual assignment: replace tiny Dialog modal with inline collapsible section
  featuring searchable juror combobox and multi-select project list with bulk assign
- Fix applicant invite URL path (/auth/accept-invite -> /accept-invite)
- Add APPLICANT role redirect to /my-submission from root page
- Add Applicant label to accept-invite role display
- Fix a/an grammar in invitation emails and accept-invite page
- Set-password page: use MOPC logo instead of lock icon
- Notification bell: remove filter tabs, always show all notifications

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-11 01:26:19 +01:00
parent 74515768f5
commit 09091d7c08
8 changed files with 411 additions and 236 deletions

View File

@ -3,6 +3,7 @@
import { Suspense, use, useState, useEffect } from 'react' import { Suspense, use, useState, useEffect } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { cn } from '@/lib/utils'
import { import {
Card, Card,
CardContent, CardContent,
@ -42,21 +43,20 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { import {
Dialog, Command,
DialogContent, CommandEmpty,
DialogDescription, CommandGroup,
DialogFooter, CommandInput,
DialogHeader, CommandItem,
DialogTitle, CommandList,
DialogTrigger, } from '@/components/ui/command'
} from '@/components/ui/dialog'
import { import {
Select, Popover,
SelectContent, PopoverContent,
SelectItem, PopoverTrigger,
SelectTrigger, } from '@/components/ui/popover'
SelectValue, import { Input } from '@/components/ui/input'
} from '@/components/ui/select' import { ScrollArea } from '@/components/ui/scroll-area'
import { import {
ArrowLeft, ArrowLeft,
Users, Users,
@ -72,6 +72,10 @@ import {
UserPlus, UserPlus,
Cpu, Cpu,
Brain, Brain,
Search,
ChevronsUpDown,
Check,
X,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -195,9 +199,11 @@ interface PageProps {
function AssignmentManagementContent({ roundId }: { roundId: string }) { function AssignmentManagementContent({ roundId }: { roundId: string }) {
const [selectedSuggestions, setSelectedSuggestions] = useState<Set<string>>(new Set()) const [selectedSuggestions, setSelectedSuggestions] = useState<Set<string>>(new Set())
const [manualDialogOpen, setManualDialogOpen] = useState(false) const [manualOpen, setManualOpen] = useState(false)
const [selectedJuror, setSelectedJuror] = useState<string>('') const [selectedJuror, setSelectedJuror] = useState<string>('')
const [selectedProject, setSelectedProject] = useState<string>('') const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
const [projectSearch, setProjectSearch] = useState('')
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(new Set())
const [activeTab, setActiveTab] = useState<'algorithm' | 'ai'>('algorithm') const [activeTab, setActiveTab] = useState<'algorithm' | 'ai'>('algorithm')
const [activeJobId, setActiveJobId] = useState<string | null>(null) const [activeJobId, setActiveJobId] = useState<string | null>(null)
@ -302,13 +308,13 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
// Get available jurors for manual assignment // Get available jurors for manual assignment
const { data: availableJurors } = trpc.user.getJuryMembers.useQuery( const { data: availableJurors } = trpc.user.getJuryMembers.useQuery(
{ roundId }, { roundId },
{ enabled: manualDialogOpen } { enabled: manualOpen }
) )
// Get projects in this round for manual assignment // Get projects in this round for manual assignment
const { data: roundProjects } = trpc.project.list.useQuery( const { data: roundProjects } = trpc.project.list.useQuery(
{ roundId, perPage: 500 }, { roundId, perPage: 500 },
{ enabled: manualDialogOpen } { enabled: manualOpen }
) )
const utils = trpc.useUtils() const utils = trpc.useUtils()
@ -335,26 +341,45 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
utils.assignment.listByRound.invalidate({ roundId }) utils.assignment.listByRound.invalidate({ roundId })
utils.assignment.getStats.invalidate({ roundId }) utils.assignment.getStats.invalidate({ roundId })
utils.assignment.getSuggestions.invalidate({ roundId }) utils.assignment.getSuggestions.invalidate({ roundId })
setManualDialogOpen(false)
setSelectedJuror('')
setSelectedProject('')
toast.success('Assignment created successfully')
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message || 'Failed to create assignment') toast.error(error.message || 'Failed to create assignment')
}, },
}) })
const handleCreateManualAssignment = () => { const [bulkAssigning, setBulkAssigning] = useState(false)
if (!selectedJuror || !selectedProject) {
toast.error('Please select both a juror and a project') const handleBulkAssign = async () => {
if (!selectedJuror || selectedProjects.size === 0) {
toast.error('Please select a juror and at least one project')
return return
} }
createAssignment.mutate({ setBulkAssigning(true)
userId: selectedJuror, let successCount = 0
projectId: selectedProject, let errorCount = 0
roundId, for (const projectId of selectedProjects) {
}) try {
await createAssignment.mutateAsync({
userId: selectedJuror,
projectId,
roundId,
})
successCount++
} catch {
errorCount++
}
}
setBulkAssigning(false)
setSelectedProjects(new Set())
if (successCount > 0) {
toast.success(`${successCount} assignment${successCount > 1 ? 's' : ''} created successfully`)
}
if (errorCount > 0) {
toast.error(`${errorCount} assignment${errorCount > 1 ? 's' : ''} failed`)
}
utils.assignment.listByRound.invalidate({ roundId })
utils.assignment.getStats.invalidate({ roundId })
utils.assignment.getSuggestions.invalidate({ roundId })
} }
if (loadingRound || loadingAssignments) { if (loadingRound || loadingAssignments) {
@ -457,124 +482,278 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
</h1> </h1>
</div> </div>
{/* Manual Assignment Button */} {/* Manual Assignment Toggle */}
<Dialog open={manualDialogOpen} onOpenChange={setManualDialogOpen}> <Button
<DialogTrigger asChild> variant={manualOpen ? 'secondary' : 'default'}
<Button> onClick={() => {
setManualOpen(!manualOpen)
if (!manualOpen) {
setSelectedJuror('')
setSelectedProjects(new Set())
setProjectSearch('')
}
}}
>
{manualOpen ? (
<>
<X className="mr-2 h-4 w-4" />
Close
</>
) : (
<>
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="mr-2 h-4 w-4" />
Manual Assignment Manual Assignment
</Button> </>
</DialogTrigger> )}
<DialogContent> </Button>
<DialogHeader> </div>
<DialogTitle>Create Manual Assignment</DialogTitle>
<DialogDescription>
Assign a jury member to evaluate a specific project
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4"> {/* Inline Manual Assignment Section */}
{/* Juror Select */} {manualOpen && (
<div className="space-y-2"> <Card>
<Label htmlFor="juror">Jury Member</Label> <CardHeader className="pb-3">
<Select value={selectedJuror} onValueChange={setSelectedJuror}> <div className="flex items-center justify-between">
<SelectTrigger id="juror"> <CardTitle className="text-lg flex items-center gap-2">
<SelectValue placeholder="Select a jury member..." /> <UserPlus className="h-5 w-5" />
</SelectTrigger> Manual Assignment
<SelectContent> </CardTitle>
{availableJurors?.map((juror) => { <Button variant="ghost" size="sm" onClick={() => setManualOpen(false)}>
const maxAllowed = juror.maxAssignments ?? 10 <X className="h-4 w-4" />
const isFull = juror.currentAssignments >= maxAllowed </Button>
return ( </div>
<SelectItem key={juror.id} value={juror.id} disabled={isFull}> <CardDescription>
<div className="flex items-center justify-between gap-4"> Select a jury member, then pick projects to assign
<span className={isFull ? 'opacity-50' : ''}>{juror.name || juror.email}</span> </CardDescription>
<span className="text-xs text-muted-foreground"> </CardHeader>
<CardContent className="space-y-5">
{/* Step 1: Juror Picker */}
<div className="space-y-2">
<Label className="text-sm font-semibold">Step 1: Select Jury Member</Label>
<Popover open={jurorPopoverOpen} onOpenChange={setJurorPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={jurorPopoverOpen}
className="w-full justify-between font-normal"
>
{selectedJuror && availableJurors ? (
(() => {
const juror = availableJurors.find(j => j.id === selectedJuror)
if (!juror) return 'Select a jury member...'
const maxAllowed = juror.maxAssignments ?? 10
return (
<span className="flex items-center gap-2">
<span>{juror.name || juror.email}</span>
<Badge variant="secondary" className="text-xs">
{juror.currentAssignments}/{maxAllowed} assigned {juror.currentAssignments}/{maxAllowed} assigned
</span> </Badge>
</div> </span>
</SelectItem> )
) })()
})} ) : (
{availableJurors?.length === 0 && ( 'Select a jury member...'
<div className="px-2 py-4 text-sm text-muted-foreground text-center">
No jury members available
</div>
)} )}
</SelectContent> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Select> </Button>
{selectedJuror && availableJurors && ( </PopoverTrigger>
<p className="text-xs text-muted-foreground"> <PopoverContent className="w-[400px] p-0" align="start">
{(() => { <Command>
const juror = availableJurors.find(j => j.id === selectedJuror) <CommandInput placeholder="Search jurors..." />
if (!juror) return null <CommandList>
const available = (juror.maxAssignments ?? 10) - juror.currentAssignments <CommandEmpty>No jurors found.</CommandEmpty>
return `${available} assignment slot${available !== 1 ? 's' : ''} available` <CommandGroup>
})()} {availableJurors?.map((juror) => {
</p> const maxAllowed = juror.maxAssignments ?? 10
)} const isFull = juror.currentAssignments >= maxAllowed
</div> return (
<CommandItem
{/* Project Select */} key={juror.id}
<div className="space-y-2"> value={`${juror.name || ''} ${juror.email}`}
<Label htmlFor="project">Project</Label> onSelect={() => {
<Select value={selectedProject} onValueChange={setSelectedProject}> setSelectedJuror(juror.id === selectedJuror ? '' : juror.id)
<SelectTrigger id="project"> setSelectedProjects(new Set())
<SelectValue placeholder="Select a project..." /> setJurorPopoverOpen(false)
</SelectTrigger> }}
<SelectContent> disabled={isFull}
{roundProjects?.projects.map((project) => { className={isFull ? 'opacity-50' : ''}
const assignmentCount = assignments?.filter(a => a.projectId === project.id).length ?? 0 >
const isAlreadyAssigned = selectedJuror && assignments?.some( <Check
a => a.userId === selectedJuror && a.projectId === project.id className={cn(
) 'mr-2 h-4 w-4',
return ( selectedJuror === juror.id ? 'opacity-100' : 'opacity-0'
<SelectItem )}
key={project.id} />
value={project.id} <div className="flex items-center justify-between w-full">
disabled={!!isAlreadyAssigned} <div className="min-w-0">
> <p className="text-sm font-medium truncate">{juror.name || juror.email}</p>
<div className="flex items-center justify-between gap-4"> {juror.name && (
<span className={isAlreadyAssigned ? 'line-through opacity-50' : ''}> <p className="text-xs text-muted-foreground truncate">{juror.email}</p>
{project.title} )}
</span> </div>
<span className="text-xs text-muted-foreground"> <Badge variant={isFull ? 'destructive' : 'secondary'} className="text-xs ml-2 shrink-0">
{assignmentCount}/{round.requiredReviews} reviewers {juror.currentAssignments}/{maxAllowed}
</span> {isFull && ' Full'}
</div> </Badge>
</SelectItem> </div>
) </CommandItem>
})} )
{roundProjects?.projects.length === 0 && ( })}
<div className="px-2 py-4 text-sm text-muted-foreground text-center"> </CommandGroup>
No projects in this round </CommandList>
</div> </Command>
)} </PopoverContent>
</SelectContent> </Popover>
</Select>
</div>
</div> </div>
<DialogFooter> {/* Step 2: Project Multi-Select */}
<div className="space-y-2">
<Label className="text-sm font-semibold">Step 2: Select Projects</Label>
{!selectedJuror ? (
<p className="text-sm text-muted-foreground py-4 text-center">
Select a jury member first to see available projects
</p>
) : (
<>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
className="pl-9"
/>
</div>
{(() => {
const projects = roundProjects?.projects ?? []
const filtered = projects.filter(p =>
p.title.toLowerCase().includes(projectSearch.toLowerCase())
)
const unassignedToJuror = filtered.filter(p =>
!assignments?.some(a => a.userId === selectedJuror && a.projectId === p.id)
)
const allUnassignedSelected = unassignedToJuror.length > 0 &&
unassignedToJuror.every(p => selectedProjects.has(p.id))
return (
<>
<div className="flex items-center gap-2 py-1">
<Checkbox
checked={allUnassignedSelected}
onCheckedChange={() => {
if (allUnassignedSelected) {
setSelectedProjects(new Set())
} else {
setSelectedProjects(new Set(unassignedToJuror.map(p => p.id)))
}
}}
/>
<span className="text-sm text-muted-foreground">
Select all unassigned ({unassignedToJuror.length})
</span>
</div>
<ScrollArea className="h-[280px] rounded-lg border">
<div className="divide-y">
{filtered.map((project) => {
const assignmentCount = assignments?.filter(a => a.projectId === project.id).length ?? 0
const isAlreadyAssigned = assignments?.some(
a => a.userId === selectedJuror && a.projectId === project.id
)
const isFullCoverage = assignmentCount >= round.requiredReviews
const isChecked = selectedProjects.has(project.id)
return (
<div
key={project.id}
className={cn(
'flex items-center gap-3 px-3 py-2.5 transition-colors',
isAlreadyAssigned ? 'opacity-40 bg-muted/30' : 'hover:bg-muted/50',
isChecked && !isAlreadyAssigned && 'bg-blue-50/50 dark:bg-blue-950/20',
)}
>
<Checkbox
checked={isChecked}
disabled={!!isAlreadyAssigned}
onCheckedChange={() => {
setSelectedProjects(prev => {
const next = new Set(prev)
if (next.has(project.id)) {
next.delete(project.id)
} else {
next.add(project.id)
}
return next
})
}}
/>
<span className={cn(
'flex-1 text-sm truncate',
isAlreadyAssigned && 'line-through'
)}>
{project.title}
</span>
<Badge
variant={isFullCoverage ? 'default' : 'outline'}
className={cn(
'text-xs shrink-0',
isFullCoverage && 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-400 border-0',
isAlreadyAssigned && 'bg-muted text-muted-foreground border-0'
)}
>
{isAlreadyAssigned ? (
<>
<Check className="mr-1 h-3 w-3" />
Assigned
</>
) : isFullCoverage ? (
<>
<CheckCircle2 className="mr-1 h-3 w-3" />
{assignmentCount}/{round.requiredReviews}
</>
) : (
`${assignmentCount}/${round.requiredReviews} reviewers`
)}
</Badge>
</div>
)
})}
{filtered.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">
No projects match your search
</div>
)}
</div>
</ScrollArea>
</>
)
})()}
</>
)}
</div>
{/* Assign Button */}
{selectedJuror && selectedProjects.size > 0 && (
<Button <Button
variant="outline" onClick={handleBulkAssign}
onClick={() => setManualDialogOpen(false)} disabled={bulkAssigning}
className="w-full"
size="lg"
> >
Cancel {bulkAssigning ? (
</Button>
<Button
onClick={handleCreateManualAssignment}
disabled={!selectedJuror || !selectedProject || createAssignment.isPending}
>
{createAssignment.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)} )}
Create Assignment Assign {selectedProjects.size} Project{selectedProjects.size > 1 ? 's' : ''}
{availableJurors && (() => {
const juror = availableJurors.find(j => j.id === selectedJuror)
return juror ? ` to ${juror.name || juror.email}` : ''
})()}
</Button> </Button>
</DialogFooter> )}
</DialogContent> </CardContent>
</Dialog> </Card>
</div> )}
{/* Stats */} {/* Stats */}
{stats && ( {stats && (

View File

@ -85,6 +85,7 @@ function AcceptInviteContent() {
case 'PROGRAM_ADMIN': return 'Program Admin' case 'PROGRAM_ADMIN': return 'Program Admin'
case 'MENTOR': return 'Mentor' case 'MENTOR': return 'Mentor'
case 'OBSERVER': return 'Observer' case 'OBSERVER': return 'Observer'
case 'APPLICANT': return 'Applicant'
default: return role default: return role
} }
} }
@ -182,7 +183,7 @@ function AcceptInviteContent() {
</CardTitle> </CardTitle>
<CardDescription className="text-base"> <CardDescription className="text-base">
You&apos;ve been invited to join the Monaco Ocean Protection Challenge platform You&apos;ve been invited to join the Monaco Ocean Protection Challenge platform
{user?.role ? ` as a ${getRoleLabel(user.role)}.` : '.'} {user?.role ? ` as ${/^[aeiou]/i.test(getRoleLabel(user.role)) ? 'an' : 'a'} ${getRoleLabel(user.role)}.` : '.'}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">

View File

@ -15,6 +15,7 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { Loader2, Lock, CheckCircle2, AlertCircle, Eye, EyeOff } from 'lucide-react' import { Loader2, Lock, CheckCircle2, AlertCircle, Eye, EyeOff } from 'lucide-react'
import Image from 'next/image'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
export default function SetPasswordPage() { export default function SetPasswordPage() {
@ -149,8 +150,8 @@ export default function SetPasswordPage() {
return ( return (
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader className="text-center"> <CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-sm border">
<Lock className="h-6 w-6 text-primary" /> <Image src="/images/MOPC-blue-small.png" alt="MOPC" width={32} height={32} className="object-contain" />
</div> </div>
<CardTitle className="text-xl">Set Your Password</CardTitle> <CardTitle className="text-xl">Set Your Password</CardTitle>
<CardDescription> <CardDescription>

View File

@ -215,6 +215,54 @@ async function JuryDashboardContent() {
}, },
] ]
// Zero-assignment state: compact welcome card
if (totalAssignments === 0) {
return (
<AnimatedCard index={0}>
<Card className="overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" />
<CardContent className="py-8 px-6">
<div className="flex flex-col items-center text-center mb-6">
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-4 mb-3 dark:from-brand-teal/20 dark:to-brand-blue/20">
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
</div>
<p className="text-lg font-semibold">No assignments yet</p>
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
Your project assignments will appear here once an administrator assigns them to you.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 max-w-md mx-auto">
<Link
href="/jury/assignments"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
>
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40">
<ClipboardList className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="text-left">
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p>
<p className="text-xs text-muted-foreground">View evaluations</p>
</div>
</Link>
<Link
href="/jury/compare"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40">
<GitCompare className="h-4 w-4 text-brand-teal" />
</div>
<div className="text-left">
<p className="font-semibold text-sm group-hover:text-brand-teal transition-colors">Compare Projects</p>
<p className="text-xs text-muted-foreground">Side-by-side view</p>
</div>
</Link>
</div>
</CardContent>
</Card>
</AnimatedCard>
)
}
return ( return (
<> <>
{/* Hero CTA - Jump to next evaluation */} {/* Hero CTA - Jump to next evaluation */}
@ -248,8 +296,8 @@ async function JuryDashboardContent() {
</AnimatedCard> </AnimatedCard>
)} )}
{/* Stats */} {/* Stats + Overall Completion in one row */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{stats.map((stat, i) => ( {stats.map((stat, i) => (
<AnimatedCard key={stat.label} index={i + 1}> <AnimatedCard key={stat.label} index={i + 1}>
<Card className={cn( <Card className={cn(
@ -268,43 +316,33 @@ async function JuryDashboardContent() {
</Card> </Card>
</AnimatedCard> </AnimatedCard>
))} ))}
{/* Overall completion as 5th stat card */}
<AnimatedCard index={5}>
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="flex items-center gap-4 py-5 px-5">
<div className="rounded-xl p-3 bg-brand-blue/10 dark:bg-brand-blue/20">
<BarChart3 className="h-5 w-5 text-brand-blue dark:text-brand-teal" />
</div>
<div className="flex-1 min-w-0">
<p className="text-2xl font-bold tabular-nums tracking-tight text-brand-blue dark:text-brand-teal">
{completionRate.toFixed(0)}%
</p>
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted/60 mt-1">
<div
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
style={{ width: `${completionRate}%` }}
/>
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div> </div>
{/* Overall Progress */}
<AnimatedCard index={5}>
<Card className="overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-teal via-brand-blue to-brand-teal" />
<CardContent className="py-5 px-6">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-brand-blue/10 p-2 dark:bg-brand-blue/20">
<BarChart3 className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
</div>
<span className="text-sm font-semibold">Overall Completion</span>
</div>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold tabular-nums text-brand-blue dark:text-brand-teal">
{completionRate.toFixed(0)}%
</span>
<span className="text-xs text-muted-foreground ml-1">
({completedAssignments}/{totalAssignments})
</span>
</div>
</div>
<div className="relative h-3 w-full overflow-hidden rounded-full bg-muted/60">
<div
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
style={{ width: `${completionRate}%` }}
/>
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Main content -- two column layout */} {/* Main content -- two column layout */}
<div className="grid gap-6 lg:grid-cols-12"> <div className="grid gap-4 lg:grid-cols-12">
{/* Left column */} {/* Left column */}
<div className="lg:col-span-7 space-y-6"> <div className="lg:col-span-7 space-y-4">
{/* Recent Assignments */} {/* Recent Assignments */}
<AnimatedCard index={6}> <AnimatedCard index={6}>
<Card> <Card>
@ -402,11 +440,11 @@ async function JuryDashboardContent() {
})} })}
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-10 text-center"> <div className="flex flex-col items-center justify-center py-6 text-center">
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3"> <div className="rounded-2xl bg-brand-teal/10 p-3 mb-2">
<ClipboardList className="h-8 w-8 text-brand-teal/60" /> <ClipboardList className="h-6 w-6 text-brand-teal/60" />
</div> </div>
<p className="font-medium text-muted-foreground"> <p className="font-medium text-sm text-muted-foreground">
No assignments yet No assignments yet
</p> </p>
<p className="text-xs text-muted-foreground/70 mt-1 max-w-[240px]"> <p className="text-xs text-muted-foreground/70 mt-1 max-w-[240px]">
@ -462,7 +500,7 @@ async function JuryDashboardContent() {
</div> </div>
{/* Right column */} {/* Right column */}
<div className="lg:col-span-5 space-y-6"> <div className="lg:col-span-5 space-y-4">
{/* Active Rounds */} {/* Active Rounds */}
{activeRounds.length > 0 && ( {activeRounds.length > 0 && (
<AnimatedCard index={8}> <AnimatedCard index={8}>
@ -561,12 +599,12 @@ async function JuryDashboardContent() {
)} )}
{/* No active rounds */} {/* No active rounds */}
{activeRounds.length === 0 && totalAssignments > 0 && ( {activeRounds.length === 0 && (
<AnimatedCard index={8}> <AnimatedCard index={8}>
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-10 text-center"> <CardContent className="flex flex-col items-center justify-center py-6 text-center">
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3 dark:bg-brand-teal/20"> <div className="rounded-2xl bg-brand-teal/10 p-3 mb-2 dark:bg-brand-teal/20">
<Clock className="h-7 w-7 text-brand-teal/70" /> <Clock className="h-6 w-6 text-brand-teal/70" />
</div> </div>
<p className="font-semibold text-sm">No active voting rounds</p> <p className="font-semibold text-sm">No active voting rounds</p>
<p className="text-xs text-muted-foreground mt-1 max-w-[220px]"> <p className="text-xs text-muted-foreground mt-1 max-w-[220px]">
@ -618,24 +656,6 @@ async function JuryDashboardContent() {
)} )}
</div> </div>
</div> </div>
{/* No assignments at all */}
{totalAssignments === 0 && (
<AnimatedCard index={1}>
<Card className="overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" />
<CardContent className="flex flex-col items-center justify-center py-14 text-center">
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-5 mb-4 dark:from-brand-teal/20 dark:to-brand-blue/20">
<ClipboardList className="h-10 w-10 text-brand-teal/60" />
</div>
<p className="text-lg font-semibold">No assignments yet</p>
<p className="text-sm text-muted-foreground mt-1.5 max-w-sm">
You&apos;ll see your project assignments here once they&apos;re assigned to you by an administrator.
</p>
</CardContent>
</Card>
</AnimatedCard>
)}
</> </>
) )
} }
@ -721,7 +741,7 @@ export default async function JuryDashboardPage() {
const session = await auth() const session = await auth()
return ( return (
<div className="space-y-6"> <div className="space-y-4">
{/* Header */} {/* Header */}
<div className="relative"> <div className="relative">
<div className="absolute -top-6 -left-6 -right-6 h-32 bg-gradient-to-b from-brand-blue/[0.03] to-transparent dark:from-brand-blue/[0.06] pointer-events-none rounded-xl" /> <div className="absolute -top-6 -left-6 -right-6 h-32 bg-gradient-to-b from-brand-blue/[0.03] to-transparent dark:from-brand-blue/[0.06] pointer-events-none rounded-xl" />

View File

@ -23,6 +23,8 @@ export default async function HomePage() {
redirect('/mentor' as Route) redirect('/mentor' as Route)
} else if (session.user.role === 'OBSERVER') { } else if (session.user.role === 'OBSERVER') {
redirect('/observer') redirect('/observer')
} else if (session.user.role === 'APPLICANT') {
redirect('/my-submission' as Route)
} }
} }

View File

@ -210,7 +210,6 @@ function NotificationItem({
} }
export function NotificationBell() { export function NotificationBell() {
const [filter, setFilter] = useState<'all' | 'unread'>('all')
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const pathname = usePathname() const pathname = usePathname()
@ -238,7 +237,7 @@ export function NotificationBell() {
const { data: notificationData, refetch } = trpc.notification.list.useQuery( const { data: notificationData, refetch } = trpc.notification.list.useQuery(
{ {
unreadOnly: filter === 'unread', unreadOnly: false,
limit: 20, limit: 20,
}, },
{ {
@ -379,32 +378,6 @@ export function NotificationBell() {
</div> </div>
</div> </div>
{/* Filter tabs */}
<div className="flex border-b">
<button
className={cn(
'flex-1 py-2 text-sm transition-colors',
filter === 'all'
? 'border-b-2 border-primary font-medium'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => setFilter('all')}
>
All
</button>
<button
className={cn(
'flex-1 py-2 text-sm transition-colors',
filter === 'unread'
? 'border-b-2 border-primary font-medium'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => setFilter('unread')}
>
Unread ({unreadCount})
</button>
</div>
{/* Notification list */} {/* Notification list */}
<ScrollArea className="h-[400px]"> <ScrollArea className="h-[400px]">
<div className="divide-y"> <div className="divide-y">
@ -424,9 +397,7 @@ export function NotificationBell() {
<div className="p-8 text-center"> <div className="p-8 text-center">
<Bell className="h-10 w-10 mx-auto text-muted-foreground/30" /> <Bell className="h-10 w-10 mx-auto text-muted-foreground/30" />
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
{filter === 'unread' No notifications yet
? 'No unread notifications'
: 'No notifications yet'}
</p> </p>
</div> </div>
)} )}

View File

@ -291,11 +291,12 @@ function getGenericInvitationTemplate(
role: string role: string
): EmailTemplate { ): EmailTemplate {
const roleLabel = role === 'JURY_MEMBER' ? 'jury member' : role.toLowerCase().replace('_', ' ') const roleLabel = role === 'JURY_MEMBER' ? 'jury member' : role.toLowerCase().replace('_', ' ')
const article = /^[aeiou]/i.test(roleLabel) ? 'an' : 'a'
const greeting = name ? `Hello ${name},` : 'Hello,' const greeting = name ? `Hello ${name},` : 'Hello,'
const content = ` const content = `
${sectionTitle(greeting)} ${sectionTitle(greeting)}
${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as a <strong>${roleLabel}</strong>.`)} ${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as ${article} <strong>${roleLabel}</strong>.`)}
${paragraph('Click the button below to set up your account and get started.')} ${paragraph('Click the button below to set up your account and get started.')}
${ctaButton(url, 'Accept Invitation')} ${ctaButton(url, 'Accept Invitation')}
${infoBox('This link will expire in 7 days.', 'info')} ${infoBox('This link will expire in 7 days.', 'info')}
@ -307,7 +308,7 @@ function getGenericInvitationTemplate(
text: ` text: `
${greeting} ${greeting}
You've been invited to join the Monaco Ocean Protection Challenge platform as a ${roleLabel}. You've been invited to join the Monaco Ocean Protection Challenge platform as ${article} ${roleLabel}.
Click the link below to set up your account and get started: Click the link below to set up your account and get started:

View File

@ -561,7 +561,7 @@ export const projectRouter = router({
}, },
}) })
const inviteUrl = `${baseUrl}/auth/accept-invite?token=${token}` const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(member.email, member.name, inviteUrl, 'APPLICANT') await sendInvitationEmail(member.email, member.name, inviteUrl, 'APPLICANT')
// Log notification // Log notification