Multi-role members, round detail UI overhaul, dashboard jury progress, and submit bug fix
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

- Add roles UserRole[] to User model with migration + backfill from existing role column
- Update auth JWT/session to propagate roles array with [role] fallback for stale tokens
- Update tRPC hasRole() middleware and add userHasRole() helper for inline role checks
- Update ~15 router inline checks and ~13 DB queries to use roles array
- Add updateRoles admin mutation with SUPER_ADMIN guard and priority-based primary role
- Add role switcher UI in admin sidebar and role-nav for multi-role users
- Remove redundant stats cards from round detail, add window dates to header banner
- Merge Members section into JuryProgressTable with inline cap editor and remove buttons
- Reorder round detail assignments tab: Progress > Score Dist > Assignments > Coverage > Jury Group
- Make score distribution fill full vertical height, reassignment history always open
- Add per-juror progress bars to admin dashboard ActiveRoundPanel for EVALUATION rounds
- Fix evaluation submit bug: use isSubmitting state instead of startMutation.isPending

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 17:44:55 +01:00
parent 230347005c
commit f3fd9eebee
25 changed files with 963 additions and 714 deletions

View File

@@ -0,0 +1,59 @@
-- DropForeignKey
ALTER TABLE "ConflictOfInterest" DROP CONSTRAINT "ConflictOfInterest_roundId_fkey";
-- DropForeignKey
ALTER TABLE "Project" DROP CONSTRAINT "Project_programId_fkey";
-- DropForeignKey
ALTER TABLE "TaggingJob" DROP CONSTRAINT "TaggingJob_roundId_fkey";
-- DropIndex
DROP INDEX "DiscussionComment_discussionId_createdAt_idx";
-- DropIndex
DROP INDEX "EvaluationDiscussion_status_idx";
-- DropIndex
DROP INDEX "LiveVote_isAudienceVote_idx";
-- DropIndex
DROP INDEX "Message_scheduledAt_idx";
-- DropIndex
DROP INDEX "MessageRecipient_messageId_idx";
-- DropIndex
DROP INDEX "MessageRecipient_userId_isRead_idx";
-- DropIndex
DROP INDEX "Project_programId_roundId_idx";
-- DropIndex
DROP INDEX "Project_roundId_idx";
-- DropIndex
DROP INDEX "ProjectFile_projectId_roundId_idx";
-- DropIndex
DROP INDEX "ProjectFile_roundId_idx";
-- DropIndex
DROP INDEX "TaggingJob_roundId_idx";
-- DropIndex
DROP INDEX "WebhookDelivery_createdAt_idx";
-- AlterTable
ALTER TABLE "User" ADD COLUMN "roles" "UserRole"[] DEFAULT ARRAY[]::"UserRole"[];
-- Backfill: populate roles array from existing role column
UPDATE "User" SET "roles" = ARRAY["role"]::"UserRole"[];
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MessageTemplate" ADD CONSTRAINT "MessageTemplate_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- RenameIndex
ALTER INDEX "DeliberationVote_sessionId_juryMemberId_projectId_runoffRo_key" RENAME TO "DeliberationVote_sessionId_juryMemberId_projectId_runoffRou_key";

View File

