feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s

- processRoundClose EVALUATION uses ranking scores + advanceMode config
  (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED
- Advancement emails generate invite tokens for passwordless users with
  "Create Your Account" CTA; rejection emails have no link
- Finalization UI shows account stats (invite vs dashboard link counts)
- Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson)
- New award pool notification system: getAwardSelectionNotificationTemplate email,
  notifyEligibleProjects mutation with invite token generation,
  "Notify Pool" button on award detail page with custom message dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:14:41 +01:00
parent 7735f3ecdf
commit cfee3bc8a9
48 changed files with 5294 additions and 676 deletions

View File

@@ -53,6 +53,7 @@ import {
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Progress } from '@/components/ui/progress'
import { UserAvatar } from '@/components/shared/user-avatar'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -91,6 +92,7 @@ import {
AlertCircle,
Layers,
Info,
Mail,
} from 'lucide-react'
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
@@ -155,6 +157,8 @@ export default function AwardDetailPage({
const [activeTab, setActiveTab] = useState('eligibility')
const [addRoundOpen, setAddRoundOpen] = useState(false)
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
const [notifyDialogOpen, setNotifyDialogOpen] = useState(false)
const [notifyCustomMessage, setNotifyCustomMessage] = useState('')
// Pagination for eligibility list
const [eligibilityPage, setEligibilityPage] = useState(1)
@@ -283,6 +287,19 @@ export default function AwardDetailPage({
onError: (err) => toast.error(err.message),
})
const { data: notifyStats } = trpc.specialAward.getNotificationStats.useQuery(
{ awardId },
{ enabled: notifyDialogOpen }
)
const notifyEligible = trpc.specialAward.notifyEligibleProjects.useMutation({
onSuccess: (result) => {
toast.success(`Notified ${result.notified} projects (${result.emailsSent} emails sent${result.emailsFailed ? `, ${result.emailsFailed} failed` : ''})`)
setNotifyDialogOpen(false)
setNotifyCustomMessage('')
},
onError: (err) => toast.error(err.message),
})
const handleStatusChange = async (
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
) => {
@@ -468,13 +485,72 @@ export default function AwardDetailPage({
</Button>
)}
{award.status === 'NOMINATIONS_OPEN' && (
<Button
onClick={() => handleStatusChange('VOTING_OPEN')}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open Voting
</Button>
<>
<Dialog open={notifyDialogOpen} onOpenChange={setNotifyDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" disabled={award.eligibleCount === 0}>
<Mail className="mr-2 h-4 w-4" />
Notify Pool
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Notify Eligible Projects</DialogTitle>
<DialogDescription>
Send &quot;Selected for {award.name}&quot; emails to all {award.eligibleCount} eligible projects.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{notifyStats && (
<div className="flex flex-wrap gap-2">
{notifyStats.needsInvite > 0 && (
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700">
{notifyStats.needsInvite} will receive Create Account link
</Badge>
)}
{notifyStats.hasAccount > 0 && (
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700">
{notifyStats.hasAccount} will receive Dashboard link
</Badge>
)}
</div>
)}
<div className="space-y-2">
<Label>Custom message (optional)</Label>
<Textarea
placeholder="Add a personal message to include in the email..."
value={notifyCustomMessage}
onChange={(e) => setNotifyCustomMessage(e.target.value)}
rows={4}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNotifyDialogOpen(false)}>Cancel</Button>
<Button
onClick={() => notifyEligible.mutate({
awardId,
customMessage: notifyCustomMessage.trim() || undefined,
})}
disabled={notifyEligible.isPending}
>
{notifyEligible.isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Sending...</>
) : (
<><Mail className="mr-2 h-4 w-4" />Send {award.eligibleCount} Emails</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Button
onClick={() => handleStatusChange('VOTING_OPEN')}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open Voting
</Button>
</>
)}
{award.status === 'VOTING_OPEN' && (
<Button

View File

@@ -64,12 +64,13 @@ import {
import { toast } from 'sonner'
import { formatDate } from '@/lib/utils'
type RecipientType = 'ALL' | 'ROLE' | 'ROUND_JURY' | 'PROGRAM_TEAM' | 'USER'
type RecipientType = 'ALL' | 'ROLE' | 'ROUND_JURY' | 'ROUND_APPLICANTS' | 'PROGRAM_TEAM' | 'USER'
const RECIPIENT_TYPE_OPTIONS: { value: RecipientType; label: string }[] = [
{ value: 'ALL', label: 'All Users' },
{ value: 'ROLE', label: 'By Role' },
{ value: 'ROUND_JURY', label: 'Round Jury' },
{ value: 'ROUND_APPLICANTS', label: 'Round Applicants' },
{ value: 'PROGRAM_TEAM', label: 'Program Team' },
{ value: 'USER', label: 'Specific User' },
]
@@ -110,6 +111,16 @@ export default function MessagesPage() {
{ refetchInterval: 30_000 }
)
const emailPreview = trpc.message.previewEmail.useQuery(
{ subject, body },
{ enabled: showPreview && subject.length > 0 && body.length > 0 }
)
const sendTestMutation = trpc.message.sendTest.useMutation({
onSuccess: (data) => toast.success(`Test email sent to ${data.to}`),
onError: (e) => toast.error(e.message),
})
const sendMutation = trpc.message.send.useMutation({
onSuccess: (data) => {
const count = (data as Record<string, unknown>)?.recipientCount || ''
@@ -183,6 +194,13 @@ export default function MessagesPage() {
? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}`
: 'Stage Jury'
}
case 'ROUND_APPLICANTS': {
if (!roundId) return 'Round Applicants (none selected)'
const appRound = rounds?.find((r) => r.id === roundId)
return appRound
? `Applicants in ${appRound.program ? `${appRound.program.name} - ` : ''}${appRound.name}`
: 'Round Applicants'
}
case 'PROGRAM_TEAM': {
if (!selectedProgramId) return 'Program Team (none selected)'
const program = (programs as Array<{ id: string; name: string }> | undefined)?.find(
@@ -218,7 +236,7 @@ export default function MessagesPage() {
toast.error('Please select a role')
return
}
if (recipientType === 'ROUND_JURY' && !roundId) {
if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && !roundId) {
toast.error('Please select a round')
return
}
@@ -333,7 +351,7 @@ export default function MessagesPage() {
</div>
)}
{recipientType === 'ROUND_JURY' && (
{(recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && (
<div className="space-y-2">
<Label>Select Round</Label>
<Select value={roundId} onValueChange={setRoundId}>
@@ -670,9 +688,20 @@ export default function MessagesPage() {
<p className="text-sm font-medium mt-1">{subject}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Message</p>
<div className="mt-1 rounded-lg border bg-muted/30 p-4">
<p className="text-sm whitespace-pre-wrap">{body}</p>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Email Preview</p>
<div className="mt-1 rounded-lg border overflow-hidden bg-gray-50">
{emailPreview.data?.html ? (
<iframe
srcDoc={emailPreview.data.html}
sandbox="allow-same-origin"
className="w-full h-[500px] border-0"
title="Email Preview"
/>
) : (
<div className="p-4">
<p className="text-sm whitespace-pre-wrap">{body}</p>
</div>
)}
</div>
</div>
<div>
@@ -699,7 +728,21 @@ export default function MessagesPage() {
</div>
)}
</div>
<DialogFooter>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => sendTestMutation.mutate({ subject, body })}
disabled={sendTestMutation.isPending}
className="sm:mr-auto"
>
{sendTestMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Send Test to Me
</Button>
<Button variant="outline" onClick={() => setShowPreview(false)}>
Edit
</Button>

View File

@@ -90,7 +90,7 @@ const updateProjectSchema = z.object({
'SEMIFINALIST',
'FINALIST',
'REJECTED',
]),
]).optional(),
tags: z.array(z.string()),
competitionCategory: z.string().optional(),
oceanIssue: z.string().optional(),
@@ -186,7 +186,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
title: '',
teamName: '',
description: '',
status: 'SUBMITTED',
status: undefined,
tags: [],
competitionCategory: '',
oceanIssue: '',
@@ -221,7 +221,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
const tags = form.watch('tags')
const selectedStatus = form.watch('status')
const previousStatus = (project?.status ?? 'SUBMITTED') as UpdateProjectForm['status']
const statusTriggersNotifications = ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(selectedStatus)
const statusTriggersNotifications = !!selectedStatus && ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(selectedStatus)
const requiresStatusNotificationConfirmation = Boolean(
project && selectedStatus !== previousStatus && statusTriggersNotifications
)
@@ -439,7 +439,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
>
<FormControl>
<SelectTrigger>
<SelectValue />
<SelectValue placeholder="Select status..." />
</SelectTrigger>
</FormControl>
<SelectContent>

View File

@@ -296,8 +296,8 @@ export default function ProjectsPage() {
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null)
// Fetch programs and rounds for the AI tagging dialog
const { data: programs } = trpc.program.list.useQuery()
// Fetch programs and rounds for the AI tagging dialog + assign-to-round
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
// Start tagging job mutation
const startTaggingJob = trpc.tag.startTaggingJob.useMutation({

View File

@@ -13,7 +13,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
DropdownMenu,
@@ -77,6 +76,7 @@ import {
Trash2,
ArrowRight,
RotateCcw,
ListChecks,
} from 'lucide-react'
import {
Tooltip,
@@ -116,7 +116,11 @@ import { AIRecommendationsDisplay } from '@/components/admin/round/ai-recommenda
import { EvaluationCriteriaEditor } from '@/components/admin/round/evaluation-criteria-editor'
import { COIReviewSection } from '@/components/admin/assignment/coi-review-section'
import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-section-header'
import { NotifyAdvancedButton } from '@/components/admin/round/notify-advanced-button'
import { NotifyRejectedButton } from '@/components/admin/round/notify-rejected-button'
import { BulkInviteButton } from '@/components/admin/round/bulk-invite-button'
import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
import { FinalizationTab } from '@/components/admin/round/finalization-tab'
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -265,6 +269,20 @@ export default function RoundDetailPage() {
}
}, [juryWorkload])
// Auto-select finalization tab when round is closed and not yet finalized
const finalizationAutoSelected = useRef(false)
useEffect(() => {
if (
round &&
!finalizationAutoSelected.current &&
round.status === 'ROUND_CLOSED' &&
!round.finalizedAt
) {
finalizationAutoSelected.current = true
setActiveTab('finalization')
}
}, [round])
// ── Mutations ──────────────────────────────────────────────────────────
const updateMutation = trpc.round.update.useMutation({
onSuccess: () => {
@@ -291,12 +309,12 @@ export default function RoundDetailPage() {
const closeMutation = trpc.roundEngine.close.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round closed')
if (closeAndAdvance) {
setCloseAndAdvance(false)
// Small delay to let cache invalidation complete before opening dialog
setTimeout(() => setAdvanceDialogOpen(true), 300)
}
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
toast.success('Round closed — use the Finalization tab to review and advance projects')
setCloseAndAdvance(false)
// Auto-switch to finalization tab
setActiveTab('finalization')
},
onError: (err) => {
setCloseAndAdvance(false)
@@ -308,6 +326,7 @@ export default function RoundDetailPage() {
onSuccess: (data) => {
utils.round.getById.invalidate({ id: roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
const msg = data.pausedRounds?.length
? `Round reopened. Paused: ${data.pausedRounds.join(', ')}`
: 'Round reopened'
@@ -319,6 +338,8 @@ export default function RoundDetailPage() {
const archiveMutation = trpc.roundEngine.archive.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
toast.success('Round archived')
},
onError: (err) => toast.error(err.message),
@@ -495,6 +516,7 @@ export default function RoundDetailPage() {
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
const hasAwards = roundAwards.length > 0
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
const showFinalization = ['ROUND_CLOSED', 'ROUND_ARCHIVED'].includes(round?.status ?? '')
const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route
@@ -846,6 +868,7 @@ export default function RoundDetailPage() {
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []),
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []),
{ value: 'config', label: 'Config', icon: Settings },
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
].map((tab) => (
@@ -1166,49 +1189,54 @@ export default function RoundDetailPage() {
</div>
</button>
{/* Advance projects (always visible when projects exist) */}
{/* Advance projects — closed rounds go to Finalization tab, active rounds use old dialog */}
{projectCount > 0 && (
<button
onClick={() => (isSimpleAdvance || passedCount > 0)
? setAdvanceDialogOpen(true)
: toast.info('Mark projects as "Passed" first in the Projects tab')}
onClick={() => {
if (showFinalization) {
setActiveTab('finalization')
} else if (isSimpleAdvance || passedCount > 0) {
setAdvanceDialogOpen(true)
} else {
toast.info('Mark projects as "Passed" first in the Projects tab')
}
}}
className={cn(
'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left',
(isSimpleAdvance || passedCount > 0)
(showFinalization || isSimpleAdvance || passedCount > 0)
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
: 'border-dashed opacity-60',
)}
>
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} />
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (showFinalization || isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} />
<div>
<p className="text-sm font-medium">Advance Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
{isSimpleAdvance
? `Advance all ${projectCount} project(s) to the next round`
: passedCount > 0
? `Move ${passedCount} passed project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'}
{showFinalization
? 'Review and confirm advancement in the Finalization tab'
: isSimpleAdvance
? `Advance all ${projectCount} project(s) to the next round`
: passedCount > 0
? `Move ${passedCount} passed project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'}
</p>
</div>
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{isSimpleAdvance ? projectCount : passedCount}</Badge>
</button>
)}
{/* Close & Advance (active rounds with passed projects) */}
{status === 'ROUND_ACTIVE' && passedCount > 0 && (
{/* Close & Finalize (active rounds — closes round and opens finalization tab) */}
{status === 'ROUND_ACTIVE' && projectCount > 0 && (
<button
onClick={() => {
setCloseAndAdvance(true)
closeMutation.mutate({ roundId })
}}
onClick={() => closeMutation.mutate({ roundId })}
disabled={isTransitioning}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-purple-500 bg-purple-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<Square className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Close & Advance</p>
<p className="text-sm font-medium">Close & Finalize</p>
<p className="text-xs text-muted-foreground mt-0.5">
Close this round and advance {passedCount} passed project(s) to the next round
Close this round and review outcomes in the Finalization tab
</p>
</div>
</button>
@@ -1289,12 +1317,24 @@ export default function RoundDetailPage() {
</div>
</div>
)}
{/* Notifications Group */}
{projectCount > 0 && (
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Notifications</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<NotifyAdvancedButton roundId={roundId} />
<NotifyRejectedButton roundId={roundId} />
<BulkInviteButton roundId={roundId} />
</div>
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Advance Projects Dialog */}
<AdvanceProjectsDialog
{/* Advance Projects Dialog — only for active rounds; closed rounds use Finalization tab */}
{!showFinalization && <AdvanceProjectsDialog
open={advanceDialogOpen}
onOpenChange={setAdvanceDialogOpen}
roundId={roundId}
@@ -1309,7 +1349,7 @@ export default function RoundDetailPage() {
roundType: r.roundType,
}))}
currentSortOrder={round?.sortOrder}
/>
/>}
{/* AI Shortlist Confirmation Dialog */}
<AlertDialog open={shortlistDialogOpen} onOpenChange={setShortlistDialogOpen}>
@@ -1435,10 +1475,17 @@ export default function RoundDetailPage() {
{/* ═══════════ PROJECTS TAB ═══════════ */}
<TabsContent value="projects" className="space-y-4">
<ProjectStatesTable competitionId={competitionId} roundId={roundId} onAssignProjects={() => {
setActiveTab('assignments')
setTimeout(() => setPreviewSheetOpen(true), 100)
}} />
<ProjectStatesTable
competitionId={competitionId}
roundId={roundId}
roundStatus={round?.status}
competitionRounds={competition?.rounds}
currentSortOrder={round?.sortOrder}
onAssignProjects={() => {
setActiveTab('assignments')
setTimeout(() => setPreviewSheetOpen(true), 100)
}}
/>
</TabsContent>
{/* ═══════════ FILTERING TAB ═══════════ */}
@@ -2059,6 +2106,13 @@ export default function RoundDetailPage() {
</TabsContent>
)}
{/* ═══════════ FINALIZATION TAB ═══════════ */}
{showFinalization && (
<TabsContent value="finalization" className="space-y-4">
<FinalizationTab roundId={roundId} roundStatus={round.status} />
</TabsContent>
)}
{/* ═══════════ CONFIG TAB ═══════════ */}
<TabsContent value="config" className="space-y-6">
{/* Round Dates */}
@@ -2123,42 +2177,6 @@ export default function RoundDetailPage() {
/>
</CardHeader>
<CardContent className="space-y-0 pt-0">
<div className="flex items-center justify-between p-4 rounded-md">
<div className="space-y-0.5">
<Label htmlFor="notify-on-entry" className="text-sm font-medium">
Notify on round entry
</Label>
<p className="text-xs text-muted-foreground">
Send an automated email to project applicants when their project enters this round
</p>
</div>
<Switch
id="notify-on-entry"
checked={!!config.notifyOnEntry}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, notifyOnEntry: checked })
}}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div className="space-y-0.5">
<Label htmlFor="notify-on-advance" className="text-sm font-medium">
Notify on advance
</Label>
<p className="text-xs text-muted-foreground">
Send an email to project applicants when their project advances from this round to the next
</p>
</div>
<Switch
id="notify-on-advance"
checked={!!config.notifyOnAdvance}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, notifyOnAdvance: checked })
}}
/>
</div>
{(isEvaluation || isFiltering) && (
<div className="border-t mt-2 pt-4 px-4 pb-2 bg-[#053d57]/[0.03] rounded-b-lg -mx-6 -mb-6 p-6">
<Label className="text-sm font-medium">Advancement Targets</Label>

View File

@@ -13,14 +13,14 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { StatusTracker } from '@/components/shared/status-tracker'
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
import { WithdrawButton } from '@/components/applicant/withdraw-button'
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
FileText,
Calendar,
Clock,
CheckCircle,
Users,
Crown,
@@ -43,7 +43,7 @@ const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destru
}
export default function ApplicantDashboardPage() {
const { status: sessionStatus } = useSession()
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
@@ -112,7 +112,6 @@ export default function ApplicantDashboardPage() {
}
const { project, timeline, currentStatus, openRounds, hasPassedIntake } = data
const isDraft = !project.submittedAt
const programYear = project.program?.year
const programName = project.program?.name
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
@@ -121,32 +120,34 @@ export default function ApplicantDashboardPage() {
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
{currentStatus && (
<Badge variant={statusColors[currentStatus] || 'secondary'}>
{currentStatus.replace('_', ' ')}
</Badge>
<div className="flex items-center gap-4">
{/* Project logo */}
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
{data.logoUrl ? (
<img src={data.logoUrl} alt={project.title} className="h-full w-full object-cover" />
) : (
<FileText className="h-7 w-7 text-muted-foreground/60" />
)}
</div>
<p className="text-muted-foreground">
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
</p>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
{currentStatus && (
<Badge variant={statusColors[currentStatus] || 'secondary'}>
{currentStatus.replace('_', ' ')}
</Badge>
)}
</div>
<p className="text-muted-foreground">
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
</p>
</div>
</div>
{project.isTeamLead && currentStatus !== 'REJECTED' && (currentStatus as string) !== 'WINNER' && (
<WithdrawButton projectId={project.id} />
)}
</div>
{/* Draft warning */}
{isDraft && (
<Alert>
<Clock className="h-4 w-4" />
<AlertTitle>Draft Submission</AlertTitle>
<AlertDescription>
This submission has not been submitted yet. You can continue editing and submit when ready.
</AlertDescription>
</Alert>
)}
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
@@ -205,16 +206,11 @@ export default function ApplicantDashboardPage() {
<Calendar className="h-4 w-4" />
Created {new Date(project.createdAt).toLocaleDateString()}
</div>
{project.submittedAt ? (
{project.submittedAt && (
<div className="flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-green-500" />
Submitted {new Date(project.submittedAt).toLocaleDateString()}
</div>
) : (
<div className="flex items-center gap-1">
<Clock className="h-4 w-4 text-orange-500" />
Draft
</div>
)}
<div className="flex items-center gap-1">
<FileText className="h-4 w-4" />
@@ -310,23 +306,25 @@ export default function ApplicantDashboardPage() {
<AnimatedCard index={3}>
<Card>
<CardHeader>
<CardTitle>
{hasPassedIntake ? 'Competition Progress' : 'Status Timeline'}
</CardTitle>
<CardTitle>Status Timeline</CardTitle>
</CardHeader>
<CardContent>
{hasPassedIntake ? (
<CompetitionTimelineSidebar />
) : (
<StatusTracker
timeline={timeline}
currentStatus={currentStatus || 'SUBMITTED'}
/>
)}
<CompetitionTimelineSidebar />
</CardContent>
</Card>
</AnimatedCard>
{/* Mentoring Request Card — show when there's an active MENTORING round */}
{project.isTeamLead && openRounds.filter((r) => r.roundType === 'MENTORING').map((mentoringRound) => (
<AnimatedCard key={mentoringRound.id} index={4}>
<MentoringRequestCard
projectId={project.id}
roundId={mentoringRound.id}
roundName={mentoringRound.name}
/>
</AnimatedCard>
))}
{/* Jury Feedback Card */}
{totalEvaluations > 0 && (
<AnimatedCard index={4}>

View File

@@ -48,7 +48,10 @@ import {
} from '@/components/ui/alert-dialog'
import { CountrySelect } from '@/components/ui/country-select'
import { Checkbox as CheckboxPrimitive } from '@/components/ui/checkbox'
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
import { UserAvatar } from '@/components/shared/user-avatar'
import {
FolderOpen,
Users,
UserPlus,
Crown,
@@ -59,7 +62,14 @@ import {
CheckCircle,
Clock,
FileText,
ImageIcon,
MapPin,
Waves,
GraduationCap,
Heart,
Calendar,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
const inviteSchema = z.object({
name: z.string().min(1, 'Name is required'),
@@ -86,7 +96,21 @@ const statusLabels: Record<string, { label: string; icon: React.ComponentType<{
SUSPENDED: { label: 'Suspended', icon: AlertCircle },
}
export default function ApplicantTeamPage() {
const OCEAN_ISSUE_LABELS: Record<string, string> = {
POLLUTION_REDUCTION: 'Pollution Reduction',
CLIMATE_MITIGATION: 'Climate Mitigation',
TECHNOLOGY_INNOVATION: 'Technology Innovation',
SUSTAINABLE_SHIPPING: 'Sustainable Shipping',
BLUE_CARBON: 'Blue Carbon',
HABITAT_RESTORATION: 'Habitat Restoration',
COMMUNITY_CAPACITY: 'Community Capacity',
SUSTAINABLE_FISHING: 'Sustainable Fishing',
CONSUMER_AWARENESS: 'Consumer Awareness',
OCEAN_ACIDIFICATION: 'Ocean Acidification',
OTHER: 'Other',
}
export default function ApplicantProjectPage() {
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const [isInviteOpen, setIsInviteOpen] = useState(false)
@@ -96,13 +120,20 @@ export default function ApplicantTeamPage() {
{ enabled: isAuthenticated }
)
const projectId = dashboardData?.project?.id
const project = dashboardData?.project
const projectId = project?.id
const isIntakeOpen = dashboardData?.isIntakeOpen ?? false
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
{ projectId: projectId! },
{ enabled: !!projectId }
)
const { data: logoUrl, refetch: refetchLogo } = trpc.applicant.getProjectLogoUrl.useQuery(
{ projectId: projectId! },
{ enabled: !!projectId }
)
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
onSuccess: (result) => {
if (result.requiresAccountSetup) {
@@ -180,18 +211,18 @@ export default function ApplicantTeamPage() {
)
}
if (!projectId) {
if (!projectId || !project) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Team</h1>
<h1 className="text-2xl font-semibold tracking-tight">Project</h1>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project</h2>
<p className="text-muted-foreground text-center">
Submit a project first to manage your team.
Submit a project first to view details.
</p>
</CardContent>
</Card>
@@ -210,159 +241,297 @@ export default function ApplicantTeamPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Project logo */}
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
{logoUrl ? (
<img src={logoUrl} alt={project.title} className="h-full w-full object-cover" />
) : (
<FolderOpen className="h-7 w-7 text-muted-foreground/60" />
)}
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Users className="h-6 w-6" />
Team Members
<h1 className="text-2xl font-semibold tracking-tight">
{project.title}
</h1>
<p className="text-muted-foreground">
Manage your project team
{project.teamName ? `Team: ${project.teamName}` : 'Project details and team management'}
</p>
</div>
{isTeamLead && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
Invite Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your project team. They will receive an email
with instructions to create their account.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="Jane Doe"
{...form.register('name')}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="jane@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={form.watch('role')}
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Team Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
placeholder="CTO, Designer..."
{...form.register('title')}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Nationality</Label>
<CountrySelect
value={form.watch('nationality') || ''}
onChange={(v) => form.setValue('nationality', v)}
placeholder="Select nationality"
/>
</div>
<div className="space-y-2">
<Label>Country of Residence</Label>
<CountrySelect
value={form.watch('country') || ''}
onChange={(v) => form.setValue('country', v)}
placeholder="Select country"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="institution">Institution (optional)</Label>
<Input
id="institution"
placeholder="e.g., Ocean Research Institute"
{...form.register('institution')}
/>
</div>
<div className="flex items-center gap-2">
<CheckboxPrimitive
id="sendInvite"
checked={form.watch('sendInvite')}
onCheckedChange={(checked) => form.setValue('sendInvite', !!checked)}
/>
<Label htmlFor="sendInvite" className="text-sm font-normal cursor-pointer">
Send platform invite email
</Label>
</div>
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
<p className="font-medium mb-1">What invited members can do:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Upload documents for submission rounds</li>
<li>View project status and competition progress</li>
<li>Receive email notifications about round updates</li>
</ul>
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsInviteOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={inviteMutation.isPending}>
{inviteMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send Invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
{/* Project Details Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Project Information
</CardTitle>
{isIntakeOpen && (
<Badge variant="outline" className="text-amber-600 border-amber-200 bg-amber-50">
Editable during intake
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Category & Ocean Issue badges */}
<div className="flex flex-wrap gap-2">
{project.competitionCategory && (
<Badge variant="outline" className="gap-1">
<GraduationCap className="h-3 w-3" />
{project.competitionCategory === 'STARTUP' ? 'Start-up' : 'Business Concept'}
</Badge>
)}
{project.oceanIssue && (
<Badge variant="outline" className="gap-1">
<Waves className="h-3 w-3" />
{OCEAN_ISSUE_LABELS[project.oceanIssue] || project.oceanIssue.replace(/_/g, ' ')}
</Badge>
)}
{project.wantsMentorship && (
<Badge variant="outline" className="gap-1 text-pink-600 border-pink-200 bg-pink-50">
<Heart className="h-3 w-3" />
Wants Mentorship
</Badge>
)}
</div>
{/* Description */}
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
</div>
)}
{/* Location, Institution, Founded */}
<div className="grid gap-4 sm:grid-cols-2">
{(project.country || project.geographicZone) && (
<div className="flex items-start gap-2">
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Location</p>
<p className="text-sm">{project.geographicZone || project.country}</p>
</div>
</div>
)}
{project.institution && (
<div className="flex items-start gap-2">
<GraduationCap className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Institution</p>
<p className="text-sm">{project.institution}</p>
</div>
</div>
)}
{project.foundedAt && (
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Founded</p>
<p className="text-sm">{formatDateOnly(project.foundedAt)}</p>
</div>
</div>
)}
</div>
{/* Mentor info */}
{project.mentorAssignment?.mentor && (
<div className="rounded-lg border p-3 bg-muted/50">
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
<p className="text-sm text-muted-foreground">
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email})
</p>
</div>
)}
{/* Tags */}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">Tags</p>
<div className="flex flex-wrap gap-1">
{project.tags.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Project Logo */}
{isTeamLead && projectId && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ImageIcon className="h-5 w-5" />
Project Logo
</CardTitle>
<CardDescription>
Click the image to upload or change your project logo.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<ProjectLogoUpload
projectId={projectId}
currentLogoUrl={logoUrl}
onUploadComplete={() => refetchLogo()}
/>
</CardContent>
</Card>
)}
{/* Team Members List */}
<Card>
<CardHeader>
<CardTitle>Team ({teamData?.teamMembers.length || 0} members)</CardTitle>
<CardDescription>
Everyone on this list can view and collaborate on this project.
</CardDescription>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Team ({teamData?.teamMembers.length || 0} members)
</CardTitle>
<CardDescription>
Everyone on this list can view and collaborate on this project.
</CardDescription>
</div>
{isTeamLead && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button size="sm">
<UserPlus className="mr-2 h-4 w-4" />
Invite
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your project team. They will receive an email
with instructions to create their account.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="Jane Doe"
{...form.register('name')}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="jane@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={form.watch('role')}
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Team Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
placeholder="CTO, Designer..."
{...form.register('title')}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Nationality</Label>
<CountrySelect
value={form.watch('nationality') || ''}
onChange={(v) => form.setValue('nationality', v)}
placeholder="Select nationality"
/>
</div>
<div className="space-y-2">
<Label>Country of Residence</Label>
<CountrySelect
value={form.watch('country') || ''}
onChange={(v) => form.setValue('country', v)}
placeholder="Select country"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="institution">Institution (optional)</Label>
<Input
id="institution"
placeholder="e.g., Ocean Research Institute"
{...form.register('institution')}
/>
</div>
<div className="flex items-center gap-2">
<CheckboxPrimitive
id="sendInvite"
checked={form.watch('sendInvite')}
onCheckedChange={(checked) => form.setValue('sendInvite', !!checked)}
/>
<Label htmlFor="sendInvite" className="text-sm font-normal cursor-pointer">
Send platform invite email
</Label>
</div>
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
<p className="font-medium mb-1">What invited members can do:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Upload documents for submission rounds</li>
<li>View project status and competition progress</li>
<li>Receive email notifications about round updates</li>
</ul>
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsInviteOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={inviteMutation.isPending}>
{inviteMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send Invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{teamData?.teamMembers.map((member) => {
@@ -374,13 +543,16 @@ export default function ApplicantTeamPage() {
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-5 w-5 text-yellow-500" />
) : (
<span className="text-sm font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
<div className="relative">
<UserAvatar
user={member.user}
avatarUrl={teamData?.avatarUrls?.[member.userId] || null}
size="md"
/>
{member.role === 'LEAD' && (
<div className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-yellow-100 ring-2 ring-white">
<Crown className="h-2.5 w-2.5 text-yellow-600" />
</div>
)}
</div>
<div>
@@ -455,25 +627,6 @@ export default function ApplicantTeamPage() {
)}
</CardContent>
</Card>
{/* Team Documents - visible via applicant documents page */}
{/* Info Card */}
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">About Team Access</p>
<p className="mt-1">
All team members can view project details and status updates.
Only the team lead can invite or remove team members.
Invited members will receive an email to set up their account.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -12,6 +12,7 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react'
import Image from 'next/image'
import { trpc } from '@/lib/trpc/client'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -179,13 +180,14 @@ function AcceptInviteContent() {
// Valid invitation - show welcome
const user = data?.user
const team = data?.team
return (
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
<CheckCircle2 className="h-6 w-6 text-green-600" />
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-white shadow-sm border">
<Image src="/images/MOPC-blue-small.png" alt="MOPC" width={32} height={32} className="object-contain" />
</div>
<CardTitle className="text-xl">
{user?.name ? `Welcome, ${user.name}!` : 'Welcome!'}
@@ -196,6 +198,14 @@ function AcceptInviteContent() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{team?.projectTitle && (
<div className="rounded-md border border-blue-200 bg-blue-50 p-3 text-center">
<p className="text-sm text-blue-700">
You&apos;ve been invited to join the team for
</p>
<p className="font-semibold text-blue-900">&ldquo;{team.projectTitle}&rdquo;</p>
</div>
)}
{user?.email && (
<div className="rounded-md bg-muted/50 p-3 text-center">
<p className="text-sm text-muted-foreground">Signing in as</p>

View File

@@ -2,6 +2,7 @@
import { useState, useMemo, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -39,8 +40,17 @@ import {
Building2,
Flag,
ImageIcon,
Compass,
LayoutDashboard,
Upload,
ClipboardList,
Users,
Trophy,
BookOpen,
GraduationCap,
} from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
import { UserAvatar } from '@/components/shared/user-avatar'
type Step =
| 'name'
@@ -51,6 +61,7 @@ type Step =
| 'bio'
| 'logo'
| 'preferences'
| 'guide'
| 'complete'
type ApplicantWizardProps = {
@@ -136,7 +147,7 @@ export function ApplicantOnboardingWizard({
if (onboardingCtx?.projectId) {
base.push('logo')
}
base.push('preferences', 'complete')
base.push('preferences', 'guide', 'complete')
return base
}, [onboardingCtx?.projectId])
@@ -191,6 +202,7 @@ export function ApplicantOnboardingWizard({
bio: 'About',
logo: 'Logo',
preferences: 'Settings',
guide: 'Guide',
complete: 'Done',
}
@@ -203,11 +215,11 @@ export function ApplicantOnboardingWizard({
{/* Progress indicator */}
{step !== 'complete' && (
<div className="px-6 pt-6">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{steps.slice(0, -1).map((s, i) => (
<div key={s} className="flex items-center flex-1">
<div key={s} className="flex-1 flex flex-col items-center gap-1">
<div
className={`h-2 flex-1 rounded-full transition-colors ${
className={`h-2 w-full rounded-full transition-colors ${
i < currentIndex
? 'bg-primary'
: i === currentIndex
@@ -215,15 +227,9 @@ export function ApplicantOnboardingWizard({
: 'bg-muted'
}`}
/>
</div>
))}
</div>
<div className="flex items-center gap-2 mt-1">
{steps.slice(0, -1).map((s, i) => (
<div key={s} className="flex-1 text-center">
<span
className={cn(
'text-[10px]',
'text-[10px] leading-none',
i <= currentIndex ? 'text-primary font-medium' : 'text-muted-foreground'
)}
>
@@ -291,7 +297,16 @@ export function ApplicantOnboardingWizard({
}}
currentAvatarUrl={avatarUrl}
onUploadComplete={() => refetchUser()}
/>
>
<div className="cursor-pointer">
<UserAvatar
user={{ name: userData?.name, email: userData?.email }}
avatarUrl={avatarUrl}
size="2xl"
showEditOverlay
/>
</div>
</AvatarUpload>
</div>
<p className="text-sm text-muted-foreground text-center">
Click the avatar to upload a new photo.
@@ -555,6 +570,83 @@ export function ApplicantOnboardingWizard({
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Portal Guide */}
{step === 'guide' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Compass className="h-5 w-5 text-primary" />
Your Applicant Portal
</CardTitle>
<CardDescription>
Here&apos;s what you can do through the MOPC Applicant Portal.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
{[
{
icon: LayoutDashboard,
title: 'Dashboard',
desc: 'Overview of your project status, team, and upcoming deadlines.',
},
{
icon: Upload,
title: 'Documents',
desc: 'Upload required files for each round and track submission progress.',
},
{
icon: ClipboardList,
title: 'Evaluations',
desc: 'View anonymized jury feedback and scores for your project.',
},
{
icon: Users,
title: 'Team',
desc: 'Manage your team members, invite collaborators, and update your project logo.',
},
{
icon: Trophy,
title: 'Competition',
desc: 'Track your progress through competition rounds and milestones.',
},
{
icon: GraduationCap,
title: 'Mentorship',
desc: 'Connect with your assigned mentor for guidance and support.',
},
{
icon: BookOpen,
title: 'Resources',
desc: 'Access helpful materials, guides, and competition resources.',
},
].map(({ icon: Icon, title, desc }) => (
<div key={title} className="flex items-start gap-3 rounded-lg border p-3">
<div className="rounded-md bg-primary/10 p-2 shrink-0">
<Icon className="h-4 w-4 text-primary" />
</div>
<div>
<p className="font-medium text-sm">{title}</p>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
</div>
))}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
@@ -580,8 +672,8 @@ export function ApplicantOnboardingWizard({
{/* Step: Complete */}
{step === 'complete' && (
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="rounded-2xl bg-emerald-50 p-4 mb-4 animate-in zoom-in-50 duration-500">
<CheckCircle className="h-12 w-12 text-green-600" />
<div className="mb-4 animate-in zoom-in-50 duration-500">
<Image src="/images/MOPC-blue-small.png" alt="MOPC Logo" width={64} height={64} className="h-16 w-16" />
</div>
<h2 className="text-xl font-semibold mb-2 animate-in fade-in slide-in-from-bottom-2 duration-500 delay-200">
Welcome, {name}!

View File

@@ -43,7 +43,7 @@ export default function SetPasswordPage() {
} else if (session?.user?.role === 'SUPER_ADMIN' || session?.user?.role === 'PROGRAM_ADMIN') {
router.push('/admin')
} else if (session?.user?.role === 'APPLICANT') {
router.push('/applicant')
router.push('/onboarding')
} else {
router.push('/')
}
@@ -148,7 +148,7 @@ export default function SetPasswordPage() {
</CardHeader>
<CardContent className="text-center">
<p className="text-sm text-muted-foreground">
Redirecting you to the dashboard...
Redirecting you to onboarding...
</p>
</CardContent>
</Card>

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { processRoundClose } from '@/server/services/round-finalization'
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 now = new Date()
// Find rounds with expired grace periods that haven't been finalized
const expiredRounds = await prisma.round.findMany({
where: {
status: 'ROUND_CLOSED',
gracePeriodEndsAt: { lt: now },
finalizedAt: null,
},
select: { id: true, name: true },
})
const results: Array<{ roundId: string; roundName: string; processed: number }> = []
for (const round of expiredRounds) {
try {
const result = await processRoundClose(round.id, 'system-cron', prisma)
results.push({ roundId: round.id, roundName: round.name, processed: result.processed })
} catch (err) {
console.error(`[Cron] processRoundClose failed for round ${round.id}:`, err)
}
}
return NextResponse.json({
ok: true,
processedRounds: results.length,
results,
timestamp: now.toISOString(),
})
} catch (error) {
console.error('Cron grace period processing failed:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}