Compare commits
12 Commits
6b40fe7726
...
2864579e92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2864579e92 | ||
|
|
8800f2bcc7 | ||
|
|
0be8c5ecc7 | ||
|
|
fbc8b5165a | ||
|
|
9368c1221f | ||
|
|
9fcafec346 | ||
|
|
f61cebe6ae | ||
|
|
4d68392ada | ||
|
|
de12b232d9 | ||
|
|
a002f544a6 | ||
|
|
3ccf9b0542 | ||
|
|
7ead21114e |
@@ -0,0 +1,17 @@
|
|||||||
|
-- AlterTable: add nullable category column to EvaluationForm
|
||||||
|
ALTER TABLE "EvaluationForm" ADD COLUMN "category" "CompetitionCategory";
|
||||||
|
|
||||||
|
-- Drop old unique constraint
|
||||||
|
ALTER TABLE "EvaluationForm" DROP CONSTRAINT "EvaluationForm_roundId_version_key";
|
||||||
|
|
||||||
|
-- Add new unique constraint including category
|
||||||
|
ALTER TABLE "EvaluationForm" ADD CONSTRAINT "EvaluationForm_roundId_version_category_key" UNIQUE ("roundId", "version", "category");
|
||||||
|
|
||||||
|
-- Partial unique index: prevent duplicate shared forms at the same version
|
||||||
|
-- (PostgreSQL treats NULLs as distinct in unique constraints, so we need this)
|
||||||
|
CREATE UNIQUE INDEX "EvaluationForm_roundId_version_null_category"
|
||||||
|
ON "EvaluationForm" ("roundId", "version") WHERE "category" IS NULL;
|
||||||
|
|
||||||
|
-- Compound index for category-aware active form lookups
|
||||||
|
CREATE INDEX "EvaluationForm_roundId_isActive_category_idx"
|
||||||
|
ON "EvaluationForm" ("roundId", "isActive", "category");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "AwardJuror" ADD COLUMN "isChair" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "AwardVote" ADD COLUMN "justification" TEXT;
|
||||||
@@ -519,9 +519,10 @@ model WizardTemplate {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
model EvaluationForm {
|
model EvaluationForm {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
roundId String
|
roundId String
|
||||||
version Int @default(1)
|
version Int @default(1)
|
||||||
|
category CompetitionCategory? // null=shared form, STARTUP or BUSINESS_CONCEPT=category-specific
|
||||||
|
|
||||||
// Form configuration
|
// Form configuration
|
||||||
// criteriaJson: Array of { id, label, description, scale, weight, required }
|
// criteriaJson: Array of { id, label, description, scale, weight, required }
|
||||||
@@ -537,8 +538,9 @@ model EvaluationForm {
|
|||||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||||
evaluations Evaluation[]
|
evaluations Evaluation[]
|
||||||
|
|
||||||
@@unique([roundId, version])
|
@@unique([roundId, version, category])
|
||||||
@@index([roundId, isActive])
|
@@index([roundId, isActive])
|
||||||
|
@@index([roundId, isActive, category])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -1644,9 +1646,10 @@ model AwardEligibility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model AwardJuror {
|
model AwardJuror {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
awardId String
|
awardId String
|
||||||
userId String
|
userId String
|
||||||
|
isChair Boolean @default(false)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -1664,8 +1667,9 @@ model AwardVote {
|
|||||||
awardId String
|
awardId String
|
||||||
userId String
|
userId String
|
||||||
projectId String
|
projectId String
|
||||||
rank Int? // For RANKED mode
|
rank Int? // For RANKED mode
|
||||||
votedAt DateTime @default(now())
|
justification String? @db.Text
|
||||||
|
votedAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
|
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -477,6 +477,13 @@ export default function AwardDetailPage({
|
|||||||
const removeJuror = trpc.specialAward.removeJuror.useMutation({
|
const removeJuror = trpc.specialAward.removeJuror.useMutation({
|
||||||
onSuccess: () => utils.specialAward.listJurors.invalidate({ awardId }),
|
onSuccess: () => utils.specialAward.listJurors.invalidate({ awardId }),
|
||||||
})
|
})
|
||||||
|
const setChair = trpc.specialAward.setChair.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.specialAward.listJurors.invalidate({ awardId })
|
||||||
|
toast.success('Chair status updated')
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to update chair status'),
|
||||||
|
})
|
||||||
const setWinner = trpc.specialAward.setWinner.useMutation({
|
const setWinner = trpc.specialAward.setWinner.useMutation({
|
||||||
onSuccess: invalidateAward,
|
onSuccess: invalidateAward,
|
||||||
})
|
})
|
||||||
@@ -1328,6 +1335,7 @@ export default function AwardDetailPage({
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Member</TableHead>
|
<TableHead>Member</TableHead>
|
||||||
<TableHead>Role</TableHead>
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Chair</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -1352,6 +1360,15 @@ export default function AwardDetailPage({
|
|||||||
{j.user.role.replace('_', ' ')}
|
{j.user.role.replace('_', ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch
|
||||||
|
checked={j.isChair}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setChair.mutate({ awardId, userId: j.userId, isChair: checked })
|
||||||
|
}
|
||||||
|
disabled={setChair.isPending}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react'
|
import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Papa from 'papaparse'
|
import Papa from 'papaparse'
|
||||||
@@ -270,6 +270,8 @@ export default function MemberInvitePage() {
|
|||||||
skipped: number
|
skipped: number
|
||||||
assignmentsCreated?: number
|
assignmentsCreated?: number
|
||||||
invitationSent?: boolean
|
invitationSent?: boolean
|
||||||
|
emailsSent?: number
|
||||||
|
emailErrors?: string[]
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
// Pre-assignment state
|
// Pre-assignment state
|
||||||
@@ -511,10 +513,34 @@ export default function MemberInvitePage() {
|
|||||||
setParsedUsers(parsedUsers.filter((u) => u.isValid))
|
setParsedUsers(parsedUsers.filter((u) => u.isValid))
|
||||||
|
|
||||||
// --- Send ---
|
// --- Send ---
|
||||||
|
// Simulated progress: ramps up gradually while waiting for the backend
|
||||||
|
const progressIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
const stopProgressSimulation = useCallback(() => {
|
||||||
|
if (progressIntervalRef.current) {
|
||||||
|
clearInterval(progressIntervalRef.current)
|
||||||
|
progressIntervalRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => stopProgressSimulation, [stopProgressSimulation])
|
||||||
|
|
||||||
const handleSendInvites = async () => {
|
const handleSendInvites = async () => {
|
||||||
if (summary.valid === 0) return
|
if (summary.valid === 0) return
|
||||||
setStep('sending')
|
setStep('sending')
|
||||||
setSendProgress(0)
|
setSendProgress(0)
|
||||||
|
|
||||||
|
// Simulate progress: advance quickly at first, then slow down (never reaches 100)
|
||||||
|
progressIntervalRef.current = setInterval(() => {
|
||||||
|
setSendProgress((prev) => {
|
||||||
|
if (prev >= 90) return prev // Cap at 90 — real completion sets 100
|
||||||
|
// Slow down as we approach 90
|
||||||
|
const increment = Math.max(0.5, (90 - prev) * 0.04)
|
||||||
|
return Math.min(90, prev + increment)
|
||||||
|
})
|
||||||
|
}, 300)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await bulkCreate.mutateAsync({
|
const result = await bulkCreate.mutateAsync({
|
||||||
users: summary.validUsers.map((u) => ({
|
users: summary.validUsers.map((u) => ({
|
||||||
@@ -526,10 +552,12 @@ export default function MemberInvitePage() {
|
|||||||
})),
|
})),
|
||||||
sendInvitation,
|
sendInvitation,
|
||||||
})
|
})
|
||||||
|
stopProgressSimulation()
|
||||||
setSendProgress(100)
|
setSendProgress(100)
|
||||||
setResult(result)
|
setResult(result)
|
||||||
setStep('complete')
|
setStep('complete')
|
||||||
} catch {
|
} catch {
|
||||||
|
stopProgressSimulation()
|
||||||
setStep('preview')
|
setStep('preview')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -999,9 +1027,16 @@ export default function MemberInvitePage() {
|
|||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||||
<p className="mt-4 font-medium">
|
<p className="mt-4 font-medium">
|
||||||
{sendInvitation ? 'Creating members and sending invitations...' : 'Creating members...'}
|
{sendInvitation
|
||||||
|
? `Creating ${summary.valid} member${summary.valid !== 1 ? 's' : ''} and sending invitations...`
|
||||||
|
: `Creating ${summary.valid} member${summary.valid !== 1 ? 's' : ''}...`}
|
||||||
</p>
|
</p>
|
||||||
<Progress value={sendProgress} className="mt-4 w-48" />
|
<Progress value={sendProgress} className="mt-4 w-64" />
|
||||||
|
{sendInvitation && summary.valid > 3 && (
|
||||||
|
<p className="text-muted-foreground text-sm mt-3">
|
||||||
|
This may take a moment for large batches
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
@@ -1019,6 +1054,16 @@ export default function MemberInvitePage() {
|
|||||||
<p className="text-muted-foreground text-center max-w-sm mt-2">
|
<p className="text-muted-foreground text-center max-w-sm mt-2">
|
||||||
{result?.created} member{result?.created !== 1 ? 's' : ''}{' '}
|
{result?.created} member{result?.created !== 1 ? 's' : ''}{' '}
|
||||||
{result?.invitationSent ? 'created and invited' : 'created'}.
|
{result?.invitationSent ? 'created and invited' : 'created'}.
|
||||||
|
{result?.invitationSent && result.emailsSent != null && (
|
||||||
|
<span className="block mt-1">
|
||||||
|
{result.emailsSent} invitation{result.emailsSent !== 1 ? 's' : ''} sent successfully.
|
||||||
|
{result.emailErrors && result.emailErrors.length > 0 && (
|
||||||
|
<span className="text-destructive">
|
||||||
|
{' '}{result.emailErrors.length} failed to send.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{result?.skipped
|
{result?.skipped
|
||||||
? ` ${result.skipped} skipped (already exist).`
|
? ` ${result.skipped} skipped (already exist).`
|
||||||
: ''}
|
: ''}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export default function MessagesPage() {
|
|||||||
// Fetch supporting data
|
// Fetch supporting data
|
||||||
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
|
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
const rounds = programs?.flatMap((p) =>
|
const rounds = programs?.flatMap((p) =>
|
||||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, program: { name: p.name } }))
|
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ ...s, program: { name: p.name } }))
|
||||||
) || []
|
) || []
|
||||||
const { data: templates } = trpc.message.listTemplates.useQuery()
|
const { data: templates } = trpc.message.listTemplates.useQuery()
|
||||||
const { data: users } = trpc.user.list.useQuery(
|
const { data: users } = trpc.user.list.useQuery(
|
||||||
@@ -465,8 +465,9 @@ export default function MessagesPage() {
|
|||||||
{rounds?.map((round) => {
|
{rounds?.map((round) => {
|
||||||
const label = round.program ? `${round.program.name} - ${round.name}` : round.name
|
const label = round.program ? `${round.program.name} - ${round.name}` : round.name
|
||||||
const isChecked = roundIds.includes(round.id)
|
const isChecked = roundIds.includes(round.id)
|
||||||
|
const isActive = round.status === 'ROUND_ACTIVE'
|
||||||
return (
|
return (
|
||||||
<div key={round.id} className="flex items-center gap-2">
|
<div key={round.id} className={`flex items-center gap-2 ${!isActive ? 'opacity-60' : ''}`}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`round-${round.id}`}
|
id={`round-${round.id}`}
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
@@ -478,7 +479,8 @@ export default function MessagesPage() {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={`round-${round.id}`} className="text-sm cursor-pointer">
|
<label htmlFor={`round-${round.id}`} className="text-sm cursor-pointer flex items-center gap-1.5">
|
||||||
|
{isActive && <span className="inline-block h-2 w-2 rounded-full bg-green-500 shrink-0" />}
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1006,6 +1006,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
open={!!selectedEvalAssignment}
|
open={!!selectedEvalAssignment}
|
||||||
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
|
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
|
||||||
onSaved={() => utils.project.getFullDetail.invalidate({ id: projectId })}
|
onSaved={() => utils.project.getFullDetail.invalidate({ id: projectId })}
|
||||||
|
category={project?.competitionCategory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* AI Evaluation Summary */}
|
{/* AI Evaluation Summary */}
|
||||||
|
|||||||
@@ -2291,7 +2291,7 @@ export default function RoundDetailPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Evaluation Criteria Editor (EVALUATION rounds only) */}
|
{/* Evaluation Criteria Editor (EVALUATION rounds only) */}
|
||||||
{isEvaluation && <EvaluationCriteriaEditor roundId={roundId} />}
|
{isEvaluation && <EvaluationCriteriaEditor roundId={roundId} perCategoryCriteria={!!(config.perCategoryCriteria)} />}
|
||||||
|
|
||||||
{/* Document Requirements — hidden for EVALUATION rounds unless requireDocumentUpload is on */}
|
{/* Document Requirements — hidden for EVALUATION rounds unless requireDocumentUpload is on */}
|
||||||
{(!isEvaluation || !!(config.requireDocumentUpload as boolean)) && (
|
{(!isEvaluation || !!(config.requireDocumentUpload as boolean)) && (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react'
|
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock, RefreshCw } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
@@ -64,6 +64,48 @@ function ErrorRedirectCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NetworkErrorCard({ onRetry, isRetrying }: { onRetry: () => void; isRetrying: boolean }) {
|
||||||
|
return (
|
||||||
|
<AnimatedCard>
|
||||||
|
<Card className="w-full max-w-md overflow-hidden">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
|
||||||
|
<AlertCircle className="h-6 w-6 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">Something Went Wrong</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
We couldn't verify your invitation due to a server or network issue.
|
||||||
|
This is not a problem with your invitation link.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
|
>
|
||||||
|
{isRetrying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Retrying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Try Again
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
|
If the problem persists, please contact your administrator.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AcceptInviteContent() {
|
function AcceptInviteContent() {
|
||||||
const [state, setState] = useState<InviteState>('loading')
|
const [state, setState] = useState<InviteState>('loading')
|
||||||
const [errorType, setErrorType] = useState<string | null>(null)
|
const [errorType, setErrorType] = useState<string | null>(null)
|
||||||
@@ -72,7 +114,7 @@ function AcceptInviteContent() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const token = searchParams.get('token') || ''
|
const token = searchParams.get('token') || ''
|
||||||
|
|
||||||
const { data, isLoading, error } = trpc.user.validateInviteToken.useQuery(
|
const { data, isLoading, error, refetch, isRefetching } = trpc.user.validateInviteToken.useQuery(
|
||||||
{ token },
|
{ token },
|
||||||
{ enabled: !!token, retry: false }
|
{ enabled: !!token, retry: false }
|
||||||
)
|
)
|
||||||
@@ -197,8 +239,23 @@ function AcceptInviteContent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error state — auto-redirect to login after 4 seconds for known errors
|
// Error state
|
||||||
if (state === 'error') {
|
if (state === 'error') {
|
||||||
|
// Network/server errors get a retry button instead of auto-redirect
|
||||||
|
if (errorType === 'NETWORK_ERROR') {
|
||||||
|
return (
|
||||||
|
<NetworkErrorCard
|
||||||
|
onRetry={() => {
|
||||||
|
setState('loading')
|
||||||
|
setErrorType(null)
|
||||||
|
refetch()
|
||||||
|
}}
|
||||||
|
isRetrying={isRefetching}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token validation errors auto-redirect to login after 4 seconds
|
||||||
const errorContent = getErrorContent()
|
const errorContent = getErrorContent()
|
||||||
const redirectTarget = errorContent.redirect || '/login'
|
const redirectTarget = errorContent.redirect || '/login'
|
||||||
|
|
||||||
|
|||||||
579
src/app/(award-master)/award-master/awards/[id]/page.tsx
Normal file
579
src/app/(award-master)/award-master/awards/[id]/page.tsx
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { use, useRef, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Trophy,
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
FileText,
|
||||||
|
Star,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { CountryDisplay } from '@/components/shared/country-display'
|
||||||
|
import { ProjectFilesSection } from '@/components/jury/project-files-section'
|
||||||
|
|
||||||
|
export default function AwardMasterVotingPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}) {
|
||||||
|
const { id: awardId } = use(params)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
const [expandedProjectId, setExpandedProjectId] = useState<string | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
const [justification, setJustification] = useState('')
|
||||||
|
|
||||||
|
// Queries & mutations
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data, isLoading } =
|
||||||
|
trpc.specialAward.getMyAwardDetailEnhanced.useQuery({ awardId })
|
||||||
|
|
||||||
|
const submitVote = trpc.specialAward.submitAwardMasterVote.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.specialAward.getMyAwardDetailEnhanced.invalidate({ awardId })
|
||||||
|
toast.success('Vote submitted')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const confirmWinner = trpc.specialAward.confirmWinner.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.specialAward.getMyAwardDetailEnhanced.invalidate({ awardId })
|
||||||
|
toast.success('Winner confirmed and award closed')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize selection from existing vote
|
||||||
|
const initializedRef = useRef(false)
|
||||||
|
if (data && !initializedRef.current && data.myVotes.length > 0) {
|
||||||
|
initializedRef.current = true
|
||||||
|
setSelectedProjectId(data.myVotes[0].projectId)
|
||||||
|
if (data.myVotes[0].justification) {
|
||||||
|
setJustification(data.myVotes[0].justification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<Skeleton className="h-6 w-72" />
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-44" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
// Destructure data
|
||||||
|
const { award, projects, myVotes, isChair, otherVotes, totalJurors } = data
|
||||||
|
const hasVoted = myVotes.length > 0
|
||||||
|
const isVotingOpen = award.status === 'VOTING_OPEN'
|
||||||
|
const isClosed = award.status === 'CLOSED'
|
||||||
|
const selectedProject = projects.find((p) => p.id === selectedProjectId)
|
||||||
|
|
||||||
|
// Toggle project expansion
|
||||||
|
const handleProjectClick = (projectId: string) => {
|
||||||
|
if (isVotingOpen) setSelectedProjectId(projectId)
|
||||||
|
setExpandedProjectId(expandedProjectId === projectId ? null : projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit vote handler
|
||||||
|
const handleSubmitVote = () => {
|
||||||
|
if (!selectedProjectId) return
|
||||||
|
submitVote.mutate({
|
||||||
|
awardId,
|
||||||
|
projectId: selectedProjectId,
|
||||||
|
justification: justification.trim() || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm winner handler
|
||||||
|
const handleConfirmWinner = () => {
|
||||||
|
confirmWinner.mutate({ awardId })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the winner project for closed state
|
||||||
|
const winnerProject = isClosed
|
||||||
|
? projects.find((p) => p.id === award.winnerProjectId)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Back button */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.push('/award-master' as Route)}
|
||||||
|
className="-ml-4"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||||
|
<Trophy className="h-6 w-6 text-amber-500" />
|
||||||
|
{award.name}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
isVotingOpen
|
||||||
|
? 'default'
|
||||||
|
: isClosed
|
||||||
|
? 'secondary'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{award.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
{hasVoted && !isClosed && (
|
||||||
|
<Badge variant="outline" className="text-green-600">
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
Voted
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{award.competition && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{award.competition.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{award.criteriaText && (
|
||||||
|
<Card className="mt-3 bg-muted/30">
|
||||||
|
<CardContent className="py-3 px-4">
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Star className="mr-1.5 inline h-4 w-4 text-amber-500" />
|
||||||
|
<span className="font-medium text-foreground">Criteria: </span>
|
||||||
|
{award.criteriaText}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Closed State */}
|
||||||
|
{isClosed ? (
|
||||||
|
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="rounded-full bg-amber-100 p-4 dark:bg-amber-900/40">
|
||||||
|
<Trophy className="h-12 w-12 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-lg font-semibold">Award Finalized</p>
|
||||||
|
{winnerProject ? (
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<p className="text-xl font-bold text-[#053d57] dark:text-blue-300">
|
||||||
|
{winnerProject.title}
|
||||||
|
</p>
|
||||||
|
{winnerProject.teamName && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{winnerProject.teamName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
This award has been finalized
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Project Grid */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-3">
|
||||||
|
Eligible Projects ({projects.length})
|
||||||
|
</h2>
|
||||||
|
{isVotingOpen && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Click a project to select it as your pick and expand details
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className={cn(
|
||||||
|
expandedProjectId === project.id && 'sm:col-span-2 lg:col-span-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer transition-all',
|
||||||
|
selectedProjectId === project.id
|
||||||
|
? 'ring-2 ring-primary bg-primary/5'
|
||||||
|
: 'hover:bg-muted/50'
|
||||||
|
)}
|
||||||
|
onClick={() => handleProjectClick(project.id)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{project.title}
|
||||||
|
</CardTitle>
|
||||||
|
{project.teamName && (
|
||||||
|
<CardDescription className="mt-0.5">
|
||||||
|
{project.teamName}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 shrink-0">
|
||||||
|
{expandedProjectId === project.id ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{project.competitionCategory && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{project.competitionCategory.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{project.country && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<CountryDisplay country={project.country} />
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{project.evaluationScore && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
|
||||||
|
>
|
||||||
|
<Star className="mr-0.5 h-3 w-3" />
|
||||||
|
Avg: {project.evaluationScore.avg.toFixed(1)}/10 (
|
||||||
|
{project.evaluationScore.count}{' '}
|
||||||
|
{project.evaluationScore.count === 1
|
||||||
|
? 'review'
|
||||||
|
: 'reviews'}
|
||||||
|
)
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{selectedProjectId === project.id && (
|
||||||
|
<Badge className="text-xs bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300">
|
||||||
|
<CheckCircle2 className="mr-0.5 h-3 w-3" />
|
||||||
|
Selected
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Expanded Project Detail */}
|
||||||
|
{expandedProjectId === project.id && (
|
||||||
|
<Card className="mt-2 border-dashed">
|
||||||
|
<CardContent className="space-y-4 py-4">
|
||||||
|
{project.description && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-1 flex items-center gap-1.5">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
Description
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-line">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{award.evaluationRoundId && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
Documents
|
||||||
|
</h4>
|
||||||
|
<ProjectFilesSection
|
||||||
|
projectId={project.id}
|
||||||
|
roundId={award.evaluationRoundId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.evaluationScore && (
|
||||||
|
<Card className="bg-blue-50/50 dark:bg-blue-950/20">
|
||||||
|
<CardContent className="py-3 px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Star className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Evaluation Score
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-blue-700 dark:text-blue-300">
|
||||||
|
{project.evaluationScore.avg.toFixed(1)} / 10
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Based on {project.evaluationScore.count}{' '}
|
||||||
|
{project.evaluationScore.count === 1
|
||||||
|
? 'evaluation'
|
||||||
|
: 'evaluations'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vote Section */}
|
||||||
|
{isVotingOpen && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Your Vote</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{hasVoted
|
||||||
|
? 'You can update your vote until the award is finalized'
|
||||||
|
: 'Select a project above and submit your vote'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{selectedProject ? (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your selection
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold">{selectedProject.title}</p>
|
||||||
|
{selectedProject.teamName && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{selectedProject.teamName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
No project selected. Click a project card above to select it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="justification"
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
Justification
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="justification"
|
||||||
|
value={justification}
|
||||||
|
onChange={(e) => setJustification(e.target.value)}
|
||||||
|
placeholder="Why did you choose this project? (optional)"
|
||||||
|
maxLength={2000}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
|
{justification.length} / 2000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmitVote}
|
||||||
|
disabled={!selectedProjectId || submitVote.isPending}
|
||||||
|
>
|
||||||
|
{submitVote.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chair Section */}
|
||||||
|
{isChair && isVotingOpen && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5 text-muted-foreground" />
|
||||||
|
Team Votes
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
As chair, you can view team votes and confirm the winner
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{otherVotes.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{otherVotes.map((vote) => {
|
||||||
|
const votedProject = projects.find(
|
||||||
|
(p) => p.id === vote.projectId
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={vote.userId}
|
||||||
|
className="rounded-lg border p-3 space-y-1"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="font-medium text-sm">
|
||||||
|
{vote.userName || 'Anonymous Juror'}
|
||||||
|
</p>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
voted for
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{votedProject?.title || 'Unknown project'}
|
||||||
|
</p>
|
||||||
|
{vote.justification && (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
“{vote.justification}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
Waiting for other team members to vote
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vote tally */}
|
||||||
|
<div className="rounded-lg bg-muted/30 p-3">
|
||||||
|
<p className="text-sm font-medium">Vote Summary</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{otherVotes.length + (hasVoted ? 1 : 0)} of{' '}
|
||||||
|
{totalJurors} jurors have voted
|
||||||
|
</p>
|
||||||
|
{(() => {
|
||||||
|
const allVotes = [
|
||||||
|
...otherVotes.map((v) => v.projectId),
|
||||||
|
...(hasVoted && myVotes[0]
|
||||||
|
? [myVotes[0].projectId]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
const tally = new Map<string, number>()
|
||||||
|
for (const pid of allVotes) {
|
||||||
|
tally.set(pid, (tally.get(pid) || 0) + 1)
|
||||||
|
}
|
||||||
|
const sorted = [...tally.entries()].sort(
|
||||||
|
(a, b) => b[1] - a[1]
|
||||||
|
)
|
||||||
|
if (sorted.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{sorted.map(([pid, count]) => {
|
||||||
|
const proj = projects.find((p) => p.id === pid)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={pid}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span>{proj?.title || 'Unknown'}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{count} {count === 1 ? 'vote' : 'votes'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Winner button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
disabled={!hasVoted || confirmWinner.isPending}
|
||||||
|
>
|
||||||
|
{confirmWinner.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trophy className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Confirm Winner
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Confirm Award Winner
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will finalize the winner and close the award.
|
||||||
|
This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirmWinner}>
|
||||||
|
Confirm Winner
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
src/app/(award-master)/award-master/page.tsx
Normal file
91
src/app/(award-master)/award-master/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Trophy } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function AwardMasterDashboard() {
|
||||||
|
const { data: awards, isLoading } = trpc.specialAward.getMyAwards.useQuery()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{[...Array(2)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-40" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Award Master Dashboard
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Review eligible projects and select award winners
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{awards && awards.length > 0 ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{awards.map((award) => (
|
||||||
|
<Link key={award.id} href={`/award-master/awards/${award.id}` as Route}>
|
||||||
|
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Trophy className="h-5 w-5 text-amber-500" />
|
||||||
|
{award.name}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
award.status === 'VOTING_OPEN' ? 'default' : 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{award.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{award.description && (
|
||||||
|
<CardDescription className="line-clamp-2">
|
||||||
|
{award.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{award._count.eligibilities} eligible projects
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Trophy className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">No awards assigned</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You will see your awards here when they are assigned to you
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/app/(award-master)/layout.tsx
Normal file
24
src/app/(award-master)/layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { requireRole } from '@/lib/auth-redirect'
|
||||||
|
import { AwardMasterNav } from '@/components/layouts/award-master-nav'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default async function AwardMasterLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const session = await requireRole('AWARD_MASTER', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<AwardMasterNav
|
||||||
|
user={{
|
||||||
|
name: session.user.name,
|
||||||
|
email: session.user.email,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<main className="container-app py-6 lg:py-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -80,10 +80,10 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
const [coiCompleted, setCOICompleted] = useState(false)
|
const [coiCompleted, setCOICompleted] = useState(false)
|
||||||
const [coiHasConflict, setCOIHasConflict] = useState(false)
|
const [coiHasConflict, setCOIHasConflict] = useState(false)
|
||||||
|
|
||||||
// Fetch the active evaluation form for this round
|
// Fetch the active evaluation form for this round (category-aware)
|
||||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||||
{ roundId },
|
{ roundId, category: project?.competitionCategory },
|
||||||
{ enabled: !!roundId }
|
{ enabled: !!roundId && !!project }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Start evaluation mutation (creates draft)
|
// Start evaluation mutation (creates draft)
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type EvaluationEditSheetProps = {
|
|||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
/** Called after a successful feedback edit */
|
/** Called after a successful feedback edit */
|
||||||
onSaved?: () => void
|
onSaved?: () => void
|
||||||
|
/** Optional project competition category for category-aware form lookup */
|
||||||
|
category?: 'STARTUP' | 'BUSINESS_CONCEPT' | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EvaluationEditSheet({
|
export function EvaluationEditSheet({
|
||||||
@@ -41,6 +43,7 @@ export function EvaluationEditSheet({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
category,
|
||||||
}: EvaluationEditSheetProps) {
|
}: EvaluationEditSheetProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editedFeedback, setEditedFeedback] = useState('')
|
const [editedFeedback, setEditedFeedback] = useState('')
|
||||||
@@ -115,7 +118,7 @@ export function EvaluationEditSheet({
|
|||||||
|
|
||||||
{/* Criterion Scores */}
|
{/* Criterion Scores */}
|
||||||
{hasScores && (
|
{hasScores && (
|
||||||
<CriterionScoresSection criterionScores={criterionScores} roundId={roundId} />
|
<CriterionScoresSection criterionScores={criterionScores} roundId={roundId} category={category ?? assignment.project?.competitionCategory} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Feedback Text — editable */}
|
{/* Feedback Text — editable */}
|
||||||
@@ -147,12 +150,14 @@ export function EvaluationEditSheet({
|
|||||||
function CriterionScoresSection({
|
function CriterionScoresSection({
|
||||||
criterionScores,
|
criterionScores,
|
||||||
roundId,
|
roundId,
|
||||||
|
category,
|
||||||
}: {
|
}: {
|
||||||
criterionScores: Record<string, number | boolean | string>
|
criterionScores: Record<string, number | boolean | string>
|
||||||
roundId?: string
|
roundId?: string
|
||||||
|
category?: 'STARTUP' | 'BUSINESS_CONCEPT' | null
|
||||||
}) {
|
}) {
|
||||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||||
{ roundId: roundId ?? '' },
|
{ roundId: roundId ?? '', category },
|
||||||
{ enabled: !!roundId }
|
{ enabled: !!roundId }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,165 @@ import { toast } from 'sonner'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-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'
|
||||||
|
|
||||||
export type EvaluationCriteriaEditorProps = {
|
export type EvaluationCriteriaEditorProps = {
|
||||||
roundId: string
|
roundId: string
|
||||||
|
perCategoryCriteria?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorProps) {
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function parseCriteria(criteriaJson: unknown): Criterion[] {
|
||||||
|
if (!criteriaJson) return []
|
||||||
|
return (criteriaJson as Criterion[]).map((c) => {
|
||||||
|
const type = c.type || 'numeric'
|
||||||
|
if (type === 'numeric' && typeof c.scale === 'string') {
|
||||||
|
const parts = (c.scale as string).split('-').map(Number)
|
||||||
|
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||||
|
return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...c, type } as Criterion
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpsertPayload(criteria: Criterion[]) {
|
||||||
|
return criteria.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
description: c.description,
|
||||||
|
type: c.type || 'numeric',
|
||||||
|
weight: c.weight,
|
||||||
|
scale: typeof c.scale === 'number' ? c.scale : undefined,
|
||||||
|
minScore: (c as any).minScore,
|
||||||
|
maxScore: (c as any).maxScore,
|
||||||
|
required: c.required,
|
||||||
|
maxLength: c.maxLength,
|
||||||
|
placeholder: c.placeholder,
|
||||||
|
trueLabel: c.trueLabel,
|
||||||
|
falseLabel: c.falseLabel,
|
||||||
|
condition: c.condition,
|
||||||
|
sectionId: c.sectionId,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Single-category panel (used both standalone and inside tabs)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SingleCriteriaPanel({
|
||||||
|
roundId,
|
||||||
|
category,
|
||||||
|
}: {
|
||||||
|
roundId: string
|
||||||
|
category?: 'STARTUP' | 'BUSINESS_CONCEPT'
|
||||||
|
}) {
|
||||||
|
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const { data: form, isLoading } = trpc.evaluation.getForm.useQuery(
|
||||||
|
{ roundId, category: category ?? null },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const upsertMutation = trpc.evaluation.upsertForm.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.evaluation.getForm.invalidate({ roundId, category: category ?? null })
|
||||||
|
toast.success('Evaluation criteria saved')
|
||||||
|
setPendingCriteria(null)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverCriteria: Criterion[] = useMemo(
|
||||||
|
() => parseCriteria(form?.criteriaJson),
|
||||||
|
[form?.criteriaJson],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleChange = useCallback((criteria: Criterion[]) => {
|
||||||
|
setPendingCriteria(criteria)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const criteria = pendingCriteria ?? serverCriteria
|
||||||
|
const validCriteria = criteria.filter((c) => c.label.trim())
|
||||||
|
if (validCriteria.length === 0) {
|
||||||
|
toast.error('Add at least one criterion')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upsertMutation.mutate({
|
||||||
|
roundId,
|
||||||
|
category: category ?? undefined,
|
||||||
|
criteria: buildUpsertPayload(validCriteria),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-end mb-3">
|
||||||
|
{form && (
|
||||||
|
<span className="text-xs text-muted-foreground mr-auto">
|
||||||
|
Version {form.version} —{' '}
|
||||||
|
{(form.criteriaJson as Criterion[]).filter((c) => (c.type || 'numeric') !== 'section_header').length} criteria
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!form && !isLoading && (
|
||||||
|
<span className="text-xs text-muted-foreground mr-auto">
|
||||||
|
No criteria defined yet. Add numeric scores, yes/no questions, and text fields.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{pendingCriteria && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setPendingCriteria(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={upsertMutation.isPending}>
|
||||||
|
{upsertMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
|
Save Criteria
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EvaluationFormBuilder
|
||||||
|
initialCriteria={serverCriteria}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function EvaluationCriteriaEditor({ roundId, perCategoryCriteria }: EvaluationCriteriaEditorProps) {
|
||||||
|
if (!perCategoryCriteria) {
|
||||||
|
// Original single-form layout
|
||||||
|
return <SingleFormEditor roundId={roundId} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-category tabbed layout
|
||||||
|
return <TabbedCriteriaEditor roundId={roundId} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Single form editor — preserves original Card layout exactly
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SingleFormEditor({ roundId }: { roundId: string }) {
|
||||||
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
|
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
@@ -32,21 +182,10 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr
|
|||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Convert server criteriaJson to Criterion[] format
|
const serverCriteria: Criterion[] = useMemo(
|
||||||
const serverCriteria: Criterion[] = useMemo(() => {
|
() => parseCriteria(form?.criteriaJson),
|
||||||
if (!form?.criteriaJson) return []
|
[form?.criteriaJson],
|
||||||
return (form.criteriaJson as Criterion[]).map((c) => {
|
)
|
||||||
// Handle legacy numeric-only format: convert "scale" string like "1-10" back to minScore/maxScore
|
|
||||||
const type = c.type || 'numeric'
|
|
||||||
if (type === 'numeric' && typeof c.scale === 'string') {
|
|
||||||
const parts = (c.scale as string).split('-').map(Number)
|
|
||||||
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
|
||||||
return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ...c, type } as Criterion
|
|
||||||
})
|
|
||||||
}, [form?.criteriaJson])
|
|
||||||
|
|
||||||
const handleChange = useCallback((criteria: Criterion[]) => {
|
const handleChange = useCallback((criteria: Criterion[]) => {
|
||||||
setPendingCriteria(criteria)
|
setPendingCriteria(criteria)
|
||||||
@@ -59,26 +198,9 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr
|
|||||||
toast.error('Add at least one criterion')
|
toast.error('Add at least one criterion')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Map to upsertForm format
|
|
||||||
upsertMutation.mutate({
|
upsertMutation.mutate({
|
||||||
roundId,
|
roundId,
|
||||||
criteria: validCriteria.map((c) => ({
|
criteria: buildUpsertPayload(validCriteria),
|
||||||
id: c.id,
|
|
||||||
label: c.label,
|
|
||||||
description: c.description,
|
|
||||||
type: c.type || 'numeric',
|
|
||||||
weight: c.weight,
|
|
||||||
scale: typeof c.scale === 'number' ? c.scale : undefined,
|
|
||||||
minScore: (c as any).minScore,
|
|
||||||
maxScore: (c as any).maxScore,
|
|
||||||
required: c.required,
|
|
||||||
maxLength: c.maxLength,
|
|
||||||
placeholder: c.placeholder,
|
|
||||||
trueLabel: c.trueLabel,
|
|
||||||
falseLabel: c.falseLabel,
|
|
||||||
condition: c.condition,
|
|
||||||
sectionId: c.sectionId,
|
|
||||||
})),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,3 +244,59 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tabbed criteria editor — separate forms per category
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TabbedCriteriaEditor({ roundId }: { roundId: string }) {
|
||||||
|
const { data: startupForm } = trpc.evaluation.getForm.useQuery(
|
||||||
|
{ roundId, category: 'STARTUP' },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
const { data: conceptForm } = trpc.evaluation.getForm.useQuery(
|
||||||
|
{ roundId, category: 'BUSINESS_CONCEPT' },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Evaluation Criteria</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Separate criteria for each project category. Configure each tab independently.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="STARTUP">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="STARTUP" className="gap-2">
|
||||||
|
Startup Criteria
|
||||||
|
{!startupForm && (
|
||||||
|
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-300 text-[10px] px-1.5 py-0">
|
||||||
|
No form
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="BUSINESS_CONCEPT" className="gap-2">
|
||||||
|
Business Concept Criteria
|
||||||
|
{!conceptForm && (
|
||||||
|
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-300 text-[10px] px-1.5 py-0">
|
||||||
|
No form
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="STARTUP" className="mt-4">
|
||||||
|
<SingleCriteriaPanel roundId={roundId} category="STARTUP" />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="BUSINESS_CONCEPT" className="mt-4">
|
||||||
|
<SingleCriteriaPanel roundId={roundId} category="BUSINESS_CONCEPT" />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="perCategoryCriteria">Separate Criteria per Category</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">Define different evaluation criteria for Startup and Business Concept projects</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="perCategoryCriteria"
|
||||||
|
checked={(config.perCategoryCriteria as boolean) ?? false}
|
||||||
|
onCheckedChange={(v) => update('perCategoryCriteria', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="anonymizationLevel">Anonymization Level</Label>
|
<Label htmlFor="anonymizationLevel">Anonymization Level</Label>
|
||||||
<p className="text-xs text-muted-foreground">How much of other jurors' identities are revealed</p>
|
<p className="text-xs text-muted-foreground">How much of other jurors' identities are revealed</p>
|
||||||
|
|||||||
@@ -70,8 +70,12 @@ function getMetric(round: PipelineRound): string {
|
|||||||
return evalTotal > 0
|
return evalTotal > 0
|
||||||
? `${evalSubmitted}/${evalTotal} evaluated`
|
? `${evalSubmitted}/${evalTotal} evaluated`
|
||||||
: `${assignmentCount} assignments`
|
: `${assignmentCount} assignments`
|
||||||
case 'SUBMISSION':
|
case 'SUBMISSION': {
|
||||||
return `${projectStates.COMPLETED} submitted`
|
const active = projectStates.IN_PROGRESS + projectStates.COMPLETED
|
||||||
|
return active > 0
|
||||||
|
? `${projectStates.COMPLETED}/${projectStates.total} submitted${projectStates.IN_PROGRESS > 0 ? ` (${projectStates.IN_PROGRESS} in progress)` : ''}`
|
||||||
|
: `${projectStates.total} awaiting`
|
||||||
|
}
|
||||||
case 'MENTORING':
|
case 'MENTORING':
|
||||||
return `${projectStates.COMPLETED ?? 0} mentored`
|
return `${projectStates.COMPLETED ?? 0} mentored`
|
||||||
case 'LIVE_FINAL': {
|
case 'LIVE_FINAL': {
|
||||||
@@ -100,7 +104,8 @@ function getProgressPct(round: PipelineRound): number | null {
|
|||||||
return round.evalTotal > 0 ? Math.round((round.evalSubmitted / round.evalTotal) * 100) : 0
|
return round.evalTotal > 0 ? Math.round((round.evalSubmitted / round.evalTotal) * 100) : 0
|
||||||
case 'SUBMISSION': {
|
case 'SUBMISSION': {
|
||||||
const total = round.projectStates.total
|
const total = round.projectStates.total
|
||||||
return total > 0 ? Math.round((round.projectStates.COMPLETED / total) * 100) : 0
|
const active = round.projectStates.IN_PROGRESS + round.projectStates.COMPLETED
|
||||||
|
return total > 0 ? Math.round((active / total) * 100) : 0
|
||||||
}
|
}
|
||||||
case 'MENTORING': {
|
case 'MENTORING': {
|
||||||
const total = round.projectStates.total
|
const total = round.projectStates.total
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: t
|
|||||||
JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale },
|
JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale },
|
||||||
MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake },
|
MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake },
|
||||||
OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye },
|
OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye },
|
||||||
|
AWARD_MASTER: { label: 'Award Master', path: '/award-master', icon: Trophy },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSidebar({ user }: AdminSidebarProps) {
|
export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||||
|
|||||||
23
src/components/layouts/award-master-nav.tsx
Normal file
23
src/components/layouts/award-master-nav.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Home } from 'lucide-react'
|
||||||
|
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||||
|
|
||||||
|
interface AwardMasterNavProps {
|
||||||
|
user: RoleNavUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AwardMasterNav({ user }: AwardMasterNavProps) {
|
||||||
|
const navigation: NavItem[] = [
|
||||||
|
{ name: 'Dashboard', href: '/award-master', icon: Home },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RoleNav
|
||||||
|
navigation={navigation}
|
||||||
|
roleName="Award Master"
|
||||||
|
user={user}
|
||||||
|
basePath="/award-master"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
LogOut, Menu, Moon, Settings, Sun, User, X,
|
LogOut, Menu, Moon, Settings, Sun, User, X, Trophy,
|
||||||
LayoutDashboard, Scale, Handshake, Eye, ArrowRightLeft,
|
LayoutDashboard, Scale, Handshake, Eye, ArrowRightLeft,
|
||||||
ExternalLink as ExternalLinkIcon, HelpCircle, Mail,
|
ExternalLink as ExternalLinkIcon, HelpCircle, Mail,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -61,6 +61,7 @@ const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: t
|
|||||||
JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale },
|
JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale },
|
||||||
MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake },
|
MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake },
|
||||||
OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye },
|
OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye },
|
||||||
|
AWARD_MASTER: { label: 'Award Master', path: '/award-master', icon: Trophy },
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
|
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
const roundId = data?.assignments?.[0]?.roundId as string | undefined
|
const roundId = data?.assignments?.[0]?.roundId as string | undefined
|
||||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||||
{ roundId: roundId ?? '' },
|
{ roundId: roundId ?? '', category: data?.project?.competitionCategory },
|
||||||
{ enabled: !!roundId },
|
{ enabled: !!roundId },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const ROLE_DASHBOARDS: Record<string, string> = {
|
|||||||
MENTOR: '/mentor',
|
MENTOR: '/mentor',
|
||||||
OBSERVER: '/observer',
|
OBSERVER: '/observer',
|
||||||
APPLICANT: '/applicant',
|
APPLICANT: '/applicant',
|
||||||
|
AWARD_MASTER: '/award-master',
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireRole(...allowedRoles: UserRole[]) {
|
export async function requireRole(...allowedRoles: UserRole[]) {
|
||||||
|
|||||||
@@ -1993,10 +1993,14 @@ Together for a healthier ocean.
|
|||||||
export function getEmailPreviewHtml(subject: string, body: string): string {
|
export function getEmailPreviewHtml(subject: string, body: string): string {
|
||||||
const formattedBody = escapeHtml(body).replace(/\n/g, '<br>')
|
const formattedBody = escapeHtml(body).replace(/\n/g, '<br>')
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(subject)}
|
${sectionTitle('Hello [Name],')}
|
||||||
<div style="color: #1f2937; font-size: 15px; line-height: 1.7; margin: 20px 0;">
|
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">
|
||||||
${formattedBody}
|
${formattedBody}
|
||||||
</div>
|
</div>
|
||||||
|
${ctaButton('#', 'View Details')}
|
||||||
|
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
|
||||||
|
You received this email because of your notification preferences on the MOPC Portal.
|
||||||
|
</p>
|
||||||
`
|
`
|
||||||
return getEmailWrapper(content)
|
return getEmailWrapper(content)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { generateSummary } from '@/server/services/ai-evaluation-summary'
|
|||||||
import { quickRank as aiQuickRank } from '../services/ai-ranking'
|
import { quickRank as aiQuickRank } from '../services/ai-ranking'
|
||||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||||
import type { PrismaClient } from '@prisma/client'
|
import type { PrismaClient } from '@prisma/client'
|
||||||
|
import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
|
||||||
import { triggerInProgressOnActivity, checkEvaluationCompletionAndTransition } from '../services/round-engine'
|
import { triggerInProgressOnActivity, checkEvaluationCompletionAndTransition } from '../services/round-engine'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1298,11 +1299,9 @@ export const evaluationRouter = router({
|
|||||||
* Get active evaluation form for a round (admin view with full details)
|
* Get active evaluation form for a round (admin view with full details)
|
||||||
*/
|
*/
|
||||||
getForm: adminProcedure
|
getForm: adminProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string(), category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
const form = await findActiveForm(ctx.prisma, input.roundId, input.category)
|
||||||
where: { roundId: input.roundId, isActive: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!form) return null
|
if (!form) return null
|
||||||
|
|
||||||
@@ -1333,6 +1332,7 @@ export const evaluationRouter = router({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
roundId: z.string(),
|
roundId: z.string(),
|
||||||
|
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish(),
|
||||||
criteria: z.array(
|
criteria: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -1364,7 +1364,7 @@ export const evaluationRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { roundId, criteria } = input
|
const { roundId, category, criteria } = input
|
||||||
|
|
||||||
// Enforce max one advance criterion per form
|
// Enforce max one advance criterion per form
|
||||||
const advanceCount = criteria.filter((c) => c.type === 'advance').length
|
const advanceCount = criteria.filter((c) => c.type === 'advance').length
|
||||||
@@ -1378,9 +1378,9 @@ export const evaluationRouter = router({
|
|||||||
// Verify round exists
|
// Verify round exists
|
||||||
await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId } })
|
await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId } })
|
||||||
|
|
||||||
// Get current max version for this round
|
// Get current max version for this round + category
|
||||||
const latestForm = await ctx.prisma.evaluationForm.findFirst({
|
const latestForm = await ctx.prisma.evaluationForm.findFirst({
|
||||||
where: { roundId },
|
where: { roundId, category: category ?? null },
|
||||||
orderBy: { version: 'desc' },
|
orderBy: { version: 'desc' },
|
||||||
select: { version: true },
|
select: { version: true },
|
||||||
})
|
})
|
||||||
@@ -1440,10 +1440,10 @@ export const evaluationRouter = router({
|
|||||||
scalesJson[scale] = { min, max }
|
scalesJson[scale] = { min, max }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transaction: deactivate old → create new
|
// Transaction: deactivate old → create new (scoped to category)
|
||||||
const form = await ctx.prisma.$transaction(async (tx) => {
|
const form = await ctx.prisma.$transaction(async (tx) => {
|
||||||
await tx.evaluationForm.updateMany({
|
await tx.evaluationForm.updateMany({
|
||||||
where: { roundId, isActive: true },
|
where: { roundId, isActive: true, category: category ?? null },
|
||||||
data: { isActive: false },
|
data: { isActive: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1454,6 +1454,7 @@ export const evaluationRouter = router({
|
|||||||
criteriaJson,
|
criteriaJson,
|
||||||
scalesJson,
|
scalesJson,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
category: category ?? null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1553,10 +1554,14 @@ export const evaluationRouter = router({
|
|||||||
})
|
})
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
// Get active evaluation form for this stage
|
// Fetch project's competition category for category-aware form lookup
|
||||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||||
where: { roundId: input.roundId, isActive: true },
|
where: { id: assignment.projectId },
|
||||||
|
select: { competitionCategory: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get active evaluation form for this stage (category-aware)
|
||||||
|
const form = await findActiveForm(ctx.prisma, input.roundId, project.competitionCategory)
|
||||||
if (!form) {
|
if (!form) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@@ -1577,11 +1582,9 @@ export const evaluationRouter = router({
|
|||||||
* Get the active evaluation form for a stage
|
* Get the active evaluation form for a stage
|
||||||
*/
|
*/
|
||||||
getStageForm: protectedProcedure
|
getStageForm: protectedProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string(), category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
const form = await findActiveForm(ctx.prisma, input.roundId, input.category)
|
||||||
where: { roundId: input.roundId, isActive: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!form) {
|
if (!form) {
|
||||||
return null
|
return null
|
||||||
@@ -1846,7 +1849,7 @@ export const evaluationRouter = router({
|
|||||||
const assignments = await ctx.prisma.assignment.findMany({
|
const assignments = await ctx.prisma.assignment.findMany({
|
||||||
where: { userId: input.userId },
|
where: { userId: input.userId },
|
||||||
include: {
|
include: {
|
||||||
project: { select: { id: true, title: true } },
|
project: { select: { id: true, title: true, competitionCategory: true } },
|
||||||
round: { select: { id: true, name: true, roundType: true, sortOrder: true } },
|
round: { select: { id: true, name: true, roundType: true, sortOrder: true } },
|
||||||
evaluation: {
|
evaluation: {
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -103,14 +103,23 @@ export const exportRouter = router({
|
|||||||
projectScores: adminProcedure
|
projectScores: adminProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
// Fetch evaluation form to get criteria labels
|
// Fetch all active evaluation forms for this round (shared + category-specific)
|
||||||
const evalForm = await ctx.prisma.evaluationForm.findFirst({
|
const activeForms = await ctx.prisma.evaluationForm.findMany({
|
||||||
where: { roundId: input.roundId, isActive: true },
|
where: { roundId: input.roundId, isActive: true },
|
||||||
select: { criteriaJson: true },
|
select: { criteriaJson: true },
|
||||||
})
|
})
|
||||||
const criteria = (evalForm?.criteriaJson as Array<{
|
// Merge criteria across all forms, deduplicating by criterion id
|
||||||
id: string; label: string; type?: string
|
const seenCriterionIds = new Set<string>()
|
||||||
}> | null) ?? []
|
const criteria: Array<{ id: string; label: string; type?: string }> = []
|
||||||
|
for (const f of activeForms) {
|
||||||
|
const fc = (f.criteriaJson as Array<{ id: string; label: string; type?: string }> | null) ?? []
|
||||||
|
for (const c of fc) {
|
||||||
|
if (!seenCriterionIds.has(c.id)) {
|
||||||
|
seenCriterionIds.add(c.id)
|
||||||
|
criteria.push(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const numericCriteria = criteria.filter((c) => !c.type || c.type === 'numeric')
|
const numericCriteria = criteria.filter((c) => !c.type || c.type === 'numeric')
|
||||||
|
|
||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
@@ -632,12 +641,24 @@ export const exportRouter = router({
|
|||||||
|
|
||||||
// Criteria breakdown
|
// Criteria breakdown
|
||||||
if (includeSection('criteriaBreakdown')) {
|
if (includeSection('criteriaBreakdown')) {
|
||||||
const form = await ctx.prisma.evaluationForm.findFirst({
|
// Fetch all active forms (shared + category-specific) and merge criteria
|
||||||
|
const allForms = await ctx.prisma.evaluationForm.findMany({
|
||||||
where: { roundId: input.roundId, isActive: true },
|
where: { roundId: input.roundId, isActive: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (form?.criteriaJson) {
|
const seenIds = new Set<string>()
|
||||||
const criteria = form.criteriaJson as Array<{ id: string; label: string }>
|
const allCriteria: Array<{ id: string; label: string }> = []
|
||||||
|
for (const f of allForms) {
|
||||||
|
const fc = (f.criteriaJson as Array<{ id: string; label: string }> | null) ?? []
|
||||||
|
for (const c of fc) {
|
||||||
|
if (!seenIds.has(c.id)) {
|
||||||
|
seenIds.add(c.id)
|
||||||
|
allCriteria.push(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allCriteria.length > 0) {
|
||||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||||
where: {
|
where: {
|
||||||
assignment: { roundId: input.roundId },
|
assignment: { roundId: input.roundId },
|
||||||
@@ -646,7 +667,7 @@ export const exportRouter = router({
|
|||||||
select: { criterionScoresJson: true },
|
select: { criterionScoresJson: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
result.criteriaBreakdown = criteria.map((c) => {
|
result.criteriaBreakdown = allCriteria.map((c) => {
|
||||||
const scores: number[] = []
|
const scores: number[] = []
|
||||||
evaluations.forEach((e) => {
|
evaluations.forEach((e) => {
|
||||||
const cs = e.criterionScoresJson as Record<string, number> | null
|
const cs = e.criterionScoresJson as Record<string, number> | null
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const fileRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
|
||||||
|
|
||||||
if (!isAdminOrObserver) {
|
if (!isAdminOrObserver) {
|
||||||
const file = await ctx.prisma.projectFile.findFirst({
|
const file = await ctx.prisma.projectFile.findFirst({
|
||||||
@@ -307,7 +307,7 @@ export const fileRouter = router({
|
|||||||
roundId: z.string().optional(),
|
roundId: z.string().optional(),
|
||||||
}))
|
}))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
|
||||||
|
|
||||||
if (!isAdminOrObserver) {
|
if (!isAdminOrObserver) {
|
||||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||||
@@ -376,7 +376,7 @@ export const fileRouter = router({
|
|||||||
roundId: z.string(),
|
roundId: z.string(),
|
||||||
}))
|
}))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
|
||||||
|
|
||||||
if (!isAdminOrObserver) {
|
if (!isAdminOrObserver) {
|
||||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||||
@@ -496,7 +496,7 @@ export const fileRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
|
||||||
|
|
||||||
if (!isAdminOrObserver) {
|
if (!isAdminOrObserver) {
|
||||||
// Check user has access to the project (assigned or team member)
|
// Check user has access to the project (assigned or team member)
|
||||||
@@ -680,7 +680,7 @@ export const fileRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
|
||||||
|
|
||||||
if (!isAdminOrObserver) {
|
if (!isAdminOrObserver) {
|
||||||
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
|
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||||
|
|||||||
@@ -86,9 +86,61 @@ export const messageRouter = router({
|
|||||||
if (!isScheduled && input.deliveryChannels.includes('EMAIL')) {
|
if (!isScheduled && input.deliveryChannels.includes('EMAIL')) {
|
||||||
const users = await ctx.prisma.user.findMany({
|
const users = await ctx.prisma.user.findMany({
|
||||||
where: { id: { in: recipientUserIds } },
|
where: { id: { in: recipientUserIds } },
|
||||||
select: { id: true, name: true, email: true, passwordHash: true, inviteToken: true },
|
select: {
|
||||||
|
id: true, name: true, email: true, passwordHash: true, inviteToken: true,
|
||||||
|
teamMemberships: {
|
||||||
|
select: { project: { select: { title: true } } },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fetch round & program context for template variable substitution
|
||||||
|
let roundName = ''
|
||||||
|
let programName = ''
|
||||||
|
let deadline = ''
|
||||||
|
if (effectiveRoundIds.length > 0) {
|
||||||
|
const rounds = await ctx.prisma.round.findMany({
|
||||||
|
where: { id: { in: effectiveRoundIds } },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
windowCloseAt: true,
|
||||||
|
competition: {
|
||||||
|
select: {
|
||||||
|
program: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (rounds.length > 0) {
|
||||||
|
roundName = rounds.map((r) => r.name).join(', ')
|
||||||
|
programName = rounds[0].competition?.program?.name ?? ''
|
||||||
|
// Use the earliest upcoming deadline across selected rounds
|
||||||
|
const deadlines = rounds
|
||||||
|
.map((r) => r.windowCloseAt)
|
||||||
|
.filter((d): d is Date => d !== null)
|
||||||
|
.sort((a, b) => a.getTime() - b.getTime())
|
||||||
|
if (deadlines.length > 0) {
|
||||||
|
deadline = deadlines[0].toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric', month: 'long', year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Substitute template variables in a string for a specific user */
|
||||||
|
function substituteVariables(
|
||||||
|
text: string,
|
||||||
|
user: { name: string | null; teamMemberships: { project: { title: string } }[] }
|
||||||
|
): string {
|
||||||
|
return text
|
||||||
|
.replace(/\{\{userName\}\}/g, user.name || '')
|
||||||
|
.replace(/\{\{projectName\}\}/g, user.teamMemberships[0]?.project?.title || '')
|
||||||
|
.replace(/\{\{roundName\}\}/g, roundName)
|
||||||
|
.replace(/\{\{programName\}\}/g, programName)
|
||||||
|
.replace(/\{\{deadline\}\}/g, deadline)
|
||||||
|
}
|
||||||
|
|
||||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
||||||
|
|
||||||
function getLinkUrl(user: { id: string; passwordHash: string | null; inviteToken: string | null }): string | undefined {
|
function getLinkUrl(user: { id: string; passwordHash: string | null; inviteToken: string | null }): string | undefined {
|
||||||
@@ -117,8 +169,8 @@ export const messageRouter = router({
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
context: {
|
context: {
|
||||||
name: user.name || undefined,
|
name: user.name || undefined,
|
||||||
title: input.subject,
|
title: substituteVariables(input.subject, user),
|
||||||
message: input.body,
|
message: substituteVariables(input.body, user),
|
||||||
linkUrl: getLinkUrl(user),
|
linkUrl: getLinkUrl(user),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -651,13 +703,24 @@ export const messageRouter = router({
|
|||||||
sendTest: adminProcedure
|
sendTest: adminProcedure
|
||||||
.input(z.object({ subject: z.string(), body: z.string() }))
|
.input(z.object({ subject: z.string(), body: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userName = ctx.user.name || ''
|
||||||
|
/** Substitute template variables with admin name + placeholder values for test emails */
|
||||||
|
function substituteTestVariables(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\{\{userName\}\}/g, userName)
|
||||||
|
.replace(/\{\{projectName\}\}/g, '[Project Name]')
|
||||||
|
.replace(/\{\{roundName\}\}/g, '[Round Name]')
|
||||||
|
.replace(/\{\{programName\}\}/g, '[Program Name]')
|
||||||
|
.replace(/\{\{deadline\}\}/g, '[Deadline]')
|
||||||
|
}
|
||||||
|
|
||||||
await sendStyledNotificationEmail(
|
await sendStyledNotificationEmail(
|
||||||
ctx.user.email,
|
ctx.user.email,
|
||||||
ctx.user.name || '',
|
userName,
|
||||||
'MESSAGE',
|
'MESSAGE',
|
||||||
{
|
{
|
||||||
title: input.subject,
|
title: substituteTestVariables(input.subject),
|
||||||
message: input.body,
|
message: substituteTestVariables(input.body),
|
||||||
linkUrl: '/admin/messages',
|
linkUrl: '/admin/messages',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -443,17 +443,24 @@ export const rankingRouter = router({
|
|||||||
roundEvaluationScores: adminProcedure
|
roundEvaluationScores: adminProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.input(z.object({ roundId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
// Find the boolean criterion ID from the EvaluationForm (not round configJson)
|
// Find the boolean criterion ID across all active forms (shared + category-specific)
|
||||||
const evalForm = await ctx.prisma.evaluationForm.findFirst({
|
const activeForms = await ctx.prisma.evaluationForm.findMany({
|
||||||
where: { roundId: input.roundId, isActive: true },
|
where: { roundId: input.roundId, isActive: true },
|
||||||
select: { criteriaJson: true },
|
select: { criteriaJson: true },
|
||||||
})
|
})
|
||||||
const formCriteria = (evalForm?.criteriaJson as Array<{
|
let boolCriterionId: string | null = null
|
||||||
id: string; label: string; type?: string
|
for (const f of activeForms) {
|
||||||
}> | null) ?? []
|
const fc = (f.criteriaJson as Array<{
|
||||||
const boolCriterionId = formCriteria.find(
|
id: string; label: string; type?: string
|
||||||
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
|
}> | null) ?? []
|
||||||
)?.id ?? null
|
const found = fc.find(
|
||||||
|
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
|
||||||
|
)
|
||||||
|
if (found) {
|
||||||
|
boolCriterionId = found.id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const assignments = await ctx.prisma.assignment.findMany({
|
const assignments = await ctx.prisma.assignment.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
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 } from '@prisma/client'
|
||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure, awardMasterProcedure } from '../trpc'
|
||||||
import { getUserAvatarUrl } from '../utils/avatar-url'
|
import { getUserAvatarUrl } from '../utils/avatar-url'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
import { processEligibilityJob } from '../services/award-eligibility-job'
|
import { processEligibilityJob } from '../services/award-eligibility-job'
|
||||||
|
import { resolveAwardWinner } from '../services/award-winner-resolver'
|
||||||
import { getAwardSelectionNotificationTemplate } from '@/lib/email'
|
import { getAwardSelectionNotificationTemplate } from '@/lib/email'
|
||||||
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
||||||
import { sendBatchNotifications } from '../services/notification-sender'
|
import { sendBatchNotifications } from '../services/notification-sender'
|
||||||
@@ -652,6 +653,117 @@ export const specialAwardRouter = router({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced award detail for Award Master — includes project scores and chair vote visibility
|
||||||
|
*/
|
||||||
|
getMyAwardDetailEnhanced: awardMasterProcedure
|
||||||
|
.input(z.object({ awardId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const juror = await ctx.prisma.awardJuror.findUnique({
|
||||||
|
where: {
|
||||||
|
awardId_userId: { awardId: input.awardId, userId: ctx.user.id },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!juror) {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not assigned to this award' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [award, eligibleProjects, myVotes, allJurors] = await Promise.all([
|
||||||
|
ctx.prisma.specialAward.findUniqueOrThrow({
|
||||||
|
where: { id: input.awardId },
|
||||||
|
include: {
|
||||||
|
competition: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.prisma.awardEligibility.findMany({
|
||||||
|
where: { awardId: input.awardId, eligible: true },
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true, title: true, teamName: true, description: true,
|
||||||
|
competitionCategory: true, country: true, tags: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.prisma.awardVote.findMany({
|
||||||
|
where: { awardId: input.awardId, userId: ctx.user.id },
|
||||||
|
}),
|
||||||
|
ctx.prisma.awardJuror.findMany({
|
||||||
|
where: { awardId: input.awardId },
|
||||||
|
select: { userId: true, isChair: true, user: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Fetch evaluation scores for eligible projects
|
||||||
|
const projectIds = eligibleProjects.map((e) => e.project.id)
|
||||||
|
const projectScores: Record<string, { avg: number; count: number }> = {}
|
||||||
|
|
||||||
|
if (award.evaluationRoundId) {
|
||||||
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
assignment: {
|
||||||
|
roundId: award.evaluationRoundId,
|
||||||
|
projectId: { in: projectIds },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
globalScore: true,
|
||||||
|
assignment: { select: { projectId: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const scoreMap = new Map<string, number[]>()
|
||||||
|
for (const ev of evaluations) {
|
||||||
|
if (ev.globalScore !== null) {
|
||||||
|
const pid = ev.assignment.projectId
|
||||||
|
if (!scoreMap.has(pid)) scoreMap.set(pid, [])
|
||||||
|
scoreMap.get(pid)!.push(ev.globalScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [pid, scores] of scoreMap) {
|
||||||
|
projectScores[pid] = {
|
||||||
|
avg: scores.reduce((a, b) => a + b, 0) / scores.length,
|
||||||
|
count: scores.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chair sees other votes
|
||||||
|
const isSolo = allJurors.length === 1
|
||||||
|
const isChair = juror.isChair || isSolo
|
||||||
|
let otherVotes: Array<{ userId: string; userName: string | null; projectId: string; justification: string | null }> = []
|
||||||
|
if (isChair && !isSolo) {
|
||||||
|
const votes = await ctx.prisma.awardVote.findMany({
|
||||||
|
where: { awardId: input.awardId, userId: { not: ctx.user.id } },
|
||||||
|
select: {
|
||||||
|
userId: true, projectId: true, justification: true,
|
||||||
|
user: { select: { name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
otherVotes = votes.map((v) => ({
|
||||||
|
userId: v.userId,
|
||||||
|
userName: v.user.name,
|
||||||
|
projectId: v.projectId,
|
||||||
|
justification: v.justification,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
award,
|
||||||
|
projects: eligibleProjects.map((e) => ({
|
||||||
|
...e.project,
|
||||||
|
evaluationScore: projectScores[e.project.id] ?? null,
|
||||||
|
})),
|
||||||
|
myVotes,
|
||||||
|
isChair,
|
||||||
|
otherVotes,
|
||||||
|
totalJurors: allJurors.length,
|
||||||
|
jurors: allJurors.map((j) => ({ userId: j.userId, name: j.user.name, isChair: j.isChair })),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
// ─── Voting ─────────────────────────────────────────────────────────────
|
// ─── Voting ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -731,6 +843,55 @@ export const specialAwardRouter = router({
|
|||||||
return { submitted: input.votes.length }
|
return { submitted: input.votes.length }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit award master vote with optional justification (PICK_WINNER only)
|
||||||
|
*/
|
||||||
|
submitAwardMasterVote: awardMasterProcedure
|
||||||
|
.input(z.object({
|
||||||
|
awardId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
justification: z.string().max(2000).optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const juror = await ctx.prisma.awardJuror.findUnique({
|
||||||
|
where: { awardId_userId: { awardId: input.awardId, userId: ctx.user.id } },
|
||||||
|
})
|
||||||
|
if (!juror) {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Not assigned to this award' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||||
|
where: { id: input.awardId },
|
||||||
|
})
|
||||||
|
if (award.status !== 'VOTING_OPEN') {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting is not open' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.prisma.$transaction([
|
||||||
|
ctx.prisma.awardVote.deleteMany({
|
||||||
|
where: { awardId: input.awardId, userId: ctx.user.id },
|
||||||
|
}),
|
||||||
|
ctx.prisma.awardVote.create({
|
||||||
|
data: {
|
||||||
|
awardId: input.awardId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
projectId: input.projectId,
|
||||||
|
justification: input.justification || null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
entityType: 'AwardVote',
|
||||||
|
entityId: input.awardId,
|
||||||
|
detailsJson: { awardId: input.awardId, projectId: input.projectId, mode: 'AWARD_MASTER_PICK' },
|
||||||
|
})
|
||||||
|
|
||||||
|
return { submitted: true }
|
||||||
|
}),
|
||||||
|
|
||||||
// ─── Results ────────────────────────────────────────────────────────────
|
// ─── Results ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -842,6 +1003,106 @@ export const specialAwardRouter = router({
|
|||||||
return award
|
return award
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chair confirms the winner — resolves tiebreaks, sets winner, closes the award
|
||||||
|
*/
|
||||||
|
confirmWinner: awardMasterProcedure
|
||||||
|
.input(z.object({ awardId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const allJurors = await ctx.prisma.awardJuror.findMany({
|
||||||
|
where: { awardId: input.awardId },
|
||||||
|
select: { userId: true, isChair: true },
|
||||||
|
})
|
||||||
|
const myJuror = allJurors.find((j) => j.userId === ctx.user.id)
|
||||||
|
if (!myJuror) {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Not assigned to this award' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSolo = allJurors.length === 1
|
||||||
|
if (!myJuror.isChair && !isSolo) {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the chair can confirm the winner' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||||
|
where: { id: input.awardId },
|
||||||
|
})
|
||||||
|
if (award.status !== 'VOTING_OPEN') {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Award must be in VOTING_OPEN status' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const chairVote = await ctx.prisma.awardVote.findFirst({
|
||||||
|
where: { awardId: input.awardId, userId: ctx.user.id },
|
||||||
|
})
|
||||||
|
if (!chairVote) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'You must vote before confirming' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const allVotes = await ctx.prisma.awardVote.findMany({
|
||||||
|
where: { awardId: input.awardId },
|
||||||
|
select: { projectId: true, userId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const winnerId = resolveAwardWinner(allVotes, ctx.user.id)
|
||||||
|
|
||||||
|
await ctx.prisma.specialAward.update({
|
||||||
|
where: { id: input.awardId },
|
||||||
|
data: {
|
||||||
|
winnerProjectId: winnerId,
|
||||||
|
status: 'CLOSED',
|
||||||
|
winnerOverridden: false,
|
||||||
|
winnerOverriddenBy: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'SpecialAward',
|
||||||
|
entityId: input.awardId,
|
||||||
|
detailsJson: {
|
||||||
|
action: 'CONFIRM_WINNER',
|
||||||
|
winnerId,
|
||||||
|
totalVotes: allVotes.length,
|
||||||
|
confirmedBy: ctx.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { winnerId, closed: true }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin: set/unset chair status for an award juror (only one chair per award)
|
||||||
|
*/
|
||||||
|
setChair: adminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
awardId: z.string(),
|
||||||
|
userId: z.string(),
|
||||||
|
isChair: z.boolean(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (input.isChair) {
|
||||||
|
await ctx.prisma.awardJuror.updateMany({
|
||||||
|
where: { awardId: input.awardId, isChair: true },
|
||||||
|
data: { isChair: false },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.prisma.awardJuror.update({
|
||||||
|
where: { awardId_userId: { awardId: input.awardId, userId: input.userId } },
|
||||||
|
data: { isChair: input.isChair },
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'AwardJuror',
|
||||||
|
entityId: `${input.awardId}:${input.userId}`,
|
||||||
|
detailsJson: { action: 'SET_CHAIR', isChair: input.isChair },
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}),
|
||||||
|
|
||||||
// ─── Round-Scoped Eligibility & Shortlists ──────────────────────────────
|
// ─── Round-Scoped Eligibility & Shortlists ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server'
|
|||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import { UserRole } from '@prisma/client'
|
import { UserRole } from '@prisma/client'
|
||||||
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
|
||||||
import { sendInvitationEmail, sendMagicLinkEmail, sendPasswordResetEmail } from '@/lib/email'
|
import { sendInvitationEmail, sendJuryInvitationEmail, sendMagicLinkEmail, sendPasswordResetEmail } from '@/lib/email'
|
||||||
import { hashPassword, validatePassword } from '@/lib/password'
|
import { hashPassword, validatePassword } from '@/lib/password'
|
||||||
import { attachAvatarUrls, getUserAvatarUrl } from '@/server/utils/avatar-url'
|
import { attachAvatarUrls, getUserAvatarUrl } from '@/server/utils/avatar-url'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
@@ -507,10 +507,18 @@ export const userRouter = router({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate invite token upfront so the user can accept even if the
|
||||||
|
// subsequent invitation email fails to send. Re-sending from the
|
||||||
|
// members table will just overwrite the token.
|
||||||
|
const inviteToken = generateInviteToken()
|
||||||
|
const expiryHours = await getInviteExpiryHours(ctx.prisma)
|
||||||
|
|
||||||
const user = await ctx.prisma.user.create({
|
const user = await ctx.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
...input,
|
...input,
|
||||||
status: 'INVITED',
|
status: 'INVITED',
|
||||||
|
inviteToken,
|
||||||
|
inviteTokenExpiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -979,7 +987,13 @@ export const userRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
|
|
||||||
|
// Use jury-specific template for jury members
|
||||||
|
if (user.role === 'JURY_MEMBER') {
|
||||||
|
await sendJuryInvitationEmail(user.email, user.name, inviteUrl, 'the evaluation round')
|
||||||
|
} else {
|
||||||
|
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.prisma.notificationLog.create({
|
await ctx.prisma.notificationLog.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -1121,8 +1135,23 @@ export const userRouter = router({
|
|||||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
||||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||||
|
|
||||||
// Send invitation email
|
// Send invitation email — use jury-specific template for jury members
|
||||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
|
if (user.role === 'JURY_MEMBER') {
|
||||||
|
// Try to resolve a round name for the jury invitation email
|
||||||
|
let roundName = 'the evaluation round'
|
||||||
|
if (input.juryGroupId) {
|
||||||
|
const juryGroup = await ctx.prisma.juryGroup.findUnique({
|
||||||
|
where: { id: input.juryGroupId },
|
||||||
|
select: { rounds: { select: { name: true }, take: 1, orderBy: { sortOrder: 'asc' } } },
|
||||||
|
})
|
||||||
|
if (juryGroup?.rounds[0]?.name) {
|
||||||
|
roundName = juryGroup.rounds[0].name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sendJuryInvitationEmail(user.email, user.name, inviteUrl, roundName)
|
||||||
|
} else {
|
||||||
|
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
|
||||||
|
}
|
||||||
|
|
||||||
// Log notification
|
// Log notification
|
||||||
await ctx.prisma.notificationLog.create({
|
await ctx.prisma.notificationLog.create({
|
||||||
@@ -1187,7 +1216,13 @@ export const userRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
|
|
||||||
|
// Use jury-specific template for jury members
|
||||||
|
if (user.role === 'JURY_MEMBER') {
|
||||||
|
await sendJuryInvitationEmail(user.email, user.name, inviteUrl, 'the evaluation round')
|
||||||
|
} else {
|
||||||
|
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.prisma.notificationLog.create({
|
await ctx.prisma.notificationLog.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
|||||||
import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
||||||
import { sanitizeText } from './anonymization'
|
import { sanitizeText } from './anonymization'
|
||||||
import type { PrismaClient, Prisma } from '@prisma/client'
|
import type { PrismaClient, Prisma } from '@prisma/client'
|
||||||
|
import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -290,6 +291,7 @@ export async function generateSummary({
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
competitionCategory: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -329,11 +331,8 @@ export async function generateSummary({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get evaluation form criteria for this round
|
// Get evaluation form criteria for this round (category-aware)
|
||||||
const form = await prisma.evaluationForm.findFirst({
|
const form = await findActiveForm(prisma, roundId, project.competitionCategory)
|
||||||
where: { roundId, isActive: true },
|
|
||||||
select: { criteriaJson: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const criteria: CriterionDef[] = form?.criteriaJson
|
const criteria: CriterionDef[] = form?.criteriaJson
|
||||||
? (form.criteriaJson as unknown as CriterionDef[])
|
? (form.criteriaJson as unknown as CriterionDef[])
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
||||||
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||||
import { classifyAIError, logAIError } from './ai-errors'
|
import { classifyAIError, logAIError } from './ai-errors'
|
||||||
|
import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
|
||||||
import { sanitizeUserInput } from '@/server/services/ai-prompt-guard'
|
import { sanitizeUserInput } from '@/server/services/ai-prompt-guard'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
|
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
|
||||||
@@ -625,16 +626,13 @@ async function fetchCategoryProjects(
|
|||||||
roundId: string,
|
roundId: string,
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
): Promise<CategoryProjectData> {
|
): Promise<CategoryProjectData> {
|
||||||
// Fetch the round config and evaluation form in parallel
|
// Fetch the round config and evaluation form in parallel (category-aware)
|
||||||
const [round, evalForm] = await Promise.all([
|
const [round, evalForm] = await Promise.all([
|
||||||
prisma.round.findUniqueOrThrow({
|
prisma.round.findUniqueOrThrow({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
select: { configJson: true },
|
select: { configJson: true },
|
||||||
}),
|
}),
|
||||||
prisma.evaluationForm.findFirst({
|
findActiveForm(prisma, roundId, category),
|
||||||
where: { roundId, isActive: true },
|
|
||||||
select: { criteriaJson: true },
|
|
||||||
}),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const roundConfig = round.configJson as Record<string, unknown> | null
|
const roundConfig = round.configJson as Record<string, unknown> | null
|
||||||
|
|||||||
37
src/server/services/award-winner-resolver.ts
Normal file
37
src/server/services/award-winner-resolver.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Resolve the winner of a PICK_WINNER award given all votes and the chair's userId.
|
||||||
|
* Logic: count votes per project. If one project has the most, it wins.
|
||||||
|
* If tied, the chair's pick wins among the tied projects.
|
||||||
|
*/
|
||||||
|
export function resolveAwardWinner(
|
||||||
|
votes: Array<{ userId: string; projectId: string }>,
|
||||||
|
chairUserId: string
|
||||||
|
): string {
|
||||||
|
if (votes.length === 0) {
|
||||||
|
throw new Error('Cannot resolve winner with no votes')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tally votes per project
|
||||||
|
const tally = new Map<string, number>()
|
||||||
|
for (const v of votes) {
|
||||||
|
tally.set(v.projectId, (tally.get(v.projectId) || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxVotes = Math.max(...tally.values())
|
||||||
|
const topProjects = [...tally.entries()]
|
||||||
|
.filter(([, count]) => count === maxVotes)
|
||||||
|
.map(([pid]) => pid)
|
||||||
|
|
||||||
|
if (topProjects.length === 1) {
|
||||||
|
return topProjects[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tie: chair's pick wins if among tied projects
|
||||||
|
const chairVote = votes.find((v) => v.userId === chairUserId)
|
||||||
|
if (chairVote && topProjects.includes(chairVote.projectId)) {
|
||||||
|
return chairVote.projectId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chair voted for a non-tied project — pick alphabetically for stability
|
||||||
|
return topProjects.sort()[0]
|
||||||
|
}
|
||||||
@@ -826,76 +826,36 @@ export async function checkRequirementsAndTransition(
|
|||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
): Promise<{ transitioned: boolean; newState?: string }> {
|
): Promise<{ transitioned: boolean; newState?: string }> {
|
||||||
try {
|
try {
|
||||||
// Get all required FileRequirements for this round (legacy model)
|
// Get all required FileRequirements for this round
|
||||||
|
// Note: only FileRequirement (admin-managed via UI) is checked.
|
||||||
|
// SubmissionFileRequirement (on SubmissionWindow) has no admin UI and is not checked.
|
||||||
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 },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Also check SubmissionFileRequirement via the round's submissionWindow
|
// If the round has no file requirements, nothing to check
|
||||||
const round = await prisma.round.findUnique({
|
if (requirements.length === 0) {
|
||||||
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 legacy requirements this project has satisfied
|
// Check which requirements this project has satisfied
|
||||||
let legacyAllMet = true
|
const fulfilledFiles = await prisma.projectFile.findMany({
|
||||||
if (requirements.length > 0) {
|
where: {
|
||||||
const fulfilledFiles = await prisma.projectFile.findMany({
|
projectId,
|
||||||
where: {
|
roundId,
|
||||||
projectId,
|
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
|
||||||
roundId,
|
},
|
||||||
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
|
select: { requirementId: true },
|
||||||
},
|
})
|
||||||
select: { requirementId: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const fulfilledIds = new Set(
|
const fulfilledIds = new Set(
|
||||||
fulfilledFiles
|
fulfilledFiles
|
||||||
.map((f: { requirementId: string | null }) => f.requirementId)
|
.map((f: { requirementId: string | null }) => f.requirementId)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
|
|
||||||
legacyAllMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
|
if (!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) {
|
|
||||||
return { transitioned: false }
|
return { transitioned: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,7 +879,7 @@ export async function checkRequirementsAndTransition(
|
|||||||
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
|
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${requirements.length + submissionRequirements.length} requirements met)`)
|
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${requirements.length} requirements met)`)
|
||||||
return { transitioned: true, newState: 'COMPLETED' }
|
return { transitioned: true, newState: 'COMPLETED' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -944,32 +904,20 @@ export async function batchCheckRequirementsAndTransition(
|
|||||||
if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] }
|
if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] }
|
||||||
|
|
||||||
// Pre-load all requirements for this round in batch (avoids per-project queries)
|
// Pre-load all requirements for this round in batch (avoids per-project queries)
|
||||||
const [requirements, round] = await Promise.all([
|
// Note: only FileRequirement (admin-managed via UI) is checked.
|
||||||
prisma.fileRequirement.findMany({
|
// SubmissionFileRequirement (on SubmissionWindow) has no admin UI and is not checked.
|
||||||
where: { roundId, isRequired: true },
|
const requirements = await prisma.fileRequirement.findMany({
|
||||||
select: { id: true },
|
where: { roundId, isRequired: true },
|
||||||
}),
|
select: { id: true },
|
||||||
prisma.round.findUnique({
|
})
|
||||||
where: { id: roundId },
|
|
||||||
select: { submissionWindowId: true },
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
let submissionRequirements: Array<{ id: string }> = []
|
// If no requirements, nothing to check
|
||||||
if (round?.submissionWindowId) {
|
if (requirements.length === 0) {
|
||||||
submissionRequirements = await prisma.submissionFileRequirement.findMany({
|
|
||||||
where: { submissionWindowId: round.submissionWindowId, required: true },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no requirements at all, nothing to check
|
|
||||||
if (requirements.length === 0 && submissionRequirements.length === 0) {
|
|
||||||
return { transitionedCount: 0, projectIds: [] }
|
return { transitionedCount: 0, projectIds: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-load all project files and current states in batch
|
// Pre-load all project files and current states in batch
|
||||||
type FileRow = { projectId: string; requirementId: string | null; submissionFileRequirementId: string | null }
|
type FileRow = { projectId: string; requirementId: string | null }
|
||||||
type StateRow = { projectId: string; state: string }
|
type StateRow = { projectId: string; state: string }
|
||||||
|
|
||||||
const [allFiles, allStates] = await Promise.all([
|
const [allFiles, allStates] = await Promise.all([
|
||||||
@@ -978,7 +926,7 @@ export async function batchCheckRequirementsAndTransition(
|
|||||||
projectId: { in: projectIds },
|
projectId: { in: projectIds },
|
||||||
roundId,
|
roundId,
|
||||||
},
|
},
|
||||||
select: { projectId: true, requirementId: true, submissionFileRequirementId: true },
|
select: { projectId: true, requirementId: true },
|
||||||
}) as Promise<FileRow[]>,
|
}) as Promise<FileRow[]>,
|
||||||
prisma.projectRoundState.findMany({
|
prisma.projectRoundState.findMany({
|
||||||
where: { roundId, projectId: { in: projectIds } },
|
where: { roundId, projectId: { in: projectIds } },
|
||||||
@@ -1004,18 +952,8 @@ export async function batchCheckRequirementsAndTransition(
|
|||||||
if (!currentState || !eligibleStates.includes(currentState)) continue
|
if (!currentState || !eligibleStates.includes(currentState)) continue
|
||||||
|
|
||||||
const files = filesByProject.get(projectId) ?? []
|
const files = filesByProject.get(projectId) ?? []
|
||||||
|
const fulfilledIds = new Set(files.map((f) => f.requirementId).filter(Boolean))
|
||||||
// Check legacy requirements
|
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) continue
|
||||||
if (requirements.length > 0) {
|
|
||||||
const fulfilledIds = new Set(files.map((f) => f.requirementId).filter(Boolean))
|
|
||||||
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check submission requirements
|
|
||||||
if (submissionRequirements.length > 0) {
|
|
||||||
const fulfilledSubIds = new Set(files.map((f) => f.submissionFileRequirementId).filter(Boolean))
|
|
||||||
if (!submissionRequirements.every((r: { id: string }) => fulfilledSubIds.has(r.id))) continue
|
|
||||||
}
|
|
||||||
|
|
||||||
toTransition.push(projectId)
|
toTransition.push(projectId)
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/server/utils/evaluation-form-lookup.ts
Normal file
26
src/server/utils/evaluation-form-lookup.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { PrismaClient, CompetitionCategory, EvaluationForm } from '@prisma/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the active EvaluationForm for a round, with category-aware resolution.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. If `category` is provided, try the category-specific active form first.
|
||||||
|
* 2. Fall back to the shared form (category = null).
|
||||||
|
* 3. If no `category` provided, return the shared form directly.
|
||||||
|
*/
|
||||||
|
export async function findActiveForm(
|
||||||
|
prisma: PrismaClient | Pick<PrismaClient, 'evaluationForm'>,
|
||||||
|
roundId: string,
|
||||||
|
category?: CompetitionCategory | null,
|
||||||
|
): Promise<EvaluationForm | null> {
|
||||||
|
if (category) {
|
||||||
|
const specific = await prisma.evaluationForm.findFirst({
|
||||||
|
where: { roundId, isActive: true, category },
|
||||||
|
})
|
||||||
|
if (specific) return specific
|
||||||
|
}
|
||||||
|
// Fallback to shared form (category = null)
|
||||||
|
return prisma.evaluationForm.findFirst({
|
||||||
|
where: { roundId, isActive: true, category: null },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ export const EvaluationConfigSchema = z.object({
|
|||||||
requiredReviewsPerProject: z.number().int().positive().default(3),
|
requiredReviewsPerProject: z.number().int().positive().default(3),
|
||||||
|
|
||||||
scoringMode: z.enum(['criteria', 'global', 'binary']).default('criteria'),
|
scoringMode: z.enum(['criteria', 'global', 'binary']).default('criteria'),
|
||||||
|
perCategoryCriteria: z.boolean().default(false),
|
||||||
requireFeedback: z.boolean().default(true),
|
requireFeedback: z.boolean().default(true),
|
||||||
feedbackMinLength: z.number().int().nonnegative().default(0),
|
feedbackMinLength: z.number().int().nonnegative().default(0),
|
||||||
requireAllCriteriaScored: z.boolean().default(true),
|
requireAllCriteriaScored: z.boolean().default(true),
|
||||||
|
|||||||
47
tests/unit/award-master.test.ts
Normal file
47
tests/unit/award-master.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { resolveAwardWinner } from '@/server/services/award-winner-resolver'
|
||||||
|
|
||||||
|
describe('resolveAwardWinner', () => {
|
||||||
|
it('returns the sole voted project when all agree', () => {
|
||||||
|
const votes = [
|
||||||
|
{ userId: 'u1', projectId: 'p1' },
|
||||||
|
{ userId: 'u2', projectId: 'p1' },
|
||||||
|
]
|
||||||
|
expect(resolveAwardWinner(votes, 'u1')).toBe('p1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the majority winner when no tie', () => {
|
||||||
|
const votes = [
|
||||||
|
{ userId: 'u1', projectId: 'p1' },
|
||||||
|
{ userId: 'u2', projectId: 'p2' },
|
||||||
|
{ userId: 'u3', projectId: 'p1' },
|
||||||
|
]
|
||||||
|
expect(resolveAwardWinner(votes, 'u1')).toBe('p1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses chair vote as tiebreaker', () => {
|
||||||
|
const votes = [
|
||||||
|
{ userId: 'chair', projectId: 'p2' },
|
||||||
|
{ userId: 'u2', projectId: 'p1' },
|
||||||
|
]
|
||||||
|
expect(resolveAwardWinner(votes, 'chair')).toBe('p2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns chair pick when tied and chair voted for one of the tied projects', () => {
|
||||||
|
const votes = [
|
||||||
|
{ userId: 'chair', projectId: 'p3' },
|
||||||
|
{ userId: 'u2', projectId: 'p1' },
|
||||||
|
{ userId: 'u3', projectId: 'p2' },
|
||||||
|
]
|
||||||
|
expect(resolveAwardWinner(votes, 'chair')).toBe('p3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns sole vote for solo sponsor', () => {
|
||||||
|
const votes = [{ userId: 'sponsor', projectId: 'p1' }]
|
||||||
|
expect(resolveAwardWinner(votes, 'sponsor')).toBe('p1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws if no votes', () => {
|
||||||
|
expect(() => resolveAwardWinner([], 'chair')).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user