From f3fd9eebee8745175cc66e88c7d813ed7f417fd9 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 24 Feb 2026 17:44:55 +0100 Subject: [PATCH] Multi-role members, round detail UI overhaul, dashboard jury progress, and submit bug fix - 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 --- .../migration.sql | 59 + prisma/migrations/migration_lock.toml | 6 +- prisma/schema.prisma | 1 + prisma/seed.ts | 5 + .../(admin)/admin/rounds/[roundId]/page.tsx | 1013 +++++++---------- .../projects/[projectId]/evaluate/page.tsx | 12 +- src/app/(mentor)/layout.tsx | 3 +- .../admin/assignment/jury-progress-table.tsx | 219 +++- .../admin/assignment/reassignment-history.tsx | 16 +- src/components/admin/members-content.tsx | 26 +- .../admin/round/score-distribution.tsx | 8 +- .../dashboard/active-round-panel.tsx | 102 +- src/components/layouts/admin-sidebar.tsx | 43 +- src/components/layouts/role-nav.tsx | 56 +- src/lib/auth-redirect.ts | 7 +- src/lib/auth.config.ts | 3 + src/lib/auth.ts | 15 +- src/server/routers/analytics.ts | 2 +- src/server/routers/assignment.ts | 18 +- src/server/routers/dashboard.ts | 4 +- src/server/routers/evaluation.ts | 4 +- src/server/routers/project.ts | 6 +- src/server/routers/user.ts | 29 +- src/server/services/mentor-matching.ts | 4 +- src/server/trpc.ts | 16 +- 25 files changed, 963 insertions(+), 714 deletions(-) create mode 100644 prisma/migrations/20260224163014_add_user_roles_array/migration.sql diff --git a/prisma/migrations/20260224163014_add_user_roles_array/migration.sql b/prisma/migrations/20260224163014_add_user_roles_array/migration.sql new file mode 100644 index 0000000..6d5dae9 --- /dev/null +++ b/prisma/migrations/20260224163014_add_user_roles_array/migration.sql @@ -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"; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 1af22ce..044d57c 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ -# Please do not edit this file manually -# It should be added in your version-control system (e.g., Git) -provider = "postgresql" +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e89a1bb..e43b79d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -303,6 +303,7 @@ model User { name String? emailVerified DateTime? // Required by NextAuth Prisma adapter role UserRole @default(JURY_MEMBER) + roles UserRole[] @default([]) status UserStatus @default(INVITED) expertiseTags String[] @default([]) maxAssignments Int? // Per-round limit diff --git a/prisma/seed.ts b/prisma/seed.ts index 95bbbd5..ff47da9 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -347,6 +347,7 @@ async function main() { email: account.email, name: account.name, role: account.role, + roles: [account.role], status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE, passwordHash: isSuperAdmin ? passwordHash : null, mustSetPassword: !isSuperAdmin, @@ -385,6 +386,7 @@ async function main() { email: j.email, name: j.name, role: UserRole.JURY_MEMBER, + roles: [UserRole.JURY_MEMBER], status: UserStatus.NONE, country: j.country, expertiseTags: j.tags, @@ -416,6 +418,7 @@ async function main() { email: m.email, name: m.name, role: UserRole.MENTOR, + roles: [UserRole.MENTOR], status: UserStatus.NONE, country: m.country, expertiseTags: m.tags, @@ -444,6 +447,7 @@ async function main() { email: o.email, name: o.name, role: UserRole.OBSERVER, + roles: [UserRole.OBSERVER], status: UserStatus.NONE, country: o.country, }, @@ -545,6 +549,7 @@ async function main() { email, name: name || `Applicant ${rowIdx + 1}`, role: UserRole.APPLICANT, + roles: [UserRole.APPLICANT], status: UserStatus.NONE, phoneNumber: phone, country, diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index 1021941..cc93b11 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -54,6 +54,7 @@ import { Save, Loader2, ChevronDown, + ChevronRight, Play, Square, Archive, @@ -182,6 +183,7 @@ export default function RoundDetailPage() { const [nameValue, setNameValue] = useState('') const nameInputRef = useRef(null) const [statusConfirmAction, setStatusConfirmAction] = useState<'activate' | 'close' | 'reopen' | 'archive' | null>(null) + const [coverageOpen, setCoverageOpen] = useState(false) const utils = trpc.useUtils() @@ -218,6 +220,12 @@ export default function RoundDetailPage() { ) const roundAwards = awards?.filter((a) => a.evaluationRoundId === roundId) ?? [] + // Jury workload (for assignments tab coverage auto-open logic) + const { data: juryWorkload } = trpc.analytics.getJurorWorkload.useQuery( + { roundId }, + { enabled: round?.roundType === 'EVALUATION', refetchInterval: 15_000 }, + ) + // Filtering results stats (only for FILTERING rounds) const { data: filteringStats } = trpc.filtering.getResultStats.useQuery( { roundId }, @@ -247,6 +255,13 @@ export default function RoundDetailPage() { [config, serverConfig], ) + // Auto-open coverage section when no assignments exist yet + useEffect(() => { + if (juryWorkload && juryWorkload.length === 0) { + setCoverageOpen(true) + } + }, [juryWorkload]) + // ── Mutations ────────────────────────────────────────────────────────── const updateMutation = trpc.round.update.useMutation({ onSuccess: () => { @@ -494,9 +509,6 @@ export default function RoundDetailPage() { -
- {[1, 2, 3, 4].map((i) => )} -
@@ -776,6 +788,29 @@ export default function RoundDetailPage() {

{typeCfg.description}

+ {(round.windowOpenAt || round.windowCloseAt) && ( +
+ + {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 Opens {getRelativeTime(openAt)} + } + if (closeAt && now < closeAt) { + return Closes {getRelativeTime(closeAt)} + } + if (closeAt && now >= closeAt) { + return Closed {getRelativeTime(closeAt)} + } + return null + })()} +
+ )} @@ -797,148 +832,6 @@ export default function RoundDetailPage() { - {/* ===== STATS BAR — Accent-bordered cards ===== */} -
- {/* Projects */} - - - -
-
- -
- Projects -
-

{projectCount}

-
- {Object.entries(stateCounts).map(([state, count]) => ( - - {String(count)} {state.toLowerCase().replace('_', ' ')} - - ))} -
-
-
-
- - {/* Jury (with inline group selector) — only for jury-relevant rounds */} - {hasJury && ( - - - -
-
- -
- Jury -
- {juryGroup ? ( - <> -

{juryMemberCount}

-
-

{juryGroup.name}

- -
- - ) : ( - <> -

- - - )} -
-
-
- )} - - {/* Window */} - - - -
-
- -
- Window -
- {round.windowOpenAt || round.windowCloseAt ? ( - <> -

- {round.windowOpenAt - ? new Date(round.windowOpenAt).toLocaleDateString() - : 'No start'} -

-

- {round.windowCloseAt - ? `Closes ${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

Opens {getRelativeTime(openAt)}

- } - if (closeAt && now < closeAt) { - return

Closes {getRelativeTime(closeAt)}

- } - if (closeAt && now >= closeAt) { - return

Closed {getRelativeTime(closeAt)}

- } - return null - })()} - - ) : ( - <> -

-

No dates set

- - )} -
-
-
- - {/* Advancement */} - - - -
-
- -
- Advancement -
- {round.advancementRules && round.advancementRules.length > 0 ? ( - <> -

{round.advancementRules.length}

-

- {round.advancementRules.map((r: any) => r.ruleType.replace('_', ' ').toLowerCase()).join(', ')} -

- - ) : ( - <> -

-

Admin selection

- - )} -
-
-
-
- {/* ===== TABS — Underline style ===== */}
@@ -1544,99 +1437,6 @@ export default function RoundDetailPage() { {/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */} {hasJury && !isEvaluation && ( - {/* Jury Group Selector + Create */} - - -
-
- Jury Group - - Select or create a jury group for this round - -
-
- -
-
-
- - {juryGroups && juryGroups.length > 0 ? ( -
- - - {/* Delete button for currently selected jury group */} - {round.juryGroupId && ( - - - - - - - Delete jury group? - - This will permanently delete "{juryGroup?.name}" and remove all its members. - Rounds using this jury group will be unlinked. This action cannot be undone. - - - - Cancel - deleteJuryMutation.mutate({ id: round.juryGroupId! })} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - disabled={deleteJuryMutation.isPending} - > - {deleteJuryMutation.isPending && } - Delete Jury - - - - - )} -
- ) : ( -
-
- -
-

No Jury Groups

-

- Create a jury group to assign members who will evaluate projects in this round. -

- -
- )} -
-
{/* Members list (only if a jury group is assigned) */} {juryGroupDetail && ( @@ -1823,71 +1623,307 @@ export default function RoundDetailPage() { )} - {/* Create Jury Dialog */} - - - - Create Jury Group - - Create a new jury group for this competition. It will be automatically assigned to this round. - - -
-
- - 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, ''), - }) - } - }} - /> + {/* Jury Group Selector (at bottom for non-evaluation jury rounds) */} + + +
+
+ Jury Group + + Select or create a jury group for this round + +
+
+
- - - - - -
+ + + {juryGroups && juryGroups.length > 0 ? ( +
+ + + {round.juryGroupId && ( + + + + + + + Delete jury group? + + This will permanently delete "{juryGroup?.name}" and remove all its members. + Rounds using this jury group will be unlinked. This action cannot be undone. + + + + Cancel + deleteJuryMutation.mutate({ id: round.juryGroupId! })} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + disabled={deleteJuryMutation.isPending} + > + {deleteJuryMutation.isPending && } + Delete Jury + + + + + )} +
+ ) : ( +
+
+ +
+

No Jury Groups

+

+ Create a jury group to assign members who will evaluate projects in this round. +

+ +
+ )} +
+ - {/* Add Member Dialog */} - {juryGroupId && ( - { - setAddMemberOpen(open) - if (!open) utils.juryGroup.getById.invalidate({ id: juryGroupId }) - }} - /> - )}
)} {/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds — includes Jury section) ═══════════ */} {isEvaluation && ( - {/* ── Jury Group Selector (merged from Jury tab for EVALUATION) ── */} + + {/* 1. Jury Members & Progress (merged) */} + {!round?.juryGroupId ? ( + + +

Select a jury group below to get started.

+
+
+ ) : ( + ({ + 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) */} + + + {/* 3. Reassignment History (always open) */} + + + {/* ── Remaining content only shown when jury group is assigned ── */} + {round?.juryGroupId && ( + <> + + {/* 4. Assignments */} + + +
+
+ Assignments + Individual jury-project assignments and actions +
+
+ + + +
+
+
+ + + +
+ + {/* 5. Monitoring — COI + Unassigned Queue */} + + + Monitoring + Conflict of interest declarations and unassigned projects + + + + + + + + {/* 6. Coverage & Generation (collapsible, default collapsed when assignments exist) */} + + setCoverageOpen((o) => !o)} + > + + Coverage & Generation + + + Assignment coverage overview and AI generation + + {coverageOpen && ( + + + + {/* Generate Assignments */} +
+
+
+

+ Assignment Generation + {aiAssignmentMutation.isPending && ( + + + AI generating... + + )} + {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && ( + + + {aiAssignmentMutation.data.stats.assignmentsGenerated} ready + + )} +

+

+ AI-suggested jury-to-project assignments based on expertise and workload +

+
+
+ {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && ( + + )} + +
+
+ {projectCount === 0 && ( +
+ + Add projects to this round first. +
+ )} + {juryGroup && projectCount > 0 && !aiAssignmentMutation.isPending && !aiAssignmentMutation.data && ( +

+ Click "Generate with AI" to create assignments using GPT analysis of juror expertise, project descriptions, and documents. Or open the preview to use the algorithm instead. +

+ )} + {aiAssignmentMutation.isPending && ( +
+
+
+
+
+

AI is analyzing projects and jurors...

+

+ Matching expertise, reviewing bios, and balancing workloads +

+
+
+ )} + {aiAssignmentMutation.error && !aiAssignmentMutation.isPending && ( +
+ +
+

+ AI generation failed +

+

+ {aiAssignmentMutation.error.message} +

+
+
+ )} + {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && ( +
+ +
+

+ {aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated +

+

+ {aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects + {aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'} +

+
+ +
+ )} +
+ + )} + + + {/* 7. Jury Group (at bottom) */}
@@ -1931,7 +1967,6 @@ export default function RoundDetailPage() { - {/* Delete button for currently selected jury group */} {round.juryGroupId && ( @@ -1981,336 +2016,6 @@ export default function RoundDetailPage() { - {/* ── Members list (only if a jury group is assigned) ── */} - {juryGroupDetail && ( - - -
-
- - Members — {juryGroupDetail.name} - - - {juryGroupDetail.members.length} member{juryGroupDetail.members.length !== 1 ? 's' : ''} - -
- -
-
- - {juryGroupDetail.members.length === 0 ? ( -
-
- -
-

No Members Yet

-

- Add jury members to start assigning projects for evaluation. -

- -
- ) : ( -
- {juryGroupDetail.members.map((member: any, idx: number) => ( -
-
-

- {member.user.name || 'Unnamed User'} -

-

{member.user.email}

-
-
- updateJuryMemberMutation.mutate({ - id: member.id, - maxAssignmentsOverride: val, - })} - /> - - - - - -

Notify juror of assignments

-
-
- - - - - -

Transfer assignments to other jurors

-
-
- - - - - -

Drop juror & reshuffle pending projects

-
-
- - - - - - - Remove member? - - Remove {member.user.name || member.user.email} from {juryGroupDetail.name}? - - - - Cancel - removeJuryMemberMutation.mutate({ id: member.id })} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - Remove - - - - -
-
- ))} -
- )} -
-
- )} - - {/* ── Assignments content (only shown when jury group is assigned) ── */} - {round?.juryGroupId && ( - <> - {/* Card 1: Coverage & Generation */} - - - Coverage & Generation - Assignment coverage overview and AI generation - - - - - {/* Generate Assignments */} -
-
-
-

- Assignment Generation - {aiAssignmentMutation.isPending && ( - - - AI generating... - - )} - {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && ( - - - {aiAssignmentMutation.data.stats.assignmentsGenerated} ready - - )} -

-

- AI-suggested jury-to-project assignments based on expertise and workload -

-
-
- {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && ( - - )} - -
-
- {projectCount === 0 && ( -
- - Add projects to this round first. -
- )} - {juryGroup && projectCount > 0 && !aiAssignmentMutation.isPending && !aiAssignmentMutation.data && ( -

- Click "Generate with AI" to create assignments using GPT analysis of juror expertise, project descriptions, and documents. Or open the preview to use the algorithm instead. -

- )} - {aiAssignmentMutation.isPending && ( -
-
-
-
-
-

AI is analyzing projects and jurors...

-

- Matching expertise, reviewing bios, and balancing workloads -

-
-
- )} - {aiAssignmentMutation.error && !aiAssignmentMutation.isPending && ( -
- -
-

- AI generation failed -

-

- {aiAssignmentMutation.error.message} -

-
-
- )} - {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && ( -
- -
-

- {aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated -

-

- {aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects - {aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'} -

-
- -
- )} -
- - - - {/* Jury Progress + Score Distribution (standalone 2-col grid) */} -
- - -
- - {/* Reassignment History (collapsible) */} - - - {/* Card 2: Assignments — with action buttons in header */} - - -
-
- Assignments - Individual jury-project assignments and actions -
-
- - - -
-
-
- - - -
- - {/* Card 3: Monitoring — COI + Unassigned Queue */} - - - Monitoring - Conflict of interest declarations and unassigned projects - - - - - - - {/* Assignment Preview Sheet */} + {/* ── Page-level dialogs (shared between jury/assignments tabs) ── */} + + + + Create Jury Group + + Create a new jury group for this competition. It will be automatically assigned to this round. + + +
+
+ + 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, ''), + }) + } + }} + /> +
+
+ + + + +
+
+ + {juryGroupId && ( + { + setAddMemberOpen(open) + if (!open) utils.juryGroup.getById.invalidate({ id: juryGroupId }) + }} + /> + )} + {/* Autosave error bar — only shows when save fails */} {autosaveStatus === 'error' && (
diff --git a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx index e00d154..e6aa174 100644 --- a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx +++ b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx @@ -41,6 +41,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { const evaluationIdRef = useRef(null) const isSubmittedRef = useRef(false) const isSubmittingRef = useRef(false) + const [isSubmitting, setIsSubmitting] = useState(false) const autosaveTimerRef = useRef | null>(null) const [lastSavedAt, setLastSavedAt] = useState(null) @@ -318,10 +319,12 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { autosaveTimerRef.current = null } isSubmittingRef.current = true + setIsSubmitting(true) if (!myAssignment) { toast.error('Assignment not found') isSubmittingRef.current = false + setIsSubmitting(false) return } @@ -335,16 +338,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { if (c.type === 'numeric' && (val === undefined || val === null)) { toast.error(`Please score "${c.label}"`) isSubmittingRef.current = false + setIsSubmitting(false) return } if (c.type === 'boolean' && val === undefined) { toast.error(`Please answer "${c.label}"`) isSubmittingRef.current = false + setIsSubmitting(false) return } if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) { toast.error(`Please fill in "${c.label}"`) isSubmittingRef.current = false + setIsSubmitting(false) return } } @@ -355,6 +361,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { if (isNaN(score) || score < 1 || score > 10) { toast.error('Please enter a valid score between 1 and 10') isSubmittingRef.current = false + setIsSubmitting(false) return } } @@ -363,6 +370,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { if (!binaryDecision) { toast.error('Please select accept or reject') isSubmittingRef.current = false + setIsSubmitting(false) return } } @@ -371,6 +379,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) { toast.error(`Please provide feedback (minimum ${feedbackMinLength} characters)`) isSubmittingRef.current = false + setIsSubmitting(false) return } } @@ -414,6 +423,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { } catch { // Error toast already handled by onError callback isSubmittingRef.current = false + setIsSubmitting(false) } } @@ -878,7 +888,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) { + )} +
{isLoading ? ( @@ -65,11 +113,28 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) { {[1, 2, 3].map((i) => )}
) : !workload || workload.length === 0 ? ( -

- No assignments yet -

+ hasMembersData && members && members.length > 0 ? ( + // Show members-only view when there are members but no assignments yet +
+ {members.map((member, idx) => ( + + ))} +
+ ) : ( +

+ {hasMembersData ? 'No members yet. Add jury members to get started.' : 'No assignments yet'} +

+ ) ) : ( -
+
{workload.map((juror) => { const pct = juror.completionRate const barGradient = pct === 100 @@ -80,11 +145,23 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) { ? 'bg-gradient-to-r from-amber-400 to-amber-600' : 'bg-gray-300' + // Find the corresponding member entry for cap editing + const member = members?.find((m) => m.userId === juror.id) + return (
- {juror.name} + {juror.name}
+ {member && onSaveCap && ( + onSaveCap(member.id, val)} + /> + )} {juror.completed}/{juror.assigned} ({pct}%) @@ -151,6 +228,37 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {

Drop juror + reshuffle pending projects

+ + {member && onRemoveMember && ( + + + + + + + Remove member? + + Remove {member.name} from this jury group? + + + + Cancel + onRemoveMember(member.id, member.name)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Remove + + + + + )}
@@ -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 +}) { + return ( +
+
+

{member.name}

+

{member.email}

+
+
+ {onSaveCap && ( + onSaveCap(member.id, val)} + /> + )} + + + + + +

Notify juror of assignments

+
+
+ + {onRemoveMember && ( + + + + + + + Remove member? + + Remove {member.name} from this jury group? + + + + Cancel + onRemoveMember(member.id, member.name)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Remove + + + + + )} +
+
+ ) +} diff --git a/src/components/admin/assignment/reassignment-history.tsx b/src/components/admin/assignment/reassignment-history.tsx index ffc7182..d05e117 100644 --- a/src/components/admin/assignment/reassignment-history.tsx +++ b/src/components/admin/assignment/reassignment-history.tsx @@ -1,39 +1,30 @@ 'use client' -import { useState } from 'react' import { trpc } from '@/lib/trpc/client' -import { cn } from '@/lib/utils' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { Badge } from '@/components/ui/badge' -import { History, ChevronRight } from 'lucide-react' +import { History } from 'lucide-react' export type ReassignmentHistoryProps = { roundId: string } export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) { - const [expanded, setExpanded] = useState(false) const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery( { roundId }, - { enabled: expanded }, ) return ( - setExpanded(!expanded)} - > + Reassignment History - Juror dropout, COI, transfer, and cap redistribution audit trail - {expanded && ( - + {isLoading ? (
{[1, 2].map((i) => )} @@ -105,7 +96,6 @@ export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
)}
- )}
) } diff --git a/src/components/admin/members-content.tsx b/src/components/admin/members-content.tsx index b51bfb0..0613731 100644 --- a/src/components/admin/members-content.tsx +++ b/src/components/admin/members-content.tsx @@ -364,9 +364,16 @@ export function MembersContent() {
- - {user.role.replace(/_/g, ' ')} - +
+ {((user as unknown as { roles?: string[] }).roles?.length + ? (user as unknown as { roles: string[] }).roles + : [user.role] + ).map((r) => ( + + {r.replace(/_/g, ' ')} + + ))} +
{user.expertiseTags && user.expertiseTags.length > 0 ? ( @@ -469,9 +476,16 @@ export function MembersContent() {
Role - - {user.role.replace(/_/g, ' ')} - +
+ {((user as unknown as { roles?: string[] }).roles?.length + ? (user as unknown as { roles: string[] }).roles + : [user.role] + ).map((r) => ( + + {r.replace(/_/g, ' ')} + + ))} +
Assignments diff --git a/src/components/admin/round/score-distribution.tsx b/src/components/admin/round/score-distribution.tsx index 7858693..6ac8432 100644 --- a/src/components/admin/round/score-distribution.tsx +++ b/src/components/admin/round/score-distribution.tsx @@ -21,16 +21,16 @@ export function ScoreDistribution({ roundId }: ScoreDistributionProps) { [dist]) return ( - + Score Distribution {dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'} - + {isLoading ? ( -
+
{Array.from({ length: 10 }).map((_, i) => )}
) : !dist || dist.totalEvaluations === 0 ? ( @@ -38,7 +38,7 @@ export function ScoreDistribution({ roundId }: ScoreDistributionProps) { No evaluations submitted yet

) : ( -
+
{dist.globalDistribution.map((bucket) => { const heightPct = (bucket.count / maxCount) * 100 return ( diff --git a/src/components/dashboard/active-round-panel.tsx b/src/components/dashboard/active-round-panel.tsx index 6b900f9..8d4d1fb 100644 --- a/src/components/dashboard/active-round-panel.tsx +++ b/src/components/dashboard/active-round-panel.tsx @@ -1,5 +1,6 @@ 'use client' +import { useState } from 'react' import Link from 'next/link' import { motion } from 'motion/react' import { @@ -10,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Progress } from '@/components/ui/progress' +import { Skeleton } from '@/components/ui/skeleton' import { Tooltip, TooltipContent, @@ -19,6 +21,7 @@ import { import { StatusBadge } from '@/components/shared/status-badge' import { cn, formatEnumLabel, daysUntil } from '@/lib/utils' import { roundTypeConfig, projectStateConfig } from '@/lib/round-config' +import { trpc } from '@/lib/trpc/client' export type PipelineRound = { id: string @@ -138,6 +141,80 @@ function ProjectStateBar({ ) } +function EvaluationRoundContent({ round }: { round: PipelineRound }) { + const [showAll, setShowAll] = useState(false) + + const { data: workload, isLoading: isLoadingWorkload } = trpc.analytics.getJurorWorkload.useQuery( + { roundId: round.id }, + { enabled: round.roundType === 'EVALUATION' } + ) + + const pct = + round.evalTotal > 0 + ? Math.round((round.evalSubmitted / round.evalTotal) * 100) + : 0 + + return ( +
+
+ Evaluation progress + + {round.evalSubmitted} / {round.evalTotal} ({pct}%) + +
+ + {round.evalDraft > 0 && ( +

+ {round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress +

+ )} + {/* Per-juror progress */} +
+
+ Jury Progress + {workload && workload.length > 8 && ( + + )} +
+ {isLoadingWorkload ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : workload && workload.length > 0 ? ( +
+ {(showAll ? workload : workload.slice(0, 8)).map((juror) => { + const pct = juror.assigned > 0 ? (juror.completed / juror.assigned) * 100 : 0 + return ( +
+ {juror.name} +
+
+
+ + {juror.completed}/{juror.assigned} + +
+ ) + })} +
+ ) : ( +

No jurors assigned yet

+ )} +
+
+ ) +} + function RoundTypeContent({ round }: { round: PipelineRound }) { const { projectStates } = round @@ -171,29 +248,8 @@ function RoundTypeContent({ round }: { round: PipelineRound }) { ) } - case 'EVALUATION': { - const pct = - round.evalTotal > 0 - ? Math.round((round.evalSubmitted / round.evalTotal) * 100) - : 0 - - return ( -
-
- Evaluation progress - - {round.evalSubmitted} / {round.evalTotal} ({pct}%) - -
- - {round.evalDraft > 0 && ( -

- {round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress -

- )} -
- ) - } + case 'EVALUATION': + return case 'SUBMISSION': return ( diff --git a/src/components/layouts/admin-sidebar.tsx b/src/components/layouts/admin-sidebar.tsx index 409f85c..bbcecc7 100644 --- a/src/components/layouts/admin-sidebar.tsx +++ b/src/components/layouts/admin-sidebar.tsx @@ -35,7 +35,10 @@ import { LayoutTemplate, Layers, Scale, + Eye, + ArrowRightLeft, } from 'lucide-react' +import type { UserRole } from '@prisma/client' import { getInitials } from '@/lib/utils' import { Logo } from '@/components/shared/logo' import { EditionSelector } from '@/components/shared/edition-selector' @@ -147,12 +150,21 @@ const roleLabels: Record = { PROGRAM_ADMIN: 'Program Admin', JURY_MEMBER: 'Jury Member', OBSERVER: 'Observer', + MENTOR: 'Mentor', + AWARD_MASTER: 'Award Master', +} + +// Role switcher config — maps roles to their dashboard views +const ROLE_SWITCH_OPTIONS: Record = { + JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale }, + MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake }, + OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye }, } export function AdminSidebar({ user }: AdminSidebarProps) { const pathname = usePathname() const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) - const { status: sessionStatus } = useSession() + const { data: session, status: sessionStatus } = useSession() const isAuthenticated = sessionStatus === 'authenticated' const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, { enabled: isAuthenticated, @@ -162,6 +174,12 @@ export function AdminSidebar({ user }: AdminSidebarProps) { const isSuperAdmin = user.role === 'SUPER_ADMIN' const roleLabel = roleLabels[user.role || ''] || 'User' + // Roles the user can switch to (non-admin roles they hold) + const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? [] + const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS).filter( + ([role]) => userRoles.includes(role as UserRole) + ) + // Build dynamic admin nav with current edition's apply page const dynamicAdminNav = adminNavigation.map((item) => { if (item.name === 'Apply Page' && currentEdition?.id) { @@ -344,6 +362,29 @@ export function AdminSidebar({ user }: AdminSidebarProps) { + {switchableRoles.length > 0 && ( + <> + +
+

+ + Switch View +

+
+ {switchableRoles.map(([, opt]) => ( + + + + {opt.label} + + + ))} + + )} + = { + SUPER_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard }, + PROGRAM_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard }, + JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale }, + MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake }, + OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye }, +} + function isNavItemActive(pathname: string, href: string, basePath: string): boolean { return pathname === href || (href !== basePath && pathname.startsWith(href)) } @@ -52,7 +65,7 @@ function isNavItemActive(pathname: string, href: string, basePath: string): bool export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector }: RoleNavProps) { const pathname = usePathname() const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) - const { status: sessionStatus } = useSession() + const { data: session, status: sessionStatus } = useSession() const isAuthenticated = sessionStatus === 'authenticated' const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, { enabled: isAuthenticated, @@ -61,6 +74,13 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) + // Roles the user can switch to (excluding current view) + const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? [] + const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS) + .filter(([role, opt]) => userRoles.includes(role as UserRole) && opt.path !== basePath) + // Deduplicate admin paths (SUPER_ADMIN and PROGRAM_ADMIN both go to /admin) + .filter((entry, i, arr) => arr.findIndex(([, o]) => o.path === entry[1].path) === i) + return (
@@ -136,6 +156,19 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi Settings + {switchableRoles.length > 0 && ( + <> + + {switchableRoles.map(([, opt]) => ( + + + + {opt.label} + + + ))} + + )} signOut({ callbackUrl: '/login' })} @@ -198,6 +231,25 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi {editionSelector}
)} + {switchableRoles.length > 0 && ( +
+

+ + Switch View +

+ {switchableRoles.map(([, opt]) => ( + 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.label} + + ))} +
+ )}