refactor(awards): remove AWARD_MASTER role, fold features into jury chair flow
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m5s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m5s
The AWARD_MASTER role split sponsor jurors into a parallel UI that hid project files (only showed when the award was anchored to an evaluation round) and duplicated the jury voting path with no real difference in authority — tie-break and finalize were already governed by AwardJuror.isChair regardless of the user's global role. Inviting a juror via the award page defaulted to AWARD_MASTER, randomly fragmenting jury panels. This collapses the role into JURY_MEMBER + isChair: - specialAward.getMyAwardDetail now returns evaluation scores, chair visibility into other jurors' votes, and juror roster - specialAward.submitVote accepts an optional justification per vote - specialAward.confirmWinner moves from awardMasterProcedure to protectedProcedure (juror+chair check inside) - bulkInviteJurors creates JURY_MEMBER accounts and, when the award has a juryGroupId, also adds them to that JuryGroup so they appear on the round-page jury panel - jury award page renders justification, eval-score badges, and a chair tools panel with vote tally + finalize-winner CTA - juryGroup.list includes attached SpecialAwards; the jury-list UI shows a trophy pill alongside round pills - (award-master) route group, awardMasterProcedure, AWARD_MASTER role enum value, and AWARD_MASTER_DECISION decisionMode are deleted - migration demotes any residual AWARD_MASTER users to JURY_MEMBER and recreates the UserRole enum without the value Coup de Coeur on prod: Didier (the sponsor juror added today as AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward itself was linked to that group (juryGroupId was NULL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
-- Drops AWARD_MASTER from the UserRole enum.
|
||||
--
|
||||
-- Any row still holding AWARD_MASTER is demoted to JURY_MEMBER (singular role)
|
||||
-- or filtered out of the roles[] array (multi-role) before the enum swap, so
|
||||
-- the type alteration is safe even if the prod migration was missed.
|
||||
|
||||
UPDATE "User" SET role = 'JURY_MEMBER' WHERE role = 'AWARD_MASTER';
|
||||
UPDATE "User" SET roles = array_remove(roles, 'AWARD_MASTER') WHERE 'AWARD_MASTER' = ANY(roles);
|
||||
|
||||
CREATE TYPE "UserRole_new" AS ENUM (
|
||||
'SUPER_ADMIN',
|
||||
'PROGRAM_ADMIN',
|
||||
'JURY_MEMBER',
|
||||
'MENTOR',
|
||||
'OBSERVER',
|
||||
'APPLICANT',
|
||||
'AUDIENCE'
|
||||
);
|
||||
|
||||
ALTER TABLE "User" ALTER COLUMN role DROP DEFAULT;
|
||||
ALTER TABLE "User"
|
||||
ALTER COLUMN role TYPE "UserRole_new" USING role::text::"UserRole_new";
|
||||
ALTER TABLE "User" ALTER COLUMN role SET DEFAULT 'APPLICANT';
|
||||
|
||||
ALTER TABLE "User"
|
||||
ALTER COLUMN roles TYPE "UserRole_new"[] USING roles::text[]::"UserRole_new"[];
|
||||
|
||||
DROP TYPE "UserRole";
|
||||
ALTER TYPE "UserRole_new" RENAME TO "UserRole";
|
||||
@@ -29,7 +29,6 @@ enum UserRole {
|
||||
MENTOR
|
||||
OBSERVER
|
||||
APPLICANT
|
||||
AWARD_MASTER
|
||||
AUDIENCE
|
||||
}
|
||||
|
||||
@@ -1600,7 +1599,7 @@ model SpecialAward {
|
||||
evaluationRoundId String?
|
||||
juryGroupId String?
|
||||
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
|
||||
decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION"
|
||||
decisionMode String? // "JURY_VOTE" | "ADMIN_DECISION"
|
||||
shortlistSize Int @default(10)
|
||||
|
||||
// Eligibility job tracking
|
||||
|
||||
@@ -317,7 +317,6 @@ async function main() {
|
||||
{ email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' },
|
||||
{ email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
|
||||
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
|
||||
{ email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' },
|
||||
]
|
||||
|
||||
const staffUsers: Record<string, string> = {}
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function EditAwardPage({
|
||||
const [votingEndAt, setVotingEndAt] = useState('')
|
||||
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
||||
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
||||
const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'>('JURY_VOTE')
|
||||
const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'ADMIN_DECISION'>('JURY_VOTE')
|
||||
|
||||
// Helper to format date for datetime-local input
|
||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||
@@ -236,7 +236,6 @@ export default function EditAwardPage({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="JURY_VOTE">Jury Vote — tallied from all jurors</SelectItem>
|
||||
<SelectItem value="AWARD_MASTER_DECISION">Award Master — sponsor picks winner</SelectItem>
|
||||
<SelectItem value="ADMIN_DECISION">Admin Decision — admin selects winner</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -408,7 +408,7 @@ export default function AwardDetailPage({
|
||||
|
||||
// Deferred queries - only load when needed
|
||||
const { data: allUsers } = trpc.user.list.useQuery(
|
||||
{ page: 1, perPage: 100, roles: ['JURY_MEMBER', 'AWARD_MASTER'] },
|
||||
{ page: 1, perPage: 100, roles: ['JURY_MEMBER'] },
|
||||
{ enabled: activeTab === 'jurors' }
|
||||
)
|
||||
const { data: juryGroups } = trpc.juryGroup.list.useQuery(
|
||||
@@ -1518,7 +1518,6 @@ export default function AwardDetailPage({
|
||||
onSubmit={async (rows) => {
|
||||
await bulkInvite.mutateAsync({
|
||||
awardId,
|
||||
role: 'AWARD_MASTER',
|
||||
invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })),
|
||||
})
|
||||
}}
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { toast } from 'sonner'
|
||||
import { cn, formatEnumLabel } from '@/lib/utils'
|
||||
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot } from 'lucide-react'
|
||||
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot, Trophy } from 'lucide-react'
|
||||
|
||||
const capModeLabels = {
|
||||
HARD: 'Hard Cap',
|
||||
@@ -301,10 +301,10 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Round assignments */}
|
||||
{(group as any).rounds?.length > 0 && (
|
||||
{/* Round + Special-award assignments */}
|
||||
{((group as any).rounds?.length > 0 || (group as any).awards?.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(group as any).rounds.map((r: any) => (
|
||||
{(group as any).rounds?.map((r: any) => (
|
||||
<Badge
|
||||
key={r.id}
|
||||
variant="outline"
|
||||
@@ -319,6 +319,21 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
|
||||
{r.name}
|
||||
</Badge>
|
||||
))}
|
||||
{(group as any).awards?.map((a: any) => (
|
||||
<Badge
|
||||
key={a.id}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] gap-1',
|
||||
a.status === 'VOTING_OPEN' && 'border-amber-300 bg-amber-50 text-amber-700',
|
||||
a.status === 'CLOSED' && 'border-emerald-300 bg-emerald-50 text-emerald-700',
|
||||
(a.status === 'DRAFT' || a.status === 'NOMINATIONS_OPEN') && 'border-slate-200 text-slate-500',
|
||||
)}
|
||||
>
|
||||
<Trophy className="h-2.5 w-2.5" />
|
||||
{a.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ const ROLE_OPTIONS = [
|
||||
{ value: 'MENTOR', label: 'Mentors' },
|
||||
{ value: 'OBSERVER', label: 'Observers' },
|
||||
{ value: 'APPLICANT', label: 'Applicants' },
|
||||
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
||||
]
|
||||
|
||||
type AccessRule =
|
||||
|
||||
@@ -60,7 +60,6 @@ const ROLE_OPTIONS = [
|
||||
{ value: 'MENTOR', label: 'Mentors' },
|
||||
{ value: 'OBSERVER', label: 'Observers' },
|
||||
{ value: 'APPLICANT', label: 'Applicants' },
|
||||
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
||||
]
|
||||
|
||||
type AccessRule =
|
||||
|
||||
@@ -97,7 +97,6 @@ const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
||||
PROGRAM_ADMIN: 'default',
|
||||
SUPER_ADMIN: 'default',
|
||||
APPLICANT: 'secondary',
|
||||
AWARD_MASTER: 'outline',
|
||||
AUDIENCE: 'outline',
|
||||
}
|
||||
|
||||
@@ -154,7 +153,7 @@ export default function MemberDetailPage() {
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const allRoles = [role, ...additionalRoles.filter((r) => r !== role)] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
|
||||
const allRoles = [role, ...additionalRoles.filter((r) => r !== role)] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
|
||||
await updateUser.mutateAsync({
|
||||
id: userId,
|
||||
email: email || undefined,
|
||||
@@ -622,7 +621,6 @@ export default function MemberDetailPage() {
|
||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||
<SelectItem value="APPLICANT">Applicant</SelectItem>
|
||||
<SelectItem value="AWARD_MASTER">Award Master</SelectItem>
|
||||
<SelectItem value="AUDIENCE">Audience</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -633,7 +631,7 @@ export default function MemberDetailPage() {
|
||||
Grant additional dashboard access beyond the primary role
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['JURY_MEMBER', 'OBSERVER', 'MENTOR', 'AWARD_MASTER'] as const)
|
||||
{(['JURY_MEMBER', 'OBSERVER', 'MENTOR'] as const)
|
||||
.filter((r) => r !== role)
|
||||
.map((r) => (
|
||||
<label key={r} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
|
||||
@@ -74,7 +74,7 @@ import { useSession } from 'next-auth/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
||||
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||
|
||||
interface Assignment {
|
||||
projectId: string
|
||||
@@ -106,7 +106,6 @@ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
SUPER_ADMIN: 'Super Admin',
|
||||
PROGRAM_ADMIN: 'Program Admin',
|
||||
AWARD_MASTER: 'Award Master',
|
||||
JURY_MEMBER: 'Jury Member',
|
||||
MENTOR: 'Mentor',
|
||||
OBSERVER: 'Observer',
|
||||
@@ -289,7 +288,7 @@ export default function MemberInvitePage() {
|
||||
const availableRoles = useMemo((): Role[] => {
|
||||
const roles: Role[] = []
|
||||
if (isSuperAdmin) roles.push('SUPER_ADMIN')
|
||||
if (isAdmin) roles.push('PROGRAM_ADMIN', 'AWARD_MASTER')
|
||||
if (isAdmin) roles.push('PROGRAM_ADMIN')
|
||||
roles.push('JURY_MEMBER', 'MENTOR', 'OBSERVER')
|
||||
return roles
|
||||
}, [isSuperAdmin, isAdmin])
|
||||
@@ -423,13 +422,11 @@ export default function MemberInvitePage() {
|
||||
? 'SUPER_ADMIN'
|
||||
: rawRole === 'PROGRAM_ADMIN'
|
||||
? 'PROGRAM_ADMIN'
|
||||
: rawRole === 'AWARD_MASTER'
|
||||
? 'AWARD_MASTER'
|
||||
: rawRole === 'MENTOR'
|
||||
? 'MENTOR'
|
||||
: rawRole === 'OBSERVER'
|
||||
? 'OBSERVER'
|
||||
: 'JURY_MEMBER'
|
||||
: rawRole === 'MENTOR'
|
||||
? 'MENTOR'
|
||||
: rawRole === 'OBSERVER'
|
||||
? 'OBSERVER'
|
||||
: 'JURY_MEMBER'
|
||||
const isValidFormat = emailRegex.test(email)
|
||||
const isDuplicate = email ? seenEmails.has(email) : false
|
||||
const isUnauthorizedAdmin = role === 'SUPER_ADMIN' && !isSuperAdmin
|
||||
|
||||
@@ -1,581 +0,0 @@
|
||||
'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'
|
||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||
|
||||
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 gap-3">
|
||||
<ProjectLogo project={project} logoUrl={project.logoUrl} size="sm" fallback="initials" className="mt-0.5" />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -13,16 +13,29 @@ import {
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Trophy,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
Users,
|
||||
Tag,
|
||||
Star,
|
||||
Gavel,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
@@ -50,11 +63,19 @@ export default function JuryAwardVotingPage({
|
||||
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
|
||||
},
|
||||
})
|
||||
const confirmWinner = trpc.specialAward.confirmWinner.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
|
||||
toast.success('Winner confirmed and award closed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const [rankedIds, setRankedIds] = useState<string[]>([])
|
||||
const [justification, setJustification] = useState('')
|
||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleExpanded = (projectId: string) => {
|
||||
@@ -71,6 +92,9 @@ export default function JuryAwardVotingPage({
|
||||
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
|
||||
if (data.award.scoringMode === 'PICK_WINNER') {
|
||||
setSelectedProjectId(data.myVotes[0]?.projectId || null)
|
||||
if (data.myVotes[0]?.justification) {
|
||||
setJustification(data.myVotes[0].justification)
|
||||
}
|
||||
} else if (data.award.scoringMode === 'RANKED') {
|
||||
const sorted = [...data.myVotes]
|
||||
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
|
||||
@@ -84,7 +108,10 @@ export default function JuryAwardVotingPage({
|
||||
try {
|
||||
await submitVote.mutateAsync({
|
||||
awardId,
|
||||
votes: [{ projectId: selectedProjectId }],
|
||||
votes: [{
|
||||
projectId: selectedProjectId,
|
||||
justification: justification.trim() || undefined,
|
||||
}],
|
||||
})
|
||||
toast.success('Vote submitted')
|
||||
refetch()
|
||||
@@ -136,9 +163,10 @@ export default function JuryAwardVotingPage({
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const { award, projects, myVotes } = 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'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -197,10 +225,30 @@ export default function JuryAwardVotingPage({
|
||||
isExpanded={expandedProjects.has(project.id)}
|
||||
onSelect={() => setSelectedProjectId(project.id)}
|
||||
onToggleExpand={() => toggleExpanded(project.id)}
|
||||
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedProjectId && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Justification (optional)</CardTitle>
|
||||
<CardDescription>
|
||||
Visible to the jury chair when they finalize the award.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
placeholder="Why this project? (optional)"
|
||||
value={justification}
|
||||
onChange={(e) => setJustification(e.target.value)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmitPickWinner}
|
||||
@@ -214,6 +262,18 @@ export default function JuryAwardVotingPage({
|
||||
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isChair && totalJurors > 1 && (
|
||||
<ChairPanel
|
||||
award={award}
|
||||
projects={projects}
|
||||
otherVotes={otherVotes}
|
||||
totalJurors={totalJurors}
|
||||
hasVoted={hasVoted}
|
||||
onConfirm={() => confirmWinner.mutate({ awardId })}
|
||||
isPending={confirmWinner.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : award.scoringMode === 'RANKED' ? (
|
||||
/* RANKED Mode */
|
||||
@@ -332,6 +392,7 @@ type ProjectData = {
|
||||
tags: string[]
|
||||
logoKey?: string | null
|
||||
logoUrl?: string | null
|
||||
evaluationScore?: { avg: number; count: number } | null
|
||||
files: Array<{
|
||||
id: string
|
||||
fileName: string
|
||||
@@ -355,9 +416,31 @@ type ProjectData = {
|
||||
}>
|
||||
}
|
||||
|
||||
type OtherVote = {
|
||||
userId: string
|
||||
userName: string | null
|
||||
projectId: string
|
||||
justification: string | null
|
||||
}
|
||||
|
||||
function ProjectDetails({ project }: { project: ProjectData }) {
|
||||
return (
|
||||
<div className="px-4 pb-4 pt-2 space-y-4 border-t mt-2">
|
||||
{project.evaluationScore && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-blue-50/50 dark:bg-blue-950/20 px-3 py-2">
|
||||
<Star className="h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold text-blue-700 dark:text-blue-300">
|
||||
{project.evaluationScore.avg.toFixed(1)} / 10
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
from {project.evaluationScore.count}{' '}
|
||||
{project.evaluationScore.count === 1 ? 'evaluation' : 'evaluations'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p>
|
||||
)}
|
||||
@@ -469,3 +552,139 @@ function ProjectCard({
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ChairPanel({
|
||||
award,
|
||||
projects,
|
||||
otherVotes,
|
||||
totalJurors,
|
||||
hasVoted,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: {
|
||||
award: { id: string; status: string }
|
||||
projects: ProjectData[]
|
||||
otherVotes: OtherVote[]
|
||||
totalJurors: number
|
||||
hasVoted: boolean
|
||||
onConfirm: () => void
|
||||
isPending: boolean
|
||||
}) {
|
||||
const projectMap = new Map(projects.map((p) => [p.id, p]))
|
||||
const tally = new Map<string, number>()
|
||||
for (const v of otherVotes) {
|
||||
tally.set(v.projectId, (tally.get(v.projectId) ?? 0) + 1)
|
||||
}
|
||||
const ranked = Array.from(tally.entries())
|
||||
.map(([projectId, votes]) => ({
|
||||
project: projectMap.get(projectId),
|
||||
votes,
|
||||
}))
|
||||
.filter((r) => r.project)
|
||||
.sort((a, b) => b.votes - a.votes)
|
||||
|
||||
const votedCount = new Set(otherVotes.map((v) => v.userId)).size + (hasVoted ? 1 : 0)
|
||||
const isClosed = award.status === 'CLOSED'
|
||||
|
||||
return (
|
||||
<Card className="border-amber-200 dark:border-amber-900">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Gavel className="h-5 w-5 text-amber-600" />
|
||||
<CardTitle className="text-base">Chair tools</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{votedCount} of {totalJurors} jurors have voted. As the chair you
|
||||
can review their picks and finalize the award.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{ranked.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No other juror votes yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Tally so far
|
||||
</p>
|
||||
{ranked.map(({ project, votes }) => (
|
||||
<div key={project!.id} className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<span className="text-sm font-medium truncate">{project!.title}</span>
|
||||
<Badge variant="secondary">{votes} {votes === 1 ? 'vote' : 'votes'}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otherVotes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Justifications
|
||||
</p>
|
||||
{otherVotes.map((v) => {
|
||||
const project = projectMap.get(v.projectId)
|
||||
return (
|
||||
<div key={v.userId} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{v.userName || 'Anonymous juror'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
→ {project?.title || 'Unknown project'}
|
||||
</span>
|
||||
</div>
|
||||
{v.justification && (
|
||||
<p className="text-xs text-muted-foreground mt-2 whitespace-pre-line">
|
||||
{v.justification}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isClosed && (
|
||||
<div className="flex justify-end pt-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button disabled={!hasVoted || isPending}>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trophy className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Confirm winner & close award
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Finalize the award?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The project with the most votes will be set as the
|
||||
winner. If there's a tie, your own vote breaks it.
|
||||
Voting will close immediately and this can't be
|
||||
reopened from this page.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasVoted && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
You must submit your own vote before finalizing.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ export default async function HomePage() {
|
||||
if (session?.user) {
|
||||
const roles = (session.user.roles as UserRole[] | undefined) ?? [session.user.role as UserRole]
|
||||
if (roles.includes('SUPER_ADMIN') || roles.includes('PROGRAM_ADMIN')) redirect('/admin')
|
||||
if (roles.includes('AWARD_MASTER')) redirect('/award-master')
|
||||
if (roles.includes('JURY_MEMBER')) redirect('/jury')
|
||||
if (roles.includes('MENTOR')) redirect('/mentor' as Route)
|
||||
if (roles.includes('APPLICANT')) redirect('/applicant' as Route)
|
||||
|
||||
@@ -167,7 +167,6 @@ const roleLabels: Record<string, string> = {
|
||||
JURY_MEMBER: 'Jury Member',
|
||||
OBSERVER: 'Observer',
|
||||
MENTOR: 'Mentor',
|
||||
AWARD_MASTER: 'Award Master',
|
||||
}
|
||||
|
||||
export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
'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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Handshake,
|
||||
LayoutDashboard,
|
||||
Scale,
|
||||
Trophy,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
@@ -33,7 +32,6 @@ export const ROLE_SWITCH_OPTIONS: Record<string, RoleSwitchOption> = {
|
||||
JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale },
|
||||
MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake },
|
||||
OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye },
|
||||
AWARD_MASTER: { label: 'Award Master', path: '/award-master', icon: Trophy },
|
||||
}
|
||||
|
||||
export function useRoleSwitcher(currentBasePath: string): {
|
||||
|
||||
@@ -10,7 +10,6 @@ const ROLE_DASHBOARDS: Record<string, string> = {
|
||||
MENTOR: '/mentor',
|
||||
OBSERVER: '/observer',
|
||||
APPLICANT: '/applicant',
|
||||
AWARD_MASTER: '/award-master',
|
||||
}
|
||||
|
||||
export async function requireRole(...allowedRoles: UserRole[]) {
|
||||
|
||||
@@ -22,7 +22,7 @@ export const fileRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
|
||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdminOrObserver) {
|
||||
const file = await ctx.prisma.projectFile.findFirst({
|
||||
@@ -321,7 +321,7 @@ export const fileRouter = router({
|
||||
roundId: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
|
||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdminOrObserver) {
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
@@ -393,7 +393,7 @@ export const fileRouter = router({
|
||||
roundId: z.string(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
|
||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdminOrObserver) {
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
@@ -716,7 +716,7 @@ export const fileRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
|
||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
||||
|
||||
// For non-admin/observer callers, mirror the per-round scope used by
|
||||
// file.getDownloadUrl: a juror assigned to round N may only pull URLs
|
||||
|
||||
@@ -102,6 +102,10 @@ export const juryGroupRouter = router({
|
||||
select: { id: true, name: true, roundType: true, status: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
awards: {
|
||||
select: { id: true, name: true, status: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
members: {
|
||||
take: 5,
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
|
||||
@@ -177,11 +177,11 @@ export const projectRouter = router({
|
||||
]
|
||||
}
|
||||
|
||||
// Per-role visibility filters. Admin / Observer / Award master see all
|
||||
// (these roles are designed for cross-program oversight). Other roles
|
||||
// are scoped to projects they have a relationship with.
|
||||
// Per-role visibility filters. Admin / Observer see all (these roles
|
||||
// are designed for cross-program oversight). Other roles are scoped to
|
||||
// projects they have a relationship with.
|
||||
const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')
|
||||
const isObserverLevel = userHasRole(ctx.user, 'OBSERVER', 'AWARD_MASTER')
|
||||
const isObserverLevel = userHasRole(ctx.user, 'OBSERVER')
|
||||
if (!isAdmin && !isObserverLevel) {
|
||||
const orClauses: Array<Record<string, unknown>> = []
|
||||
if (userHasRole(ctx.user, 'JURY_MEMBER')) {
|
||||
@@ -534,10 +534,10 @@ export const projectRouter = router({
|
||||
// ProjectTag table may not exist yet
|
||||
}
|
||||
|
||||
// Per-role access check. Admin / Observer / Award master can read any
|
||||
// project. Jury / Mentor / Applicant must have a relationship to it.
|
||||
// Per-role access check. Admin / Observer can read any project.
|
||||
// Jury / Mentor / Applicant must have a relationship to it.
|
||||
const isAdmin = userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')
|
||||
const isObserverLevel = userHasRole(ctx.user, 'OBSERVER', 'AWARD_MASTER')
|
||||
const isObserverLevel = userHasRole(ctx.user, 'OBSERVER')
|
||||
if (!isAdmin && !isObserverLevel) {
|
||||
const checks: Array<Promise<unknown>> = []
|
||||
if (userHasRole(ctx.user, 'JURY_MEMBER')) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure, awardMasterProcedure } from '../trpc'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { getUserAvatarUrl } from '../utils/avatar-url'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { processEligibilityJob } from '../services/award-eligibility-job'
|
||||
@@ -267,7 +267,7 @@ export const specialAwardRouter = router({
|
||||
evaluationRoundId: z.string().nullable().optional(),
|
||||
juryGroupId: z.string().nullable().optional(),
|
||||
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
|
||||
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).nullable().optional(),
|
||||
decisionMode: z.enum(['JURY_VOTE', 'ADMIN_DECISION']).nullable().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -656,13 +656,15 @@ export const specialAwardRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk invite new users as award jurors — creates accounts, assigns role, sends invite emails
|
||||
* Bulk invite new users as award jurors. Creates JURY_MEMBER accounts,
|
||||
* attaches them as AwardJuror, and (if the award has an assigned jury
|
||||
* group) also adds them as JuryGroupMember so they appear on the
|
||||
* round-page jury panel alongside existing members.
|
||||
*/
|
||||
bulkInviteJurors: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
awardId: z.string(),
|
||||
role: z.enum(['JURY_MEMBER', 'AWARD_MASTER']).default('AWARD_MASTER'),
|
||||
invitees: z.array(
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
@@ -674,7 +676,7 @@ export const specialAwardRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
select: { id: true, name: true },
|
||||
select: { id: true, name: true, juryGroupId: true },
|
||||
})
|
||||
|
||||
const results: Array<{ email: string; status: 'created' | 'existing' | 'error'; error?: string }> = []
|
||||
@@ -694,7 +696,7 @@ export const specialAwardRouter = router({
|
||||
data: {
|
||||
email: invitee.email,
|
||||
name: invitee.name || null,
|
||||
role: input.role,
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'INVITED',
|
||||
inviteToken,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
||||
@@ -734,6 +736,23 @@ export const specialAwardRouter = router({
|
||||
create: { awardId: input.awardId, userId: user.id },
|
||||
})
|
||||
|
||||
if (award.juryGroupId) {
|
||||
await ctx.prisma.juryGroupMember.upsert({
|
||||
where: {
|
||||
juryGroupId_userId: {
|
||||
juryGroupId: award.juryGroupId,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
juryGroupId: award.juryGroupId,
|
||||
userId: user.id,
|
||||
role: 'MEMBER',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// For existing-user invitees the new-account invite email above
|
||||
// never fired (no `created` branch). Send the juror-assignment
|
||||
// notification so they know they were added — but only if this
|
||||
@@ -760,7 +779,7 @@ export const specialAwardRouter = router({
|
||||
detailsJson: {
|
||||
action: 'BULK_INVITE',
|
||||
awardName: award.name,
|
||||
role: input.role,
|
||||
juryGroupId: award.juryGroupId,
|
||||
count: input.invitees.length,
|
||||
results,
|
||||
},
|
||||
@@ -842,12 +861,14 @@ export const specialAwardRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get award detail for voting (jury view)
|
||||
* Get award detail for voting (jury view). Includes upstream evaluation
|
||||
* scores per project (when the award is anchored to an evaluation round)
|
||||
* and — for the chair — the votes cast by other jurors so they can
|
||||
* tie-break and finalize the award.
|
||||
*/
|
||||
getMyAwardDetail: protectedProcedure
|
||||
.input(z.object({ awardId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Verify user is a juror
|
||||
const juror = await ctx.prisma.awardJuror.findUnique({
|
||||
where: {
|
||||
awardId_userId: {
|
||||
@@ -864,8 +885,7 @@ export const specialAwardRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch award, eligible projects, and votes in parallel
|
||||
const [award, eligibleProjects, myVotes] = await Promise.all([
|
||||
const [award, eligibleProjects, myVotes, allJurors] = await Promise.all([
|
||||
ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
}),
|
||||
@@ -917,62 +937,12 @@ export const specialAwardRouter = router({
|
||||
ctx.prisma.awardVote.findMany({
|
||||
where: { awardId: input.awardId, userId: ctx.user.id },
|
||||
}),
|
||||
])
|
||||
|
||||
const projectsRaw = eligibleProjects.map((e) => e.project)
|
||||
const projectsWithLogos = await attachProjectLogoUrls(projectsRaw)
|
||||
|
||||
return {
|
||||
award,
|
||||
projects: projectsWithLogos,
|
||||
myVotes,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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,
|
||||
logoKey: true, logoProvider: 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 }> = {}
|
||||
|
||||
@@ -1007,7 +977,6 @@ export const specialAwardRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }> = []
|
||||
@@ -1047,7 +1016,8 @@ export const specialAwardRouter = router({
|
||||
// ─── Voting ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Submit vote (PICK_WINNER or RANKED)
|
||||
* Submit vote (PICK_WINNER or RANKED). For PICK_WINNER, jurors may attach
|
||||
* an optional justification — visible to the chair when they review votes.
|
||||
*/
|
||||
submitVote: protectedProcedure
|
||||
.input(
|
||||
@@ -1057,12 +1027,12 @@ export const specialAwardRouter = router({
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
rank: z.number().int().min(1).optional(),
|
||||
justification: z.string().max(2000).optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify juror
|
||||
const juror = await ctx.prisma.awardJuror.findUnique({
|
||||
where: {
|
||||
awardId_userId: {
|
||||
@@ -1079,7 +1049,6 @@ export const specialAwardRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Verify award is open for voting
|
||||
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
})
|
||||
@@ -1091,7 +1060,6 @@ export const specialAwardRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Delete existing votes and create new ones
|
||||
await ctx.prisma.$transaction([
|
||||
ctx.prisma.awardVote.deleteMany({
|
||||
where: { awardId: input.awardId, userId: ctx.user.id },
|
||||
@@ -1103,6 +1071,7 @@ export const specialAwardRouter = router({
|
||||
userId: ctx.user.id,
|
||||
projectId: vote.projectId,
|
||||
rank: vote.rank,
|
||||
justification: vote.justification || null,
|
||||
},
|
||||
})
|
||||
),
|
||||
@@ -1123,55 +1092,6 @@ export const specialAwardRouter = router({
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1286,7 +1206,7 @@ export const specialAwardRouter = router({
|
||||
/**
|
||||
* Chair confirms the winner — resolves tiebreaks, sets winner, closes the award
|
||||
*/
|
||||
confirmWinner: awardMasterProcedure
|
||||
confirmWinner: protectedProcedure
|
||||
.input(z.object({ awardId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const allJurors = await ctx.prisma.awardJuror.findMany({
|
||||
|
||||
@@ -217,8 +217,8 @@ export const userRouter = router({
|
||||
list: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
|
||||
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||
search: z.string().optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
@@ -364,8 +364,8 @@ export const userRouter = router({
|
||||
listInvitableIds: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
|
||||
search: z.string().optional(),
|
||||
})
|
||||
)
|
||||
@@ -457,7 +457,7 @@ export const userRouter = router({
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
||||
expertiseTags: z.array(z.string()).optional(),
|
||||
maxAssignments: z.number().int().min(1).max(100).optional(),
|
||||
})
|
||||
@@ -528,8 +528,8 @@ export const userRouter = router({
|
||||
id: z.string(),
|
||||
email: z.string().email().optional(),
|
||||
name: z.string().optional().nullable(),
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE']).optional(),
|
||||
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE'])).optional(),
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE']).optional(),
|
||||
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE'])).optional(),
|
||||
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||
expertiseTags: z.array(z.string()).optional(),
|
||||
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
|
||||
@@ -708,7 +708,7 @@ export const userRouter = router({
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
||||
expertiseTags: z.array(z.string()).optional(),
|
||||
// Optional pre-assignments for jury members
|
||||
assignments: z
|
||||
@@ -1835,7 +1835,7 @@ export const userRouter = router({
|
||||
}
|
||||
|
||||
// Set primary role to highest privilege role
|
||||
const rolePriority: UserRole[] = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'AWARD_MASTER', 'APPLICANT', 'AUDIENCE']
|
||||
const rolePriority: UserRole[] = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE']
|
||||
const primaryRole = rolePriority.find(r => input.roles.includes(r)) || input.roles[0]
|
||||
|
||||
return ctx.prisma.user.update({
|
||||
@@ -1936,7 +1936,6 @@ export const userRouter = router({
|
||||
'JURY_MEMBER',
|
||||
'MENTOR',
|
||||
'OBSERVER',
|
||||
'AWARD_MASTER',
|
||||
'APPLICANT',
|
||||
'AUDIENCE',
|
||||
]
|
||||
@@ -2236,7 +2235,7 @@ export const userRouter = router({
|
||||
* which the user has actionable work right now, or the highest-priority
|
||||
* role they hold (static fallback) if nothing is actionable.
|
||||
*
|
||||
* Priority order: SUPER_ADMIN > PROGRAM_ADMIN > AWARD_MASTER > JURY_MEMBER >
|
||||
* Priority order: SUPER_ADMIN > PROGRAM_ADMIN > JURY_MEMBER >
|
||||
* MENTOR > APPLICANT > OBSERVER > AUDIENCE.
|
||||
*
|
||||
* Used by src/app/page.tsx to route users at login.
|
||||
@@ -2253,14 +2252,6 @@ export const userRouter = router({
|
||||
const PRIORITY: Entry[] = [
|
||||
{ role: 'SUPER_ADMIN', path: '/admin', predicate: () => true },
|
||||
{ role: 'PROGRAM_ADMIN', path: '/admin', predicate: () => true },
|
||||
{
|
||||
role: 'AWARD_MASTER',
|
||||
path: '/award-master',
|
||||
predicate: async () => {
|
||||
const cnt = await ctx.prisma.awardJuror.count({ where: { userId: user.id } })
|
||||
return cnt > 0
|
||||
},
|
||||
},
|
||||
{
|
||||
role: 'JURY_MEMBER',
|
||||
path: '/jury',
|
||||
|
||||
@@ -407,16 +407,6 @@ export const observerProcedure = t.procedure
|
||||
.use(withErrorAudit)
|
||||
.use(withMutationAudit)
|
||||
|
||||
/**
|
||||
* Award master procedure - requires AWARD_MASTER role (or admin).
|
||||
* AWARD_MASTER and PROGRAM_ADMIN mutations audit-logged, errors tracked.
|
||||
*/
|
||||
export const awardMasterProcedure = t.procedure
|
||||
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER'))
|
||||
.use(withRateLimit)
|
||||
.use(withErrorAudit)
|
||||
.use(withMutationAudit)
|
||||
|
||||
/**
|
||||
* Audience procedure - requires any authenticated user.
|
||||
* All mutations auto-audited, errors tracked.
|
||||
|
||||
@@ -347,7 +347,6 @@ export const AwardStatusSchema = z.enum([
|
||||
])
|
||||
export const AwardDecisionModeSchema = z.enum([
|
||||
'JURY_VOTE',
|
||||
'AWARD_MASTER_DECISION',
|
||||
'ADMIN_DECISION',
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user