@@ -303,6 +303,7 @@ model User {
name String?
emailVerified DateTime? // Required by NextAuth Prisma adapter
role UserRole @default(JURY_MEMBER)
roles UserRole[] @default([])
status UserStatus @default(INVITED)
expertiseTags String[] @default([])
maxAssignments Int? // Per-round limit

View File

@@ -347,6 +347,7 @@ async function main() {
email: account.email,
name: account.name,
role: account.role,
roles: [account.role],
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE,
passwordHash: isSuperAdmin ? passwordHash : null,
mustSetPassword: !isSuperAdmin,
@@ -385,6 +386,7 @@ async function main() {
email: j.email,
name: j.name,
role: UserRole.JURY_MEMBER,
roles: [UserRole.JURY_MEMBER],
status: UserStatus.NONE,
country: j.country,
expertiseTags: j.tags,
@@ -416,6 +418,7 @@ async function main() {
email: m.email,
name: m.name,
role: UserRole.MENTOR,
roles: [UserRole.MENTOR],
status: UserStatus.NONE,
country: m.country,
expertiseTags: m.tags,
@@ -444,6 +447,7 @@ async function main() {
email: o.email,
name: o.name,
role: UserRole.OBSERVER,
roles: [UserRole.OBSERVER],
status: UserStatus.NONE,
country: o.country,
},
@@ -545,6 +549,7 @@ async function main() {
email,
name: name || `Applicant ${rowIdx + 1}`,
role: UserRole.APPLICANT,
roles: [UserRole.APPLICANT],
status: UserStatus.NONE,
phoneNumber: phone,
country,

View File

@@ -54,6 +54,7 @@ import {
Save,
Loader2,
ChevronDown,
ChevronRight,
Play,
Square,
Archive,
@@ -182,6 +183,7 @@ export default function RoundDetailPage() {
const [nameValue, setNameValue] = useState('')
const nameInputRef = useRef<HTMLInputElement>(null)
const [statusConfirmAction, setStatusConfirmAction] = useState<'activate' | 'close' | 'reopen' | 'archive' | null>(null)
const [coverageOpen, setCoverageOpen] = useState(false)
const utils = trpc.useUtils()
@@ -218,6 +220,12 @@ export default function RoundDetailPage() {
)
const roundAwards = awards?.filter((a) => a.evaluationRoundId === roundId) ?? []
// Jury workload (for assignments tab coverage auto-open logic)
const { data: juryWorkload } = trpc.analytics.getJurorWorkload.useQuery(
{ roundId },
{ enabled: round?.roundType === 'EVALUATION', refetchInterval: 15_000 },
)
// Filtering results stats (only for FILTERING rounds)
const { data: filteringStats } = trpc.filtering.getResultStats.useQuery(
{ roundId },
@@ -247,6 +255,13 @@ export default function RoundDetailPage() {
[config, serverConfig],
)
// Auto-open coverage section when no assignments exist yet
useEffect(() => {
if (juryWorkload && juryWorkload.length === 0) {
setCoverageOpen(true)
}
}, [juryWorkload])
// ── Mutations ──────────────────────────────────────────────────────────
const updateMutation = trpc.round.update.useMutation({
onSuccess: () => {
@@ -494,9 +509,6 @@ export default function RoundDetailPage() {
</div>
</div>
</div>
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
{[1, 2, 3, 4].map((i) => <Skeleton key={i} className="h-28 rounded-lg" />)}
</div>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-96 w-full rounded-lg" />
</div>
@@ -776,6 +788,29 @@ export default function RoundDetailPage() {
</AlertDialog>
</div>
<p className="text-sm text-white/60 mt-1">{typeCfg.description}</p>
{(round.windowOpenAt || round.windowCloseAt) && (
<div className="flex items-center gap-1.5 text-white/60 text-xs mt-1">
<CalendarDays className="h-3 w-3" />
{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleDateString() : 'No start'}
{' \u2014 '}
{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleDateString() : 'No deadline'}
{(() => {
const now = new Date()
const openAt = round.windowOpenAt ? new Date(round.windowOpenAt) : null
const closeAt = round.windowCloseAt ? new Date(round.windowCloseAt) : null
if (openAt && now < openAt) {
return <span className="text-[#80c4dc] font-medium ml-1">Opens {getRelativeTime(openAt)}</span>
}
if (closeAt && now < closeAt) {
return <span className="text-amber-300 font-medium ml-1">Closes {getRelativeTime(closeAt)}</span>
}
if (closeAt && now >= closeAt) {
return <span className="text-white/40 ml-1">Closed {getRelativeTime(closeAt)}</span>
}
return null
})()}
</div>
)}
</div>
</div>
@@ -797,148 +832,6 @@ export default function RoundDetailPage() {
</div>
</motion.div>
{/* ===== STATS BAR — Accent-bordered cards ===== */}
<div className={cn("grid gap-3 grid-cols-2", hasJury ? "sm:grid-cols-4" : "sm:grid-cols-3")}>
{/* Projects */}
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-[#557f8c] hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5">
<div className="rounded-full bg-[#557f8c]/10 p-1.5">
<Layers className="h-4 w-4 text-[#557f8c]" />
</div>
<span className="text-sm font-medium text-muted-foreground">Projects</span>
</div>
<p className="text-3xl font-bold mt-2">{projectCount}</p>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{Object.entries(stateCounts).map(([state, count]) => (
<span key={state} className="text-[10px] text-muted-foreground">
{String(count)} {state.toLowerCase().replace('_', ' ')}
</span>
))}
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Jury (with inline group selector) — only for jury-relevant rounds */}
{hasJury && (
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5 mb-1" data-jury-select>
<div className="rounded-full bg-purple-50 p-1.5">
<Users className="h-4 w-4 text-purple-500" />
</div>
<span className="text-sm font-medium text-muted-foreground">Jury</span>
</div>
{juryGroup ? (
<>
<p className="text-3xl font-bold mt-2">{juryMemberCount}</p>
<div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p>
<button
type="button"
className="text-[10px] text-[#557f8c] hover:underline shrink-0"
onClick={() => setActiveTab(isEvaluation ? 'assignments' : 'jury')}
>
Change
</button>
</div>
</>
) : (
<>
<p className="text-3xl font-bold mt-2 text-muted-foreground">&mdash;</p>
<button
type="button"
className="text-xs text-[#557f8c] hover:underline"
onClick={() => setActiveTab(isEvaluation ? 'assignments' : 'jury')}
>
Assign jury group
</button>
</>
)}
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Window */}
<AnimatedCard index={hasJury ? 2 : 1}>
<Card className="border-l-4 border-l-emerald-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5">
<div className="rounded-full bg-emerald-50 p-1.5">
<CalendarDays className="h-4 w-4 text-emerald-500" />
</div>
<span className="text-sm font-medium text-muted-foreground">Window</span>
</div>
{round.windowOpenAt || round.windowCloseAt ? (
<>
<p className="text-sm font-bold mt-2">
{round.windowOpenAt
? new Date(round.windowOpenAt).toLocaleDateString()
: 'No start'}
</p>
<p className="text-xs text-muted-foreground">
{round.windowCloseAt
? `Closes ${new Date(round.windowCloseAt).toLocaleDateString()}`
: 'No deadline'}
</p>
{(() => {
const now = new Date()
const openAt = round.windowOpenAt ? new Date(round.windowOpenAt) : null
const closeAt = round.windowCloseAt ? new Date(round.windowCloseAt) : null
if (openAt && now < openAt) {
return <p className="text-[10px] text-[#557f8c] font-medium mt-0.5">Opens {getRelativeTime(openAt)}</p>
}
if (closeAt && now < closeAt) {
return <p className="text-[10px] text-amber-600 font-medium mt-0.5">Closes {getRelativeTime(closeAt)}</p>
}
if (closeAt && now >= closeAt) {
return <p className="text-[10px] text-muted-foreground mt-0.5">Closed {getRelativeTime(closeAt)}</p>
}
return null
})()}
</>
) : (
<>
<p className="text-3xl font-bold mt-2 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">No dates set</p>
</>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Advancement */}
<AnimatedCard index={hasJury ? 3 : 2}>
<Card className="border-l-4 border-l-amber-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5">
<div className="rounded-full bg-amber-50 p-1.5">
<BarChart3 className="h-4 w-4 text-amber-500" />
</div>
<span className="text-sm font-medium text-muted-foreground">Advancement</span>
</div>
{round.advancementRules && round.advancementRules.length > 0 ? (
<>
<p className="text-3xl font-bold mt-2">{round.advancementRules.length}</p>
<p className="text-xs text-muted-foreground">
{round.advancementRules.map((r: any) => r.ruleType.replace('_', ' ').toLowerCase()).join(', ')}
</p>
</>
) : (
<>
<p className="text-3xl font-bold mt-2 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">Admin selection</p>
</>
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* ===== TABS — Underline style ===== */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<div className="border-b overflow-x-auto">
@@ -1544,99 +1437,6 @@ export default function RoundDetailPage() {
{/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */}
{hasJury && !isEvaluation && (
<TabsContent value="jury" className="space-y-6">
{/* Jury Group Selector + Create */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Jury Group</CardTitle>
<CardDescription>
Select or create a jury group for this round
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setCreateJuryOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
New Jury
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{juryGroups && juryGroups.length > 0 ? (
<div className="space-y-4">
<Select
value={round.juryGroupId ?? '__none__'}
onValueChange={(value) => {
assignJuryMutation.mutate({
id: roundId,
juryGroupId: value === '__none__' ? null : value,
})
}}
disabled={assignJuryMutation.isPending}
>
<SelectTrigger className="w-full sm:w-80">
<SelectValue placeholder="Select jury group..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No jury assigned</SelectItem>
{juryGroups.map((jg: any) => (
<SelectItem key={jg.id} value={jg.id}>
{jg.name} ({jg._count?.members ?? 0} members)
</SelectItem>
))}
</SelectContent>
</Select>
{/* Delete button for currently selected jury group */}
{round.juryGroupId && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4 mr-1.5" />
Delete &quot;{juryGroup?.name}&quot;
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete jury group?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{juryGroup?.name}&quot; and remove all its members.
Rounds using this jury group will be unlinked. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteJuryMutation.mutate({ id: round.juryGroupId! })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteJuryMutation.isPending}
>
{deleteJuryMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Delete Jury
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="rounded-full bg-purple-50 p-4 mb-4">
<Users className="h-8 w-8 text-purple-400" />
</div>
<p className="text-sm font-medium">No Jury Groups</p>
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
Create a jury group to assign members who will evaluate projects in this round.
</p>
<Button size="sm" className="mt-4" onClick={() => setCreateJuryOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
Create First Jury
</Button>
</div>
)}
</CardContent>
</Card>
{/* Members list (only if a jury group is assigned) */}
{juryGroupDetail && (
@@ -1823,71 +1623,7 @@ export default function RoundDetailPage() {
</Card>
)}
{/* Create Jury Dialog */}
<Dialog open={createJuryOpen} onOpenChange={setCreateJuryOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Jury Group</DialogTitle>
<DialogDescription>
Create a new jury group for this competition. It will be automatically assigned to this round.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Name</label>
<Input
placeholder="e.g. Round 1 Jury, Expert Panel, Final Jury"
value={newJuryName}
onChange={(e) => setNewJuryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && newJuryName.trim()) {
createJuryMutation.mutate({
competitionId,
name: newJuryName.trim(),
slug: newJuryName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''),
})
}
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateJuryOpen(false)}>Cancel</Button>
<Button
onClick={() => {
createJuryMutation.mutate({
competitionId,
name: newJuryName.trim(),
slug: newJuryName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''),
})
}}
disabled={createJuryMutation.isPending || !newJuryName.trim()}
>
{createJuryMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Member Dialog */}
{juryGroupId && (
<AddMemberDialog
juryGroupId={juryGroupId}
open={addMemberOpen}
onOpenChange={(open) => {
setAddMemberOpen(open)
if (!open) utils.juryGroup.getById.invalidate({ id: juryGroupId })
}}
/>
)}
</TabsContent>
)}
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds — includes Jury section) ═══════════ */}
{isEvaluation && (
<TabsContent value="assignments" className="space-y-6">
{/* ── Jury Group Selector (merged from Jury tab for EVALUATION) ── */}
{/* Jury Group Selector (at bottom for non-evaluation jury rounds) */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
@@ -1931,7 +1667,6 @@ export default function RoundDetailPage() {
</SelectContent>
</Select>
{/* Delete button for currently selected jury group */}
{round.juryGroupId && (
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -1981,174 +1716,94 @@ export default function RoundDetailPage() {
</CardContent>
</Card>
{/* ── Members list (only if a jury group is assigned) ── */}
{juryGroupDetail && (
</TabsContent>
)}
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds — includes Jury section) ═══════════ */}
{isEvaluation && (
<TabsContent value="assignments" className="space-y-6">
{/* 1. Jury Members & Progress (merged) */}
{!round?.juryGroupId ? (
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">Select a jury group below to get started.</p>
</CardContent>
</Card>
) : (
<JuryProgressTable
roundId={roundId}
members={juryGroupDetail?.members.map((m: any) => ({
id: m.id,
userId: m.userId,
name: m.user.name || 'Unnamed',
email: m.user.email,
maxAssignmentsOverride: m.maxAssignmentsOverride as number | null,
}))}
onSaveCap={(id, val) => updateJuryMemberMutation.mutate({ id, maxAssignmentsOverride: val })}
onRemoveMember={(id) => removeJuryMemberMutation.mutate({ id })}
onAddMember={() => setAddMemberOpen(true)}
/>
)}
{/* 2. Score Distribution (full-width) */}
<ScoreDistribution roundId={roundId} />
{/* 3. Reassignment History (always open) */}
<ReassignmentHistory roundId={roundId} />
{/* ── Remaining content only shown when jury group is assigned ── */}
{round?.juryGroupId && (
<>
{/* 4. Assignments */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
Members &mdash; {juryGroupDetail.name}
</CardTitle>
<CardDescription>
{juryGroupDetail.members.length} member{juryGroupDetail.members.length !== 1 ? 's' : ''}
</CardDescription>
<CardTitle className="text-base">Assignments</CardTitle>
<CardDescription>Individual jury-project assignments and actions</CardDescription>
</div>
<Button size="sm" onClick={() => setAddMemberOpen(true)}>
<UserPlus className="h-4 w-4 mr-1.5" />
Add Member
<div className="flex flex-wrap items-center gap-2">
<SendRemindersButton roundId={roundId} />
<NotifyJurorsButton roundId={roundId} />
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
<Download className="h-4 w-4 mr-1.5" />
Export
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{juryGroupDetail.members.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<UserPlus className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm font-medium">No Members Yet</p>
<p className="text-xs text-muted-foreground mt-1">
Add jury members to start assigning projects for evaluation.
</p>
<Button size="sm" variant="outline" className="mt-4" onClick={() => setAddMemberOpen(true)}>
<UserPlus className="h-4 w-4 mr-1.5" />
Add First Member
</Button>
</div>
) : (
<div className="space-y-1">
{juryGroupDetail.members.map((member: any, idx: number) => (
<div
key={member.id}
className={cn(
'flex items-center justify-between py-2.5 px-3 rounded-md transition-colors',
idx % 2 === 1 && 'bg-muted/30',
)}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{member.user.name || 'Unnamed User'}
</p>
<p className="text-xs text-muted-foreground truncate">{member.user.email}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<InlineMemberCap
memberId={member.id}
currentValue={member.maxAssignmentsOverride as number | null}
roundId={roundId}
jurorUserId={member.userId}
onSave={(val) => updateJuryMemberMutation.mutate({
id: member.id,
maxAssignmentsOverride: val,
})}
/>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
disabled={notifyMemberMutation.isPending}
onClick={() => notifyMemberMutation.mutate({ roundId, userId: member.userId })}
>
{notifyMemberMutation.isPending && notifyMemberMutation.variables?.userId === member.userId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Mail className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Notify juror of assignments</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setMemberTransferJuror({ id: member.userId, name: member.user.name || member.user.email })}
>
<ArrowRightLeft className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Transfer assignments to other jurors</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={reshuffleMemberMutation.isPending}
onClick={() => {
const ok = window.confirm(
`Remove ${member.user.name || member.user.email} from this jury pool and reassign all their unsubmitted projects to other jurors? Submitted evaluations will be preserved. This cannot be undone.`
)
if (!ok) return
reshuffleMemberMutation.mutate({ roundId, jurorId: member.userId })
}}
>
{reshuffleMemberMutation.isPending && reshuffleMemberMutation.variables?.jurorId === member.userId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UserPlus className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Drop juror & reshuffle pending projects</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
Remove {member.user.name || member.user.email} from {juryGroupDetail.name}?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => removeJuryMemberMutation.mutate({ id: member.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
)}
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
</CardContent>
</Card>
)}
{/* ── Assignments content (only shown when jury group is assigned) ── */}
{round?.juryGroupId && (
<>
{/* Card 1: Coverage & Generation */}
{/* 5. Monitoring — COI + Unassigned Queue */}
<Card>
<CardHeader>
<CardTitle className="text-base">Coverage & Generation</CardTitle>
<CardTitle className="text-base">Monitoring</CardTitle>
<CardDescription>Conflict of interest declarations and unassigned projects</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<COIReviewSection roundId={roundId} />
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
</CardContent>
</Card>
{/* 6. Coverage & Generation (collapsible, default collapsed when assignments exist) */}
<Card>
<CardHeader
className="cursor-pointer select-none"
onClick={() => setCoverageOpen((o) => !o)}
>
<CardTitle className="text-base flex items-center gap-2">
Coverage &amp; Generation
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', coverageOpen && 'rotate-90')} />
</CardTitle>
<CardDescription>Assignment coverage overview and AI generation</CardDescription>
</CardHeader>
{coverageOpen && (
<CardContent className="space-y-6">
<CoverageReport roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
@@ -2265,49 +1920,99 @@ export default function RoundDetailPage() {
)}
</div>
</CardContent>
)}
</Card>
{/* Jury Progress + Score Distribution (standalone 2-col grid) */}
<div className="grid gap-4 lg:grid-cols-2">
<JuryProgressTable roundId={roundId} />
<ScoreDistribution roundId={roundId} />
</div>
{/* Reassignment History (collapsible) */}
<ReassignmentHistory roundId={roundId} />
{/* Card 2: Assignments — with action buttons in header */}
{/* 7. Jury Group (at bottom) */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Assignments</CardTitle>
<CardDescription>Individual jury-project assignments and actions</CardDescription>
<CardTitle className="text-base">Jury Group</CardTitle>
<CardDescription>
Select or create a jury group for this round
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<SendRemindersButton roundId={roundId} />
<NotifyJurorsButton roundId={roundId} />
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
<Download className="h-4 w-4 mr-1.5" />
Export
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setCreateJuryOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
New Jury
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
</CardContent>
</Card>
{juryGroups && juryGroups.length > 0 ? (
<div className="space-y-4">
<Select
value={round.juryGroupId ?? '__none__'}
onValueChange={(value) => {
assignJuryMutation.mutate({
id: roundId,
juryGroupId: value === '__none__' ? null : value,
})
}}
disabled={assignJuryMutation.isPending}
>
<SelectTrigger className="w-full sm:w-80">
<SelectValue placeholder="Select jury group..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No jury assigned</SelectItem>
{juryGroups.map((jg: any) => (
<SelectItem key={jg.id} value={jg.id}>
{jg.name} ({jg._count?.members ?? 0} members)
</SelectItem>
))}
</SelectContent>
</Select>
{/* Card 3: Monitoring — COI + Unassigned Queue */}
<Card>
<CardHeader>
<CardTitle className="text-base">Monitoring</CardTitle>
<CardDescription>Conflict of interest declarations and unassigned projects</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<COIReviewSection roundId={roundId} />
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
{round.juryGroupId && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4 mr-1.5" />
Delete &quot;{juryGroup?.name}&quot;
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete jury group?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{juryGroup?.name}&quot; and remove all its members.
Rounds using this jury group will be unlinked. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteJuryMutation.mutate({ id: round.juryGroupId! })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteJuryMutation.isPending}
>
{deleteJuryMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Delete Jury
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="rounded-full bg-purple-50 p-4 mb-4">
<Users className="h-8 w-8 text-purple-400" />
</div>
<p className="text-sm font-medium">No Jury Groups</p>
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
Create a jury group to assign members who will evaluate projects in this round.
</p>
<Button size="sm" className="mt-4" onClick={() => setCreateJuryOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
Create First Jury
</Button>
</div>
)}
</CardContent>
</Card>
@@ -2590,6 +2295,64 @@ export default function RoundDetailPage() {
)}
</Tabs>
{/* ── Page-level dialogs (shared between jury/assignments tabs) ── */}
<Dialog open={createJuryOpen} onOpenChange={setCreateJuryOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Jury Group</DialogTitle>
<DialogDescription>
Create a new jury group for this competition. It will be automatically assigned to this round.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Name</label>
<Input
placeholder="e.g. Round 1 Jury, Expert Panel, Final Jury"
value={newJuryName}
onChange={(e) => setNewJuryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && newJuryName.trim()) {
createJuryMutation.mutate({
competitionId,
name: newJuryName.trim(),
slug: newJuryName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''),
})
}
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateJuryOpen(false)}>Cancel</Button>
<Button
onClick={() => {
createJuryMutation.mutate({
competitionId,
name: newJuryName.trim(),
slug: newJuryName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''),
})
}}
disabled={createJuryMutation.isPending || !newJuryName.trim()}
>
{createJuryMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{juryGroupId && (
<AddMemberDialog
juryGroupId={juryGroupId}
open={addMemberOpen}
onOpenChange={(open) => {
setAddMemberOpen(open)
if (!open) utils.juryGroup.getById.invalidate({ id: juryGroupId })
}}
/>
)}
{/* Autosave error bar — only shows when save fails */}
{autosaveStatus === 'error' && (
<div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-red-50 dark:bg-red-950/50 shadow-[0_-4px_12px_rgba(0,0,0,0.1)]">

View File

@@ -41,6 +41,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const evaluationIdRef = useRef<string | null>(null)
const isSubmittedRef = useRef(false)
const isSubmittingRef = useRef(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
@@ -318,10 +319,12 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
autosaveTimerRef.current = null
}
isSubmittingRef.current = true
setIsSubmitting(true)
if (!myAssignment) {
toast.error('Assignment not found')
isSubmittingRef.current = false
setIsSubmitting(false)
return
}
@@ -335,16 +338,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (c.type === 'numeric' && (val === undefined || val === null)) {
toast.error(`Please score "${c.label}"`)
isSubmittingRef.current = false
setIsSubmitting(false)
return
}
if (c.type === 'boolean' && val === undefined) {
toast.error(`Please answer "${c.label}"`)
isSubmittingRef.current = false
setIsSubmitting(false)
return
}
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
toast.error(`Please fill in "${c.label}"`)
isSubmittingRef.current = false
setIsSubmitting(false)
return
}
}
@@ -355,6 +361,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (isNaN(score) || score < 1 || score > 10) {
toast.error('Please enter a valid score between 1 and 10')
isSubmittingRef.current = false
setIsSubmitting(false)
return
}
}
@@ -363,6 +370,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (!binaryDecision) {
toast.error('Please select accept or reject')
isSubmittingRef.current = false
setIsSubmitting(false)
return
}
}
@@ -371,6 +379,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) {
toast.error(`Please provide feedback (minimum ${feedbackMinLength} characters)`)
isSubmittingRef.current = false
setIsSubmitting(false)
return
}
}
@@ -414,6 +423,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
} catch {
// Error toast already handled by onError callback
isSubmittingRef.current = false
setIsSubmitting(false)
}
}
@@ -878,7 +888,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</Button>
<Button
onClick={handleSubmit}
disabled={submitMutation.isPending || startMutation.isPending}
disabled={submitMutation.isPending || isSubmitting}
className="bg-brand-blue hover:bg-brand-blue-light"
>
<Send className="mr-2 h-4 w-4" />

View File

@@ -13,7 +13,8 @@ export default async function MentorLayout({
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
// Check if user has completed onboarding (for mentors)
if (session.user.role === 'MENTOR') {
const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
if (userRoles.includes('MENTOR') && !userRoles.some(r => r === 'SUPER_ADMIN' || r === 'PROGRAM_ADMIN')) {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { onboardingCompletedAt: true },

View File

@@ -13,14 +13,44 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { Loader2, Mail, ArrowRightLeft, UserPlus } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Loader2, Mail, ArrowRightLeft, UserPlus, Trash2 } from 'lucide-react'
import { TransferAssignmentsDialog } from './transfer-assignments-dialog'
import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
export type JuryProgressTableMember = {
id: string
userId: string
name: string
email: string
maxAssignmentsOverride: number | null
}
export type JuryProgressTableProps = {
roundId: string
members?: JuryProgressTableMember[]
onSaveCap?: (memberId: string, val: number | null) => void
onRemoveMember?: (memberId: string, memberName: string) => void
onAddMember?: () => void
}
export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
export function JuryProgressTable({
roundId,
members,
onSaveCap,
onRemoveMember,
onAddMember,
}: JuryProgressTableProps) {
const utils = trpc.useUtils()
const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
@@ -52,12 +82,30 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
onError: (err) => toast.error(err.message),
})
const hasMembersData = members !== undefined
return (
<>
<Card>
<CardHeader>
<CardTitle className="text-base">Jury Progress</CardTitle>
<CardDescription>Evaluation completion per juror. Click the mail icon to notify an individual juror.</CardDescription>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
{hasMembersData ? 'Jury Members & Progress' : 'Jury Progress'}
</CardTitle>
<CardDescription>
{hasMembersData
? 'Manage jury members, caps, and evaluation progress per juror.'
: 'Evaluation completion per juror. Click the mail icon to notify an individual juror.'}
</CardDescription>
</div>
{onAddMember && (
<Button size="sm" onClick={onAddMember}>
<UserPlus className="h-4 w-4 mr-1.5" />
Add Member
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
@@ -65,11 +113,28 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : !workload || workload.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No assignments yet
</p>
hasMembersData && members && members.length > 0 ? (
// Show members-only view when there are members but no assignments yet
<div className="space-y-1">
{members.map((member, idx) => (
<MemberOnlyRow
key={member.id}
member={member}
idx={idx}
roundId={roundId}
onSaveCap={onSaveCap}
onRemoveMember={onRemoveMember}
notifyMutation={notifyMutation}
/>
))}
</div>
) : (
<div className="space-y-3 max-h-[350px] overflow-y-auto">
<p className="text-sm text-muted-foreground text-center py-6">
{hasMembersData ? 'No members yet. Add jury members to get started.' : 'No assignments yet'}
</p>
)
) : (
<div className="space-y-3 max-h-[500px] overflow-y-auto overflow-x-hidden">
{workload.map((juror) => {
const pct = juror.completionRate
const barGradient = pct === 100
@@ -80,11 +145,23 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
? 'bg-gradient-to-r from-amber-400 to-amber-600'
: 'bg-gray-300'
// Find the corresponding member entry for cap editing
const member = members?.find((m) => m.userId === juror.id)
return (
<div key={juror.id} className="space-y-1 hover:bg-muted/20 rounded px-1 py-0.5 -mx-1 transition-colors group">
<div className="flex justify-between items-center text-xs">
<span className="font-medium truncate max-w-[50%]">{juror.name}</span>
<span className="font-medium truncate max-w-[140px]">{juror.name}</span>
<div className="flex items-center gap-2 shrink-0">
{member && onSaveCap && (
<InlineMemberCap
memberId={member.id}
currentValue={member.maxAssignmentsOverride}
roundId={roundId}
jurorUserId={member.userId}
onSave={(val) => onSaveCap(member.id, val)}
/>
)}
<span className="text-muted-foreground tabular-nums">
{juror.completed}/{juror.assigned} ({pct}%)
</span>
@@ -151,6 +228,37 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
<TooltipContent side="left"><p>Drop juror + reshuffle pending projects</p></TooltipContent>
</Tooltip>
</TooltipProvider>
{member && onRemoveMember && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
Remove {member.name} from this jury group?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onRemoveMember(member.id, member.name)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
@@ -178,3 +286,96 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
</>
)
}
// Sub-component for member-only rows (no workload data yet)
function MemberOnlyRow({
member,
idx,
roundId,
onSaveCap,
onRemoveMember,
notifyMutation,
}: {
member: JuryProgressTableMember
idx: number
roundId: string
onSaveCap?: (memberId: string, val: number | null) => void
onRemoveMember?: (memberId: string, memberName: string) => void
notifyMutation: ReturnType<typeof trpc.assignment.notifySingleJurorOfAssignments.useMutation>
}) {
return (
<div
className={cn(
'flex items-center justify-between py-2 px-2 rounded-md transition-colors text-xs',
idx % 2 === 1 && 'bg-muted/30',
)}
>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{member.name}</p>
<p className="text-muted-foreground truncate">{member.email}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{onSaveCap && (
<InlineMemberCap
memberId={member.id}
currentValue={member.maxAssignmentsOverride}
roundId={roundId}
jurorUserId={member.userId}
onSave={(val) => onSaveCap(member.id, val)}
/>
)}
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
disabled={notifyMutation.isPending}
onClick={() => notifyMutation.mutate({ roundId, userId: member.userId })}
>
{notifyMutation.isPending && notifyMutation.variables?.userId === member.userId ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Mail className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Notify juror of assignments</p></TooltipContent>
</Tooltip>
</TooltipProvider>
{onRemoveMember && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
Remove {member.name} from this jury group?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onRemoveMember(member.id, member.name)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
)
}

View File

@@ -1,38 +1,29 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { History, ChevronRight } from 'lucide-react'
import { History } from 'lucide-react'
export type ReassignmentHistoryProps = {
roundId: string
}
export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
const [expanded, setExpanded] = useState(false)
const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery(
{ roundId },
{ enabled: expanded },
)
return (
<Card>
<CardHeader
className="cursor-pointer select-none"
onClick={() => setExpanded(!expanded)}
>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<History className="h-4 w-4" />
Reassignment History
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', expanded && 'rotate-90')} />
</CardTitle>
<CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription>
</CardHeader>
{expanded && (
<CardContent>
{isLoading ? (
<div className="space-y-3">
@@ -105,7 +96,6 @@ export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
</div>
)}
</CardContent>
)}
</Card>
)
}

View File

@@ -364,9 +364,16 @@ export function MembersContent() {
</div>
</TableCell>
<TableCell>
<Badge variant={roleColors[user.role] || 'secondary'}>
{user.role.replace(/_/g, ' ')}
<div className="flex flex-wrap gap-1">
{((user as unknown as { roles?: string[] }).roles?.length
? (user as unknown as { roles: string[] }).roles
: [user.role]
).map((r) => (
<Badge key={r} variant={roleColors[r] || 'secondary'}>
{r.replace(/_/g, ' ')}
</Badge>
))}
</div>
</TableCell>
<TableCell>
{user.expertiseTags && user.expertiseTags.length > 0 ? (
@@ -469,9 +476,16 @@ export function MembersContent() {
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Role</span>
<Badge variant={roleColors[user.role] || 'secondary'}>
{user.role.replace(/_/g, ' ')}
<div className="flex flex-wrap gap-1 justify-end">
{((user as unknown as { roles?: string[] }).roles?.length
? (user as unknown as { roles: string[] }).roles
: [user.role]
).map((r) => (
<Badge key={r} variant={roleColors[r] || 'secondary'}>
{r.replace(/_/g, ' ')}
</Badge>
))}
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>

View File

@@ -21,16 +21,16 @@ export function ScoreDistribution({ roundId }: ScoreDistributionProps) {
[dist])
return (
<Card>
<Card className="flex flex-col">
<CardHeader>
<CardTitle className="text-base">Score Distribution</CardTitle>
<CardDescription>
{dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'}
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="flex flex-col flex-1 pb-4">
{isLoading ? (
<div className="flex items-end gap-1 h-32">
<div className="flex items-end gap-1 flex-1 min-h-[120px]">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="flex-1 h-full" />)}
</div>
) : !dist || dist.totalEvaluations === 0 ? (
@@ -38,7 +38,7 @@ export function ScoreDistribution({ roundId }: ScoreDistributionProps) {
No evaluations submitted yet
</p>
) : (
<div className="flex gap-1 h-32">
<div className="flex gap-1 flex-1 min-h-[120px]">
{dist.globalDistribution.map((bucket) => {
const heightPct = (bucket.count / maxCount) * 100
return (

View File

@@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { motion } from 'motion/react'
import {
@@ -10,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
TooltipContent,
@@ -19,6 +21,7 @@ import {
import { StatusBadge } from '@/components/shared/status-badge'
import { cn, formatEnumLabel, daysUntil } from '@/lib/utils'
import { roundTypeConfig, projectStateConfig } from '@/lib/round-config'
import { trpc } from '@/lib/trpc/client'
export type PipelineRound = {
id: string
@@ -138,6 +141,80 @@ function ProjectStateBar({
)
}
function EvaluationRoundContent({ round }: { round: PipelineRound }) {
const [showAll, setShowAll] = useState(false)
const { data: workload, isLoading: isLoadingWorkload } = trpc.analytics.getJurorWorkload.useQuery(
{ roundId: round.id },
{ enabled: round.roundType === 'EVALUATION' }
)
const pct =
round.evalTotal > 0
? Math.round((round.evalSubmitted / round.evalTotal) * 100)
: 0
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Evaluation progress</span>
<span className="font-medium">
{round.evalSubmitted} / {round.evalTotal} ({pct}%)
</span>
</div>
<Progress value={pct} gradient />
{round.evalDraft > 0 && (
<p className="text-xs text-amber-600">
{round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress
</p>
)}
{/* Per-juror progress */}
<div className="mt-3 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Jury Progress</span>
{workload && workload.length > 8 && (
<button
onClick={() => setShowAll(!showAll)}
className="text-xs text-primary hover:underline"
>
{showAll ? 'Show less' : `Show all (${workload.length})`}
</button>
)}
</div>
{isLoadingWorkload ? (
<div className="space-y-1">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
) : workload && workload.length > 0 ? (
<div className="space-y-1">
{(showAll ? workload : workload.slice(0, 8)).map((juror) => {
const pct = juror.assigned > 0 ? (juror.completed / juror.assigned) * 100 : 0
return (
<div key={juror.id} className="flex items-center gap-2">
<span className="max-w-[140px] truncate text-xs">{juror.name}</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<span className="whitespace-nowrap text-xs text-muted-foreground">
{juror.completed}/{juror.assigned}
</span>
</div>
)
})}
</div>
) : (
<p className="text-xs text-muted-foreground">No jurors assigned yet</p>
)}
</div>
</div>
)
}
function RoundTypeContent({ round }: { round: PipelineRound }) {
const { projectStates } = round
@@ -171,29 +248,8 @@ function RoundTypeContent({ round }: { round: PipelineRound }) {
)
}
case 'EVALUATION': {
const pct =
round.evalTotal > 0
? Math.round((round.evalSubmitted / round.evalTotal) * 100)
: 0
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Evaluation progress</span>
<span className="font-medium">
{round.evalSubmitted} / {round.evalTotal} ({pct}%)
</span>
</div>
<Progress value={pct} gradient />
{round.evalDraft > 0 && (
<p className="text-xs text-amber-600">
{round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress
</p>
)}
</div>
)
}
case 'EVALUATION':
return <EvaluationRoundContent round={round} />
case 'SUBMISSION':
return (

View File

@@ -35,7 +35,10 @@ import {
LayoutTemplate,
Layers,
Scale,
Eye,
ArrowRightLeft,
} from 'lucide-react'
import type { UserRole } from '@prisma/client'
import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
import { EditionSelector } from '@/components/shared/edition-selector'
@@ -147,12 +150,21 @@ const roleLabels: Record<string, string> = {
PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member',
OBSERVER: 'Observer',
MENTOR: 'Mentor',
AWARD_MASTER: 'Award Master',
}
// Role switcher config — maps roles to their dashboard views
const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: typeof LayoutDashboard }> = {
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 },
}
export function AdminSidebar({ user }: AdminSidebarProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { status: sessionStatus } = useSession()
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
@@ -162,6 +174,12 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
const isSuperAdmin = user.role === 'SUPER_ADMIN'
const roleLabel = roleLabels[user.role || ''] || 'User'
// Roles the user can switch to (non-admin roles they hold)
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS).filter(
([role]) => userRoles.includes(role as UserRole)
)
// Build dynamic admin nav with current edition's apply page
const dynamicAdminNav = adminNavigation.map((item) => {
if (item.name === 'Apply Page' && currentEdition?.id) {
@@ -344,6 +362,29 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
</Link>
</DropdownMenuItem>
{switchableRoles.length > 0 && (
<>
<DropdownMenuSeparator className="my-1" />
<div className="px-2 py-1.5">
<p className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60">
<ArrowRightLeft className="h-3 w-3" />
Switch View
</p>
</div>
{switchableRoles.map(([, opt]) => (
<DropdownMenuItem key={opt.path} asChild>
<Link
href={opt.path as Route}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
>
<opt.icon className="h-4 w-4 text-muted-foreground" />
<span>{opt.label}</span>
</Link>
</DropdownMenuItem>
))}
</>
)}
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem

View File

@@ -17,7 +17,11 @@ import {
} from '@/components/ui/dropdown-menu'
import type { Route } from 'next'
import type { LucideIcon } from 'lucide-react'
import { LogOut, Menu, Moon, Settings, Sun, User, X } from 'lucide-react'
import {
LogOut, Menu, Moon, Settings, Sun, User, X,
LayoutDashboard, Scale, Handshake, Eye, ArrowRightLeft,
} from 'lucide-react'
import type { UserRole } from '@prisma/client'
import { useTheme } from 'next-themes'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
@@ -45,6 +49,15 @@ type RoleNavProps = {
editionSelector?: React.ReactNode
}
// Role switcher config — maps roles to their dashboard views
const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: typeof LayoutDashboard }> = {
SUPER_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard },
PROGRAM_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard },
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 },
}
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
return pathname === href || (href !== basePath && pathname.startsWith(href))
}
@@ -52,7 +65,7 @@ function isNavItemActive(pathname: string, href: string, basePath: string): bool
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector }: RoleNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { status: sessionStatus } = useSession()
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
@@ -61,6 +74,13 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
// Roles the user can switch to (excluding current view)
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS)
.filter(([role, opt]) => userRoles.includes(role as UserRole) && opt.path !== basePath)
// Deduplicate admin paths (SUPER_ADMIN and PROGRAM_ADMIN both go to /admin)
.filter((entry, i, arr) => arr.findIndex(([, o]) => o.path === entry[1].path) === i)
return (
<header className="sticky top-0 z-40 border-b bg-card">
<div className="container-app">
@@ -136,6 +156,19 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
Settings
</Link>
</DropdownMenuItem>
{switchableRoles.length > 0 && (
<>
<DropdownMenuSeparator />
{switchableRoles.map(([, opt]) => (
<DropdownMenuItem key={opt.path} asChild>
<Link href={opt.path as Route} className="flex cursor-pointer items-center">
<opt.icon className="mr-2 h-4 w-4" />
{opt.label}
</Link>
</DropdownMenuItem>
))}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
@@ -198,6 +231,25 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
{editionSelector}
</div>
)}
{switchableRoles.length > 0 && (
<div className="border-t pt-4 mt-4 space-y-1">
<p className="flex items-center gap-1.5 px-3 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60">
<ArrowRightLeft className="h-3 w-3" />
Switch View
</p>
{switchableRoles.map(([, opt]) => (
<Link
key={opt.path}
href={opt.path as Route}
onClick={() => setIsMobileMenuOpen(false)}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<opt.icon className="h-4 w-4" />
{opt.label}
</Link>
))}
</div>
)}
<div className="border-t pt-4 mt-4">
<Button
variant="ghost"

View File

@@ -19,10 +19,11 @@ export async function requireRole(...allowedRoles: UserRole[]) {
redirect('/login')
}
const userRole = session.user.role
// Use roles array, fallback to [role] for stale JWT tokens
const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
if (!allowedRoles.includes(userRole)) {
const dashboard = ROLE_DASHBOARDS[userRole]
if (!allowedRoles.some(r => userRoles.includes(r))) {
const dashboard = ROLE_DASHBOARDS[session.user.role]
redirect((dashboard || '/login') as Route)
}

View File

@@ -9,12 +9,14 @@ declare module 'next-auth' {
email: string
name?: string | null
role: UserRole
roles: UserRole[]
mustSetPassword?: boolean
}
}
interface User {
role?: UserRole
roles?: UserRole[]
mustSetPassword?: boolean
}
}
@@ -23,6 +25,7 @@ declare module '@auth/core/jwt' {
interface JWT {
id: string
role: UserRole
roles?: UserRole[]
mustSetPassword?: boolean
}
}

View File

@@ -54,6 +54,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: true,
name: true,
role: true,
roles: true,
status: true,
inviteTokenExpiresAt: true,
},
@@ -95,6 +96,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: user.email,
name: user.name,
role: user.role,
roles: user.roles.length ? user.roles : [user.role],
mustSetPassword: true,
}
}
@@ -120,6 +122,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: true,
name: true,
role: true,
roles: true,
status: true,
passwordHash: true,
mustSetPassword: true,
@@ -183,6 +186,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: user.email,
name: user.name,
role: user.role,
roles: user.roles.length ? user.roles : [user.role],
mustSetPassword: user.mustSetPassword,
}
},
@@ -195,6 +199,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (user) {
token.id = user.id as string
token.role = user.role as UserRole
token.roles = user.roles?.length ? user.roles : [user.role as UserRole]
token.mustSetPassword = user.mustSetPassword
}
@@ -202,10 +207,11 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (trigger === 'update') {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
select: { role: true, mustSetPassword: true },
select: { role: true, roles: true, mustSetPassword: true },
})
if (dbUser) {
token.role = dbUser.role
token.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
token.mustSetPassword = dbUser.mustSetPassword
}
}
@@ -216,6 +222,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (token && session.user) {
session.user.id = token.id as string
session.user.role = token.role as UserRole
session.user.roles = (token.roles as UserRole[]) ?? [token.role as UserRole]
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
}
return session
@@ -231,6 +238,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
passwordHash: true,
mustSetPassword: true,
role: true,
roles: true,
},
})
@@ -250,6 +258,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (dbUser) {
user.id = dbUser.id
user.role = dbUser.role
user.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash
}
}
@@ -309,7 +318,9 @@ export async function requireAuth() {
// Helper to require specific role(s)
export async function requireRole(...roles: UserRole[]) {
const session = await requireAuth()
if (!roles.includes(session.user.role)) {
// Use roles array, fallback to [role] for stale JWT tokens
const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
if (!roles.some(r => userRoles.includes(r))) {
throw new Error('Forbidden')
}
return session

View File

@@ -743,7 +743,7 @@ export const analyticsRouter = router({
select: { userId: true },
distinct: ['userId'],
}).then((rows) => rows.length)
: ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }),
: ctx.prisma.user.count({ where: { roles: { has: 'JURY_MEMBER' }, status: 'ACTIVE' } }),
ctx.prisma.evaluation.count({ where: evalFilter }),
ctx.prisma.assignment.count({ where: assignmentFilter }),
ctx.prisma.evaluation.findMany({

View File

@@ -1,6 +1,6 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { router, protectedProcedure, adminProcedure, userHasRole } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
import {
generateAIAssignments,
@@ -114,7 +114,7 @@ export async function reassignAfterCOI(params: {
? await prisma.user.findMany({
where: {
id: { in: activeRoundJurorIds },
role: 'JURY_MEMBER',
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
},
select: { id: true, name: true, email: true, maxAssignments: true },
@@ -340,7 +340,7 @@ async function reassignDroppedJurorAssignments(params: {
? await prisma.user.findMany({
where: {
id: { in: activeRoundJurorIds },
role: 'JURY_MEMBER',
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
},
select: { id: true, name: true, email: true, maxAssignments: true },
@@ -627,7 +627,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
const jurors = await prisma.user.findMany({
where: {
role: 'JURY_MEMBER',
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
},
@@ -899,7 +899,7 @@ export const assignmentRouter = router({
// Verify access
if (
ctx.user.role === 'JURY_MEMBER' &&
userHasRole(ctx.user, 'JURY_MEMBER') &&
assignment.userId !== ctx.user.id
) {
throw new TRPCError({
@@ -1322,7 +1322,7 @@ export const assignmentRouter = router({
const jurors = await ctx.prisma.user.findMany({
where: {
role: 'JURY_MEMBER',
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
},
@@ -2199,7 +2199,7 @@ export const assignmentRouter = router({
const ids = roundJurorIds.map((a) => a.userId).filter((id) => id !== input.jurorId)
candidateJurors = ids.length > 0
? await ctx.prisma.user.findMany({
where: { id: { in: ids }, role: 'JURY_MEMBER', status: 'ACTIVE' },
where: { id: { in: ids }, roles: { has: 'JURY_MEMBER' }, status: 'ACTIVE' },
select: { id: true, name: true, email: true, maxAssignments: true },
})
: []
@@ -2427,7 +2427,7 @@ export const assignmentRouter = router({
? await ctx.prisma.user.findMany({
where: {
id: { in: activeRoundJurorIds },
role: 'JURY_MEMBER',
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
},
select: { id: true, name: true, email: true, maxAssignments: true },
@@ -2890,7 +2890,7 @@ export const assignmentRouter = router({
? await ctx.prisma.user.findMany({
where: {
id: { in: activeRoundJurorIds },
role: 'JURY_MEMBER',
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
},
select: { id: true, name: true, email: true, maxAssignments: true },

View File

@@ -185,7 +185,7 @@ export const dashboardRouter = router({
// 9. Total jurors
ctx.prisma.user.count({
where: {
role: 'JURY_MEMBER',
roles: { has: 'JURY_MEMBER' },
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
assignments: { some: { round: { competition: { programId: editionId } } } },
},
@@ -194,7 +194,7 @@ export const dashboardRouter = router({
// 10. Active jurors
ctx.prisma.user.count({
where: {
role: 'JURY_MEMBER',
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
assignments: { some: { round: { competition: { programId: editionId } } } },
},

View File

@@ -1,6 +1,6 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure, juryProcedure } from '../trpc'
import { router, protectedProcedure, adminProcedure, juryProcedure, userHasRole } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
import { reassignAfterCOI } from './assignment'
@@ -20,7 +20,7 @@ export const evaluationRouter = router({
})
if (
ctx.user.role === 'JURY_MEMBER' &&
userHasRole(ctx.user, 'JURY_MEMBER') &&
assignment.userId !== ctx.user.id
) {
throw new TRPCError({ code: 'FORBIDDEN' })

View File

@@ -2,7 +2,7 @@ import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { router, protectedProcedure, adminProcedure, userHasRole } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
import {
notifyProjectTeam,
@@ -133,7 +133,7 @@ export const projectRouter = router({
}
// Jury members can only see assigned projects
if (ctx.user.role === 'JURY_MEMBER') {
if (userHasRole(ctx.user, 'JURY_MEMBER')) {
where.assignments = {
...((where.assignments as Record<string, unknown>) || {}),
some: { userId: ctx.user.id },
@@ -428,7 +428,7 @@ export const projectRouter = router({
}
// Check access for jury members
if (ctx.user.role === 'JURY_MEMBER') {
if (userHasRole(ctx.user, 'JURY_MEMBER')) {
const assignment = await ctx.prisma.assignment.findFirst({
where: {
projectId: input.id,

View File

@@ -2,6 +2,7 @@ import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import type { Prisma } from '@prisma/client'
import { UserRole } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
@@ -275,6 +276,7 @@ export const userRouter = router({
email: true,
name: true,
role: true,
roles: true,
status: true,
expertiseTags: true,
maxAssignments: true,
@@ -929,7 +931,7 @@ export const userRouter = router({
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
role: 'JURY_MEMBER',
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
}
@@ -1525,4 +1527,29 @@ export const userRouter = router({
globalDigestSections: digestSections?.value ? JSON.parse(digestSections.value) : [],
}
}),
/**
* Update a user's roles array (admin only)
* Also updates the primary role to the highest privilege role in the array.
*/
updateRoles: adminProcedure
.input(z.object({
userId: z.string(),
roles: z.array(z.nativeEnum(UserRole)).min(1),
}))
.mutation(async ({ ctx, input }) => {
// Guard: only SUPER_ADMIN can grant SUPER_ADMIN
if (input.roles.includes('SUPER_ADMIN') && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can grant super admin role' })
}
// Set primary role to highest privilege role
const rolePriority: UserRole[] = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'AWARD_MASTER', 'APPLICANT', 'AUDIENCE']
const primaryRole = rolePriority.find(r => input.roles.includes(r)) || input.roles[0]
return ctx.prisma.user.update({
where: { id: input.userId },
data: { roles: input.roles, role: primaryRole },
})
}),
})

View File

@@ -229,7 +229,7 @@ export async function getAIMentorSuggestionsBatch(
where: {
OR: [
{ expertiseTags: { isEmpty: false } },
{ role: 'JURY_MEMBER' },
{ roles: { has: 'JURY_MEMBER' } },
],
status: 'ACTIVE',
},
@@ -455,7 +455,7 @@ export async function getRoundRobinMentor(
where: {
OR: [
{ expertiseTags: { isEmpty: false } },
{ role: 'JURY_MEMBER' },
{ roles: { has: 'JURY_MEMBER' } },
],
status: 'ACTIVE',
id: { notIn: excludeMentorIds },

View File

@@ -52,6 +52,15 @@ const isAuthenticated = middleware(async ({ ctx, next }) => {
})
})
/**
* Helper to check if a user has any of the specified roles.
* Checks the roles array first, falls back to [role] for stale JWT tokens.
*/
export function userHasRole(user: { role: UserRole; roles?: UserRole[] }, ...checkRoles: UserRole[]): boolean {
const userRoles = user.roles?.length ? user.roles : [user.role]
return checkRoles.some(r => userRoles.includes(r))
}
/**
* Middleware to require specific role(s)
*/
@@ -64,7 +73,12 @@ const hasRole = (...roles: UserRole[]) =>
})
}
if (!roles.includes(ctx.session.user.role)) {
// Use roles array, fallback to [role] for stale JWT tokens
const userRoles = ctx.session.user.roles?.length
? ctx.session.user.roles
: [ctx.session.user.role]
if (!roles.some(r => userRoles.includes(r))) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have permission to perform this action',