diff --git a/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx b/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx index 8826beb..2c2595a 100644 --- a/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx @@ -3,6 +3,7 @@ import { Suspense, use, useState, useEffect } from 'react' import Link from 'next/link' import { trpc } from '@/lib/trpc/client' +import { cn } from '@/lib/utils' import { Card, CardContent, @@ -42,21 +43,20 @@ import { AlertDialogTrigger, } from '@/components/ui/alert-dialog' import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog' + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' import { ArrowLeft, Users, @@ -72,6 +72,10 @@ import { UserPlus, Cpu, Brain, + Search, + ChevronsUpDown, + Check, + X, } from 'lucide-react' import { toast } from 'sonner' @@ -195,9 +199,11 @@ interface PageProps { function AssignmentManagementContent({ roundId }: { roundId: string }) { const [selectedSuggestions, setSelectedSuggestions] = useState>(new Set()) - const [manualDialogOpen, setManualDialogOpen] = useState(false) + const [manualOpen, setManualOpen] = useState(false) const [selectedJuror, setSelectedJuror] = useState('') - const [selectedProject, setSelectedProject] = useState('') + const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false) + const [projectSearch, setProjectSearch] = useState('') + const [selectedProjects, setSelectedProjects] = useState>(new Set()) const [activeTab, setActiveTab] = useState<'algorithm' | 'ai'>('algorithm') const [activeJobId, setActiveJobId] = useState(null) @@ -302,13 +308,13 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { // Get available jurors for manual assignment const { data: availableJurors } = trpc.user.getJuryMembers.useQuery( { roundId }, - { enabled: manualDialogOpen } + { enabled: manualOpen } ) // Get projects in this round for manual assignment const { data: roundProjects } = trpc.project.list.useQuery( { roundId, perPage: 500 }, - { enabled: manualDialogOpen } + { enabled: manualOpen } ) const utils = trpc.useUtils() @@ -335,26 +341,45 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { utils.assignment.listByRound.invalidate({ roundId }) utils.assignment.getStats.invalidate({ roundId }) utils.assignment.getSuggestions.invalidate({ roundId }) - setManualDialogOpen(false) - setSelectedJuror('') - setSelectedProject('') - toast.success('Assignment created successfully') }, onError: (error) => { toast.error(error.message || 'Failed to create assignment') }, }) - const handleCreateManualAssignment = () => { - if (!selectedJuror || !selectedProject) { - toast.error('Please select both a juror and a project') + const [bulkAssigning, setBulkAssigning] = useState(false) + + const handleBulkAssign = async () => { + if (!selectedJuror || selectedProjects.size === 0) { + toast.error('Please select a juror and at least one project') return } - createAssignment.mutate({ - userId: selectedJuror, - projectId: selectedProject, - roundId, - }) + setBulkAssigning(true) + let successCount = 0 + let errorCount = 0 + 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) { @@ -457,124 +482,278 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) { - {/* Manual Assignment Button */} - - - - - - - Create Manual Assignment - - Assign a jury member to evaluate a specific project - - + + )} + + -
- {/* Juror Select */} -
- - - {selectedJuror && availableJurors && ( -

- {(() => { - const juror = availableJurors.find(j => j.id === selectedJuror) - if (!juror) return null - const available = (juror.maxAssignments ?? 10) - juror.currentAssignments - return `${available} assignment slot${available !== 1 ? 's' : ''} available` - })()} -

- )} -
- - {/* Project Select */} -
- - -
+ + + + + + + + No jurors found. + + {availableJurors?.map((juror) => { + const maxAllowed = juror.maxAssignments ?? 10 + const isFull = juror.currentAssignments >= maxAllowed + return ( + { + setSelectedJuror(juror.id === selectedJuror ? '' : juror.id) + setSelectedProjects(new Set()) + setJurorPopoverOpen(false) + }} + disabled={isFull} + className={isFull ? 'opacity-50' : ''} + > + +
+
+

{juror.name || juror.email}

+ {juror.name && ( +

{juror.email}

+ )} +
+ + {juror.currentAssignments}/{maxAllowed} + {isFull && ' Full'} + +
+
+ ) + })} +
+
+
+
+
- + {/* Step 2: Project Multi-Select */} +
+ + {!selectedJuror ? ( +

+ Select a jury member first to see available projects +

+ ) : ( + <> +
+ + setProjectSearch(e.target.value)} + className="pl-9" + /> +
+ {(() => { + 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 ( + <> +
+ { + if (allUnassignedSelected) { + setSelectedProjects(new Set()) + } else { + setSelectedProjects(new Set(unassignedToJuror.map(p => p.id))) + } + }} + /> + + Select all unassigned ({unassignedToJuror.length}) + +
+ +
+ {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 ( +
+ { + setSelectedProjects(prev => { + const next = new Set(prev) + if (next.has(project.id)) { + next.delete(project.id) + } else { + next.add(project.id) + } + return next + }) + }} + /> + + {project.title} + + + {isAlreadyAssigned ? ( + <> + + Assigned + + ) : isFullCoverage ? ( + <> + + {assignmentCount}/{round.requiredReviews} + + ) : ( + `${assignmentCount}/${round.requiredReviews} reviewers` + )} + +
+ ) + })} + {filtered.length === 0 && ( +
+ No projects match your search +
+ )} +
+
+ + ) + })()} + + )} +
+ + {/* Assign Button */} + {selectedJuror && selectedProjects.size > 0 && ( - -
-
-
- + )} + + + )} {/* Stats */} {stats && ( diff --git a/src/app/(auth)/accept-invite/page.tsx b/src/app/(auth)/accept-invite/page.tsx index 66f0e41..5087942 100644 --- a/src/app/(auth)/accept-invite/page.tsx +++ b/src/app/(auth)/accept-invite/page.tsx @@ -85,6 +85,7 @@ function AcceptInviteContent() { case 'PROGRAM_ADMIN': return 'Program Admin' case 'MENTOR': return 'Mentor' case 'OBSERVER': return 'Observer' + case 'APPLICANT': return 'Applicant' default: return role } } @@ -182,7 +183,7 @@ function AcceptInviteContent() { You'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)}.` : '.'} diff --git a/src/app/(auth)/set-password/page.tsx b/src/app/(auth)/set-password/page.tsx index b3ea8e5..0a04ff9 100644 --- a/src/app/(auth)/set-password/page.tsx +++ b/src/app/(auth)/set-password/page.tsx @@ -15,6 +15,7 @@ import { } from '@/components/ui/card' import { Progress } from '@/components/ui/progress' import { Loader2, Lock, CheckCircle2, AlertCircle, Eye, EyeOff } from 'lucide-react' +import Image from 'next/image' import { trpc } from '@/lib/trpc/client' export default function SetPasswordPage() { @@ -149,8 +150,8 @@ export default function SetPasswordPage() { return ( -
- +
+ MOPC
Set Your Password diff --git a/src/app/(jury)/jury/page.tsx b/src/app/(jury)/jury/page.tsx index dea7581..9d26529 100644 --- a/src/app/(jury)/jury/page.tsx +++ b/src/app/(jury)/jury/page.tsx @@ -215,6 +215,54 @@ async function JuryDashboardContent() { }, ] + // Zero-assignment state: compact welcome card + if (totalAssignments === 0) { + return ( + + +
+ +
+
+ +
+

No assignments yet

+

+ Your project assignments will appear here once an administrator assigns them to you. +

+
+
+ +
+ +
+
+

All Assignments

+

View evaluations

+
+ + +
+ +
+
+

Compare Projects

+

Side-by-side view

+
+ +
+
+ + + ) + } + return ( <> {/* Hero CTA - Jump to next evaluation */} @@ -248,8 +296,8 @@ async function JuryDashboardContent() { )} - {/* Stats */} -
+ {/* Stats + Overall Completion in one row */} +
{stats.map((stat, i) => ( ))} + {/* Overall completion as 5th stat card */} + + + +
+ +
+
+

