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";
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
Play,
|
Play,
|
||||||
Square,
|
Square,
|
||||||
Archive,
|
Archive,
|
||||||
@@ -182,6 +183,7 @@ export default function RoundDetailPage() {
|
|||||||
const [nameValue, setNameValue] = useState('')
|
const [nameValue, setNameValue] = useState('')
|
||||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [statusConfirmAction, setStatusConfirmAction] = useState<'activate' | 'close' | 'reopen' | 'archive' | null>(null)
|
const [statusConfirmAction, setStatusConfirmAction] = useState<'activate' | 'close' | 'reopen' | 'archive' | null>(null)
|
||||||
|
const [coverageOpen, setCoverageOpen] = useState(false)
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
@@ -218,6 +220,12 @@ export default function RoundDetailPage() {
|
|||||||
)
|
)
|
||||||
const roundAwards = awards?.filter((a) => a.evaluationRoundId === roundId) ?? []
|
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)
|
// Filtering results stats (only for FILTERING rounds)
|
||||||
const { data: filteringStats } = trpc.filtering.getResultStats.useQuery(
|
const { data: filteringStats } = trpc.filtering.getResultStats.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
@@ -247,6 +255,13 @@ export default function RoundDetailPage() {
|
|||||||
[config, serverConfig],
|
[config, serverConfig],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Auto-open coverage section when no assignments exist yet
|
||||||
|
useEffect(() => {
|
||||||
|
if (juryWorkload && juryWorkload.length === 0) {
|
||||||
|
setCoverageOpen(true)
|
||||||
|
}
|
||||||
|
}, [juryWorkload])
|
||||||
|
|
||||||
// ── Mutations ──────────────────────────────────────────────────────────
|
// ── Mutations ──────────────────────────────────────────────────────────
|
||||||
const updateMutation = trpc.round.update.useMutation({
|
const updateMutation = trpc.round.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -494,9 +509,6 @@ export default function RoundDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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-10 w-full" />
|
||||||
<Skeleton className="h-96 w-full rounded-lg" />
|
<Skeleton className="h-96 w-full rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
@@ -776,6 +788,29 @@ export default function RoundDetailPage() {
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-white/60 mt-1">{typeCfg.description}</p>
|
<p className="text-sm text-white/60 mt-1">{typeCfg.description}</p>
|
||||||
|
{(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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -797,148 +832,6 @@ export default function RoundDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.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">—</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">—</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">—</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Admin selection</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ===== TABS — Underline style ===== */}
|
{/* ===== TABS — Underline style ===== */}
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||||
<div className="border-b overflow-x-auto">
|
<div className="border-b overflow-x-auto">
|
||||||
@@ -1544,99 +1437,6 @@ export default function RoundDetailPage() {
|
|||||||
{/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */}
|
{/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */}
|
||||||
{hasJury && !isEvaluation && (
|
{hasJury && !isEvaluation && (
|
||||||
<TabsContent value="jury" className="space-y-6">
|
<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 "{juryGroup?.name}"
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete jury group?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will permanently delete "{juryGroup?.name}" 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) */}
|
{/* Members list (only if a jury group is assigned) */}
|
||||||
{juryGroupDetail && (
|
{juryGroupDetail && (
|
||||||
@@ -1823,71 +1623,7 @@ export default function RoundDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create Jury Dialog */}
|
{/* Jury Group Selector (at bottom for non-evaluation jury rounds) */}
|
||||||
<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) ── */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -1931,7 +1667,6 @@ export default function RoundDetailPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* Delete button for currently selected jury group */}
|
|
||||||
{round.juryGroupId && (
|
{round.juryGroupId && (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
@@ -1981,174 +1716,94 @@ export default function RoundDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ── Members list (only if a jury group is assigned) ── */}
|
</TabsContent>
|
||||||
{juryGroupDetail && (
|
)}
|
||||||
|
|
||||||
|
{/* ═══════════ 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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">Assignments</CardTitle>
|
||||||
Members — {juryGroupDetail.name}
|
<CardDescription>Individual jury-project assignments and actions</CardDescription>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{juryGroupDetail.members.length} member{juryGroupDetail.members.length !== 1 ? 's' : ''}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setAddMemberOpen(true)}>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<UserPlus className="h-4 w-4 mr-1.5" />
|
<SendRemindersButton roundId={roundId} />
|
||||||
Add Member
|
<NotifyJurorsButton roundId={roundId} />
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
|
||||||
|
<Download className="h-4 w-4 mr-1.5" />
|
||||||
|
Export
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{juryGroupDetail.members.length === 0 ? (
|
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Assignments content (only shown when jury group is assigned) ── */}
|
{/* 5. Monitoring — COI + Unassigned Queue */}
|
||||||
{round?.juryGroupId && (
|
|
||||||
<>
|
|
||||||
{/* Card 1: Coverage & Generation */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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 & Generation
|
||||||
|
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', coverageOpen && 'rotate-90')} />
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>Assignment coverage overview and AI generation</CardDescription>
|
<CardDescription>Assignment coverage overview and AI generation</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
{coverageOpen && (
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<CoverageReport roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
|
<CoverageReport roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
|
||||||
|
|
||||||
@@ -2265,49 +1920,99 @@ export default function RoundDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Jury Progress + Score Distribution (standalone 2-col grid) */}
|
{/* 7. Jury Group (at bottom) */}
|
||||||
<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 */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">Assignments</CardTitle>
|
<CardTitle className="text-base">Jury Group</CardTitle>
|
||||||
<CardDescription>Individual jury-project assignments and actions</CardDescription>
|
<CardDescription>
|
||||||
|
Select or create a jury group for this round
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<SendRemindersButton roundId={roundId} />
|
<Button size="sm" variant="outline" onClick={() => setCreateJuryOpen(true)}>
|
||||||
<NotifyJurorsButton roundId={roundId} />
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
|
New Jury
|
||||||
<Download className="h-4 w-4 mr-1.5" />
|
|
||||||
Export
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
|
{juryGroups && juryGroups.length > 0 ? (
|
||||||
</CardContent>
|
<div className="space-y-4">
|
||||||
</Card>
|
<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 */}
|
{round.juryGroupId && (
|
||||||
<Card>
|
<AlertDialog>
|
||||||
<CardHeader>
|
<AlertDialogTrigger asChild>
|
||||||
<CardTitle className="text-base">Monitoring</CardTitle>
|
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive">
|
||||||
<CardDescription>Conflict of interest declarations and unassigned projects</CardDescription>
|
<Trash2 className="h-4 w-4 mr-1.5" />
|
||||||
</CardHeader>
|
Delete "{juryGroup?.name}"
|
||||||
<CardContent className="space-y-6">
|
</Button>
|
||||||
<COIReviewSection roundId={roundId} />
|
</AlertDialogTrigger>
|
||||||
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete jury group?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete "{juryGroup?.name}" 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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -2590,6 +2295,64 @@ export default function RoundDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</Tabs>
|
</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 */}
|
{/* Autosave error bar — only shows when save fails */}
|
||||||
{autosaveStatus === 'error' && (
|
{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)]">
|
<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)]">
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
<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) => {
|
{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,38 +1,29 @@
|
|||||||
'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">
|
||||||
@@ -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
|
||||||
|
? (user as unknown as { roles: string[] }).roles
|
||||||
|
: [user.role]
|
||||||
|
).map((r) => (
|
||||||
|
<Badge key={r} variant={roleColors[r] || 'secondary'}>
|
||||||
|
{r.replace(/_/g, ' ')}
|
||||||
</Badge>
|
</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
|
||||||
|
? (user as unknown as { roles: string[] }).roles
|
||||||
|
: [user.role]
|
||||||
|
).map((r) => (
|
||||||
|
<Badge key={r} variant={roleColors[r] || 'secondary'}>
|
||||||
|
{r.replace(/_/g, ' ')}
|
||||||
</Badge>
|
</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