feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
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:
@@ -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 "Selected for {award.name}" 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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've been invited to join the team for
|
||||
</p>
|
||||
<p className="font-semibold text-blue-900">“{team.projectTitle}”</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>
|
||||
|
||||
@@ -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'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}!
|
||||
|
||||
@@ -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>
|
||||
|
||||
47
src/app/api/cron/process-grace-periods/route.ts
Normal file
47
src/app/api/cron/process-grace-periods/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user