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>
|
||||
|
||||
Reference in New Issue
Block a user