+ {completionRate.toFixed(0)}% +

+
+
+
+
+ + +
- {/* Overall Progress */} - - -
- -
-
-
- -
- Overall Completion -
-
- - {completionRate.toFixed(0)}% - - - ({completedAssignments}/{totalAssignments}) - -
-
-
-
-
- - - - {/* Main content -- two column layout */} -
+
{/* Left column */} -
+
{/* Recent Assignments */} @@ -402,11 +440,11 @@ async function JuryDashboardContent() { })}
) : ( -
-
- +
+
+
-

+

No assignments yet

@@ -462,7 +500,7 @@ async function JuryDashboardContent() {

{/* Right column */} -
+
{/* Active Rounds */} {activeRounds.length > 0 && ( @@ -561,12 +599,12 @@ async function JuryDashboardContent() { )} {/* No active rounds */} - {activeRounds.length === 0 && totalAssignments > 0 && ( + {activeRounds.length === 0 && ( - -
- + +
+

No active voting rounds

@@ -618,24 +656,6 @@ async function JuryDashboardContent() { )}

- - {/* No assignments at all */} - {totalAssignments === 0 && ( - - -
- -
- -
-

No assignments yet

-

- You'll see your project assignments here once they're assigned to you by an administrator. -

-
- - - )} ) } @@ -721,7 +741,7 @@ export default async function JuryDashboardPage() { const session = await auth() return ( -
+
{/* Header */}
diff --git a/src/app/page.tsx b/src/app/page.tsx index 83c6771..dfad0c2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -23,6 +23,8 @@ export default async function HomePage() { redirect('/mentor' as Route) } else if (session.user.role === 'OBSERVER') { redirect('/observer') + } else if (session.user.role === 'APPLICANT') { + redirect('/my-submission' as Route) } } diff --git a/src/components/shared/notification-bell.tsx b/src/components/shared/notification-bell.tsx index ecd7016..d95fd71 100644 --- a/src/components/shared/notification-bell.tsx +++ b/src/components/shared/notification-bell.tsx @@ -210,7 +210,6 @@ function NotificationItem({ } export function NotificationBell() { - const [filter, setFilter] = useState<'all' | 'unread'>('all') const [open, setOpen] = useState(false) const pathname = usePathname() @@ -238,7 +237,7 @@ export function NotificationBell() { const { data: notificationData, refetch } = trpc.notification.list.useQuery( { - unreadOnly: filter === 'unread', + unreadOnly: false, limit: 20, }, { @@ -379,32 +378,6 @@ export function NotificationBell() {
- {/* Filter tabs */} -
- - -
- {/* Notification list */}
@@ -424,9 +397,7 @@ export function NotificationBell() {

- {filter === 'unread' - ? 'No unread notifications' - : 'No notifications yet'} + No notifications yet

)} diff --git a/src/lib/email.ts b/src/lib/email.ts index 702f99e..c39c3f5 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -291,11 +291,12 @@ function getGenericInvitationTemplate( role: string ): EmailTemplate { 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 content = ` ${sectionTitle(greeting)} - ${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as a ${roleLabel}.`)} + ${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as ${article} ${roleLabel}.`)} ${paragraph('Click the button below to set up your account and get started.')} ${ctaButton(url, 'Accept Invitation')} ${infoBox('This link will expire in 7 days.', 'info')} @@ -307,7 +308,7 @@ function getGenericInvitationTemplate( text: ` ${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: diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index c8733a0..2414e79 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -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') // Log notification