Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -101,6 +101,12 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover'
|
} from '@/components/ui/popover'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
||||||
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
||||||
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
|
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
|
||||||
@@ -116,6 +122,20 @@ import { motion } from 'motion/react'
|
|||||||
import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
|
import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
|
||||||
import type { Criterion } from '@/components/forms/evaluation-form-builder'
|
import type { Criterion } from '@/components/forms/evaluation-form-builder'
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getRelativeTime(date: Date): string {
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = date.getTime() - now.getTime()
|
||||||
|
const absDiffMs = Math.abs(diffMs)
|
||||||
|
const minutes = Math.floor(absDiffMs / 60_000)
|
||||||
|
const hours = Math.floor(absDiffMs / 3_600_000)
|
||||||
|
const days = Math.floor(absDiffMs / 86_400_000)
|
||||||
|
|
||||||
|
const label = days > 0 ? `${days}d` : hours > 0 ? `${hours}h` : `${minutes}m`
|
||||||
|
return diffMs > 0 ? `in ${label}` : `${label} ago`
|
||||||
|
}
|
||||||
|
|
||||||
// ── Status & type config maps ──────────────────────────────────────────────
|
// ── Status & type config maps ──────────────────────────────────────────────
|
||||||
const roundStatusConfig = {
|
const roundStatusConfig = {
|
||||||
ROUND_DRAFT: {
|
ROUND_DRAFT: {
|
||||||
@@ -203,6 +223,10 @@ export default function RoundDetailPage() {
|
|||||||
const [newJuryName, setNewJuryName] = useState('')
|
const [newJuryName, setNewJuryName] = useState('')
|
||||||
const [addMemberOpen, setAddMemberOpen] = useState(false)
|
const [addMemberOpen, setAddMemberOpen] = useState(false)
|
||||||
const [closeAndAdvance, setCloseAndAdvance] = useState(false)
|
const [closeAndAdvance, setCloseAndAdvance] = useState(false)
|
||||||
|
const [editingName, setEditingName] = useState(false)
|
||||||
|
const [nameValue, setNameValue] = useState('')
|
||||||
|
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [statusConfirmAction, setStatusConfirmAction] = useState<'activate' | 'close' | 'reopen' | 'archive' | null>(null)
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
@@ -565,18 +589,61 @@ export default function RoundDetailPage() {
|
|||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
<Link href={(round.specialAwardId ? `/admin/awards/${round.specialAwardId}` : '/admin/rounds') as Route} className="mt-0.5 shrink-0">
|
<Link href={(round.specialAwardId ? `/admin/awards/${round.specialAwardId}` : '/admin/rounds') as Route} className="mt-0.5 shrink-0">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white/80 hover:text-white hover:bg-white/10" aria-label={round.specialAwardId ? 'Back to Award' : 'Back to rounds'}>
|
<Button variant="ghost" size="sm" className="h-8 text-white/80 hover:text-white hover:bg-white/10 gap-1.5" aria-label={round.specialAwardId ? 'Back to Award' : 'Back to rounds'}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span className="text-xs hidden sm:inline">{round.specialAwardId ? 'Back to Award' : 'Back to Rounds'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
<h1 className="text-xl font-bold tracking-tight truncate">{round.name}</h1>
|
{/* 4.6 Inline-editable round name */}
|
||||||
|
{editingName ? (
|
||||||
|
<Input
|
||||||
|
ref={nameInputRef}
|
||||||
|
value={nameValue}
|
||||||
|
onChange={(e) => setNameValue(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const trimmed = nameValue.trim()
|
||||||
|
if (trimmed && trimmed !== round.name) {
|
||||||
|
updateMutation.mutate({ id: roundId, name: trimmed })
|
||||||
|
}
|
||||||
|
setEditingName(false)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
(e.target as HTMLInputElement).blur()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setNameValue(round.name)
|
||||||
|
setEditingName(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xl font-bold tracking-tight bg-white/10 border-white/30 text-white h-8 w-64"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xl font-bold tracking-tight truncate hover:bg-white/10 rounded px-1 -mx-1 transition-colors cursor-text"
|
||||||
|
onClick={() => {
|
||||||
|
setNameValue(round.name)
|
||||||
|
setEditingName(true)
|
||||||
|
setTimeout(() => nameInputRef.current?.focus(), 0)
|
||||||
|
}}
|
||||||
|
title="Click to edit round name"
|
||||||
|
>
|
||||||
|
{round.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Badge variant="secondary" className="text-xs shrink-0 bg-white/15 text-white border-white/20 hover:bg-white/20">
|
<Badge variant="secondary" className="text-xs shrink-0 bg-white/15 text-white border-white/20 hover:bg-white/20">
|
||||||
{typeCfg.label}
|
{typeCfg.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{/* Status dropdown */}
|
{/* Status dropdown with confirmation dialogs (4.1) */}
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -593,7 +660,7 @@ export default function RoundDetailPage() {
|
|||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
{status === 'ROUND_DRAFT' && (
|
{status === 'ROUND_DRAFT' && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => activateMutation.mutate({ roundId })}
|
onClick={() => setStatusConfirmAction('activate')}
|
||||||
disabled={isTransitioning}
|
disabled={isTransitioning}
|
||||||
>
|
>
|
||||||
<Play className="h-4 w-4 mr-2 text-emerald-600" />
|
<Play className="h-4 w-4 mr-2 text-emerald-600" />
|
||||||
@@ -602,7 +669,7 @@ export default function RoundDetailPage() {
|
|||||||
)}
|
)}
|
||||||
{status === 'ROUND_ACTIVE' && (
|
{status === 'ROUND_ACTIVE' && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => closeMutation.mutate({ roundId })}
|
onClick={() => setStatusConfirmAction('close')}
|
||||||
disabled={isTransitioning}
|
disabled={isTransitioning}
|
||||||
>
|
>
|
||||||
<Square className="h-4 w-4 mr-2 text-blue-600" />
|
<Square className="h-4 w-4 mr-2 text-blue-600" />
|
||||||
@@ -612,7 +679,7 @@ export default function RoundDetailPage() {
|
|||||||
{status === 'ROUND_CLOSED' && (
|
{status === 'ROUND_CLOSED' && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => reopenMutation.mutate({ roundId })}
|
onClick={() => setStatusConfirmAction('reopen')}
|
||||||
disabled={isTransitioning}
|
disabled={isTransitioning}
|
||||||
>
|
>
|
||||||
<Play className="h-4 w-4 mr-2 text-emerald-600" />
|
<Play className="h-4 w-4 mr-2 text-emerald-600" />
|
||||||
@@ -620,7 +687,7 @@ export default function RoundDetailPage() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => archiveMutation.mutate({ roundId })}
|
onClick={() => setStatusConfirmAction('archive')}
|
||||||
disabled={isTransitioning}
|
disabled={isTransitioning}
|
||||||
>
|
>
|
||||||
<Archive className="h-4 w-4 mr-2" />
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
@@ -636,6 +703,50 @@ export default function RoundDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p>{statusCfg.description}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{/* Status change confirmation dialog (4.1) */}
|
||||||
|
<AlertDialog open={!!statusConfirmAction} onOpenChange={(open) => { if (!open) setStatusConfirmAction(null) }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{statusConfirmAction === 'activate' && 'Activate this round?'}
|
||||||
|
{statusConfirmAction === 'close' && 'Close this round?'}
|
||||||
|
{statusConfirmAction === 'reopen' && 'Reopen this round?'}
|
||||||
|
{statusConfirmAction === 'archive' && 'Archive this round?'}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{statusConfirmAction === 'activate' && 'The round will go live. Projects can be processed and jury members will be able to see their assignments.'}
|
||||||
|
{statusConfirmAction === 'close' && 'No further changes will be accepted. You can reactivate later if needed.'}
|
||||||
|
{statusConfirmAction === 'reopen' && 'The round will become active again. Any rounds after this one that are currently active will be paused automatically.'}
|
||||||
|
{statusConfirmAction === 'archive' && 'The round will be archived. It will only be available as a historical record.'}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
if (statusConfirmAction === 'activate') activateMutation.mutate({ roundId })
|
||||||
|
else if (statusConfirmAction === 'close') closeMutation.mutate({ roundId })
|
||||||
|
else if (statusConfirmAction === 'reopen') reopenMutation.mutate({ roundId })
|
||||||
|
else if (statusConfirmAction === 'archive') archiveMutation.mutate({ roundId })
|
||||||
|
setStatusConfirmAction(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusConfirmAction === 'activate' && 'Activate'}
|
||||||
|
{statusConfirmAction === 'close' && 'Close Round'}
|
||||||
|
{statusConfirmAction === 'reopen' && 'Reopen'}
|
||||||
|
{statusConfirmAction === 'archive' && 'Archive'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-white/60 mt-1">{typeCfg.description}</p>
|
<p className="text-sm text-white/60 mt-1">{typeCfg.description}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -694,38 +805,30 @@ export default function RoundDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-muted-foreground">Jury</span>
|
<span className="text-sm font-medium text-muted-foreground">Jury</span>
|
||||||
</div>
|
</div>
|
||||||
{juryGroups && juryGroups.length > 0 ? (
|
{juryGroup ? (
|
||||||
<Select
|
|
||||||
value={round.juryGroupId ?? '__none__'}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
assignJuryMutation.mutate({
|
|
||||||
id: roundId,
|
|
||||||
juryGroupId: value === '__none__' ? null : value,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
disabled={assignJuryMutation.isPending}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs mt-1">
|
|
||||||
<SelectValue placeholder="Select jury group..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__none__">No jury assigned</SelectItem>
|
|
||||||
{juryGroups.map((jg: any) => (
|
|
||||||
<SelectItem key={jg.id} value={jg.id}>
|
|
||||||
{jg.name} ({jg._count?.members ?? 0} members)
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : juryGroup ? (
|
|
||||||
<>
|
<>
|
||||||
<p className="text-3xl font-bold mt-2">{juryMemberCount}</p>
|
<p className="text-3xl font-bold mt-2">{juryMemberCount}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p>
|
<p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[10px] text-[#557f8c] hover:underline shrink-0"
|
||||||
|
onClick={() => setActiveTab('jury')}
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-3xl font-bold mt-2 text-muted-foreground">—</p>
|
<p className="text-3xl font-bold mt-2 text-muted-foreground">—</p>
|
||||||
<p className="text-xs text-muted-foreground">No jury groups yet</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-[#557f8c] hover:underline"
|
||||||
|
onClick={() => setActiveTab('jury')}
|
||||||
|
>
|
||||||
|
Assign jury group
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -755,6 +858,21 @@ export default function RoundDetailPage() {
|
|||||||
? `Closes ${new Date(round.windowCloseAt).toLocaleDateString()}`
|
? `Closes ${new Date(round.windowCloseAt).toLocaleDateString()}`
|
||||||
: 'No deadline'}
|
: 'No deadline'}
|
||||||
</p>
|
</p>
|
||||||
|
{(() => {
|
||||||
|
const now = new Date()
|
||||||
|
const openAt = round.windowOpenAt ? new Date(round.windowOpenAt) : null
|
||||||
|
const closeAt = round.windowCloseAt ? new Date(round.windowCloseAt) : null
|
||||||
|
if (openAt && now < openAt) {
|
||||||
|
return <p className="text-[10px] text-[#557f8c] font-medium mt-0.5">Opens {getRelativeTime(openAt)}</p>
|
||||||
|
}
|
||||||
|
if (closeAt && now < closeAt) {
|
||||||
|
return <p className="text-[10px] text-amber-600 font-medium mt-0.5">Closes {getRelativeTime(closeAt)}</p>
|
||||||
|
}
|
||||||
|
if (closeAt && now >= closeAt) {
|
||||||
|
return <p className="text-[10px] text-muted-foreground mt-0.5">Closed {getRelativeTime(closeAt)}</p>
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -885,7 +1003,7 @@ export default function RoundDetailPage() {
|
|||||||
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
|
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className={cn('text-sm font-medium', item.ready && 'text-muted-foreground line-through opacity-60')}>
|
<p className={cn('text-sm font-medium', item.ready && 'text-emerald-600 opacity-80')}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">{item.detail}</p>
|
<p className="text-xs text-muted-foreground">{item.detail}</p>
|
||||||
@@ -1325,7 +1443,7 @@ export default function RoundDetailPage() {
|
|||||||
{[
|
{[
|
||||||
{ label: 'Type', value: <Badge variant="secondary" className={cn('text-xs', typeCfg.color)}>{typeCfg.label}</Badge> },
|
{ label: 'Type', value: <Badge variant="secondary" className={cn('text-xs', typeCfg.color)}>{typeCfg.label}</Badge> },
|
||||||
{ label: 'Status', value: <span className="font-medium">{statusCfg.label}</span> },
|
{ label: 'Status', value: <span className="font-medium">{statusCfg.label}</span> },
|
||||||
{ label: 'Sort Order', value: <span className="font-medium font-mono">{round.sortOrder}</span> },
|
{ label: 'Position', value: <span className="font-medium">{`Round ${(round.sortOrder ?? 0) + 1}${competition?.rounds ? ` of ${competition.rounds.length}` : ''}`}</span> },
|
||||||
...(round.purposeKey ? [{ label: 'Purpose', value: <span className="font-medium">{round.purposeKey}</span> }] : []),
|
...(round.purposeKey ? [{ label: 'Purpose', value: <span className="font-medium">{round.purposeKey}</span> }] : []),
|
||||||
{ label: 'Jury Group', value: <span className="font-medium">{juryGroup ? juryGroup.name : '\u2014'}</span> },
|
{ label: 'Jury Group', value: <span className="font-medium">{juryGroup ? juryGroup.name : '\u2014'}</span> },
|
||||||
{ label: 'Opens', value: <span className="font-medium">{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}</span> },
|
{ label: 'Opens', value: <span className="font-medium">{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}</span> },
|
||||||
@@ -1653,15 +1771,39 @@ export default function RoundDetailPage() {
|
|||||||
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
|
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
|
||||||
{isEvaluation && (
|
{isEvaluation && (
|
||||||
<TabsContent value="assignments" className="space-y-6">
|
<TabsContent value="assignments" className="space-y-6">
|
||||||
{/* Coverage Report */}
|
{/* 4.9 Gate assignments when no jury group */}
|
||||||
|
{!round?.juryGroupId ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="rounded-full bg-amber-50 p-4 mb-4">
|
||||||
|
<Users className="h-8 w-8 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">No Jury Group Assigned</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
|
||||||
|
Assign a jury group first to manage assignments.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" className="mt-4" onClick={() => setActiveTab('jury')}>
|
||||||
|
<Users className="h-4 w-4 mr-1.5" />
|
||||||
|
Go to Jury Tab
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Card 1: Coverage & Generation */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Coverage & Generation</CardTitle>
|
||||||
|
<CardDescription>Assignment coverage overview and AI generation</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
<CoverageReport roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
|
<CoverageReport roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
|
||||||
|
|
||||||
{/* Generate Assignments */}
|
{/* Generate Assignments */}
|
||||||
<Card className={cn(aiAssignmentMutation.isPending && 'border-violet-300 shadow-violet-100 shadow-sm')}>
|
<div className={cn('rounded-lg border p-4 space-y-3', aiAssignmentMutation.isPending && 'border-violet-300 bg-violet-50/30')}>
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<p className="text-sm font-medium flex items-center gap-2">
|
||||||
Assignment Generation
|
Assignment Generation
|
||||||
{aiAssignmentMutation.isPending && (
|
{aiAssignmentMutation.isPending && (
|
||||||
<Badge variant="outline" className="gap-1.5 text-violet-600 border-violet-300 animate-pulse">
|
<Badge variant="outline" className="gap-1.5 text-violet-600 border-violet-300 animate-pulse">
|
||||||
@@ -1675,10 +1817,10 @@ export default function RoundDetailPage() {
|
|||||||
{aiAssignmentMutation.data.stats.assignmentsGenerated} ready
|
{aiAssignmentMutation.data.stats.assignmentsGenerated} ready
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</p>
|
||||||
<CardDescription>
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
AI-suggested jury-to-project assignments based on expertise and workload
|
AI-suggested jury-to-project assignments based on expertise and workload
|
||||||
</CardDescription>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
|
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
|
||||||
@@ -1714,14 +1856,6 @@ export default function RoundDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{!juryGroup && (
|
|
||||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-800">
|
|
||||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
|
||||||
Assign a jury group first before generating assignments.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{projectCount === 0 && (
|
{projectCount === 0 && (
|
||||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-800">
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-800">
|
||||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
@@ -1776,33 +1910,50 @@ export default function RoundDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Jury Progress + Score Distribution */}
|
{/* Jury Progress + Score Distribution (standalone 2-col grid) */}
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<JuryProgressTable roundId={roundId} />
|
<JuryProgressTable roundId={roundId} />
|
||||||
<ScoreDistribution roundId={roundId} />
|
<ScoreDistribution roundId={roundId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions: Send Reminders + Notify + Export */}
|
{/* Card 2: Assignments — with action buttons in header */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">Assignments</CardTitle>
|
||||||
|
<CardDescription>Individual jury-project assignments and actions</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<SendRemindersButton roundId={roundId} />
|
<SendRemindersButton roundId={roundId} />
|
||||||
<NotifyJurorsButton roundId={roundId} />
|
<NotifyJurorsButton roundId={roundId} />
|
||||||
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
|
||||||
<Download className="h-4 w-4 mr-1.5" />
|
<Download className="h-4 w-4 mr-1.5" />
|
||||||
Export Evaluations
|
Export
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Individual Assignments Table */}
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
|
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Conflict of Interest Declarations */}
|
{/* Card 3: Monitoring — COI + Unassigned Queue */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Monitoring</CardTitle>
|
||||||
|
<CardDescription>Conflict of interest declarations and unassigned projects</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
<COIReviewSection roundId={roundId} />
|
<COIReviewSection roundId={roundId} />
|
||||||
|
|
||||||
{/* Unassigned Queue */}
|
|
||||||
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
|
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Assignment Preview Sheet */}
|
{/* Assignment Preview Sheet */}
|
||||||
<AssignmentPreviewSheet
|
<AssignmentPreviewSheet
|
||||||
@@ -1821,6 +1972,8 @@ export default function RoundDetailPage() {
|
|||||||
|
|
||||||
{/* CSV Export Dialog */}
|
{/* CSV Export Dialog */}
|
||||||
<ExportEvaluationsDialog roundId={roundId} open={exportOpen} onOpenChange={setExportOpen} />
|
<ExportEvaluationsDialog roundId={roundId} open={exportOpen} onOpenChange={setExportOpen} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1840,7 +1993,7 @@ export default function RoundDetailPage() {
|
|||||||
<Label>Start Date</Label>
|
<Label>Start Date</Label>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={round.windowOpenAt ? new Date(round.windowOpenAt) : null}
|
value={round.windowOpenAt ? new Date(round.windowOpenAt) : null}
|
||||||
onChange={(date) => updateMutation.mutate({ id: roundId, windowOpenAt: date })}
|
onChange={(date) => updateMutation.mutate({ id: roundId, windowOpenAt: date }, { onSuccess: () => toast.success('Dates saved') })}
|
||||||
placeholder="Select start date & time"
|
placeholder="Select start date & time"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
@@ -1849,7 +2002,7 @@ export default function RoundDetailPage() {
|
|||||||
<Label>End Date</Label>
|
<Label>End Date</Label>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={round.windowCloseAt ? new Date(round.windowCloseAt) : null}
|
value={round.windowCloseAt ? new Date(round.windowCloseAt) : null}
|
||||||
onChange={(date) => updateMutation.mutate({ id: roundId, windowCloseAt: date })}
|
onChange={(date) => updateMutation.mutate({ id: roundId, windowCloseAt: date }, { onSuccess: () => toast.success('Dates saved') })}
|
||||||
placeholder="Select end date & time"
|
placeholder="Select end date & time"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
@@ -2140,7 +2293,7 @@ function InlineMemberCap({
|
|||||||
>
|
>
|
||||||
<span className="text-muted-foreground">max:</span>
|
<span className="text-muted-foreground">max:</span>
|
||||||
<span className="font-medium">{currentValue ?? '\u221E'}</span>
|
<span className="font-medium">{currentValue ?? '\u221E'}</span>
|
||||||
<Pencil className="h-2.5 w-2.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
<Pencil className="h-2.5 w-2.5 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2154,12 +2307,12 @@ function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: strin
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="space-y-3">
|
||||||
<CardHeader>
|
<div>
|
||||||
<CardTitle className="text-base">Unassigned Projects</CardTitle>
|
<p className="text-sm font-medium">Unassigned Projects</p>
|
||||||
<CardDescription>Projects with fewer than {requiredReviews} jury assignments</CardDescription>
|
<p className="text-xs text-muted-foreground">Projects with fewer than {requiredReviews} jury assignments</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||||
@@ -2197,8 +2350,8 @@ function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: strin
|
|||||||
All projects have sufficient assignments
|
All projects have sufficient assignments
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2364,7 +2517,7 @@ function NotifyJurorsButton({ roundId }: { roundId: string }) {
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const mutation = trpc.assignment.notifyJurorsOfAssignments.useMutation({
|
const mutation = trpc.assignment.notifyJurorsOfAssignments.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success(`Notified ${data.jurorCount} juror(s) — ${data.emailsSent} email(s) sent`)
|
toast.success(`Notified ${data.jurorCount} juror(s) of their assignments`)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
@@ -2448,10 +2601,18 @@ function IndividualAssignmentsTable({
|
|||||||
projectStates: any[] | undefined
|
projectStates: any[] | undefined
|
||||||
}) {
|
}) {
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
|
const [confirmAction, setConfirmAction] = useState<{ type: 'reset' | 'delete'; assignment: any } | null>(null)
|
||||||
|
const [assignMode, setAssignMode] = useState<'byJuror' | 'byProject'>('byJuror')
|
||||||
|
// ── By Juror mode state ──
|
||||||
const [selectedJurorId, setSelectedJurorId] = useState('')
|
const [selectedJurorId, setSelectedJurorId] = useState('')
|
||||||
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
|
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
|
||||||
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
|
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
|
||||||
const [projectSearch, setProjectSearch] = useState('')
|
const [projectSearch, setProjectSearch] = useState('')
|
||||||
|
// ── By Project mode state ──
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState('')
|
||||||
|
const [selectedJurorIds, setSelectedJurorIds] = useState<Set<string>>(new Set())
|
||||||
|
const [projectPopoverOpen, setProjectPopoverOpen] = useState(false)
|
||||||
|
const [jurorSearch, setJurorSearch] = useState('')
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
|
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
|
||||||
@@ -2505,9 +2666,13 @@ function IndividualAssignmentsTable({
|
|||||||
|
|
||||||
const resetDialog = useCallback(() => {
|
const resetDialog = useCallback(() => {
|
||||||
setAddDialogOpen(false)
|
setAddDialogOpen(false)
|
||||||
|
setAssignMode('byJuror')
|
||||||
setSelectedJurorId('')
|
setSelectedJurorId('')
|
||||||
setSelectedProjectIds(new Set())
|
setSelectedProjectIds(new Set())
|
||||||
setProjectSearch('')
|
setProjectSearch('')
|
||||||
|
setSelectedProjectId('')
|
||||||
|
setSelectedJurorIds(new Set())
|
||||||
|
setJurorSearch('')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const selectedJuror = useMemo(
|
const selectedJuror = useMemo(
|
||||||
@@ -2580,23 +2745,81 @@ function IndividualAssignmentsTable({
|
|||||||
|
|
||||||
const isMutating = createMutation.isPending || bulkCreateMutation.isPending
|
const isMutating = createMutation.isPending || bulkCreateMutation.isPending
|
||||||
|
|
||||||
|
// ── By Project mode helpers ──
|
||||||
|
|
||||||
|
// Existing assignments for the selected project (to grey out already-assigned jurors)
|
||||||
|
const projectExistingJurorIds = useMemo(() => {
|
||||||
|
if (!selectedProjectId || !assignments) return new Set<string>()
|
||||||
|
return new Set(
|
||||||
|
assignments
|
||||||
|
.filter((a: any) => a.projectId === selectedProjectId)
|
||||||
|
.map((a: any) => a.userId)
|
||||||
|
)
|
||||||
|
}, [selectedProjectId, assignments])
|
||||||
|
|
||||||
|
// Count assignments per juror in this round (for display)
|
||||||
|
const jurorAssignmentCounts = useMemo(() => {
|
||||||
|
if (!assignments) return new Map<string, number>()
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const a of assignments) {
|
||||||
|
counts.set(a.userId, (counts.get(a.userId) || 0) + 1)
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}, [assignments])
|
||||||
|
|
||||||
|
// Filter jurors by search term
|
||||||
|
const filteredJurors = useMemo(() => {
|
||||||
|
const items = juryMembers ?? []
|
||||||
|
if (!jurorSearch) return items
|
||||||
|
const q = jurorSearch.toLowerCase()
|
||||||
|
return items.filter((j: any) =>
|
||||||
|
j.name?.toLowerCase().includes(q) ||
|
||||||
|
j.email?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}, [juryMembers, jurorSearch])
|
||||||
|
|
||||||
|
const toggleJuror = useCallback((jurorId: string) => {
|
||||||
|
setSelectedJurorIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(jurorId)) next.delete(jurorId)
|
||||||
|
else next.add(jurorId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCreateByProject = useCallback(() => {
|
||||||
|
if (!selectedProjectId || selectedJurorIds.size === 0) return
|
||||||
|
|
||||||
|
const jurorIds = Array.from(selectedJurorIds)
|
||||||
|
if (jurorIds.length === 1) {
|
||||||
|
createMutation.mutate({
|
||||||
|
userId: jurorIds[0],
|
||||||
|
projectId: selectedProjectId,
|
||||||
|
roundId,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
bulkCreateMutation.mutate({
|
||||||
|
roundId,
|
||||||
|
assignments: jurorIds.map(userId => ({
|
||||||
|
userId,
|
||||||
|
projectId: selectedProjectId,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [selectedProjectId, selectedJurorIds, roundId, createMutation, bulkCreateMutation])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="space-y-4">
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">All Assignments</CardTitle>
|
<p className="text-sm font-medium">{assignments?.length ?? 0} individual assignments</p>
|
||||||
<CardDescription>
|
|
||||||
{assignments?.length ?? 0} individual jury-project assignments
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
|
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-1.5" />
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
<div>
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[1, 2, 3, 4, 5].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
{[1, 2, 3, 4, 5].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||||
@@ -2646,11 +2869,7 @@ function IndividualAssignmentsTable({
|
|||||||
{a.evaluation && (
|
{a.evaluation && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => setConfirmAction({ type: 'reset', assignment: a })}
|
||||||
if (confirm(`Reset evaluation by ${a.user?.name || a.user?.email} for "${a.project?.title}"? This will erase all scores and feedback so they can start over.`)) {
|
|
||||||
resetEvalMutation.mutate({ assignmentId: a.id })
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={resetEvalMutation.isPending}
|
disabled={resetEvalMutation.isPending}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3.5 w-3.5 mr-2" />
|
<RotateCcw className="h-3.5 w-3.5 mr-2" />
|
||||||
@@ -2661,11 +2880,7 @@ function IndividualAssignmentsTable({
|
|||||||
)}
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onClick={() => {
|
onClick={() => setConfirmAction({ type: 'delete', assignment: a })}
|
||||||
if (confirm(`Remove assignment for ${a.user?.name || a.user?.email} on "${a.project?.title}"?`)) {
|
|
||||||
deleteMutation.mutate({ id: a.id })
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||||
@@ -2677,7 +2892,7 @@ function IndividualAssignmentsTable({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
|
|
||||||
{/* Add Assignment Dialog */}
|
{/* Add Assignment Dialog */}
|
||||||
<Dialog open={addDialogOpen} onOpenChange={(open) => {
|
<Dialog open={addDialogOpen} onOpenChange={(open) => {
|
||||||
@@ -2688,11 +2903,31 @@ function IndividualAssignmentsTable({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Assignment</DialogTitle>
|
<DialogTitle>Add Assignment</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Select a juror and one or more projects to assign
|
{assignMode === 'byJuror'
|
||||||
|
? 'Select a juror, then choose projects to assign'
|
||||||
|
: 'Select a project, then choose jurors to assign'
|
||||||
|
}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Mode Toggle */}
|
||||||
|
<Tabs value={assignMode} onValueChange={(v) => {
|
||||||
|
setAssignMode(v as 'byJuror' | 'byProject')
|
||||||
|
// Reset selections when switching
|
||||||
|
setSelectedJurorId('')
|
||||||
|
setSelectedProjectIds(new Set())
|
||||||
|
setProjectSearch('')
|
||||||
|
setSelectedProjectId('')
|
||||||
|
setSelectedJurorIds(new Set())
|
||||||
|
setJurorSearch('')
|
||||||
|
}}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="byJuror">By Juror</TabsTrigger>
|
||||||
|
<TabsTrigger value="byProject">By Project</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ── By Juror Tab ── */}
|
||||||
|
<TabsContent value="byJuror" className="space-y-4 mt-4">
|
||||||
{/* Juror Selector */}
|
{/* Juror Selector */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">Juror</Label>
|
<Label className="text-sm font-medium">Juror</Label>
|
||||||
@@ -2879,7 +3114,6 @@ function IndividualAssignmentsTable({
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={resetDialog}>
|
<Button variant="outline" onClick={resetDialog}>
|
||||||
@@ -2896,9 +3130,224 @@ function IndividualAssignmentsTable({
|
|||||||
}
|
}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── By Project Tab ── */}
|
||||||
|
<TabsContent value="byProject" className="space-y-4 mt-4">
|
||||||
|
{/* Project Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Project</Label>
|
||||||
|
<Popover open={projectPopoverOpen} onOpenChange={setProjectPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={projectPopoverOpen}
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
{selectedProjectId
|
||||||
|
? (
|
||||||
|
<span className="truncate">
|
||||||
|
{(projectStates ?? []).find((ps: any) => ps.project?.id === selectedProjectId)?.project?.title || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: <span className="text-muted-foreground">Select a project...</span>
|
||||||
|
}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search by project title..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No projects found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{(projectStates ?? []).map((ps: any) => {
|
||||||
|
const project = ps.project
|
||||||
|
if (!project) return null
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={project.id}
|
||||||
|
value={`${project.title ?? ''} ${project.teamName ?? ''}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedProjectId(project.id === selectedProjectId ? '' : project.id)
|
||||||
|
setSelectedJurorIds(new Set())
|
||||||
|
setProjectPopoverOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'mr-2 h-4 w-4',
|
||||||
|
selectedProjectId === project.id ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{project.teamName}</p>
|
||||||
|
</div>
|
||||||
|
{project.competitionCategory && (
|
||||||
|
<Badge variant="outline" className="text-[10px] ml-2 shrink-0">
|
||||||
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Juror Multi-Select */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
Jurors
|
||||||
|
{selectedJurorIds.size > 0 && (
|
||||||
|
<span className="ml-1.5 text-muted-foreground font-normal">
|
||||||
|
({selectedJurorIds.size} selected)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{selectedProjectId && selectedJurorIds.size > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => setSelectedJurorIds(new Set())}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Filter jurors..."
|
||||||
|
value={jurorSearch}
|
||||||
|
onChange={(e) => setJurorSearch(e.target.value)}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Juror checklist */}
|
||||||
|
<ScrollArea className="h-[320px] rounded-md border">
|
||||||
|
<div className="p-2 space-y-0.5">
|
||||||
|
{!selectedProjectId ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
Select a project first
|
||||||
|
</p>
|
||||||
|
) : filteredJurors.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No jurors found
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filteredJurors.map((juror: any) => {
|
||||||
|
const alreadyAssigned = projectExistingJurorIds.has(juror.id)
|
||||||
|
const isSelected = selectedJurorIds.has(juror.id)
|
||||||
|
const assignCount = jurorAssignmentCounts.get(juror.id) ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={juror.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors',
|
||||||
|
alreadyAssigned
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: isSelected
|
||||||
|
? 'bg-accent'
|
||||||
|
: 'hover:bg-muted/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
disabled={alreadyAssigned}
|
||||||
|
onCheckedChange={() => toggleJuror(juror.id)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-medium truncate block">{juror.name || 'Unnamed'}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate block">{juror.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{assignCount} assigned
|
||||||
|
</Badge>
|
||||||
|
{alreadyAssigned && (
|
||||||
|
<Badge variant="outline" className="text-[10px] bg-amber-50 text-amber-700 border-amber-200">
|
||||||
|
Already on project
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={resetDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateByProject}
|
||||||
|
disabled={!selectedProjectId || selectedJurorIds.size === 0 || isMutating}
|
||||||
|
>
|
||||||
|
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
|
{selectedJurorIds.size <= 1
|
||||||
|
? 'Create Assignment'
|
||||||
|
: `Create ${selectedJurorIds.size} Assignments`
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Card>
|
|
||||||
|
{/* 4.2 Confirmation AlertDialog for reset/delete (replaces native confirm) */}
|
||||||
|
<AlertDialog open={!!confirmAction} onOpenChange={(open) => { if (!open) setConfirmAction(null) }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{confirmAction?.type === 'reset' ? 'Reset evaluation?' : 'Delete assignment?'}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{confirmAction?.type === 'reset'
|
||||||
|
? `Reset evaluation by ${confirmAction.assignment?.user?.name || confirmAction.assignment?.user?.email} for "${confirmAction.assignment?.project?.title}"? This will erase all scores and feedback so they can start over.`
|
||||||
|
: `Remove assignment for ${confirmAction?.assignment?.user?.name || confirmAction?.assignment?.user?.email} on "${confirmAction?.assignment?.project?.title}"?`
|
||||||
|
}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={confirmAction?.type === 'delete' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirmAction?.type === 'reset') {
|
||||||
|
resetEvalMutation.mutate({ assignmentId: confirmAction.assignment.id })
|
||||||
|
} else if (confirmAction?.type === 'delete') {
|
||||||
|
deleteMutation.mutate({ id: confirmAction.assignment.id })
|
||||||
|
}
|
||||||
|
setConfirmAction(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmAction?.type === 'reset' ? 'Reset' : 'Delete'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3149,7 +3598,7 @@ function AdvanceProjectsDialog({
|
|||||||
{isSimpleAdvance ? (
|
{isSimpleAdvance ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleAdvance(true)}
|
onClick={() => handleAdvance(true)}
|
||||||
disabled={totalProjectCount === 0 || advanceMutation.isPending}
|
disabled={totalProjectCount === 0 || advanceMutation.isPending || availableTargets.length === 0}
|
||||||
>
|
>
|
||||||
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
Advance All {totalProjectCount} Project{totalProjectCount !== 1 ? 's' : ''}
|
Advance All {totalProjectCount} Project{totalProjectCount !== 1 ? 's' : ''}
|
||||||
@@ -3157,7 +3606,7 @@ function AdvanceProjectsDialog({
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleAdvance()}
|
onClick={() => handleAdvance()}
|
||||||
disabled={selected.size === 0 || advanceMutation.isPending}
|
disabled={selected.size === 0 || advanceMutation.isPending || availableTargets.length === 0}
|
||||||
>
|
>
|
||||||
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
|
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
|
||||||
@@ -3517,24 +3966,27 @@ function COIReviewSection({ roundId }: { roundId: string }) {
|
|||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Don't show section if no declarations
|
// Show placeholder when no declarations
|
||||||
if (!isLoading && (!declarations || declarations.length === 0)) {
|
if (!isLoading && (!declarations || declarations.length === 0)) {
|
||||||
return null
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed p-4 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">No conflict of interest declarations yet.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const conflictCount = declarations?.filter((d) => d.hasConflict).length ?? 0
|
const conflictCount = declarations?.filter((d) => d.hasConflict).length ?? 0
|
||||||
const unreviewedCount = declarations?.filter((d) => d.hasConflict && !d.reviewedAt).length ?? 0
|
const unreviewedCount = declarations?.filter((d) => d.hasConflict && !d.reviewedAt).length ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="space-y-3">
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<p className="text-sm font-medium flex items-center gap-2">
|
||||||
<ShieldAlert className="h-4 w-4" />
|
<ShieldAlert className="h-4 w-4" />
|
||||||
Conflict of Interest Declarations
|
Conflict of Interest Declarations
|
||||||
</CardTitle>
|
</p>
|
||||||
<CardDescription>
|
<p className="text-xs text-muted-foreground">
|
||||||
{declarations?.length ?? 0} declaration{(declarations?.length ?? 0) !== 1 ? 's' : ''}
|
{declarations?.length ?? 0} declaration{(declarations?.length ?? 0) !== 1 ? 's' : ''}
|
||||||
{conflictCount > 0 && (
|
{conflictCount > 0 && (
|
||||||
<> — <span className="text-amber-600 font-medium">{conflictCount} conflict{conflictCount !== 1 ? 's' : ''}</span></>
|
<> — <span className="text-amber-600 font-medium">{conflictCount} conflict{conflictCount !== 1 ? 's' : ''}</span></>
|
||||||
@@ -3542,11 +3994,10 @@ function COIReviewSection({ roundId }: { roundId: string }) {
|
|||||||
{unreviewedCount > 0 && (
|
{unreviewedCount > 0 && (
|
||||||
<> ({unreviewedCount} pending review)</>
|
<> ({unreviewedCount} pending review)</>
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
<div>
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||||
@@ -3640,7 +4091,7 @@ function COIReviewSection({ roundId }: { roundId: string }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -85,6 +88,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null)
|
const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null)
|
||||||
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
|
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
|
||||||
const [quickAddOpen, setQuickAddOpen] = useState(false)
|
const [quickAddOpen, setQuickAddOpen] = useState(false)
|
||||||
|
const [addProjectOpen, setAddProjectOpen] = useState(false)
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
@@ -274,16 +278,10 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={() => { setQuickAddOpen(true) }}>
|
<Button size="sm" variant="outline" onClick={() => { setAddProjectOpen(true) }}>
|
||||||
<Plus className="h-4 w-4 mr-1.5" />
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
Quick Add
|
Add Project
|
||||||
</Button>
|
</Button>
|
||||||
<Link href={poolLink}>
|
|
||||||
<Button size="sm" variant="outline">
|
|
||||||
<Plus className="h-4 w-4 mr-1.5" />
|
|
||||||
Add from Pool
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -436,7 +434,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Add Dialog */}
|
{/* Quick Add Dialog (legacy, kept for empty state) */}
|
||||||
<QuickAddDialog
|
<QuickAddDialog
|
||||||
open={quickAddOpen}
|
open={quickAddOpen}
|
||||||
onOpenChange={setQuickAddOpen}
|
onOpenChange={setQuickAddOpen}
|
||||||
@@ -447,6 +445,17 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Add Project Dialog (Create New + From Pool) */}
|
||||||
|
<AddProjectDialog
|
||||||
|
open={addProjectOpen}
|
||||||
|
onOpenChange={setAddProjectOpen}
|
||||||
|
roundId={roundId}
|
||||||
|
competitionId={competitionId}
|
||||||
|
onAssigned={() => {
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Single Remove Confirmation */}
|
{/* Single Remove Confirmation */}
|
||||||
<AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}>
|
<AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -673,3 +682,287 @@ function QuickAddDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add Project Dialog — two tabs: "Create New" and "From Pool".
|
||||||
|
* Create New: form to create a project and assign it directly to the round.
|
||||||
|
* From Pool: search existing projects not yet in this round and assign them.
|
||||||
|
*/
|
||||||
|
function AddProjectDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
roundId,
|
||||||
|
competitionId,
|
||||||
|
onAssigned,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
roundId: string
|
||||||
|
competitionId: string
|
||||||
|
onAssigned: () => void
|
||||||
|
}) {
|
||||||
|
const [activeTab, setActiveTab] = useState<'create' | 'pool'>('create')
|
||||||
|
|
||||||
|
// ── Create New tab state ──
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [teamName, setTeamName] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [country, setCountry] = useState('')
|
||||||
|
const [category, setCategory] = useState<string>('')
|
||||||
|
|
||||||
|
// ── From Pool tab state ──
|
||||||
|
const [poolSearch, setPoolSearch] = useState('')
|
||||||
|
const [selectedPoolIds, setSelectedPoolIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
// Get the competition to find programId (for pool search)
|
||||||
|
const { data: competition } = trpc.competition.getById.useQuery(
|
||||||
|
{ id: competitionId },
|
||||||
|
{ enabled: open && !!competitionId },
|
||||||
|
)
|
||||||
|
const programId = (competition as any)?.programId || ''
|
||||||
|
|
||||||
|
// Pool query
|
||||||
|
const { data: poolResults, isLoading: poolLoading } = trpc.projectPool.listUnassigned.useQuery(
|
||||||
|
{
|
||||||
|
programId,
|
||||||
|
excludeRoundId: roundId,
|
||||||
|
search: poolSearch.trim() || undefined,
|
||||||
|
perPage: 50,
|
||||||
|
},
|
||||||
|
{ enabled: open && activeTab === 'pool' && !!programId },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create mutation
|
||||||
|
const createMutation = trpc.project.createAndAssignToRound.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Project created and added to round')
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
onAssigned()
|
||||||
|
resetAndClose()
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Assign from pool mutation
|
||||||
|
const assignMutation = trpc.projectPool.assignToRound.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(`${data.assignedCount} project(s) added to round`)
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
onAssigned()
|
||||||
|
resetAndClose()
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetAndClose = () => {
|
||||||
|
setTitle('')
|
||||||
|
setTeamName('')
|
||||||
|
setDescription('')
|
||||||
|
setCountry('')
|
||||||
|
setCategory('')
|
||||||
|
setPoolSearch('')
|
||||||
|
setSelectedPoolIds(new Set())
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (!title.trim()) return
|
||||||
|
createMutation.mutate({
|
||||||
|
title: title.trim(),
|
||||||
|
teamName: teamName.trim() || undefined,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
country: country.trim() || undefined,
|
||||||
|
competitionCategory: category === 'STARTUP' || category === 'BUSINESS_CONCEPT' ? category : undefined,
|
||||||
|
roundId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAssignFromPool = () => {
|
||||||
|
if (selectedPoolIds.size === 0) return
|
||||||
|
assignMutation.mutate({
|
||||||
|
projectIds: Array.from(selectedPoolIds),
|
||||||
|
roundId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePoolProject = (id: string) => {
|
||||||
|
setSelectedPoolIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMutating = createMutation.isPending || assignMutation.isPending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) resetAndClose()
|
||||||
|
else onOpenChange(true)
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Project to Round</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new project or select existing ones to add to this round.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'create' | 'pool')}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="create">Create New</TabsTrigger>
|
||||||
|
<TabsTrigger value="pool">From Pool</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ── Create New Tab ── */}
|
||||||
|
<TabsContent value="create" className="space-y-4 mt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="add-project-title">Title *</Label>
|
||||||
|
<Input
|
||||||
|
id="add-project-title"
|
||||||
|
placeholder="Project title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="add-project-team">Team Name</Label>
|
||||||
|
<Input
|
||||||
|
id="add-project-team"
|
||||||
|
placeholder="Team or organization name"
|
||||||
|
value={teamName}
|
||||||
|
onChange={(e) => setTeamName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="add-project-country">Country</Label>
|
||||||
|
<Input
|
||||||
|
id="add-project-country"
|
||||||
|
placeholder="e.g. France"
|
||||||
|
value={country}
|
||||||
|
onChange={(e) => setCountry(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category</Label>
|
||||||
|
<Select value={category} onValueChange={setCategory}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="STARTUP">Startup</SelectItem>
|
||||||
|
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="add-project-desc">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="add-project-desc"
|
||||||
|
placeholder="Brief description (optional)"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!title.trim() || isMutating}
|
||||||
|
>
|
||||||
|
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
|
Create & Add to Round
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── From Pool Tab ── */}
|
||||||
|
<TabsContent value="pool" className="space-y-4 mt-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by project title or team..."
|
||||||
|
value={poolSearch}
|
||||||
|
onChange={(e) => setPoolSearch(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[320px] rounded-md border">
|
||||||
|
<div className="p-2 space-y-0.5">
|
||||||
|
{poolLoading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!poolLoading && poolResults?.projects.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
{poolSearch.trim() ? `No projects found matching "${poolSearch}"` : 'No projects available to add'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{poolResults?.projects.map((project: any) => {
|
||||||
|
const isSelected = selectedPoolIds.has(project.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={project.id}
|
||||||
|
className={`flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors ${
|
||||||
|
isSelected ? 'bg-accent' : 'hover:bg-muted/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => togglePoolProject(project.id)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{project.teamName}
|
||||||
|
{project.country && <> · {project.country}</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{project.competitionCategory && (
|
||||||
|
<Badge variant="outline" className="text-[10px] ml-2 shrink-0">
|
||||||
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{poolResults && poolResults.total > 50 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
Showing 50 of {poolResults.total} — refine your search for more specific results
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAssignFromPool}
|
||||||
|
disabled={selectedPoolIds.size === 0 || isMutating}
|
||||||
|
>
|
||||||
|
{assignMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
|
{selectedPoolIds.size <= 1
|
||||||
|
? 'Add to Round'
|
||||||
|
: `Add ${selectedPoolIds.size} Projects to Round`
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1083,6 +1083,60 @@ Together for a healthier ocean.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate "3 Days Remaining" email template (for jury)
|
||||||
|
*/
|
||||||
|
function getReminder3DaysTemplate(
|
||||||
|
name: string,
|
||||||
|
pendingCount: number,
|
||||||
|
roundName: string,
|
||||||
|
deadline: string,
|
||||||
|
assignmentsUrl?: string
|
||||||
|
): EmailTemplate {
|
||||||
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||||||
|
|
||||||
|
const urgentBox = `
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||||
|
<p style="color: #92400e; margin: 0; font-size: 14px; font-weight: 600;">⚠ 3 Days Remaining</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
${sectionTitle(greeting)}
|
||||||
|
${urgentBox}
|
||||||
|
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${roundName}</strong> closes in 3 days.`)}
|
||||||
|
${statCard('Pending Evaluations', pendingCount)}
|
||||||
|
${infoBox(`<strong>Deadline:</strong> ${deadline}`, 'warning')}
|
||||||
|
${paragraph('Please plan to complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
|
||||||
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
|
||||||
|
`
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 3 days`,
|
||||||
|
html: getEmailWrapper(content),
|
||||||
|
text: `
|
||||||
|
${greeting}
|
||||||
|
|
||||||
|
This is a reminder that ${roundName} closes in 3 days.
|
||||||
|
|
||||||
|
You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''}.
|
||||||
|
Deadline: ${deadline}
|
||||||
|
|
||||||
|
Please plan to complete your remaining evaluations before the deadline.
|
||||||
|
|
||||||
|
${assignmentsUrl ? `Complete evaluations: ${assignmentsUrl}` : ''}
|
||||||
|
|
||||||
|
---
|
||||||
|
Monaco Ocean Protection Challenge
|
||||||
|
Together for a healthier ocean.
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate "1 Hour Reminder" email template (for jury)
|
* Generate "1 Hour Reminder" email template (for jury)
|
||||||
*/
|
*/
|
||||||
@@ -1457,6 +1511,14 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
|||||||
ctx.metadata?.deadline as string | undefined,
|
ctx.metadata?.deadline as string | undefined,
|
||||||
ctx.linkUrl
|
ctx.linkUrl
|
||||||
),
|
),
|
||||||
|
REMINDER_3_DAYS: (ctx) =>
|
||||||
|
getReminder3DaysTemplate(
|
||||||
|
ctx.name || '',
|
||||||
|
(ctx.metadata?.pendingCount as number) || 0,
|
||||||
|
(ctx.metadata?.roundName as string) || 'this round',
|
||||||
|
(ctx.metadata?.deadline as string) || 'Soon',
|
||||||
|
ctx.linkUrl
|
||||||
|
),
|
||||||
REMINDER_24H: (ctx) =>
|
REMINDER_24H: (ctx) =>
|
||||||
getReminder24HTemplate(
|
getReminder24HTemplate(
|
||||||
ctx.name || '',
|
ctx.name || '',
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
NotificationTypes,
|
NotificationTypes,
|
||||||
} from '../services/in-app-notification'
|
} from '../services/in-app-notification'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
import { sendStyledNotificationEmail } from '@/lib/email'
|
|
||||||
|
|
||||||
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
|
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
|
||||||
try {
|
try {
|
||||||
@@ -31,11 +30,12 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
|||||||
name: true,
|
name: true,
|
||||||
configJson: true,
|
configJson: true,
|
||||||
competitionId: true,
|
competitionId: true,
|
||||||
|
juryGroupId: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = (round.configJson ?? {}) as Record<string, unknown>
|
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||||
const requiredReviews = (config.requiredReviews as number) ?? 3
|
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
||||||
const minAssignmentsPerJuror =
|
const minAssignmentsPerJuror =
|
||||||
(config.minLoadPerJuror as number) ??
|
(config.minLoadPerJuror as number) ??
|
||||||
(config.minAssignmentsPerJuror as number) ??
|
(config.minAssignmentsPerJuror as number) ??
|
||||||
@@ -45,8 +45,22 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
|||||||
(config.maxAssignmentsPerJuror as number) ??
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
20
|
20
|
||||||
|
|
||||||
|
// Scope jurors to jury group if the round has one assigned
|
||||||
|
let scopedJurorIds: string[] | undefined
|
||||||
|
if (round.juryGroupId) {
|
||||||
|
const groupMembers = await prisma.juryGroupMember.findMany({
|
||||||
|
where: { juryGroupId: round.juryGroupId },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
scopedJurorIds = groupMembers.map((m) => m.userId)
|
||||||
|
}
|
||||||
|
|
||||||
const jurors = await prisma.user.findMany({
|
const jurors = await prisma.user.findMany({
|
||||||
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
|
where: {
|
||||||
|
role: 'JURY_MEMBER',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
@@ -96,6 +110,18 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
|||||||
select: { userId: true, projectId: true },
|
select: { userId: true, projectId: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Query COI records for this round to exclude conflicted juror-project pairs
|
||||||
|
const coiRecords = await prisma.conflictOfInterest.findMany({
|
||||||
|
where: {
|
||||||
|
roundId,
|
||||||
|
hasConflict: true,
|
||||||
|
},
|
||||||
|
select: { userId: true, projectId: true },
|
||||||
|
})
|
||||||
|
const coiExclusions = new Set(
|
||||||
|
coiRecords.map((c) => `${c.userId}:${c.projectId}`)
|
||||||
|
)
|
||||||
|
|
||||||
// Calculate batch info
|
// Calculate batch info
|
||||||
const BATCH_SIZE = 15
|
const BATCH_SIZE = 15
|
||||||
const totalBatches = Math.ceil(projects.length / BATCH_SIZE)
|
const totalBatches = Math.ceil(projects.length / BATCH_SIZE)
|
||||||
@@ -144,8 +170,13 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
|||||||
onProgress
|
onProgress
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Filter out suggestions that conflict with COI declarations
|
||||||
|
const filteredSuggestions = coiExclusions.size > 0
|
||||||
|
? result.suggestions.filter((s) => !coiExclusions.has(`${s.jurorId}:${s.projectId}`))
|
||||||
|
: result.suggestions
|
||||||
|
|
||||||
// Enrich suggestions with names for storage
|
// Enrich suggestions with names for storage
|
||||||
const enrichedSuggestions = result.suggestions.map((s) => {
|
const enrichedSuggestions = filteredSuggestions.map((s) => {
|
||||||
const juror = jurors.find((j) => j.id === s.jurorId)
|
const juror = jurors.find((j) => j.id === s.jurorId)
|
||||||
const project = projects.find((p) => p.id === s.projectId)
|
const project = projects.find((p) => p.id === s.projectId)
|
||||||
return {
|
return {
|
||||||
@@ -162,7 +193,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
|||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
processedCount: projects.length,
|
processedCount: projects.length,
|
||||||
suggestionsCount: result.suggestions.length,
|
suggestionsCount: filteredSuggestions.length,
|
||||||
suggestionsJson: enrichedSuggestions,
|
suggestionsJson: enrichedSuggestions,
|
||||||
fallbackUsed: result.fallbackUsed ?? false,
|
fallbackUsed: result.fallbackUsed ?? false,
|
||||||
},
|
},
|
||||||
@@ -171,7 +202,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
|||||||
await notifyAdmins({
|
await notifyAdmins({
|
||||||
type: NotificationTypes.AI_SUGGESTIONS_READY,
|
type: NotificationTypes.AI_SUGGESTIONS_READY,
|
||||||
title: 'AI Assignment Suggestions Ready',
|
title: 'AI Assignment Suggestions Ready',
|
||||||
message: `AI generated ${result.suggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
|
message: `AI generated ${filteredSuggestions.length} assignment suggestions for ${round.name || 'round'}${result.fallbackUsed ? ' (using fallback algorithm)' : ''}.`,
|
||||||
linkUrl: `/admin/rounds/${roundId}`,
|
linkUrl: `/admin/rounds/${roundId}`,
|
||||||
linkLabel: 'View Suggestions',
|
linkLabel: 'View Suggestions',
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
@@ -179,7 +210,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
|||||||
roundId,
|
roundId,
|
||||||
jobId,
|
jobId,
|
||||||
projectCount: projects.length,
|
projectCount: projects.length,
|
||||||
suggestionsCount: result.suggestions.length,
|
suggestionsCount: filteredSuggestions.length,
|
||||||
fallbackUsed: result.fallbackUsed,
|
fallbackUsed: result.fallbackUsed,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -425,7 +456,7 @@ export const assignmentRouter = router({
|
|||||||
linkLabel: 'View Assignment',
|
linkLabel: 'View Assignment',
|
||||||
metadata: {
|
metadata: {
|
||||||
projectName: project.title,
|
projectName: project.title,
|
||||||
stageName: stageInfo.name,
|
roundName: stageInfo.name,
|
||||||
deadline,
|
deadline,
|
||||||
assignmentId: assignment.id,
|
assignmentId: assignment.id,
|
||||||
},
|
},
|
||||||
@@ -567,7 +598,7 @@ export const assignmentRouter = router({
|
|||||||
linkLabel: 'View Assignments',
|
linkLabel: 'View Assignments',
|
||||||
metadata: {
|
metadata: {
|
||||||
projectCount,
|
projectCount,
|
||||||
stageName: stage?.name,
|
roundName: stage?.name,
|
||||||
deadline,
|
deadline,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -621,7 +652,7 @@ export const assignmentRouter = router({
|
|||||||
select: { configJson: true },
|
select: { configJson: true },
|
||||||
})
|
})
|
||||||
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||||
const requiredReviews = (config.requiredReviews as number) ?? 3
|
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
||||||
|
|
||||||
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
|
const projectRoundStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
where: { roundId: input.roundId },
|
where: { roundId: input.roundId },
|
||||||
@@ -692,7 +723,7 @@ export const assignmentRouter = router({
|
|||||||
select: { configJson: true },
|
select: { configJson: true },
|
||||||
})
|
})
|
||||||
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
const config = (stage.configJson ?? {}) as Record<string, unknown>
|
||||||
const requiredReviews = (config.requiredReviews as number) ?? 3
|
const requiredReviews = (config.requiredReviewsPerProject as number) ?? 3
|
||||||
const minAssignmentsPerJuror =
|
const minAssignmentsPerJuror =
|
||||||
(config.minLoadPerJuror as number) ??
|
(config.minLoadPerJuror as number) ??
|
||||||
(config.minAssignmentsPerJuror as number) ??
|
(config.minAssignmentsPerJuror as number) ??
|
||||||
@@ -1100,7 +1131,7 @@ export const assignmentRouter = router({
|
|||||||
linkLabel: 'View Assignments',
|
linkLabel: 'View Assignments',
|
||||||
metadata: {
|
metadata: {
|
||||||
projectCount,
|
projectCount,
|
||||||
stageName: stage?.name,
|
roundName: stage?.name,
|
||||||
deadline,
|
deadline,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1252,7 +1283,7 @@ export const assignmentRouter = router({
|
|||||||
linkLabel: 'View Assignments',
|
linkLabel: 'View Assignments',
|
||||||
metadata: {
|
metadata: {
|
||||||
projectCount,
|
projectCount,
|
||||||
stageName: stage?.name,
|
roundName: stage?.name,
|
||||||
deadline,
|
deadline,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1361,7 +1392,7 @@ export const assignmentRouter = router({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify all jurors of their current assignments for a round (admin only).
|
* Notify all jurors of their current assignments for a round (admin only).
|
||||||
* Sends both in-app notifications AND direct emails to each juror.
|
* Sends in-app notifications (emails are handled by maybeSendEmail via createBulkNotifications).
|
||||||
*/
|
*/
|
||||||
notifyJurorsOfAssignments: adminProcedure
|
notifyJurorsOfAssignments: adminProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
@@ -1378,7 +1409,7 @@ export const assignmentRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (assignments.length === 0) {
|
if (assignments.length === 0) {
|
||||||
return { sent: 0, jurorCount: 0, emailsSent: 0 }
|
return { sent: 0, jurorCount: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count assignments per user
|
// Count assignments per user
|
||||||
@@ -1414,44 +1445,11 @@ export const assignmentRouter = router({
|
|||||||
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name || 'this round'}.`,
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name || 'this round'}.`,
|
||||||
linkUrl: `/jury/competitions`,
|
linkUrl: `/jury/competitions`,
|
||||||
linkLabel: 'View Assignments',
|
linkLabel: 'View Assignments',
|
||||||
metadata: { projectCount, stageName: round.name, deadline },
|
metadata: { projectCount, roundName: round.name, deadline },
|
||||||
})
|
})
|
||||||
totalSent += userIds.length
|
totalSent += userIds.length
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send direct emails to every juror (regardless of notification email settings)
|
|
||||||
const allUserIds = Object.keys(userCounts)
|
|
||||||
const users = await ctx.prisma.user.findMany({
|
|
||||||
where: { id: { in: allUserIds } },
|
|
||||||
select: { id: true, name: true, email: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
|
|
||||||
let emailsSent = 0
|
|
||||||
|
|
||||||
for (const user of users) {
|
|
||||||
const projectCount = userCounts[user.id] || 0
|
|
||||||
if (projectCount === 0) continue
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendStyledNotificationEmail(
|
|
||||||
user.email,
|
|
||||||
user.name || '',
|
|
||||||
'BATCH_ASSIGNED',
|
|
||||||
{
|
|
||||||
name: user.name || undefined,
|
|
||||||
title: `Projects Assigned - ${round.name}`,
|
|
||||||
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name}.`,
|
|
||||||
linkUrl: `${baseUrl}/jury/competitions`,
|
|
||||||
metadata: { projectCount, roundName: round.name, deadline },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
emailsSent++
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to send assignment email to ${user.email}:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
prisma: ctx.prisma,
|
prisma: ctx.prisma,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -1461,12 +1459,11 @@ export const assignmentRouter = router({
|
|||||||
detailsJson: {
|
detailsJson: {
|
||||||
jurorCount: Object.keys(userCounts).length,
|
jurorCount: Object.keys(userCounts).length,
|
||||||
totalAssignments: assignments.length,
|
totalAssignments: assignments.length,
|
||||||
emailsSent,
|
|
||||||
},
|
},
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { sent: totalSent, jurorCount: Object.keys(userCounts).length, emailsSent }
|
return { sent: totalSent, jurorCount: Object.keys(userCounts).length }
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -132,9 +132,9 @@ export const evaluationRouter = router({
|
|||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])),
|
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])),
|
||||||
globalScore: z.number().int().min(1).max(10),
|
globalScore: z.number().int().min(1).max(10).optional(),
|
||||||
binaryDecision: z.boolean(),
|
binaryDecision: z.boolean().optional(),
|
||||||
feedbackText: z.string().min(10),
|
feedbackText: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -152,6 +152,17 @@ export const evaluationRouter = router({
|
|||||||
throw new TRPCError({ code: 'FORBIDDEN' })
|
throw new TRPCError({ code: 'FORBIDDEN' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server-side COI check
|
||||||
|
const coi = await ctx.prisma.conflictOfInterest.findFirst({
|
||||||
|
where: { assignmentId: evaluation.assignmentId, hasConflict: true },
|
||||||
|
})
|
||||||
|
if (coi) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Cannot submit evaluation — conflict of interest declared',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Check voting window via round
|
// Check voting window via round
|
||||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
where: { id: evaluation.assignment.roundId },
|
where: { id: evaluation.assignment.roundId },
|
||||||
@@ -194,12 +205,66 @@ export const evaluationRouter = router({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load round config for validation
|
||||||
|
const config = (round.configJson as Record<string, unknown>) || {}
|
||||||
|
const scoringMode = (config.scoringMode as string) || 'criteria'
|
||||||
|
|
||||||
|
// Fix 3: Dynamic feedback validation based on config
|
||||||
|
const requireFeedback = config.requireFeedback !== false
|
||||||
|
if (requireFeedback) {
|
||||||
|
const feedbackMinLength = (config.feedbackMinLength as number) || 10
|
||||||
|
if (!data.feedbackText || data.feedbackText.length < feedbackMinLength) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `Feedback must be at least ${feedbackMinLength} characters`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix 4: Normalize binaryDecision and globalScore based on scoringMode
|
||||||
|
if (scoringMode !== 'binary') {
|
||||||
|
data.binaryDecision = undefined
|
||||||
|
}
|
||||||
|
if (scoringMode === 'binary') {
|
||||||
|
data.globalScore = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix 5: requireAllCriteriaScored validation
|
||||||
|
if (config.requireAllCriteriaScored && scoringMode === 'criteria') {
|
||||||
|
const evalForm = await ctx.prisma.evaluationForm.findFirst({
|
||||||
|
where: { roundId: round.id, isActive: true },
|
||||||
|
select: { criteriaJson: true },
|
||||||
|
})
|
||||||
|
if (evalForm?.criteriaJson) {
|
||||||
|
const criteria = evalForm.criteriaJson as Array<{ id: string; type?: string; required?: boolean }>
|
||||||
|
const scorableCriteria = criteria.filter(
|
||||||
|
(c) => c.type !== 'section_header' && c.type !== 'text' && c.required !== false
|
||||||
|
)
|
||||||
|
const scores = data.criterionScoresJson as Record<string, unknown> | undefined
|
||||||
|
const missingCriteria = scorableCriteria.filter(
|
||||||
|
(c) => !scores || typeof scores[c.id] !== 'number'
|
||||||
|
)
|
||||||
|
if (missingCriteria.length > 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `Missing scores for criteria: ${missingCriteria.map((c) => c.id).join(', ')}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Submit evaluation and mark assignment as completed atomically
|
// Submit evaluation and mark assignment as completed atomically
|
||||||
|
const saveData = {
|
||||||
|
criterionScoresJson: data.criterionScoresJson,
|
||||||
|
globalScore: data.globalScore ?? null,
|
||||||
|
binaryDecision: data.binaryDecision ?? null,
|
||||||
|
feedbackText: data.feedbackText ?? null,
|
||||||
|
}
|
||||||
const [updated] = await ctx.prisma.$transaction([
|
const [updated] = await ctx.prisma.$transaction([
|
||||||
ctx.prisma.evaluation.update({
|
ctx.prisma.evaluation.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...saveData,
|
||||||
status: 'SUBMITTED',
|
status: 'SUBMITTED',
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
},
|
},
|
||||||
@@ -784,7 +849,7 @@ export const evaluationRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const settings = (stage.configJson as Record<string, unknown>) || {}
|
const settings = (stage.configJson as Record<string, unknown>) || {}
|
||||||
if (!settings.peer_review_enabled) {
|
if (!settings.peerReviewEnabled) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: 'Peer review is not enabled for this stage',
|
message: 'Peer review is not enabled for this stage',
|
||||||
@@ -843,7 +908,7 @@ export const evaluationRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Anonymize individual scores based on round settings
|
// Anonymize individual scores based on round settings
|
||||||
const anonymizationLevel = (settings.anonymization_level as string) || 'fully_anonymous'
|
const anonymizationLevel = (settings.anonymizationLevel as string) || 'fully_anonymous'
|
||||||
|
|
||||||
const individualScores = evaluations.map((e) => {
|
const individualScores = evaluations.map((e) => {
|
||||||
let jurorLabel: string
|
let jurorLabel: string
|
||||||
@@ -926,7 +991,7 @@ export const evaluationRouter = router({
|
|||||||
where: { id: input.roundId },
|
where: { id: input.roundId },
|
||||||
})
|
})
|
||||||
const settings = (round.configJson as Record<string, unknown>) || {}
|
const settings = (round.configJson as Record<string, unknown>) || {}
|
||||||
const anonymizationLevel = (settings.anonymization_level as string) || 'fully_anonymous'
|
const anonymizationLevel = (settings.anonymizationLevel as string) || 'fully_anonymous'
|
||||||
|
|
||||||
const anonymizedComments = discussion.comments.map((c: { id: string; userId: string; user: { name: string | null }; content: string; createdAt: Date }, idx: number) => {
|
const anonymizedComments = discussion.comments.map((c: { id: string; userId: string; user: { name: string | null }; content: string; createdAt: Date }, idx: number) => {
|
||||||
let authorLabel: string
|
let authorLabel: string
|
||||||
@@ -978,7 +1043,7 @@ export const evaluationRouter = router({
|
|||||||
where: { id: input.roundId },
|
where: { id: input.roundId },
|
||||||
})
|
})
|
||||||
const settings = (round.configJson as Record<string, unknown>) || {}
|
const settings = (round.configJson as Record<string, unknown>) || {}
|
||||||
const maxLength = (settings.max_comment_length as number) || 2000
|
const maxLength = (settings.maxCommentLength as number) || 2000
|
||||||
if (input.content.length > maxLength) {
|
if (input.content.length > maxLength) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
|
|||||||
@@ -1249,4 +1249,97 @@ export const projectRouter = router({
|
|||||||
stats,
|
stats,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new project and assign it directly to a round.
|
||||||
|
* Used for late-arriving projects that need to enter a specific round immediately.
|
||||||
|
*/
|
||||||
|
createAndAssignToRound: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
title: z.string().min(1).max(500),
|
||||||
|
teamName: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||||
|
roundId: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { roundId, country, ...projectFields } = input
|
||||||
|
|
||||||
|
// Get the round to find competitionId, then competition to find programId
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: roundId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
competition: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
programId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Normalize country to ISO code if provided
|
||||||
|
const normalizedCountry = country
|
||||||
|
? normalizeCountryToCode(country)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const project = await ctx.prisma.$transaction(async (tx) => {
|
||||||
|
// 1. Create the project
|
||||||
|
const created = await tx.project.create({
|
||||||
|
data: {
|
||||||
|
programId: round.competition.programId,
|
||||||
|
title: projectFields.title,
|
||||||
|
teamName: projectFields.teamName,
|
||||||
|
description: projectFields.description,
|
||||||
|
country: normalizedCountry,
|
||||||
|
competitionCategory: projectFields.competitionCategory,
|
||||||
|
status: 'ASSIGNED',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Create ProjectRoundState entry
|
||||||
|
await tx.projectRoundState.create({
|
||||||
|
data: {
|
||||||
|
projectId: created.id,
|
||||||
|
roundId,
|
||||||
|
state: 'PENDING',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Create ProjectStatusHistory entry
|
||||||
|
await tx.projectStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
projectId: created.id,
|
||||||
|
status: 'ASSIGNED',
|
||||||
|
changedBy: ctx.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return created
|
||||||
|
})
|
||||||
|
|
||||||
|
// Audit outside transaction
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'CREATE_AND_ASSIGN',
|
||||||
|
entityType: 'Project',
|
||||||
|
entityId: project.id,
|
||||||
|
detailsJson: {
|
||||||
|
title: input.title,
|
||||||
|
roundId,
|
||||||
|
roundName: round.name,
|
||||||
|
programId: round.competition.programId,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return project
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma, type PrismaClient } from '@prisma/client'
|
||||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
|
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
|
||||||
import { generateShortlist } from '../services/ai-shortlist'
|
import { generateShortlist } from '../services/ai-shortlist'
|
||||||
|
import { createBulkNotifications } from '../services/in-app-notification'
|
||||||
|
import { sendAnnouncementEmail } from '@/lib/email'
|
||||||
import {
|
import {
|
||||||
openWindow,
|
openWindow,
|
||||||
closeWindow,
|
closeWindow,
|
||||||
@@ -255,19 +257,43 @@ export const roundRouter = router({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { roundId, targetRoundId, projectIds, autoPassPending } = input
|
const { roundId, targetRoundId, projectIds, autoPassPending } = input
|
||||||
|
|
||||||
// Get current round with competition context
|
// Get current round with competition context + status
|
||||||
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
select: { id: true, name: true, competitionId: true, sortOrder: true },
|
select: { id: true, name: true, competitionId: true, sortOrder: true, status: true, configJson: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Validate: current round must be ROUND_ACTIVE or ROUND_CLOSED
|
||||||
|
if (currentRound.status !== 'ROUND_ACTIVE' && currentRound.status !== 'ROUND_CLOSED') {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `Cannot advance from round with status ${currentRound.status}. Round must be ROUND_ACTIVE or ROUND_CLOSED.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Determine target round
|
// Determine target round
|
||||||
let targetRound: { id: string; name: string }
|
let targetRound: { id: string; name: string; competitionId: string; sortOrder: number; configJson: unknown }
|
||||||
if (targetRoundId) {
|
if (targetRoundId) {
|
||||||
targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
where: { id: targetRoundId },
|
where: { id: targetRoundId },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true, competitionId: true, sortOrder: true, configJson: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Validate: target must be in same competition
|
||||||
|
if (targetRound.competitionId !== currentRound.competitionId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Target round must belong to the same competition as the source round.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate: target must be after current round
|
||||||
|
if (targetRound.sortOrder <= currentRound.sortOrder) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Target round must come after the current round (higher sortOrder).',
|
||||||
|
})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Find next round in same competition by sortOrder
|
// Find next round in same competition by sortOrder
|
||||||
const nextRound = await ctx.prisma.round.findFirst({
|
const nextRound = await ctx.prisma.round.findFirst({
|
||||||
@@ -276,7 +302,7 @@ export const roundRouter = router({
|
|||||||
sortOrder: { gt: currentRound.sortOrder },
|
sortOrder: { gt: currentRound.sortOrder },
|
||||||
},
|
},
|
||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true, competitionId: true, sortOrder: true, configJson: true },
|
||||||
})
|
})
|
||||||
if (!nextRound) {
|
if (!nextRound) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -287,10 +313,30 @@ export const roundRouter = router({
|
|||||||
targetRound = nextRound
|
targetRound = nextRound
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-pass all PENDING projects first (for intake/bulk workflows)
|
// Validate projectIds exist in current round if provided
|
||||||
|
if (projectIds && projectIds.length > 0) {
|
||||||
|
const existingStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId, projectId: { in: projectIds } },
|
||||||
|
select: { projectId: true },
|
||||||
|
})
|
||||||
|
const existingIds = new Set(existingStates.map((s) => s.projectId))
|
||||||
|
const missing = projectIds.filter((id) => !existingIds.has(id))
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `Projects not found in current round: ${missing.join(', ')}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction: auto-pass + create entries in target round + mark current as COMPLETED
|
||||||
let autoPassedCount = 0
|
let autoPassedCount = 0
|
||||||
|
let idsToAdvance: string[]
|
||||||
|
|
||||||
|
await ctx.prisma.$transaction(async (tx) => {
|
||||||
|
// Auto-pass all PENDING projects first (for intake/bulk workflows) — inside tx
|
||||||
if (autoPassPending) {
|
if (autoPassPending) {
|
||||||
const result = await ctx.prisma.projectRoundState.updateMany({
|
const result = await tx.projectRoundState.updateMany({
|
||||||
where: { roundId, state: 'PENDING' },
|
where: { roundId, state: 'PENDING' },
|
||||||
data: { state: 'PASSED' },
|
data: { state: 'PASSED' },
|
||||||
})
|
})
|
||||||
@@ -298,24 +344,19 @@ export const roundRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine which projects to advance
|
// Determine which projects to advance
|
||||||
let idsToAdvance: string[]
|
|
||||||
if (projectIds && projectIds.length > 0) {
|
if (projectIds && projectIds.length > 0) {
|
||||||
idsToAdvance = projectIds
|
idsToAdvance = projectIds
|
||||||
} else {
|
} else {
|
||||||
// Default: all PASSED projects in current round
|
// Default: all PASSED projects in current round
|
||||||
const passedStates = await ctx.prisma.projectRoundState.findMany({
|
const passedStates = await tx.projectRoundState.findMany({
|
||||||
where: { roundId, state: 'PASSED' },
|
where: { roundId, state: 'PASSED' },
|
||||||
select: { projectId: true },
|
select: { projectId: true },
|
||||||
})
|
})
|
||||||
idsToAdvance = passedStates.map((s) => s.projectId)
|
idsToAdvance = passedStates.map((s) => s.projectId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (idsToAdvance.length === 0) {
|
if (idsToAdvance.length === 0) return
|
||||||
return { advancedCount: 0, targetRoundId: targetRound.id, targetRoundName: targetRound.name }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transaction: create entries in target round + mark current as COMPLETED
|
|
||||||
await ctx.prisma.$transaction(async (tx) => {
|
|
||||||
// Create ProjectRoundState in target round
|
// Create ProjectRoundState in target round
|
||||||
await tx.projectRoundState.createMany({
|
await tx.projectRoundState.createMany({
|
||||||
data: idsToAdvance.map((projectId) => ({
|
data: idsToAdvance.map((projectId) => ({
|
||||||
@@ -351,6 +392,12 @@ export const roundRouter = router({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// If nothing to advance (set inside tx), return early
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
if (!idsToAdvance! || idsToAdvance!.length === 0) {
|
||||||
|
return { advancedCount: 0, autoPassedCount, targetRoundId: targetRound.id, targetRoundName: targetRound.name }
|
||||||
|
}
|
||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
await logAudit({
|
await logAudit({
|
||||||
prisma: ctx.prisma,
|
prisma: ctx.prisma,
|
||||||
@@ -362,16 +409,105 @@ export const roundRouter = router({
|
|||||||
fromRound: currentRound.name,
|
fromRound: currentRound.name,
|
||||||
toRound: targetRound.name,
|
toRound: targetRound.name,
|
||||||
targetRoundId: targetRound.id,
|
targetRoundId: targetRound.id,
|
||||||
projectCount: idsToAdvance.length,
|
projectCount: idsToAdvance!.length,
|
||||||
autoPassedCount,
|
autoPassedCount,
|
||||||
projectIds: idsToAdvance,
|
projectIds: idsToAdvance!,
|
||||||
},
|
},
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fix 5: notifyOnEntry — notify team members when projects enter target round
|
||||||
|
try {
|
||||||
|
const targetConfig = (targetRound.configJson as Record<string, unknown>) || {}
|
||||||
|
if (targetConfig.notifyOnEntry) {
|
||||||
|
const teamMembers = await ctx.prisma.teamMember.findMany({
|
||||||
|
where: { projectId: { in: idsToAdvance! } },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
const userIds = [...new Set(teamMembers.map((tm) => tm.userId))]
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
void createBulkNotifications({
|
||||||
|
userIds,
|
||||||
|
type: 'round_entry',
|
||||||
|
title: `Projects entered: ${targetRound.name}`,
|
||||||
|
message: `Your project has been advanced to the round "${targetRound.name}".`,
|
||||||
|
linkUrl: '/dashboard',
|
||||||
|
linkLabel: 'View Dashboard',
|
||||||
|
icon: 'ArrowRight',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (notifyErr) {
|
||||||
|
console.error('[advanceProjects] notifyOnEntry notification failed (non-fatal):', notifyErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix 6: notifyOnAdvance — notify applicants from source round that projects advanced
|
||||||
|
try {
|
||||||
|
const sourceConfig = (currentRound.configJson as Record<string, unknown>) || {}
|
||||||
|
if (sourceConfig.notifyOnAdvance) {
|
||||||
|
const projects = await ctx.prisma.project.findMany({
|
||||||
|
where: { id: { in: idsToAdvance! } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
submittedByEmail: true,
|
||||||
|
teamMembers: {
|
||||||
|
select: { user: { select: { id: true, email: true, name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Collect unique user IDs for in-app notifications
|
||||||
|
const applicantUserIds = new Set<string>()
|
||||||
|
for (const project of projects) {
|
||||||
|
for (const tm of project.teamMembers) {
|
||||||
|
applicantUserIds.add(tm.user.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applicantUserIds.size > 0) {
|
||||||
|
void createBulkNotifications({
|
||||||
|
userIds: [...applicantUserIds],
|
||||||
|
type: 'project_advanced',
|
||||||
|
title: 'Your project has advanced!',
|
||||||
|
message: `Congratulations! Your project has advanced from "${currentRound.name}" to "${targetRound.name}".`,
|
||||||
|
linkUrl: '/dashboard',
|
||||||
|
linkLabel: 'View Dashboard',
|
||||||
|
icon: 'Trophy',
|
||||||
|
priority: 'high',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send emails to team members (fire-and-forget)
|
||||||
|
for (const project of projects) {
|
||||||
|
const recipients = new Map<string, string | null>()
|
||||||
|
for (const tm of project.teamMembers) {
|
||||||
|
if (tm.user.email) recipients.set(tm.user.email, tm.user.name)
|
||||||
|
}
|
||||||
|
if (recipients.size === 0 && project.submittedByEmail) {
|
||||||
|
recipients.set(project.submittedByEmail, null)
|
||||||
|
}
|
||||||
|
for (const [email, name] of recipients) {
|
||||||
|
void sendAnnouncementEmail(
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
`Your project has advanced to: ${targetRound.name}`,
|
||||||
|
`Congratulations! Your project "${project.title}" has advanced from "${currentRound.name}" to "${targetRound.name}" in the Monaco Ocean Protection Challenge.`,
|
||||||
|
'View Your Dashboard',
|
||||||
|
`${process.env.NEXTAUTH_URL || 'https://monaco-opc.com'}/dashboard`,
|
||||||
|
).catch((err) => {
|
||||||
|
console.error(`[advanceProjects] notifyOnAdvance email failed for ${email}:`, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (notifyErr) {
|
||||||
|
console.error('[advanceProjects] notifyOnAdvance notification failed (non-fatal):', notifyErr)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
advancedCount: idsToAdvance.length,
|
advancedCount: idsToAdvance!.length,
|
||||||
autoPassedCount,
|
autoPassedCount,
|
||||||
targetRoundId: targetRound.id,
|
targetRoundId: targetRound.id,
|
||||||
targetRoundName: targetRound.name,
|
targetRoundName: targetRound.name,
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export const roundEngineRouter = router({
|
|||||||
input.newState,
|
input.newState,
|
||||||
ctx.user.id,
|
ctx.user.id,
|
||||||
ctx.prisma,
|
ctx.prisma,
|
||||||
|
{ adminOverride: true },
|
||||||
)
|
)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -133,6 +134,7 @@ export const roundEngineRouter = router({
|
|||||||
input.newState,
|
input.newState,
|
||||||
ctx.user.id,
|
ctx.user.id,
|
||||||
ctx.prisma,
|
ctx.prisma,
|
||||||
|
{ adminOverride: true },
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -188,6 +190,14 @@ export const roundEngineRouter = router({
|
|||||||
|
|
||||||
const roundIds = roundsToRemoveFrom.map((r) => r.id)
|
const roundIds = roundsToRemoveFrom.map((r) => r.id)
|
||||||
|
|
||||||
|
// Delete Assignment records first (Prisma cascade handles Evaluations)
|
||||||
|
await ctx.prisma.assignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
roundId: { in: roundIds },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// Delete ProjectRoundState entries for this project in all affected rounds
|
// Delete ProjectRoundState entries for this project in all affected rounds
|
||||||
const deleted = await ctx.prisma.projectRoundState.deleteMany({
|
const deleted = await ctx.prisma.projectRoundState.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -238,6 +248,14 @@ export const roundEngineRouter = router({
|
|||||||
|
|
||||||
const roundIds = roundsToRemoveFrom.map((r) => r.id)
|
const roundIds = roundsToRemoveFrom.map((r) => r.id)
|
||||||
|
|
||||||
|
// Delete Assignment records first (Prisma cascade handles Evaluations)
|
||||||
|
await ctx.prisma.assignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
projectId: { in: input.projectIds },
|
||||||
|
roundId: { in: roundIds },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const deleted = await ctx.prisma.projectRoundState.deleteMany({
|
const deleted = await ctx.prisma.projectRoundState.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
projectId: { in: input.projectIds },
|
projectId: { in: input.projectIds },
|
||||||
|
|||||||
@@ -177,8 +177,9 @@ export async function cancelIntent(
|
|||||||
export async function expireIntentsForRound(
|
export async function expireIntentsForRound(
|
||||||
roundId: string,
|
roundId: string,
|
||||||
actorId?: string,
|
actorId?: string,
|
||||||
|
txClient?: Prisma.TransactionClient,
|
||||||
): Promise<{ expired: number }> {
|
): Promise<{ expired: number }> {
|
||||||
return prisma.$transaction(async (tx) => {
|
const run = async (tx: Prisma.TransactionClient) => {
|
||||||
const pending = await tx.assignmentIntent.findMany({
|
const pending = await tx.assignmentIntent.findMany({
|
||||||
where: { roundId, status: 'INTENT_PENDING' },
|
where: { roundId, status: 'INTENT_PENDING' },
|
||||||
})
|
})
|
||||||
@@ -208,7 +209,13 @@ export async function expireIntentsForRound(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return { expired: pending.length }
|
return { expired: pending.length }
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// If a transaction client was provided, use it directly; otherwise open a new one
|
||||||
|
if (txClient) {
|
||||||
|
return run(txClient)
|
||||||
|
}
|
||||||
|
return prisma.$transaction(run)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ async function sendRemindersForRound(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Select email template type based on reminder type
|
// Select email template type based on reminder type
|
||||||
const emailTemplateType = type === '1H' ? 'REMINDER_1H' : 'REMINDER_24H'
|
const emailTemplateType = type === '1H' ? 'REMINDER_1H' : type === '3_DAYS' ? 'REMINDER_3_DAYS' : 'REMINDER_24H'
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
const pendingCount = pendingCounts.get(user.id) || 0
|
const pendingCount = pendingCounts.get(user.id) || 0
|
||||||
|
|||||||
@@ -268,9 +268,15 @@ export async function createBulkNotifications(params: {
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check email settings and send emails
|
// Check email settings once, then send emails only if enabled
|
||||||
|
const emailSetting = await prisma.notificationEmailSetting.findUnique({
|
||||||
|
where: { notificationType: type },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (emailSetting?.sendEmail) {
|
||||||
for (const userId of userIds) {
|
for (const userId of userIds) {
|
||||||
await maybeSendEmail(userId, type, title, message, linkUrl, metadata)
|
await maybeSendEmailWithSetting(userId, type, title, message, emailSetting, linkUrl, metadata)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,19 +396,36 @@ async function maybeSendEmail(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await maybeSendEmailWithSetting(userId, type, title, message, emailSetting, linkUrl, metadata)
|
||||||
|
} catch (error) {
|
||||||
|
// Log but don't fail the notification creation
|
||||||
|
console.error('[Notification] Failed to send email:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email to a user using a pre-fetched email setting (skips the setting lookup)
|
||||||
|
*/
|
||||||
|
async function maybeSendEmailWithSetting(
|
||||||
|
userId: string,
|
||||||
|
type: string,
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
emailSetting: { sendEmail: boolean; emailSubject: string | null },
|
||||||
|
linkUrl?: string,
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
// Check user's notification preference
|
// Check user's notification preference
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { email: true, name: true, notificationPreference: true },
|
select: { email: true, name: true, notificationPreference: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!user || user.notificationPreference === 'NONE') {
|
if (!user || (user.notificationPreference !== 'EMAIL' && user.notificationPreference !== 'BOTH')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send styled email with full context
|
|
||||||
// The styled template will use metadata for rich content
|
|
||||||
// Subject can be overridden by admin settings
|
|
||||||
await sendStyledNotificationEmail(
|
await sendStyledNotificationEmail(
|
||||||
user.email,
|
user.email,
|
||||||
user.name || 'User',
|
user.name || 'User',
|
||||||
@@ -416,7 +439,6 @@ async function maybeSendEmail(
|
|||||||
emailSetting.emailSubject || undefined
|
emailSetting.emailSubject || undefined
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log but don't fail the notification creation
|
|
||||||
console.error('[Notification] Failed to send email:', error)
|
console.error('[Notification] Failed to send email:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
|
|||||||
ROUND_ARCHIVED: [],
|
ROUND_ARCHIVED: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
|
||||||
|
PENDING: ['IN_PROGRESS', 'PASSED', 'REJECTED', 'WITHDRAWN'],
|
||||||
|
IN_PROGRESS: ['PASSED', 'REJECTED', 'WITHDRAWN'],
|
||||||
|
PASSED: ['COMPLETED', 'WITHDRAWN'],
|
||||||
|
REJECTED: ['PENDING'], // re-include
|
||||||
|
COMPLETED: [], // terminal
|
||||||
|
WITHDRAWN: ['PENDING'], // re-include
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Round-Level Transitions ────────────────────────────────────────────────
|
// ─── Round-Level Transitions ────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -232,8 +241,8 @@ export async function closeRound(
|
|||||||
data: { status: 'ROUND_CLOSED' },
|
data: { status: 'ROUND_CLOSED' },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Expire pending intents
|
// Expire pending intents (using the transaction client)
|
||||||
await expireIntentsForRound(roundId, actorId)
|
await expireIntentsForRound(roundId, actorId, tx)
|
||||||
|
|
||||||
// Auto-close any preceding active rounds (lower sortOrder, same competition)
|
// Auto-close any preceding active rounds (lower sortOrder, same competition)
|
||||||
const precedingActiveRounds = await tx.round.findMany({
|
const precedingActiveRounds = await tx.round.findMany({
|
||||||
@@ -540,6 +549,7 @@ export async function transitionProject(
|
|||||||
newState: ProjectRoundStateValue,
|
newState: ProjectRoundStateValue,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient | any,
|
||||||
|
options?: { adminOverride?: boolean },
|
||||||
): Promise<ProjectRoundTransitionResult> {
|
): Promise<ProjectRoundTransitionResult> {
|
||||||
try {
|
try {
|
||||||
const round = await prisma.round.findUnique({ where: { id: roundId } })
|
const round = await prisma.round.findUnique({ where: { id: roundId } })
|
||||||
@@ -569,6 +579,17 @@ export async function transitionProject(
|
|||||||
where: { projectId_roundId: { projectId, roundId } },
|
where: { projectId_roundId: { projectId, roundId } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Enforce project state transition whitelist (unless admin override)
|
||||||
|
if (existing && !options?.adminOverride) {
|
||||||
|
const currentState = existing.state as string
|
||||||
|
const allowed = VALID_PROJECT_TRANSITIONS[currentState] ?? []
|
||||||
|
if (!allowed.includes(newState)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid project transition: ${currentState} → ${newState}. Allowed: ${allowed.join(', ') || 'none (terminal state)'}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let prs
|
let prs
|
||||||
if (existing) {
|
if (existing) {
|
||||||
prs = await tx.projectRoundState.update({
|
prs = await tx.projectRoundState.update({
|
||||||
@@ -649,6 +670,7 @@ export async function batchTransitionProjects(
|
|||||||
newState: ProjectRoundStateValue,
|
newState: ProjectRoundStateValue,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient | any,
|
||||||
|
options?: { adminOverride?: boolean },
|
||||||
): Promise<BatchProjectTransitionResult> {
|
): Promise<BatchProjectTransitionResult> {
|
||||||
const succeeded: string[] = []
|
const succeeded: string[] = []
|
||||||
const failed: Array<{ projectId: string; errors: string[] }> = []
|
const failed: Array<{ projectId: string; errors: string[] }> = []
|
||||||
@@ -657,7 +679,7 @@ export async function batchTransitionProjects(
|
|||||||
const batch = projectIds.slice(i, i + BATCH_SIZE)
|
const batch = projectIds.slice(i, i + BATCH_SIZE)
|
||||||
|
|
||||||
const batchPromises = batch.map(async (projectId) => {
|
const batchPromises = batch.map(async (projectId) => {
|
||||||
const result = await transitionProject(projectId, roundId, newState, actorId, prisma)
|
const result = await transitionProject(projectId, roundId, newState, actorId, prisma, options)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
succeeded.push(projectId)
|
succeeded.push(projectId)
|
||||||
@@ -725,18 +747,34 @@ export async function checkRequirementsAndTransition(
|
|||||||
prisma: PrismaClient | any,
|
prisma: PrismaClient | any,
|
||||||
): Promise<{ transitioned: boolean; newState?: string }> {
|
): Promise<{ transitioned: boolean; newState?: string }> {
|
||||||
try {
|
try {
|
||||||
// Get all required FileRequirements for this round
|
// Get all required FileRequirements for this round (legacy model)
|
||||||
const requirements = await prisma.fileRequirement.findMany({
|
const requirements = await prisma.fileRequirement.findMany({
|
||||||
where: { roundId, isRequired: true },
|
where: { roundId, isRequired: true },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
// If the round has no file requirements, nothing to check
|
// Also check SubmissionFileRequirement via the round's submissionWindow
|
||||||
if (requirements.length === 0) {
|
const round = await prisma.round.findUnique({
|
||||||
|
where: { id: roundId },
|
||||||
|
select: { submissionWindowId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let submissionRequirements: Array<{ id: string }> = []
|
||||||
|
if (round?.submissionWindowId) {
|
||||||
|
submissionRequirements = await prisma.submissionFileRequirement.findMany({
|
||||||
|
where: { submissionWindowId: round.submissionWindowId, required: true },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the round has no file requirements at all, nothing to check
|
||||||
|
if (requirements.length === 0 && submissionRequirements.length === 0) {
|
||||||
return { transitioned: false }
|
return { transitioned: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check which requirements this project has satisfied (has a file uploaded)
|
// Check which legacy requirements this project has satisfied
|
||||||
|
let legacyAllMet = true
|
||||||
|
if (requirements.length > 0) {
|
||||||
const fulfilledFiles = await prisma.projectFile.findMany({
|
const fulfilledFiles = await prisma.projectFile.findMany({
|
||||||
where: {
|
where: {
|
||||||
projectId,
|
projectId,
|
||||||
@@ -752,8 +790,31 @@ export async function checkRequirementsAndTransition(
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check if all required requirements are met
|
legacyAllMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
|
||||||
const allMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
|
}
|
||||||
|
|
||||||
|
// Check which SubmissionFileRequirements this project has satisfied
|
||||||
|
let submissionAllMet = true
|
||||||
|
if (submissionRequirements.length > 0) {
|
||||||
|
const fulfilledSubmissionFiles = await prisma.projectFile.findMany({
|
||||||
|
where: {
|
||||||
|
projectId,
|
||||||
|
submissionFileRequirementId: { in: submissionRequirements.map((r: { id: string }) => r.id) },
|
||||||
|
},
|
||||||
|
select: { submissionFileRequirementId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const fulfilledSubIds = new Set(
|
||||||
|
fulfilledSubmissionFiles
|
||||||
|
.map((f: { submissionFileRequirementId: string | null }) => f.submissionFileRequirementId)
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
|
||||||
|
submissionAllMet = submissionRequirements.every((r: { id: string }) => fulfilledSubIds.has(r.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// All requirements from both models must be met
|
||||||
|
const allMet = legacyAllMet && submissionAllMet
|
||||||
|
|
||||||
if (!allMet) {
|
if (!allMet) {
|
||||||
return { transitioned: false }
|
return { transitioned: false }
|
||||||
|
|||||||
Reference in New Issue
Block a user