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
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:
@@ -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";
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (e.g., Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ model User {
|
|||||||
name String?
|
name String?
|
||||||
emailVerified DateTime? // Required by NextAuth Prisma adapter
|
emailVerified DateTime? // Required by NextAuth Prisma adapter
|
||||||
role UserRole @default(JURY_MEMBER)
|
role UserRole @default(JURY_MEMBER)
|
||||||
|
roles UserRole[] @default([])
|
||||||
status UserStatus @default(INVITED)
|
status UserStatus @default(INVITED)
|
||||||
expertiseTags String[] @default([])
|
expertiseTags String[] @default([])
|
||||||
maxAssignments Int? // Per-round limit
|
maxAssignments Int? // Per-round limit
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ async function main() {
|
|||||||
email: account.email,
|
email: account.email,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
role: account.role,
|
role: account.role,
|
||||||
|
roles: [account.role],
|
||||||
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE,
|
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE,
|
||||||
passwordHash: isSuperAdmin ? passwordHash : null,
|
passwordHash: isSuperAdmin ? passwordHash : null,
|
||||||
mustSetPassword: !isSuperAdmin,
|
mustSetPassword: !isSuperAdmin,
|
||||||
@@ -385,6 +386,7 @@ async function main() {
|
|||||||
email: j.email,
|
email: j.email,
|
||||||
name: j.name,
|
name: j.name,
|
||||||
role: UserRole.JURY_MEMBER,
|
role: UserRole.JURY_MEMBER,
|
||||||
|
roles: [UserRole.JURY_MEMBER],
|
||||||
status: UserStatus.NONE,
|
status: UserStatus.NONE,
|
||||||
country: j.country,
|
country: j.country,
|
||||||
expertiseTags: j.tags,
|
expertiseTags: j.tags,
|
||||||
@@ -416,6 +418,7 @@ async function main() {
|
|||||||
email: m.email,
|
email: m.email,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
role: UserRole.MENTOR,
|
role: UserRole.MENTOR,
|
||||||
|
roles: [UserRole.MENTOR],
|
||||||
status: UserStatus.NONE,
|
status: UserStatus.NONE,
|
||||||
country: m.country,
|
country: m.country,
|
||||||
expertiseTags: m.tags,
|
expertiseTags: m.tags,
|
||||||
@@ -444,6 +447,7 @@ async function main() {
|
|||||||
email: o.email,
|
email: o.email,
|
||||||
name: o.name,
|
name: o.name,
|
||||||
role: UserRole.OBSERVER,
|
role: UserRole.OBSERVER,
|
||||||
|
roles: [UserRole.OBSERVER],
|
||||||
status: UserStatus.NONE,
|
status: UserStatus.NONE,
|
||||||
country: o.country,
|
country: o.country,
|
||||||
},
|
},
|
||||||
@@ -545,6 +549,7 @@ async function main() {
|
|||||||
email,
|
email,
|
||||||
name: name || `Applicant ${rowIdx + 1}`,
|
name: name || `Applicant ${rowIdx + 1}`,
|
||||||
role: UserRole.APPLICANT,
|
role: UserRole.APPLICANT,
|
||||||
|
roles: [UserRole.APPLICANT],
|
||||||
status: UserStatus.NONE,
|
status: UserStatus.NONE,
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
country,
|
country,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
const evaluationIdRef = useRef<string | null>(null)
|
const evaluationIdRef = useRef<string | null>(null)
|
||||||
const isSubmittedRef = useRef(false)
|
const isSubmittedRef = useRef(false)
|
||||||
const isSubmittingRef = useRef(false)
|
const isSubmittingRef = useRef(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
|
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
|
||||||
|
|
||||||
@@ -318,10 +319,12 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
autosaveTimerRef.current = null
|
autosaveTimerRef.current = null
|
||||||
}
|
}
|
||||||
isSubmittingRef.current = true
|
isSubmittingRef.current = true
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
if (!myAssignment) {
|
if (!myAssignment) {
|
||||||
toast.error('Assignment not found')
|
toast.error('Assignment not found')
|
||||||
isSubmittingRef.current = false
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,16 +338,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
if (c.type === 'numeric' && (val === undefined || val === null)) {
|
if (c.type === 'numeric' && (val === undefined || val === null)) {
|
||||||
toast.error(`Please score "${c.label}"`)
|
toast.error(`Please score "${c.label}"`)
|
||||||
isSubmittingRef.current = false
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (c.type === 'boolean' && val === undefined) {
|
if (c.type === 'boolean' && val === undefined) {
|
||||||
toast.error(`Please answer "${c.label}"`)
|
toast.error(`Please answer "${c.label}"`)
|
||||||
isSubmittingRef.current = false
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
|
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
|
||||||
toast.error(`Please fill in "${c.label}"`)
|
toast.error(`Please fill in "${c.label}"`)
|
||||||
isSubmittingRef.current = false
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,6 +361,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
if (isNaN(score) || score < 1 || score > 10) {
|
if (isNaN(score) || score < 1 || score > 10) {
|
||||||
toast.error('Please enter a valid score between 1 and 10')
|
toast.error('Please enter a valid score between 1 and 10')
|
||||||
isSubmittingRef.current = false
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,6 +370,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
if (!binaryDecision) {
|
if (!binaryDecision) {
|
||||||
toast.error('Please select accept or reject')
|
toast.error('Please select accept or reject')
|
||||||
isSubmittingRef.current = false
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,6 +379,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) {
|
if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) {
|
||||||
toast.error(`Please provide feedback (minimum ${feedbackMinLength} characters)`)
|
toast.error(`Please provide feedback (minimum ${feedbackMinLength} characters)`)
|
||||||
isSubmittingRef.current = false
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,6 +423,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
} catch {
|
} catch {
|
||||||
// Error toast already handled by onError callback
|
// Error toast already handled by onError callback
|
||||||
isSubmittingRef.current = false
|
isSubmittingRef.current = false
|
||||||
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,7 +888,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={submitMutation.isPending || startMutation.isPending}
|
disabled={submitMutation.isPending || isSubmitting}
|
||||||
className="bg-brand-blue hover:bg-brand-blue-light"
|
className="bg-brand-blue hover:bg-brand-blue-light"
|
||||||
>
|
>
|
||||||
<Send className="mr-2 h-4 w-4" />
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export default async function MentorLayout({
|
|||||||
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
|
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
|
||||||
|
|
||||||
// Check if user has completed onboarding (for mentors)
|
// 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({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
select: { onboardingCompletedAt: true },
|
select: { onboardingCompletedAt: true },
|
||||||
|
|||||||
@@ -13,14 +13,44 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} 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 { 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 = {
|
export type JuryProgressTableProps = {
|
||||||
roundId: string
|
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 utils = trpc.useUtils()
|
||||||
const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
|
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),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasMembersData = members !== undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Jury Progress</CardTitle>
|
<div className="flex items-center justify-between">
|
||||||
<CardDescription>Evaluation completion per juror. Click the mail icon to notify an individual juror.</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -65,11 +113,28 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
|
|||||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||||
</div>
|
</div>
|
||||||
) : !workload || workload.length === 0 ? (
|
) : !workload || workload.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-6">
|
hasMembersData && members && members.length > 0 ? (
|
||||||
No assignments yet
|
// Show members-only view when there are members but no assignments yet
|
||||||
</p>
|
<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>
|
||||||
|
) : (
|
||||||
|
<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-[350px] overflow-y-auto">
|
<div className="space-y-3 max-h-[500px] overflow-y-auto overflow-x-hidden">
|
||||||
{workload.map((juror) => {
|
{workload.map((juror) => {
|
||||||
const pct = juror.completionRate
|
const pct = juror.completionRate
|
||||||
const barGradient = pct === 100
|
const barGradient = pct === 100
|
||||||
@@ -80,11 +145,23 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
|
|||||||
? 'bg-gradient-to-r from-amber-400 to-amber-600'
|
? 'bg-gradient-to-r from-amber-400 to-amber-600'
|
||||||
: 'bg-gray-300'
|
: 'bg-gray-300'
|
||||||
|
|
||||||
|
// Find the corresponding member entry for cap editing
|
||||||
|
const member = members?.find((m) => m.userId === juror.id)
|
||||||
|
|
||||||
return (
|
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 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">
|
<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">
|
<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">
|
<span className="text-muted-foreground tabular-nums">
|
||||||
{juror.completed}/{juror.assigned} ({pct}%)
|
{juror.completed}/{juror.assigned} ({pct}%)
|
||||||
</span>
|
</span>
|
||||||
@@ -151,6 +228,37 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
|
|||||||
<TooltipContent side="left"><p>Drop juror + reshuffle pending projects</p></TooltipContent>
|
<TooltipContent side="left"><p>Drop juror + reshuffle pending projects</p></TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</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>
|
</div>
|
||||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,39 +1,30 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { History, ChevronRight } from 'lucide-react'
|
import { History } from 'lucide-react'
|
||||||
|
|
||||||
export type ReassignmentHistoryProps = {
|
export type ReassignmentHistoryProps = {
|
||||||
roundId: string
|
roundId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
|
export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery(
|
const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ enabled: expanded },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader>
|
||||||
className="cursor-pointer select-none"
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
>
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<History className="h-4 w-4" />
|
<History className="h-4 w-4" />
|
||||||
Reassignment History
|
Reassignment History
|
||||||
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', expanded && 'rotate-90')} />
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription>
|
<CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{expanded && (
|
<CardContent>
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[1, 2].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
{[1, 2].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
||||||
@@ -105,7 +96,6 @@ export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -364,9 +364,16 @@ export function MembersContent() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={roleColors[user.role] || 'secondary'}>
|
<div className="flex flex-wrap gap-1">
|
||||||
{user.role.replace(/_/g, ' ')}
|
{((user as unknown as { roles?: string[] }).roles?.length
|
||||||
</Badge>
|
? (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>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{user.expertiseTags && user.expertiseTags.length > 0 ? (
|
{user.expertiseTags && user.expertiseTags.length > 0 ? (
|
||||||
@@ -469,9 +476,16 @@ export function MembersContent() {
|
|||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Role</span>
|
<span className="text-muted-foreground">Role</span>
|
||||||
<Badge variant={roleColors[user.role] || 'secondary'}>
|
<div className="flex flex-wrap gap-1 justify-end">
|
||||||
{user.role.replace(/_/g, ' ')}
|
{((user as unknown as { roles?: string[] }).roles?.length
|
||||||
</Badge>
|
? (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>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Assignments</span>
|
<span className="text-muted-foreground">Assignments</span>
|
||||||
|
|||||||
@@ -21,16 +21,16 @@ export function ScoreDistribution({ roundId }: ScoreDistributionProps) {
|
|||||||
[dist])
|
[dist])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="flex flex-col">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Score Distribution</CardTitle>
|
<CardTitle className="text-base">Score Distribution</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'}
|
{dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex flex-col flex-1 pb-4">
|
||||||
{isLoading ? (
|
{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" />)}
|
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="flex-1 h-full" />)}
|
||||||
</div>
|
</div>
|
||||||
) : !dist || dist.totalEvaluations === 0 ? (
|
) : !dist || dist.totalEvaluations === 0 ? (
|
||||||
@@ -38,7 +38,7 @@ export function ScoreDistribution({ roundId }: ScoreDistributionProps) {
|
|||||||
No evaluations submitted yet
|
No evaluations submitted yet
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-1 h-32">
|
<div className="flex gap-1 flex-1 min-h-[120px]">
|
||||||
{dist.globalDistribution.map((bucket) => {
|
{dist.globalDistribution.map((bucket) => {
|
||||||
const heightPct = (bucket.count / maxCount) * 100
|
const heightPct = (bucket.count / maxCount) * 100
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
import { StatusBadge } from '@/components/shared/status-badge'
|
import { StatusBadge } from '@/components/shared/status-badge'
|
||||||
import { cn, formatEnumLabel, daysUntil } from '@/lib/utils'
|
import { cn, formatEnumLabel, daysUntil } from '@/lib/utils'
|
||||||
import { roundTypeConfig, projectStateConfig } from '@/lib/round-config'
|
import { roundTypeConfig, projectStateConfig } from '@/lib/round-config'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
|
||||||
export type PipelineRound = {
|
export type PipelineRound = {
|
||||||
id: string
|
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 }) {
|
function RoundTypeContent({ round }: { round: PipelineRound }) {
|
||||||
const { projectStates } = round
|
const { projectStates } = round
|
||||||
|
|
||||||
@@ -171,29 +248,8 @@ function RoundTypeContent({ round }: { round: PipelineRound }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'EVALUATION': {
|
case 'EVALUATION':
|
||||||
const pct =
|
return <EvaluationRoundContent round={round} />
|
||||||
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 'SUBMISSION':
|
case 'SUBMISSION':
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ import {
|
|||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
Layers,
|
Layers,
|
||||||
Scale,
|
Scale,
|
||||||
|
Eye,
|
||||||
|
ArrowRightLeft,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import type { UserRole } from '@prisma/client'
|
||||||
import { getInitials } from '@/lib/utils'
|
import { getInitials } from '@/lib/utils'
|
||||||
import { Logo } from '@/components/shared/logo'
|
import { Logo } from '@/components/shared/logo'
|
||||||
import { EditionSelector } from '@/components/shared/edition-selector'
|
import { EditionSelector } from '@/components/shared/edition-selector'
|
||||||
@@ -147,12 +150,21 @@ const roleLabels: Record<string, string> = {
|
|||||||
PROGRAM_ADMIN: 'Program Admin',
|
PROGRAM_ADMIN: 'Program Admin',
|
||||||
JURY_MEMBER: 'Jury Member',
|
JURY_MEMBER: 'Jury Member',
|
||||||
OBSERVER: 'Observer',
|
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) {
|
export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||||
const { status: sessionStatus } = useSession()
|
const { data: session, status: sessionStatus } = useSession()
|
||||||
const isAuthenticated = sessionStatus === 'authenticated'
|
const isAuthenticated = sessionStatus === 'authenticated'
|
||||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
@@ -162,6 +174,12 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||||||
const isSuperAdmin = user.role === 'SUPER_ADMIN'
|
const isSuperAdmin = user.role === 'SUPER_ADMIN'
|
||||||
const roleLabel = roleLabels[user.role || ''] || 'User'
|
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
|
// Build dynamic admin nav with current edition's apply page
|
||||||
const dynamicAdminNav = adminNavigation.map((item) => {
|
const dynamicAdminNav = adminNavigation.map((item) => {
|
||||||
if (item.name === 'Apply Page' && currentEdition?.id) {
|
if (item.name === 'Apply Page' && currentEdition?.id) {
|
||||||
@@ -344,6 +362,29 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</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" />
|
<DropdownMenuSeparator className="my-1" />
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
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 { useTheme } from 'next-themes'
|
||||||
import { Logo } from '@/components/shared/logo'
|
import { Logo } from '@/components/shared/logo'
|
||||||
import { NotificationBell } from '@/components/shared/notification-bell'
|
import { NotificationBell } from '@/components/shared/notification-bell'
|
||||||
@@ -45,6 +49,15 @@ type RoleNavProps = {
|
|||||||
editionSelector?: React.ReactNode
|
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 {
|
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
|
||||||
return pathname === href || (href !== basePath && pathname.startsWith(href))
|
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) {
|
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector }: RoleNavProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||||
const { status: sessionStatus } = useSession()
|
const { data: session, status: sessionStatus } = useSession()
|
||||||
const isAuthenticated = sessionStatus === 'authenticated'
|
const isAuthenticated = sessionStatus === 'authenticated'
|
||||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
@@ -61,6 +74,13 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
useEffect(() => setMounted(true), [])
|
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 (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b bg-card">
|
<header className="sticky top-0 z-40 border-b bg-card">
|
||||||
<div className="container-app">
|
<div className="container-app">
|
||||||
@@ -136,6 +156,19 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
Settings
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</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 />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||||
@@ -198,6 +231,25 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
{editionSelector}
|
{editionSelector}
|
||||||
</div>
|
</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">
|
<div className="border-t pt-4 mt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ export async function requireRole(...allowedRoles: UserRole[]) {
|
|||||||
redirect('/login')
|
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)) {
|
if (!allowedRoles.some(r => userRoles.includes(r))) {
|
||||||
const dashboard = ROLE_DASHBOARDS[userRole]
|
const dashboard = ROLE_DASHBOARDS[session.user.role]
|
||||||
redirect((dashboard || '/login') as Route)
|
redirect((dashboard || '/login') as Route)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ declare module 'next-auth' {
|
|||||||
email: string
|
email: string
|
||||||
name?: string | null
|
name?: string | null
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
roles: UserRole[]
|
||||||
mustSetPassword?: boolean
|
mustSetPassword?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
role?: UserRole
|
role?: UserRole
|
||||||
|
roles?: UserRole[]
|
||||||
mustSetPassword?: boolean
|
mustSetPassword?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +25,7 @@ declare module '@auth/core/jwt' {
|
|||||||
interface JWT {
|
interface JWT {
|
||||||
id: string
|
id: string
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
roles?: UserRole[]
|
||||||
mustSetPassword?: boolean
|
mustSetPassword?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
role: true,
|
role: true,
|
||||||
|
roles: true,
|
||||||
status: true,
|
status: true,
|
||||||
inviteTokenExpiresAt: true,
|
inviteTokenExpiresAt: true,
|
||||||
},
|
},
|
||||||
@@ -95,6 +96,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
roles: user.roles.length ? user.roles : [user.role],
|
||||||
mustSetPassword: true,
|
mustSetPassword: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +122,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
role: true,
|
role: true,
|
||||||
|
roles: true,
|
||||||
status: true,
|
status: true,
|
||||||
passwordHash: true,
|
passwordHash: true,
|
||||||
mustSetPassword: true,
|
mustSetPassword: true,
|
||||||
@@ -183,6 +186,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
roles: user.roles.length ? user.roles : [user.role],
|
||||||
mustSetPassword: user.mustSetPassword,
|
mustSetPassword: user.mustSetPassword,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -195,6 +199,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
if (user) {
|
if (user) {
|
||||||
token.id = user.id as string
|
token.id = user.id as string
|
||||||
token.role = user.role as UserRole
|
token.role = user.role as UserRole
|
||||||
|
token.roles = user.roles?.length ? user.roles : [user.role as UserRole]
|
||||||
token.mustSetPassword = user.mustSetPassword
|
token.mustSetPassword = user.mustSetPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,10 +207,11 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
if (trigger === 'update') {
|
if (trigger === 'update') {
|
||||||
const dbUser = await prisma.user.findUnique({
|
const dbUser = await prisma.user.findUnique({
|
||||||
where: { id: token.id as string },
|
where: { id: token.id as string },
|
||||||
select: { role: true, mustSetPassword: true },
|
select: { role: true, roles: true, mustSetPassword: true },
|
||||||
})
|
})
|
||||||
if (dbUser) {
|
if (dbUser) {
|
||||||
token.role = dbUser.role
|
token.role = dbUser.role
|
||||||
|
token.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
|
||||||
token.mustSetPassword = dbUser.mustSetPassword
|
token.mustSetPassword = dbUser.mustSetPassword
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,6 +222,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
if (token && session.user) {
|
if (token && session.user) {
|
||||||
session.user.id = token.id as string
|
session.user.id = token.id as string
|
||||||
session.user.role = token.role as UserRole
|
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
|
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
|
||||||
}
|
}
|
||||||
return session
|
return session
|
||||||
@@ -231,6 +238,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
passwordHash: true,
|
passwordHash: true,
|
||||||
mustSetPassword: true,
|
mustSetPassword: true,
|
||||||
role: true,
|
role: true,
|
||||||
|
roles: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -250,6 +258,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
if (dbUser) {
|
if (dbUser) {
|
||||||
user.id = dbUser.id
|
user.id = dbUser.id
|
||||||
user.role = dbUser.role
|
user.role = dbUser.role
|
||||||
|
user.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role]
|
||||||
user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash
|
user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,7 +318,9 @@ export async function requireAuth() {
|
|||||||
// Helper to require specific role(s)
|
// Helper to require specific role(s)
|
||||||
export async function requireRole(...roles: UserRole[]) {
|
export async function requireRole(...roles: UserRole[]) {
|
||||||
const session = await requireAuth()
|
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')
|
throw new Error('Forbidden')
|
||||||
}
|
}
|
||||||
return session
|
return session
|
||||||
|
|||||||
@@ -743,7 +743,7 @@ export const analyticsRouter = router({
|
|||||||
select: { userId: true },
|
select: { userId: true },
|
||||||
distinct: ['userId'],
|
distinct: ['userId'],
|
||||||
}).then((rows) => rows.length)
|
}).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.evaluation.count({ where: evalFilter }),
|
||||||
ctx.prisma.assignment.count({ where: assignmentFilter }),
|
ctx.prisma.assignment.count({ where: assignmentFilter }),
|
||||||
ctx.prisma.evaluation.findMany({
|
ctx.prisma.evaluation.findMany({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
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 { getUserAvatarUrl } from '../utils/avatar-url'
|
||||||
import {
|
import {
|
||||||
generateAIAssignments,
|
generateAIAssignments,
|
||||||
@@ -114,7 +114,7 @@ export async function reassignAfterCOI(params: {
|
|||||||
? await prisma.user.findMany({
|
? await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: activeRoundJurorIds },
|
id: { in: activeRoundJurorIds },
|
||||||
role: 'JURY_MEMBER',
|
roles: { has: 'JURY_MEMBER' },
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
},
|
},
|
||||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||||
@@ -340,7 +340,7 @@ async function reassignDroppedJurorAssignments(params: {
|
|||||||
? await prisma.user.findMany({
|
? await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: activeRoundJurorIds },
|
id: { in: activeRoundJurorIds },
|
||||||
role: 'JURY_MEMBER',
|
roles: { has: 'JURY_MEMBER' },
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
},
|
},
|
||||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
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({
|
const jurors = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
role: 'JURY_MEMBER',
|
roles: { has: 'JURY_MEMBER' },
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||||
},
|
},
|
||||||
@@ -899,7 +899,7 @@ export const assignmentRouter = router({
|
|||||||
|
|
||||||
// Verify access
|
// Verify access
|
||||||
if (
|
if (
|
||||||
ctx.user.role === 'JURY_MEMBER' &&
|
userHasRole(ctx.user, 'JURY_MEMBER') &&
|
||||||
assignment.userId !== ctx.user.id
|
assignment.userId !== ctx.user.id
|
||||||
) {
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -1322,7 +1322,7 @@ export const assignmentRouter = router({
|
|||||||
|
|
||||||
const jurors = await ctx.prisma.user.findMany({
|
const jurors = await ctx.prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
role: 'JURY_MEMBER',
|
roles: { has: 'JURY_MEMBER' },
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||||
},
|
},
|
||||||
@@ -2199,7 +2199,7 @@ export const assignmentRouter = router({
|
|||||||
const ids = roundJurorIds.map((a) => a.userId).filter((id) => id !== input.jurorId)
|
const ids = roundJurorIds.map((a) => a.userId).filter((id) => id !== input.jurorId)
|
||||||
candidateJurors = ids.length > 0
|
candidateJurors = ids.length > 0
|
||||||
? await ctx.prisma.user.findMany({
|
? 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 },
|
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
@@ -2427,7 +2427,7 @@ export const assignmentRouter = router({
|
|||||||
? await ctx.prisma.user.findMany({
|
? await ctx.prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: activeRoundJurorIds },
|
id: { in: activeRoundJurorIds },
|
||||||
role: 'JURY_MEMBER',
|
roles: { has: 'JURY_MEMBER' },
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
},
|
},
|
||||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||||
@@ -2890,7 +2890,7 @@ export const assignmentRouter = router({
|
|||||||
? await ctx.prisma.user.findMany({
|
? await ctx.prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: activeRoundJurorIds },
|
id: { in: activeRoundJurorIds },
|
||||||
role: 'JURY_MEMBER',
|
roles: { has: 'JURY_MEMBER' },
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
},
|
},
|
||||||
select: { id: true, name: true, email: true, maxAssignments: true },
|
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export const dashboardRouter = router({
|
|||||||
// 9. Total jurors
|
// 9. Total jurors
|
||||||
ctx.prisma.user.count({
|
ctx.prisma.user.count({
|
||||||
where: {
|
where: {
|
||||||
role: 'JURY_MEMBER',
|
roles: { has: 'JURY_MEMBER' },
|
||||||
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
||||||
assignments: { some: { round: { competition: { programId: editionId } } } },
|
assignments: { some: { round: { competition: { programId: editionId } } } },
|
||||||
},
|
},
|
||||||
@@ -194,7 +194,7 @@ export const dashboardRouter = router({
|
|||||||
// 10. Active jurors
|
// 10. Active jurors
|
||||||
ctx.prisma.user.count({
|
ctx.prisma.user.count({
|
||||||
where: {
|
where: {
|
||||||
role: 'JURY_MEMBER',
|
roles: { has: 'JURY_MEMBER' },
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
assignments: { some: { round: { competition: { programId: editionId } } } },
|
assignments: { some: { round: { competition: { programId: editionId } } } },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
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 { logAudit } from '@/server/utils/audit'
|
||||||
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
|
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
|
||||||
import { reassignAfterCOI } from './assignment'
|
import { reassignAfterCOI } from './assignment'
|
||||||
@@ -20,7 +20,7 @@ export const evaluationRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
ctx.user.role === 'JURY_MEMBER' &&
|
userHasRole(ctx.user, 'JURY_MEMBER') &&
|
||||||
assignment.userId !== ctx.user.id
|
assignment.userId !== ctx.user.id
|
||||||
) {
|
) {
|
||||||
throw new TRPCError({ code: 'FORBIDDEN' })
|
throw new TRPCError({ code: 'FORBIDDEN' })
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import crypto from 'crypto'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure, userHasRole } from '../trpc'
|
||||||
import { getUserAvatarUrl } from '../utils/avatar-url'
|
import { getUserAvatarUrl } from '../utils/avatar-url'
|
||||||
import {
|
import {
|
||||||
notifyProjectTeam,
|
notifyProjectTeam,
|
||||||
@@ -133,7 +133,7 @@ export const projectRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Jury members can only see assigned projects
|
// Jury members can only see assigned projects
|
||||||
if (ctx.user.role === 'JURY_MEMBER') {
|
if (userHasRole(ctx.user, 'JURY_MEMBER')) {
|
||||||
where.assignments = {
|
where.assignments = {
|
||||||
...((where.assignments as Record<string, unknown>) || {}),
|
...((where.assignments as Record<string, unknown>) || {}),
|
||||||
some: { userId: ctx.user.id },
|
some: { userId: ctx.user.id },
|
||||||
@@ -428,7 +428,7 @@ export const projectRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check access for jury members
|
// Check access for jury members
|
||||||
if (ctx.user.role === 'JURY_MEMBER') {
|
if (userHasRole(ctx.user, 'JURY_MEMBER')) {
|
||||||
const assignment = await ctx.prisma.assignment.findFirst({
|
const assignment = await ctx.prisma.assignment.findFirst({
|
||||||
where: {
|
where: {
|
||||||
projectId: input.id,
|
projectId: input.id,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import crypto from 'crypto'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
import { UserRole } from '@prisma/client'
|
||||||
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
|
||||||
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
|
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
|
||||||
import { hashPassword, validatePassword } from '@/lib/password'
|
import { hashPassword, validatePassword } from '@/lib/password'
|
||||||
@@ -275,6 +276,7 @@ export const userRouter = router({
|
|||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
role: true,
|
role: true,
|
||||||
|
roles: true,
|
||||||
status: true,
|
status: true,
|
||||||
expertiseTags: true,
|
expertiseTags: true,
|
||||||
maxAssignments: true,
|
maxAssignments: true,
|
||||||
@@ -929,7 +931,7 @@ export const userRouter = router({
|
|||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
role: 'JURY_MEMBER',
|
roles: { has: 'JURY_MEMBER' },
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1525,4 +1527,29 @@ export const userRouter = router({
|
|||||||
globalDigestSections: digestSections?.value ? JSON.parse(digestSections.value) : [],
|
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 },
|
||||||
|
})
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export async function getAIMentorSuggestionsBatch(
|
|||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ expertiseTags: { isEmpty: false } },
|
{ expertiseTags: { isEmpty: false } },
|
||||||
{ role: 'JURY_MEMBER' },
|
{ roles: { has: 'JURY_MEMBER' } },
|
||||||
],
|
],
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
},
|
},
|
||||||
@@ -455,7 +455,7 @@ export async function getRoundRobinMentor(
|
|||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ expertiseTags: { isEmpty: false } },
|
{ expertiseTags: { isEmpty: false } },
|
||||||
{ role: 'JURY_MEMBER' },
|
{ roles: { has: 'JURY_MEMBER' } },
|
||||||
],
|
],
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
id: { notIn: excludeMentorIds },
|
id: { notIn: excludeMentorIds },
|
||||||
|
|||||||
@@ -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)
|
* 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({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: 'You do not have permission to perform this action',
|
message: 'You do not have permission to perform this action',
|
||||||
|
|||||||
Reference in New Issue
Block a user