diff --git a/apps/dashboard/pages/api/judge/bulk-decision.ts b/apps/dashboard/pages/api/judge/bulk-decision.ts new file mode 100644 index 0000000..6af5ad2 --- /dev/null +++ b/apps/dashboard/pages/api/judge/bulk-decision.ts @@ -0,0 +1,98 @@ +import 'reflect-metadata'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { container } from 'tsyringe'; +import { HibiscusSupabaseClient } from '@hibiscus/hibiscus-supabase-client'; +import { getAuthenticatedUser } from '../../../common/auth'; + +/** + * Role IDs from the `roles` table: + * 1 = SUPERADMIN + * 7 = JUDGE + */ +const ALLOWED_ROLES = [1, 7]; + +const VALID_DECISIONS = ['finalist', 'waitlist', 'not_selected'] as const; +type FinalDecision = (typeof VALID_DECISIONS)[number]; + +interface BulkDecisionRequest { + teamIds: string[]; + decision: FinalDecision; +} + +/** + * POST /api/judge/bulk-decision + * Bulk update final_decision for multiple teams. + * Only accessible by admins (role=1) and judges (role=7). + * + * Body: { teamIds: string[], decision: 'finalist' | 'waitlist' | 'not_selected' } + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + return res.status(405).json({ message: 'Method not allowed' }); + } + + const user = await getAuthenticatedUser(req); + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + if (!ALLOWED_ROLES.includes(user.role)) { + return res + .status(403) + .json({ message: 'Forbidden: Admin or Judge role required' }); + } + + const body = req.body as BulkDecisionRequest; + + // Validate teamIds + if (!Array.isArray(body.teamIds) || body.teamIds.length === 0) { + return res + .status(400) + .json({ message: 'teamIds must be a non-empty array' }); + } + + if (body.teamIds.length > 500) { + return res.status(400).json({ message: 'Maximum 500 teams per request' }); + } + + // Validate decision + if (!VALID_DECISIONS.includes(body.decision)) { + return res.status(400).json({ + message: 'decision must be one of: finalist, waitlist, not_selected', + }); + } + + try { + const hbc = container.resolve(HibiscusSupabaseClient); + hbc.setOptions({ useServiceKey: true }); + const supabase = hbc.getClient(); + + // Build upsert payload for all teams + const payload = body.teamIds.map((teamId) => ({ + team_id: teamId, + final_decision: body.decision, + })); + + // Bulk upsert + const { error: upsertError } = await supabase + .from('judging_notes') + .upsert(payload, { onConflict: 'team_id' }); + + if (upsertError) { + console.error('[judge/bulk-decision] Upsert error:', upsertError); + return res.status(500).json({ message: 'Failed to update decisions' }); + } + + return res.status(200).json({ + message: 'Decisions updated successfully', + count: body.teamIds.length, + decision: body.decision, + }); + } catch (e) { + console.error('[judge/bulk-decision] Error:', e); + return res.status(500).json({ message: 'Internal server error' }); + } +} diff --git a/apps/dashboard/pages/api/judge/export.ts b/apps/dashboard/pages/api/judge/export.ts new file mode 100644 index 0000000..a255cee --- /dev/null +++ b/apps/dashboard/pages/api/judge/export.ts @@ -0,0 +1,191 @@ +import 'reflect-metadata'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { container } from 'tsyringe'; +import { HibiscusSupabaseClient } from '@hibiscus/hibiscus-supabase-client'; +import { getAuthenticatedUser } from '../../../common/auth'; + +/** + * Role IDs from the `roles` table: + * 1 = SUPERADMIN + * 7 = JUDGE + */ +const ALLOWED_ROLES = [1, 7]; + +const VALID_DECISIONS = ['finalist', 'waitlist', 'not_selected'] as const; +type FinalDecision = (typeof VALID_DECISIONS)[number]; + +interface TeamMember { + firstName: string | null; + lastName: string | null; + email: string; +} + +interface ExportTeam { + teamId: string; + teamName: string; + projectTitle: string | null; + memberCount: number; + members: TeamMember[]; +} + +/** + * GET /api/judge/export?decision=finalist|waitlist|not_selected + * Export teams with their members for a given final decision. + * Only accessible by admins (role=1) and judges (role=7). + * + * Returns JSON with team and member details for CSV export. + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'GET') { + return res.status(405).json({ message: 'Method not allowed' }); + } + + const user = await getAuthenticatedUser(req); + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + if (!ALLOWED_ROLES.includes(user.role)) { + return res + .status(403) + .json({ message: 'Forbidden: Admin or Judge role required' }); + } + + const decision = req.query.decision as string; + + if (!decision || !VALID_DECISIONS.includes(decision as FinalDecision)) { + return res.status(400).json({ + message: + 'decision query param must be one of: finalist, waitlist, not_selected', + }); + } + + try { + const hbc = container.resolve(HibiscusSupabaseClient); + hbc.setOptions({ useServiceKey: true }); + const supabase = hbc.getClient(); + + let teamIds: string[]; + + if (decision === 'not_selected') { + // For not_selected (rejected): get all submitted teams NOT marked as finalist/waitlist + // 1. Get all submitted teams + const { data: submittedTeams, error: submittedError } = await supabase + .from('teams') + .select('team_id') + .eq('submission_status', 2); + + if (submittedError) { + console.error('[judge/export] Submitted teams error:', submittedError); + return res.status(500).json({ message: 'Failed to fetch teams' }); + } + + if (!submittedTeams || submittedTeams.length === 0) { + return res.status(200).json({ teams: [], count: 0, decision }); + } + + // 2. Get teams that ARE finalists or waitlist (to exclude) + const { data: selectedNotes, error: selectedError } = await supabase + .from('judging_notes') + .select('team_id') + .in('final_decision', ['finalist', 'waitlist']); + + if (selectedError) { + console.error('[judge/export] Selected notes error:', selectedError); + return res.status(500).json({ message: 'Failed to fetch decisions' }); + } + + const selectedTeamIds = new Set( + (selectedNotes || []).map((n) => n.team_id) + ); + + // 3. Filter to submitted teams NOT in finalist/waitlist + teamIds = submittedTeams + .map((t) => t.team_id) + .filter((id) => !selectedTeamIds.has(id)); + + if (teamIds.length === 0) { + return res.status(200).json({ teams: [], count: 0, decision }); + } + } else { + // For finalist/waitlist: get teams with that specific final_decision + const { data: judgingNotes, error: notesError } = await supabase + .from('judging_notes') + .select('team_id') + .eq('final_decision', decision); + + if (notesError) { + console.error('[judge/export] Notes fetch error:', notesError); + return res.status(500).json({ message: 'Failed to fetch decisions' }); + } + + if (!judgingNotes || judgingNotes.length === 0) { + return res.status(200).json({ teams: [], count: 0, decision }); + } + + teamIds = judgingNotes.map((n) => n.team_id); + } + + // Get team details + const { data: teams, error: teamsError } = await supabase + .from('teams') + .select('team_id, name, project_title') + .in('team_id', teamIds); + + if (teamsError) { + console.error('[judge/export] Teams fetch error:', teamsError); + return res.status(500).json({ message: 'Failed to fetch teams' }); + } + + // Get members for these teams + const { data: members, error: membersError } = await supabase + .from('user_profiles') + .select('team_id, first_name, last_name, email') + .in('team_id', teamIds); + + if (membersError) { + console.error('[judge/export] Members fetch error:', membersError); + return res.status(500).json({ message: 'Failed to fetch members' }); + } + + // Group members by team + const membersByTeam = new Map(); + for (const member of members || []) { + if (!member.team_id) continue; + const list = membersByTeam.get(member.team_id) || []; + list.push({ + firstName: member.first_name, + lastName: member.last_name, + email: member.email, + }); + membersByTeam.set(member.team_id, list); + } + + // Build export data + const exportTeams: ExportTeam[] = (teams || []).map((team) => { + const teamMembers = membersByTeam.get(team.team_id) || []; + return { + teamId: team.team_id, + teamName: team.name, + projectTitle: team.project_title, + memberCount: teamMembers.length, + members: teamMembers, + }; + }); + + // Sort by team name + exportTeams.sort((a, b) => a.teamName.localeCompare(b.teamName)); + + return res.status(200).json({ + teams: exportTeams, + count: exportTeams.length, + decision, + }); + } catch (e) { + console.error('[judge/export] Error:', e); + return res.status(500).json({ message: 'Internal server error' }); + } +} diff --git a/apps/dashboard/pages/api/judge/notes/[teamId].ts b/apps/dashboard/pages/api/judge/notes/[teamId].ts new file mode 100644 index 0000000..3d861ce --- /dev/null +++ b/apps/dashboard/pages/api/judge/notes/[teamId].ts @@ -0,0 +1,189 @@ +import 'reflect-metadata'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { container } from 'tsyringe'; +import { HibiscusSupabaseClient } from '@hibiscus/hibiscus-supabase-client'; +import { getAuthenticatedUser } from '../../../../common/auth'; + +/** + * Role IDs from the `roles` table: + * 1 = SUPERADMIN + * 7 = JUDGE + * These are checked against user_profiles.role + */ +const ALLOWED_ROLES = [1, 7]; + +// Submission status that allows judging (1=not_submitted, 2=submitted, 3=finalist, 4=not_selected) +const SUBMITTED_STATUS = 2; + +interface FinalDecisionUpdate { + final_decision?: 'finalist' | 'waitlist' | 'not_selected' | null; + // Optimistic locking - client sends the updated_at value they last saw + expected_updated_at?: string | null; +} + +/** + * PATCH /api/judge/notes/[teamId] + * Updates the final_decision for a team. + * Only accessible by admins (role=1) and judges (role=7). + * + * Note: Pass 1/2 scores are now managed via /api/judge/scores/[teamId]. + * This endpoint only handles final_decision on judging_notes. + * + * Optimistic locking: + * - Client should send `expected_updated_at` (the value from their last fetch) + * - If another judge updated the record, returns 409 Conflict + * - For new records, `expected_updated_at` should be null or omitted + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'PATCH') { + return res.status(405).json({ message: 'Method not allowed' }); + } + + const user = await getAuthenticatedUser(req); + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + if (!ALLOWED_ROLES.includes(user.role)) { + return res + .status(403) + .json({ message: 'Forbidden: Admin or Judge role required' }); + } + + const { teamId } = req.query; + if (!teamId || typeof teamId !== 'string') { + return res.status(400).json({ message: 'teamId is required' }); + } + + const updates: FinalDecisionUpdate = req.body; + + // Validate final_decision value + if (updates.final_decision !== undefined && updates.final_decision !== null) { + if ( + !['finalist', 'waitlist', 'not_selected'].includes(updates.final_decision) + ) { + return res.status(400).json({ + message: 'final_decision must be finalist, waitlist, or not_selected', + }); + } + } + + // Check for legacy pass_1/pass_2 fields and return helpful error + const body = req.body as Record; + const legacyFields = [ + 'pass_1', + 'pass_1_problem', + 'pass_1_solution', + 'pass_1_implementation', + 'pass_1_roadmap', + 'pass_1_notes', + 'pass_2', + 'pass_2_problem', + 'pass_2_solution', + 'pass_2_implementation', + 'pass_2_roadmap', + 'pass_2_notes', + ]; + const usedLegacyFields = legacyFields.filter((f) => body[f] !== undefined); + if (usedLegacyFields.length > 0) { + return res.status(400).json({ + message: + 'Pass 1/2 scores are now managed via /api/judge/scores/[teamId]. This endpoint only handles final_decision.', + hint: 'Use PATCH /api/judge/scores/[teamId] with { pass: 1 | 2, problem, solution, implementation, roadmap, decision, notes }', + }); + } + + try { + const hbc = container.resolve(HibiscusSupabaseClient); + hbc.setOptions({ useServiceKey: true }); + const supabase = hbc.getClient(); + + // Verify team exists and has submitted + const { data: team, error: teamError } = await supabase + .from('teams') + .select('team_id, submission_status') + .eq('team_id', teamId) + .single(); + + if (teamError || !team) { + return res.status(404).json({ message: 'Team not found' }); + } + + if (team.submission_status !== SUBMITTED_STATUS) { + return res.status(400).json({ + message: + 'Team has not submitted yet. Only submitted teams can be judged.', + }); + } + + // Optimistic locking: check if another judge updated the record + const { expected_updated_at } = updates; + if (expected_updated_at !== undefined) { + const { data: existingNotes } = await supabase + .from('judging_notes') + .select('updated_at') + .eq('team_id', teamId) + .single(); + + if (existingNotes) { + const currentUpdatedAt = existingNotes.updated_at; + if (expected_updated_at === null) { + return res.status(409).json({ + message: + 'Another judge has already started reviewing this team. Please refresh.', + currentUpdatedAt, + }); + } + if (currentUpdatedAt !== expected_updated_at) { + return res.status(409).json({ + message: + 'Another judge has updated this record. Please refresh to see their changes.', + currentUpdatedAt, + }); + } + } else if (expected_updated_at !== null) { + return res.status(409).json({ + message: 'This judging record no longer exists. Please refresh.', + }); + } + } + + // Build update payload - only final_decision + const payload: Record = { + team_id: teamId, + final_decision: updates.final_decision, + }; + + // Upsert the judging notes + const { error: upsertError } = await supabase + .from('judging_notes') + .upsert(payload, { onConflict: 'team_id' }); + + if (upsertError) { + console.error('[judge/notes] Upsert error:', upsertError); + return res.status(500).json({ message: 'Failed to save final decision' }); + } + + // Fetch the updated record + const { data: updatedNotes, error: fetchError } = await supabase + .from('judging_notes') + .select('team_id, final_decision, updated_at') + .eq('team_id', teamId) + .single(); + + if (fetchError) { + console.error('[judge/notes] Fetch error:', fetchError); + return res.status(500).json({ + message: 'Final decision saved but failed to fetch updated record', + }); + } + + return res.status(200).json({ notes: updatedNotes }); + } catch (e) { + console.error('[judge/notes] Error:', e); + return res.status(500).json({ message: 'Internal server error' }); + } +} diff --git a/apps/dashboard/pages/api/judge/scores/[teamId].ts b/apps/dashboard/pages/api/judge/scores/[teamId].ts new file mode 100644 index 0000000..a199691 --- /dev/null +++ b/apps/dashboard/pages/api/judge/scores/[teamId].ts @@ -0,0 +1,361 @@ +import 'reflect-metadata'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { container } from 'tsyringe'; +import { HibiscusSupabaseClient } from '@hibiscus/hibiscus-supabase-client'; +import { getAuthenticatedUser } from '../../../../common/auth'; + +/** + * Role IDs from the `roles` table: + * 1 = SUPERADMIN + * 7 = JUDGE + * These are checked against user_profiles.role + */ +const ALLOWED_ROLES = [1, 7]; + +// Submission status that allows judging (1=not_submitted, 2=submitted, 3=finalist, 4=not_selected) +const SUBMITTED_STATUS = 2; + +// Reviewer caps per pass +const PASS_1_MAX_REVIEWERS = 4; +const PASS_2_MAX_REVIEWERS = 3; + +interface ScoreUpdateRequest { + pass: 1 | 2; + problem?: number | null; + solution?: number | null; + implementation?: number | null; + roadmap?: number | null; + decision?: string | null; + notes?: string | null; +} + +interface ReviewerScore { + judgeId: string | null; + judgeName: string | null; + problem: number | null; + solution: number | null; + implementation: number | null; + roadmap: number | null; + decision: string | null; + notes: string | null; + avgScore: number | null; +} + +interface PassAggregate { + reviewCount: number; + maxReviewers: number; + avgProblem: number | null; + avgSolution: number | null; + avgImplementation: number | null; + avgRoadmap: number | null; + avgTotal: number | null; + decisions: Record; + consensus: string | null; +} + +/** + * PATCH /api/judge/scores/[teamId] + * Upserts a judge's score for a team's pass. + * Only accessible by admins (role=1) and judges (role=7). + * + * Enforces reviewer caps: + * - Pass 1: max 4 reviewers + * - Pass 2: max 3 reviewers + * + * Request body: ScoreUpdateRequest + * Response: { score: ReviewerScore, aggregate: PassAggregate } + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'PATCH') { + return res.status(405).json({ message: 'Method not allowed' }); + } + + const user = await getAuthenticatedUser(req); + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + if (!ALLOWED_ROLES.includes(user.role)) { + return res + .status(403) + .json({ message: 'Forbidden: Admin or Judge role required' }); + } + + const { teamId } = req.query; + if (!teamId || typeof teamId !== 'string') { + return res.status(400).json({ message: 'teamId is required' }); + } + + const body: ScoreUpdateRequest = req.body; + + // Validate and normalize pass (handle string "1"/"2" from some clients) + const passNum = + typeof body.pass === 'string' ? parseInt(body.pass, 10) : body.pass; + if (passNum !== 1 && passNum !== 2) { + return res.status(400).json({ message: 'pass must be 1 or 2' }); + } + body.pass = passNum as 1 | 2; + + // Validate score values (1-5 if provided) + const scoreFields = [ + 'problem', + 'solution', + 'implementation', + 'roadmap', + ] as const; + for (const field of scoreFields) { + const value = body[field]; + if (value !== undefined && value !== null) { + if (typeof value !== 'number' || value < 1 || value > 5) { + return res + .status(400) + .json({ message: `${field} must be between 1 and 5` }); + } + } + } + + // Validate decision values based on pass + if (body.decision !== undefined && body.decision !== null) { + const validPass1Decisions = ['yes', 'no', 'maybe']; + const validPass2Decisions = ['yes', 'no', 'waitlist']; + const validDecisions = + body.pass === 1 ? validPass1Decisions : validPass2Decisions; + + if (!validDecisions.includes(body.decision)) { + return res.status(400).json({ + message: `decision must be one of: ${validDecisions.join(', ')}`, + }); + } + } + + try { + const hbc = container.resolve(HibiscusSupabaseClient); + hbc.setOptions({ useServiceKey: true }); + const supabase = hbc.getClient(); + + // Verify team exists and has submitted + const { data: team, error: teamError } = await supabase + .from('teams') + .select('team_id, submission_status') + .eq('team_id', teamId) + .single(); + + if (teamError || !team) { + return res.status(404).json({ message: 'Team not found' }); + } + + if (team.submission_status !== SUBMITTED_STATUS) { + return res.status(400).json({ + message: + 'Team has not submitted yet. Only submitted teams can be judged.', + }); + } + + // Check reviewer cap + const maxReviewers = + body.pass === 1 ? PASS_1_MAX_REVIEWERS : PASS_2_MAX_REVIEWERS; + const { data: existingScores, error: scoresError } = await supabase + .from('judging_scores') + .select('judge_id') + .eq('team_id', teamId) + .eq('pass', body.pass); + + if (scoresError) { + console.error('[judge/scores] Scores fetch error:', scoresError); + return res + .status(500) + .json({ message: 'Failed to check existing scores' }); + } + + const existingJudgeIds = (existingScores || []).map((s) => s.judge_id); + const userAlreadyScored = existingJudgeIds.includes(user.user_id); + + // If user hasn't scored yet and cap is reached, reject + if ( + !userAlreadyScored && + existingScores && + existingScores.length >= maxReviewers + ) { + return res.status(409).json({ + message: `Pass ${body.pass} already has ${maxReviewers} reviewers. Cannot add more.`, + reviewCount: existingScores.length, + maxReviewers, + }); + } + + // Build upsert payload + const payload: Record = { + team_id: teamId, + judge_id: user.user_id, + pass: body.pass, + }; + + // Add score fields if provided + for (const field of scoreFields) { + if (body[field] !== undefined) { + payload[field] = body[field]; + } + } + + if (body.decision !== undefined) { + payload.decision = body.decision; + } + + if (body.notes !== undefined) { + payload.notes = body.notes; + } + + // Upsert the score + const { error: upsertError } = await supabase + .from('judging_scores') + .upsert(payload, { onConflict: 'team_id,judge_id,pass' }); + + if (upsertError) { + console.error('[judge/scores] Upsert error:', upsertError); + return res.status(500).json({ message: 'Failed to save score' }); + } + + // Fetch all scores for this team/pass to compute aggregate + const { data: allScores, error: fetchError } = await supabase + .from('judging_scores') + .select('*') + .eq('team_id', teamId) + .eq('pass', body.pass); + + if (fetchError) { + console.error('[judge/scores] Fetch error:', fetchError); + return res.status(500).json({ + message: 'Score saved but failed to fetch updated data', + }); + } + + // Get judge names + const judgeIds = [ + ...new Set( + (allScores || []) + .map((s) => s.judge_id) + .filter((id): id is string => id !== null) + ), + ]; + const judgeNameMap = new Map(); + if (judgeIds.length > 0) { + const { data: judges } = await supabase + .from('user_profiles') + .select('user_id, first_name, last_name') + .in('user_id', judgeIds); + + for (const judge of judges || []) { + const name = [judge.first_name, judge.last_name] + .filter(Boolean) + .join(' '); + judgeNameMap.set(judge.user_id, name || 'Unknown'); + } + } + + // Helper functions + const computeAvg = ( + p: number | null, + s: number | null, + i: number | null, + r: number | null + ): number | null => { + const scores = [p, s, i, r].filter((x) => x !== null) as number[]; + if (scores.length === 0) return null; + return ( + Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 10) / + 10 + ); + }; + + const computeArrayAvg = (values: (number | null)[]): number | null => { + const nums = values.filter((x): x is number => x !== null); + if (nums.length === 0) return null; + return ( + Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 10) / 10 + ); + }; + + const computePass1Consensus = (decisions: string[]): string | null => { + if (decisions.length === 0) return null; + const counts = { yes: 0, no: 0, maybe: 0 }; + for (const d of decisions) { + if (d === 'yes' || d === 'no' || d === 'maybe') counts[d]++; + } + if (counts.yes > counts.no + counts.maybe) return 'yes'; + if (counts.no > counts.yes + counts.maybe) return 'no'; + if (counts.yes + counts.maybe > counts.no) return 'maybe'; + return 'no'; + }; + + const computePass2Consensus = (decisions: string[]): string | null => { + if (decisions.length === 0) return null; + const counts = { yes: 0, no: 0, waitlist: 0 }; + for (const d of decisions) { + if (d === 'yes' || d === 'no' || d === 'waitlist') counts[d]++; + } + if (counts.yes >= 2) return 'yes'; + if (counts.no >= 2) return 'no'; + if (counts.waitlist >= 2) return 'waitlist'; + return counts.yes > 0 ? 'waitlist' : 'no'; + }; + + // Build scores array + const scores: ReviewerScore[] = (allScores || []).map((s) => ({ + judgeId: s.judge_id, + judgeName: s.judge_id ? judgeNameMap.get(s.judge_id) || null : null, + problem: s.problem, + solution: s.solution, + implementation: s.implementation, + roadmap: s.roadmap, + decision: s.decision, + notes: s.notes, + avgScore: computeAvg(s.problem, s.solution, s.implementation, s.roadmap), + })); + + // Build aggregate + const decisions = (allScores || []) + .map((s) => s.decision) + .filter((d): d is string => d !== null); + + const decisionCounts: Record = {}; + for (const d of decisions) { + decisionCounts[d] = (decisionCounts[d] || 0) + 1; + } + + const computeConsensus = + body.pass === 1 ? computePass1Consensus : computePass2Consensus; + + const aggregate: PassAggregate = { + reviewCount: (allScores || []).length, + maxReviewers, + avgProblem: computeArrayAvg((allScores || []).map((s) => s.problem)), + avgSolution: computeArrayAvg((allScores || []).map((s) => s.solution)), + avgImplementation: computeArrayAvg( + (allScores || []).map((s) => s.implementation) + ), + avgRoadmap: computeArrayAvg((allScores || []).map((s) => s.roadmap)), + avgTotal: computeArrayAvg( + (allScores || []).map((s) => + computeAvg(s.problem, s.solution, s.implementation, s.roadmap) + ) + ), + decisions: decisionCounts, + consensus: computeConsensus(decisions), + }; + + // Find user's score + const myScore = scores.find((s) => s.judgeId === user.user_id) || null; + + return res.status(200).json({ + score: myScore, + scores, + aggregate, + }); + } catch (e) { + console.error('[judge/scores] Error:', e); + return res.status(500).json({ message: 'Internal server error' }); + } +} diff --git a/apps/dashboard/pages/api/judge/submissions.ts b/apps/dashboard/pages/api/judge/submissions.ts new file mode 100644 index 0000000..f8bbcd7 --- /dev/null +++ b/apps/dashboard/pages/api/judge/submissions.ts @@ -0,0 +1,555 @@ +import 'reflect-metadata'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { container } from 'tsyringe'; +import { HibiscusSupabaseClient } from '@hibiscus/hibiscus-supabase-client'; +import { getAuthenticatedUser } from '../../../common/auth'; + +/** + * Role IDs from the `roles` table: + * 1 = SUPERADMIN + * 7 = JUDGE + * These are checked against user_profiles.role + */ +const ALLOWED_ROLES = [1, 7]; + +// Pagination defaults (high limit to load all for hackathon scale) +const DEFAULT_PAGE = 1; +const DEFAULT_LIMIT = 1000; +const MAX_LIMIT = 1000; + +// Reviewer caps per pass +const PASS_1_MAX_REVIEWERS = 4; +const PASS_2_MAX_REVIEWERS = 3; + +// Type for track data from Supabase join +interface TrackData { + id: number; + name: string; + sdg_number: number; +} + +// Legacy JudgingNotes type (kept for backward compatibility) +export interface JudgingNotes { + team_id: string; + pass_1: 'yes' | 'no' | 'maybe' | null; + pass_1_problem: number | null; + pass_1_solution: number | null; + pass_1_implementation: number | null; + pass_1_roadmap: number | null; + pass_1_notes: string | null; + pass_1_by: string | null; + pass_1_at: string | null; + pass_2: 'yes' | 'no' | 'waitlist' | null; + pass_2_problem: number | null; + pass_2_solution: number | null; + pass_2_implementation: number | null; + pass_2_roadmap: number | null; + pass_2_notes: string | null; + pass_2_by: string | null; + pass_2_at: string | null; + final_decision: 'finalist' | 'waitlist' | 'not_selected' | null; + updated_at: string | null; +} + +// New multi-reviewer types +export interface ReviewerScore { + judgeId: string | null; + judgeName: string | null; + problem: number | null; + solution: number | null; + implementation: number | null; + roadmap: number | null; + decision: string | null; + notes: string | null; + avgScore: number | null; +} + +export interface PassAggregate { + reviewCount: number; + maxReviewers: number; + avgProblem: number | null; + avgSolution: number | null; + avgImplementation: number | null; + avgRoadmap: number | null; + avgTotal: number | null; + decisions: Record; + consensus: string | null; +} + +export interface SubmissionForJudging { + teamId: string; + teamName: string; + projectTitle: string | null; + memberCount: number; + track: { + id: number; + name: string; + sdgNumber: number; + } | null; + isHardware: boolean; + submission: { + youtubeUrl: string | null; + githubUrl: string | null; + liveUrl: string | null; + pdfUrl: string | null; + hwBomUrl: string | null; + tallyData: Record | null; + submittedAt: string | null; + } | null; + // New multi-reviewer data + pass1: { scores: ReviewerScore[]; aggregate: PassAggregate }; + pass2: { scores: ReviewerScore[]; aggregate: PassAggregate }; + myPass1Score: ReviewerScore | null; + myPass2Score: ReviewerScore | null; + finalDecision: string | null; + // Legacy fields (kept for backward compatibility during transition) + judgingNotes: JudgingNotes | null; + pass1Avg: number | null; + pass2Avg: number | null; +} + +export interface SubmissionsResponse { + submissions: SubmissionForJudging[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} + +/** + * GET /api/judge/submissions + * Returns submitted teams with their submissions and judging notes. + * Only accessible by admins (role=1) and judges (role=7). + * + * Query params: + * - page: Page number (default: 1) + * - limit: Items per page (default: 50, max: 100) + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'GET') { + return res.status(405).json({ message: 'Method not allowed' }); + } + + const user = await getAuthenticatedUser(req); + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + if (!ALLOWED_ROLES.includes(user.role)) { + return res + .status(403) + .json({ message: 'Forbidden: Admin or Judge role required' }); + } + + // Parse pagination params + const page = Math.max(1, parseInt(req.query.page as string) || DEFAULT_PAGE); + const limit = Math.min( + MAX_LIMIT, + Math.max(1, parseInt(req.query.limit as string) || DEFAULT_LIMIT) + ); + const offset = (page - 1) * limit; + + try { + const hbc = container.resolve(HibiscusSupabaseClient); + hbc.setOptions({ useServiceKey: true }); + const supabase = hbc.getClient(); + + // Get total count of submitted teams + const { count: totalCount, error: countError } = await supabase + .from('teams') + .select('*', { count: 'exact', head: true }) + .eq('submission_status', 2); + + if (countError) { + console.error('[judge/submissions] Count error:', countError); + return res.status(500).json({ message: 'Failed to count teams' }); + } + + const total = totalCount ?? 0; + if (total === 0) { + return res.status(200).json({ + submissions: [], + pagination: { page, limit, total: 0, totalPages: 0 }, + }); + } + + // Get paginated teams with submission_status = 2 (SUBMITTED) + const { data: teams, error: teamsError } = await supabase + .from('teams') + .select( + ` + team_id, + name, + project_title, + track_id, + is_hardware, + submission_status, + tracks ( + id, + name, + sdg_number + ) + ` + ) + .eq('submission_status', 2) + .order('name', { ascending: true }) + .range(offset, offset + limit - 1); + + if (teamsError) { + console.error('[judge/submissions] Teams fetch error:', teamsError); + return res.status(500).json({ message: 'Failed to fetch teams' }); + } + + if (!teams || teams.length === 0) { + return res.status(200).json({ + submissions: [], + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }); + } + + const teamIds = teams.map((t) => t.team_id); + + // Get member counts per team + const { data: memberCounts, error: memberCountError } = await supabase + .from('user_profiles') + .select('team_id') + .in('team_id', teamIds); + + if (memberCountError) { + console.error( + '[judge/submissions] Member count fetch error:', + memberCountError + ); + } + + // Build member count map + const memberCountByTeam = new Map(); + for (const profile of memberCounts || []) { + if (profile.team_id) { + memberCountByTeam.set( + profile.team_id, + (memberCountByTeam.get(profile.team_id) || 0) + 1 + ); + } + } + + // Get latest submission for each team + const { data: submissions, error: submissionsError } = await supabase + .from('team_submissions') + .select('*') + .in('team_id', teamIds) + .order('submitted_at', { ascending: false }); + + if (submissionsError) { + console.error( + '[judge/submissions] Submissions fetch error:', + submissionsError + ); + return res.status(500).json({ message: 'Failed to fetch submissions' }); + } + + // Get judging notes (for final_decision and backward compatibility) + const { data: judgingNotes, error: notesError } = await supabase + .from('judging_notes') + .select('*') + .in('team_id', teamIds); + + if (notesError) { + console.error('[judge/submissions] Notes fetch error:', notesError); + } + + // Get judging scores (multi-reviewer) + const { data: judgingScores, error: scoresError } = await supabase + .from('judging_scores') + .select('*') + .in('team_id', teamIds); + + if (scoresError) { + console.error('[judge/submissions] Scores fetch error:', scoresError); + } + + // Get judge names for display + const judgeIds = [ + ...new Set( + (judgingScores || []) + .map((s) => s.judge_id) + .filter((id): id is string => id !== null) + ), + ]; + const judgeNameMap = new Map(); + if (judgeIds.length > 0) { + const { data: judges } = await supabase + .from('user_profiles') + .select('user_id, first_name, last_name') + .in('user_id', judgeIds); + + for (const judge of judges || []) { + const name = [judge.first_name, judge.last_name] + .filter(Boolean) + .join(' '); + judgeNameMap.set(judge.user_id, name || 'Unknown'); + } + } + + // Build lookup maps + const latestSubmissionByTeam = new Map(); + for (const sub of submissions || []) { + if (!latestSubmissionByTeam.has(sub.team_id)) { + latestSubmissionByTeam.set(sub.team_id, sub); + } + } + + const notesByTeam = new Map(); + for (const note of judgingNotes || []) { + notesByTeam.set(note.team_id, note); + } + + // Group scores by team and pass + type ScoreRow = (typeof judgingScores)[0]; + const scoresByTeamPass = new Map< + string, + { pass1: ScoreRow[]; pass2: ScoreRow[] } + >(); + for (const score of judgingScores || []) { + const key = score.team_id; + if (!scoresByTeamPass.has(key)) { + scoresByTeamPass.set(key, { pass1: [], pass2: [] }); + } + const entry = scoresByTeamPass.get(key)!; + if (score.pass === 1) { + entry.pass1.push(score); + } else if (score.pass === 2) { + entry.pass2.push(score); + } + } + + // Compute averages helper + const computeAvg = ( + p: number | null, + s: number | null, + i: number | null, + r: number | null + ): number | null => { + const scores = [p, s, i, r].filter((x) => x !== null) as number[]; + if (scores.length === 0) return null; + return ( + Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 10) / + 10 + ); + }; + + // Compute aggregate average from array of numbers + const computeArrayAvg = (values: (number | null)[]): number | null => { + const nums = values.filter((x): x is number => x !== null); + if (nums.length === 0) return null; + return ( + Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 10) / 10 + ); + }; + + // Consensus logic for Pass 1 (4 reviewers): yes/no/maybe + const computePass1Consensus = (decisions: string[]): string | null => { + if (decisions.length === 0) return null; + const counts = { yes: 0, no: 0, maybe: 0 }; + for (const d of decisions) { + if (d === 'yes' || d === 'no' || d === 'maybe') { + counts[d]++; + } + } + if (counts.yes > counts.no + counts.maybe) return 'yes'; + if (counts.no > counts.yes + counts.maybe) return 'no'; + if (counts.yes + counts.maybe > counts.no) return 'maybe'; + return 'no'; + }; + + // Consensus logic for Pass 2 (3 reviewers): yes/no/waitlist + const computePass2Consensus = (decisions: string[]): string | null => { + if (decisions.length === 0) return null; + const counts = { yes: 0, no: 0, waitlist: 0 }; + for (const d of decisions) { + if (d === 'yes' || d === 'no' || d === 'waitlist') { + counts[d]++; + } + } + if (counts.yes >= 2) return 'yes'; + if (counts.no >= 2) return 'no'; + if (counts.waitlist >= 2) return 'waitlist'; + return counts.yes > 0 ? 'waitlist' : 'no'; + }; + + // Build ReviewerScore from a score row + const buildReviewerScore = (score: ScoreRow): ReviewerScore => ({ + judgeId: score.judge_id, + judgeName: score.judge_id + ? judgeNameMap.get(score.judge_id) || null + : null, + problem: score.problem, + solution: score.solution, + implementation: score.implementation, + roadmap: score.roadmap, + decision: score.decision, + notes: score.notes, + avgScore: computeAvg( + score.problem, + score.solution, + score.implementation, + score.roadmap + ), + }); + + // Build PassAggregate from array of scores + const buildPassAggregate = ( + scores: ScoreRow[], + maxReviewers: number, + computeConsensus: (decisions: string[]) => string | null + ): PassAggregate => { + const decisions = scores + .map((s) => s.decision) + .filter((d): d is string => d !== null); + + const decisionCounts: Record = {}; + for (const d of decisions) { + decisionCounts[d] = (decisionCounts[d] || 0) + 1; + } + + return { + reviewCount: scores.length, + maxReviewers, + avgProblem: computeArrayAvg(scores.map((s) => s.problem)), + avgSolution: computeArrayAvg(scores.map((s) => s.solution)), + avgImplementation: computeArrayAvg(scores.map((s) => s.implementation)), + avgRoadmap: computeArrayAvg(scores.map((s) => s.roadmap)), + avgTotal: computeArrayAvg( + scores.map((s) => + computeAvg(s.problem, s.solution, s.implementation, s.roadmap) + ) + ), + decisions: decisionCounts, + consensus: computeConsensus(decisions), + }; + }; + + // Helper to safely extract track data from Supabase join result + const extractTrack = ( + tracks: unknown + ): { id: number; name: string; sdgNumber: number } | null => { + if (!tracks || typeof tracks !== 'object') return null; + const t = tracks as Record; + if ( + typeof t.id === 'number' && + typeof t.name === 'string' && + typeof t.sdg_number === 'number' + ) { + return { id: t.id, name: t.name, sdgNumber: t.sdg_number }; + } + return null; + }; + + // Build response + const result: SubmissionForJudging[] = teams.map((team) => { + const sub = latestSubmissionByTeam.get(team.team_id); + const notes = notesByTeam.get(team.team_id); + const teamScores = scoresByTeamPass.get(team.team_id) || { + pass1: [], + pass2: [], + }; + + // Find current user's scores + const myPass1 = teamScores.pass1.find((s) => s.judge_id === user.user_id); + const myPass2 = teamScores.pass2.find((s) => s.judge_id === user.user_id); + + // Build pass aggregates + const pass1 = { + scores: teamScores.pass1.map(buildReviewerScore), + aggregate: buildPassAggregate( + teamScores.pass1, + PASS_1_MAX_REVIEWERS, + computePass1Consensus + ), + }; + const pass2 = { + scores: teamScores.pass2.map(buildReviewerScore), + aggregate: buildPassAggregate( + teamScores.pass2, + PASS_2_MAX_REVIEWERS, + computePass2Consensus + ), + }; + + return { + teamId: team.team_id, + teamName: team.name, + projectTitle: team.project_title, + memberCount: memberCountByTeam.get(team.team_id) || 0, + track: extractTrack(team.tracks), + isHardware: team.is_hardware ?? false, + submission: sub + ? { + youtubeUrl: sub.youtube_url, + githubUrl: sub.github_url, + liveUrl: sub.live_url, + pdfUrl: sub.pdf_url, + hwBomUrl: sub.hw_bom_url ?? null, + tallyData: sub.tally_data as Record | null, + submittedAt: sub.submitted_at, + } + : null, + // New multi-reviewer data + pass1, + pass2, + myPass1Score: myPass1 ? buildReviewerScore(myPass1) : null, + myPass2Score: myPass2 ? buildReviewerScore(myPass2) : null, + finalDecision: notes?.final_decision ?? null, + // Legacy fields (for backward compatibility) + judgingNotes: notes + ? { + team_id: notes.team_id, + pass_1: notes.pass_1, + pass_1_problem: notes.pass_1_problem, + pass_1_solution: notes.pass_1_solution, + pass_1_implementation: notes.pass_1_implementation, + pass_1_roadmap: notes.pass_1_roadmap, + pass_1_notes: notes.pass_1_notes, + pass_1_by: notes.pass_1_by, + pass_1_at: notes.pass_1_at, + pass_2: notes.pass_2, + pass_2_problem: notes.pass_2_problem, + pass_2_solution: notes.pass_2_solution, + pass_2_implementation: notes.pass_2_implementation, + pass_2_roadmap: notes.pass_2_roadmap, + pass_2_notes: notes.pass_2_notes, + pass_2_by: notes.pass_2_by, + pass_2_at: notes.pass_2_at, + final_decision: notes.final_decision, + updated_at: notes.updated_at, + } + : null, + pass1Avg: pass1.aggregate.avgTotal, + pass2Avg: pass2.aggregate.avgTotal, + }; + }); + + return res.status(200).json({ + submissions: result, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + } as SubmissionsResponse); + } catch (e) { + console.error('[judge/submissions] Error:', e); + return res.status(500).json({ message: 'Internal server error' }); + } +} diff --git a/apps/dashboard/pages/api/judge/tally-profile.ts b/apps/dashboard/pages/api/judge/tally-profile.ts new file mode 100644 index 0000000..3e550cb --- /dev/null +++ b/apps/dashboard/pages/api/judge/tally-profile.ts @@ -0,0 +1,249 @@ +import 'reflect-metadata'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { getAuthenticatedUser } from '../../../common/auth'; +import { getEnv } from '@hibiscus/env'; + +/** + * Role IDs from the `roles` table: + * 1 = SUPERADMIN + * 7 = JUDGE + */ +const ALLOWED_ROLES = [1, 7]; + +// Fields we want to extract from Tally response +const FIELD_MAPPINGS: Record = { + 'What is your name?': 'firstName', + 'Last Name': 'lastName', + 'Phone number': 'phone', + 'Date of Birth': 'dateOfBirth', + Gender: 'gender', + 'What university do you attend?': 'university', + 'What program are you in?': 'program', + 'What is your major or primary field of study (Branch/Specialisation)?': + 'major', + 'Graduation year?': 'graduationYear', + 'What state are you from?': 'state', + 'What is your country of residence?': 'country', + 'Devpost Profile URL': 'devpostUrl', + 'GitHub URL': 'githubUrl', + 'LinkedIn URL': 'linkedinUrl', + 'Twitter URL (if any)': 'twitterUrl', + 'Personal Portfolio (if any)': 'portfolioUrl', +}; + +interface ParsedProfile { + firstName: string | null; + lastName: string | null; + phone: string | null; + dateOfBirth: string | null; + gender: string | null; + university: string | null; + program: string | null; + major: string | null; + graduationYear: string | null; + state: string | null; + country: string | null; + devpostUrl: string | null; + githubUrl: string | null; + linkedinUrl: string | null; + twitterUrl: string | null; + portfolioUrl: string | null; +} + +/** + * GET /api/judge/tally-profile?appId=xxx + * Fetches personal info from Tally API for a given application ID. + * Only accessible by admins (role=1) and judges (role=7). + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'GET') { + return res.status(405).json({ message: 'Method not allowed' }); + } + + const user = await getAuthenticatedUser(req); + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + if (!ALLOWED_ROLES.includes(user.role)) { + return res + .status(403) + .json({ message: 'Forbidden: Admin or Judge role required' }); + } + + const appId = req.query.appId as string; + if (!appId) { + return res.status(400).json({ message: 'appId query param is required' }); + } + + // Get Tally form ID from env (extract from URL if needed) + const tallyFormUrl = getEnv().Hibiscus.Hackform.TallyApps2024Url; + const tallyApiToken = getEnv().Hibiscus.Hackform.TallyAPIToken; + + if (!tallyApiToken) { + return res.status(500).json({ message: 'Tally API token not configured' }); + } + + // Extract form ID from URL (format: https://tally.so/r/FORM_ID?...) + const formIdMatch = tallyFormUrl?.match(/tally\.so\/r\/([^?&/]+)/); + const formId = formIdMatch?.[1]; + + if (!formId) { + return res.status(500).json({ message: 'Tally form ID not configured' }); + } + + try { + // Fetch submission from Tally API + const tallyResponse = await fetch( + `https://api.tally.so/forms/${formId}/submissions/${appId}`, + { + headers: { + Authorization: `Bearer ${tallyApiToken}`, + }, + } + ); + + if (!tallyResponse.ok) { + if (tallyResponse.status === 404) { + return res.status(404).json({ message: 'Application not found' }); + } + console.error( + '[tally-profile] Tally API error:', + tallyResponse.status, + await tallyResponse.text() + ); + return res.status(502).json({ message: 'Failed to fetch from Tally' }); + } + + const tallyData = await tallyResponse.json(); + + // Tally API returns: + // - questions[]: question definitions with id and title + // - submission.responses[]: answers with questionId and answer + const questions = tallyData.questions || []; + const responses = tallyData.submission?.responses || []; + + if (!Array.isArray(questions) || !Array.isArray(responses)) { + console.error( + '[tally-profile] Unexpected structure:', + Object.keys(tallyData) + ); + return res.status(200).json({ + appId, + profile: null, + _debug: { + message: 'Unexpected Tally response structure', + keys: Object.keys(tallyData), + }, + }); + } + + // Build questionId → title map + const questionTitleMap = new Map(); + for (const q of questions) { + if (q.id && q.title) { + questionTitleMap.set(q.id, q.title); + } + } + + // Parse responses using question titles + const profile = parseProfileFromResponses(responses, questionTitleMap); + + // Find unmapped questions (for debugging) + const mappedTitles = new Set(Object.keys(FIELD_MAPPINGS)); + const unmappedFields = questions + .filter((q: { title: string }) => q.title && !mappedTitles.has(q.title)) + .map((q: { title: string }) => q.title); + + return res.status(200).json({ + appId, + profile, + _debug: { + questionCount: questions.length, + responseCount: responses.length, + unmappedFields, + }, + }); + } catch (e) { + console.error('[tally-profile] Error:', e); + return res.status(500).json({ message: 'Internal server error' }); + } +} + +interface TallyResponse { + questionId: string; + answer: unknown; +} + +function parseProfileFromResponses( + responses: TallyResponse[], + questionTitleMap: Map +): ParsedProfile { + const profile: ParsedProfile = { + firstName: null, + lastName: null, + phone: null, + dateOfBirth: null, + gender: null, + university: null, + program: null, + major: null, + graduationYear: null, + state: null, + country: null, + devpostUrl: null, + githubUrl: null, + linkedinUrl: null, + twitterUrl: null, + portfolioUrl: null, + }; + + for (const response of responses) { + const questionTitle = questionTitleMap.get(response.questionId); + if (!questionTitle) continue; + + const mappedKey = FIELD_MAPPINGS[questionTitle]; + if (mappedKey && mappedKey in profile) { + profile[mappedKey as keyof ParsedProfile] = extractAnswer( + response.answer + ); + } + } + + return profile; +} + +function extractAnswer(answer: unknown): string | null { + if (answer === null || answer === undefined) { + return null; + } + + // Handle string answers + if (typeof answer === 'string') { + return answer || null; + } + + // Handle array answers (dropdowns, multi-select) + if (Array.isArray(answer)) { + if (answer.length === 0) return null; + if (typeof answer[0] === 'string') { + return answer.join(', '); + } + return JSON.stringify(answer[0]); + } + + // Handle object answers (like hidden fields with key-value pairs) + if (typeof answer === 'object') { + // For hidden fields like { hibiscusUserId: "xxx" }, return the value + const values = Object.values(answer as Record); + if (values.length === 1 && typeof values[0] === 'string') { + return values[0]; + } + return JSON.stringify(answer); + } + + return String(answer); +} diff --git a/apps/dashboard/pages/api/judge/team-profiles.ts b/apps/dashboard/pages/api/judge/team-profiles.ts new file mode 100644 index 0000000..4e0ee1b --- /dev/null +++ b/apps/dashboard/pages/api/judge/team-profiles.ts @@ -0,0 +1,245 @@ +import 'reflect-metadata'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { container } from 'tsyringe'; +import { HibiscusSupabaseClient } from '@hibiscus/hibiscus-supabase-client'; +import { getAuthenticatedUser } from '../../../common/auth'; +import { getEnv } from '@hibiscus/env'; + +const ALLOWED_ROLES = [1, 7]; // SUPERADMIN, JUDGE + +// Field mappings from Tally question titles to our keys +// Note: Duplicated from tally-profile.ts - consider extracting to shared util +const FIELD_MAPPINGS: Record = { + 'What is your name?': 'firstName', + 'Last Name': 'lastName', + 'Phone number': 'phone', + 'Date of Birth': 'dateOfBirth', + Gender: 'gender', + 'What university do you attend?': 'university', + 'What program are you in?': 'program', + 'What is your major or primary field of study (Branch/Specialisation)?': + 'major', + 'Graduation year?': 'graduationYear', + 'What state are you from?': 'state', + 'What is your country of residence?': 'country', + 'Devpost Profile URL': 'devpostUrl', + 'GitHub URL': 'githubUrl', + 'LinkedIn URL': 'linkedinUrl', + 'Twitter URL (if any)': 'twitterUrl', + 'Personal Portfolio (if any)': 'portfolioUrl', +}; + +interface TallyProfile { + firstName: string | null; + lastName: string | null; + phone: string | null; + dateOfBirth: string | null; + gender: string | null; + university: string | null; + program: string | null; + major: string | null; + graduationYear: string | null; + state: string | null; + country: string | null; + devpostUrl: string | null; + githubUrl: string | null; + linkedinUrl: string | null; + twitterUrl: string | null; + portfolioUrl: string | null; +} + +interface MemberProfile { + userId: string; + email: string | null; + firstName: string; + lastName: string; + appId: string | null; + tallyProfile: TallyProfile | null; + tallyError: string | null; +} + +/** + * GET /api/judge/team-profiles?teamId=xxx + * Fetches all team members with their Tally profile data. + * Only accessible by admins (role=1) and judges (role=7). + * + * Note: Makes parallel Tally API calls (one per member with app_id). + * Max team size is 4, so this is acceptable. For larger batches, + * consider implementing request throttling. + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'GET') { + return res.status(405).json({ message: 'Method not allowed' }); + } + + const user = await getAuthenticatedUser(req); + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + if (!ALLOWED_ROLES.includes(user.role)) { + return res + .status(403) + .json({ message: 'Forbidden: Admin or Judge role required' }); + } + + const teamId = req.query.teamId as string; + if (!teamId) { + return res.status(400).json({ message: 'teamId query param is required' }); + } + + // Get Tally config upfront + const tallyFormUrl = getEnv().Hibiscus.Hackform.TallyApps2024Url; + const tallyApiToken = getEnv().Hibiscus.Hackform.TallyAPIToken; + const formIdMatch = tallyFormUrl?.match(/tally\.so\/r\/([^?&/]+)/); + const formId = formIdMatch?.[1]; + const tallyConfigured = !!(formId && tallyApiToken); + + try { + const hbc = container.resolve(HibiscusSupabaseClient); + hbc.setOptions({ useServiceKey: true }); + const supabase = hbc.getClient(); + + // Fetch team members + const { data: members, error: membersError } = await supabase + .from('user_profiles') + .select('user_id, email, first_name, last_name, app_id') + .eq('team_id', teamId); + + if (membersError) { + console.error('[team-profiles] Members fetch error:', membersError); + return res.status(500).json({ message: 'Failed to fetch team members' }); + } + + if (!members || members.length === 0) { + return res.status(200).json({ teamId, members: [] }); + } + + // Fetch Tally profiles in parallel for members with app_id + const memberProfiles: MemberProfile[] = await Promise.all( + members.map(async (member) => { + const base: MemberProfile = { + userId: member.user_id, + email: member.email, + firstName: member.first_name, + lastName: member.last_name, + appId: member.app_id, + tallyProfile: null, + tallyError: null, + }; + + // Skip Tally fetch if no app_id or Tally not configured + if (!member.app_id) { + base.tallyError = 'No application ID'; + return base; + } + + if (!tallyConfigured) { + base.tallyError = 'Tally not configured'; + return base; + } + + try { + const tallyResponse = await fetch( + `https://api.tally.so/forms/${formId}/submissions/${member.app_id}`, + { + headers: { Authorization: `Bearer ${tallyApiToken}` }, + } + ); + + if (!tallyResponse.ok) { + base.tallyError = `Tally API error: ${tallyResponse.status}`; + return base; + } + + const tallyData = await tallyResponse.json(); + const questions = tallyData.questions || []; + const responses = tallyData.submission?.responses || []; + + // Build questionId → title map + const questionTitleMap = new Map(); + for (const q of questions) { + if (q.id && q.title) { + questionTitleMap.set(q.id, q.title); + } + } + + base.tallyProfile = parseProfile(responses, questionTitleMap); + return base; + } catch (e) { + base.tallyError = e instanceof Error ? e.message : 'Unknown error'; + return base; + } + }) + ); + + return res.status(200).json({ + teamId, + members: memberProfiles, + _meta: { tallyConfigured }, + }); + } catch (e) { + console.error('[team-profiles] Error:', e); + return res.status(500).json({ message: 'Internal server error' }); + } +} + +interface TallyResponseItem { + questionId: string; + answer: unknown; +} + +function parseProfile( + responses: TallyResponseItem[], + questionTitleMap: Map +): TallyProfile { + const profile: TallyProfile = { + firstName: null, + lastName: null, + phone: null, + dateOfBirth: null, + gender: null, + university: null, + program: null, + major: null, + graduationYear: null, + state: null, + country: null, + devpostUrl: null, + githubUrl: null, + linkedinUrl: null, + twitterUrl: null, + portfolioUrl: null, + }; + + for (const response of responses) { + const questionTitle = questionTitleMap.get(response.questionId); + if (!questionTitle) continue; + + const mappedKey = FIELD_MAPPINGS[questionTitle]; + if (mappedKey && mappedKey in profile) { + profile[mappedKey as keyof TallyProfile] = extractAnswer(response.answer); + } + } + + return profile; +} + +function extractAnswer(answer: unknown): string | null { + if (answer === null || answer === undefined) return null; + if (typeof answer === 'string') return answer || null; + if (Array.isArray(answer)) { + if (answer.length === 0) return null; + if (typeof answer[0] === 'string') return answer.join(', '); + return JSON.stringify(answer[0]); + } + if (typeof answer === 'object') { + const values = Object.values(answer as Record); + if (values.length === 1 && typeof values[0] === 'string') return values[0]; + return JSON.stringify(answer); + } + return String(answer); +} diff --git a/apps/dashboard/pages/index.tsx b/apps/dashboard/pages/index.tsx index 3d36deb..479269d 100644 --- a/apps/dashboard/pages/index.tsx +++ b/apps/dashboard/pages/index.tsx @@ -133,7 +133,10 @@ export function Index({ appsOpen, waitlistOpen }: ServerSideProps) { router.push('/sponsor-booth'); return <>; } else if (user.role === HibiscusRole.JUDGE) { - window.location.replace('https://podium.hacksc.com'); + router.push('/judge'); + return <>; + } else if (user.role === HibiscusRole.SUPERADMIN) { + router.push('/judge'); return <>; } else if (user.role === HibiscusRole.VOLUNTEER) { router.push('/identity-portal/attendee-event-scan'); diff --git a/apps/dashboard/pages/judge/index.tsx b/apps/dashboard/pages/judge/index.tsx new file mode 100644 index 0000000..ffe017f --- /dev/null +++ b/apps/dashboard/pages/judge/index.tsx @@ -0,0 +1,2969 @@ +import React, { + useState, + useEffect, + useMemo, + useCallback, + useRef, + ChangeEvent, + KeyboardEvent, +} from 'react'; +import styled from 'styled-components'; +import { useRouter } from 'next/router'; +import useHibiscusUser from '../../hooks/use-hibiscus-user/use-hibiscus-user'; +import { HibiscusRole } from '@hibiscus/types'; +import { + NeoButton, + NeoInput, + neoColors, + neoBorders, + neoShadows, +} from '../../components/neo-ui'; +import type { + SubmissionForJudging, + ReviewerScore, + PassAggregate, +} from '../api/judge/submissions'; + +type Pass1Decision = 'yes' | 'no' | 'maybe'; +type Pass2Decision = 'yes' | 'no' | 'waitlist'; +type FinalDecision = 'finalist' | 'waitlist' | 'not_selected'; +type FilterStatus = + | 'all' + | 'yes' + | 'no' + | 'maybe' + | 'waitlist' + | 'unreviewed' + | 'needs_reviews'; +type FinalFilterStatus = + | 'all' + | 'finalist' + | 'waitlist' + | 'not_selected' + | 'unreviewed'; +type ActivePass = 1 | 2 | 'final'; +type HardwareFilter = 'all' | 'hardware' | 'software'; +type SortField = + | 'teamName' + | 'pass1Avg' + | 'pass2Avg' + | 'p1ReviewCount' + | 'p2ReviewCount'; + +// Tally profile types for team member display +interface TallyProfile { + firstName: string | null; + lastName: string | null; + phone: string | null; + dateOfBirth: string | null; + gender: string | null; + university: string | null; + program: string | null; + major: string | null; + graduationYear: string | null; + state: string | null; + country: string | null; + devpostUrl: string | null; + githubUrl: string | null; + linkedinUrl: string | null; + twitterUrl: string | null; + portfolioUrl: string | null; +} + +interface MemberProfile { + userId: string; + email: string | null; + firstName: string; + lastName: string; + appId: string | null; + tallyProfile: TallyProfile | null; + tallyError: string | null; +} + +// Decision color mapping +type DecisionColorType = 'success' | 'warning' | 'error' | 'neutral'; +const DECISION_COLORS: Record< + DecisionColorType, + { bg: string; border: string } +> = { + success: { bg: '#E8F5E9', border: neoColors.status.success }, + warning: { bg: '#FFF8E1', border: '#F57C00' }, + error: { bg: '#FFEBEE', border: neoColors.status.error }, + neutral: { bg: '#F5F5F5', border: '#ccc' }, +}; + +const getDecisionColorType = (decision: string | null): DecisionColorType => { + switch (decision) { + case 'yes': + case 'finalist': + return 'success'; + case 'maybe': + case 'waitlist': + return 'warning'; + case 'no': + case 'not_selected': + return 'error'; + default: + return 'neutral'; + } +}; + +const getDecisionColors = (decision: string | null) => + DECISION_COLORS[getDecisionColorType(decision)]; + +const CRITERIA = ['problem', 'solution', 'implementation', 'roadmap'] as const; +type Criterion = (typeof CRITERIA)[number]; + +const CRITERIA_LABELS: Record = { + problem: 'Problem Understanding', + solution: 'Solution Clarity', + implementation: 'Implementation', + roadmap: 'Roadmap', +}; + +const CRITERIA_HINTS: Record = { + problem: "Specific people, how they cope, why it's hard, SDG connection", + solution: 'Understandable in 3 min, clear user, why this approach', + implementation: "What works/doesn't, success metrics, real commits", + roadmap: 'Specific finals plans, room to grow, momentum', +}; + +// SDG track color mapping +const SDG_COLORS: Record = { + 4: { bg: '#FFF3E0', border: '#E65100' }, + 11: { bg: '#E8F5E9', border: '#2E7D32' }, + 13: { bg: '#E3F2FD', border: '#1565C0' }, +}; +const DEFAULT_SDG_COLOR = { bg: '#F5F5F5', border: '#666' }; + +const getSDGColor = (sdg: number) => SDG_COLORS[sdg] ?? DEFAULT_SDG_COLOR; + +// Reviewer caps +const PASS_1_MAX_REVIEWERS = 4; +const PASS_2_MAX_REVIEWERS = 3; + +export default function JudgePortal() { + const { user } = useHibiscusUser(); + const router = useRouter(); + + const [submissions, setSubmissions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedTeamId, setSelectedTeamId] = useState(null); + const [savingScore, setSavingScore] = useState(false); + const [savingFinalDecision, setSavingFinalDecision] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(null); + const saveSuccessTimeoutRef = useRef(null); + + // Mobile view state + const [isMobileDetailView, setIsMobileDetailView] = useState(false); + + // Filters + const [searchQuery, setSearchQuery] = useState(''); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); + const [pass1Filter, setPass1Filter] = useState('all'); + const [pass2Filter, setPass2Filter] = useState('all'); + const [finalFilter, setFinalFilter] = useState('all'); + const [trackFilter, setTrackFilter] = useState('all'); + const [hardwareFilter, setHardwareFilter] = useState('all'); + const [sortField, setSortField] = useState('teamName'); + const [sortAsc, setSortAsc] = useState(true); + // Quick filters + const [myUnreviewedP1, setMyUnreviewedP1] = useState(false); + const [myUnreviewedP2, setMyUnreviewedP2] = useState(false); + const [filtersExpanded, setFiltersExpanded] = useState(false); + + // Selection for counting users + const [selectedTeamIds, setSelectedTeamIds] = useState>( + new Set() + ); + + // Bulk operations state + const [bulkActionLoading, setBulkActionLoading] = useState(false); + const [exportLoading, setExportLoading] = useState(false); + + // Team member profiles state + const [teamProfilesExpanded, setTeamProfilesExpanded] = useState(false); + const [teamProfiles, setTeamProfiles] = useState( + null + ); + const [teamProfilesLoading, setTeamProfilesLoading] = useState(false); + + // Count active dropdown filters (not including search/quick filters) + const activeFilterCount = useMemo(() => { + let count = 0; + if (trackFilter !== 'all') count++; + if (hardwareFilter !== 'all') count++; + if (pass1Filter !== 'all') count++; + if (pass2Filter !== 'all') count++; + if (finalFilter !== 'all') count++; + if (sortField !== 'teamName' || !sortAsc) count++; + return count; + }, [ + trackFilter, + hardwareFilter, + pass1Filter, + pass2Filter, + finalFilter, + sortField, + sortAsc, + ]); + + // Local edits for scoring + const [localScores, setLocalScores] = useState>( + {} + ); + const [localDecision, setLocalDecision] = useState< + Pass1Decision | Pass2Decision | FinalDecision | null + >(null); + const [localNotes, setLocalNotes] = useState(''); + const [activePass, setActivePass] = useState(1); + + // Auth check + useEffect(() => { + if ( + user && + user.role !== HibiscusRole.SUPERADMIN && + user.role !== HibiscusRole.JUDGE + ) { + router.push('/'); + } + }, [user, router]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (saveSuccessTimeoutRef.current) { + clearTimeout(saveSuccessTimeoutRef.current); + } + }; + }, []); + + // Helper to show save success toast + const showSaveSuccess = useCallback((message: string) => { + if (saveSuccessTimeoutRef.current) { + clearTimeout(saveSuccessTimeoutRef.current); + } + setSaveSuccess(message); + saveSuccessTimeoutRef.current = setTimeout(() => { + setSaveSuccess(null); + }, 2500); + }, []); + + // Debounce search query + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, 300); + return () => clearTimeout(timer); + }, [searchQuery]); + + // Fetch submissions + const fetchSubmissions = useCallback(async () => { + try { + setLoading(true); + const res = await fetch('/api/judge/submissions'); + if (!res.ok) { + throw new Error('Failed to fetch submissions'); + } + const data = await res.json(); + setSubmissions(data.submissions); + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if ( + user && + (user.role === HibiscusRole.SUPERADMIN || + user.role === HibiscusRole.JUDGE) + ) { + fetchSubmissions(); + } + }, [user, fetchSubmissions]); + + // Get current submission + const selectedSubmission = useMemo( + () => submissions.find((s) => s.teamId === selectedTeamId) || null, + [submissions, selectedTeamId] + ); + + // Check for unsaved changes + const hasUnsavedChanges = useCallback(() => { + if (!selectedSubmission) return false; + + if (activePass === 'final') { + return localDecision !== selectedSubmission.finalDecision; + } + + const myScore = + activePass === 1 + ? selectedSubmission.myPass1Score + : selectedSubmission.myPass2Score; + + if (!myScore) { + // No existing score - any local data is unsaved + return ( + Object.values(localScores).some((v) => v !== null) || + localDecision !== null || + localNotes !== '' + ); + } + + const scoresChanged = CRITERIA.some( + (c) => localScores[c] !== myScore[c as keyof ReviewerScore] + ); + const decisionChanged = localDecision !== myScore.decision; + const notesChanged = localNotes !== (myScore.notes || ''); + + return scoresChanged || decisionChanged || notesChanged; + }, [selectedSubmission, activePass, localScores, localDecision, localNotes]); + + // Load scores for selected team + const loadScoresForPass = useCallback( + (sub: SubmissionForJudging, pass: ActivePass) => { + if (pass === 'final') { + setLocalScores({}); + setLocalDecision(sub.finalDecision as FinalDecision | null); + setLocalNotes(''); + return; + } + + const myScore = pass === 1 ? sub.myPass1Score : sub.myPass2Score; + if (myScore) { + setLocalScores({ + problem: myScore.problem, + solution: myScore.solution, + implementation: myScore.implementation, + roadmap: myScore.roadmap, + }); + setLocalDecision( + myScore.decision as Pass1Decision | Pass2Decision | null + ); + setLocalNotes(myScore.notes || ''); + } else { + setLocalScores({ + problem: null, + solution: null, + implementation: null, + roadmap: null, + }); + setLocalDecision(null); + setLocalNotes(''); + } + }, + [] + ); + + // Handle team selection + const handleSelectTeam = useCallback( + (teamId: string) => { + if (selectedTeamId === teamId) return; + + if (selectedTeamId && hasUnsavedChanges()) { + if (!confirm('You have unsaved changes. Discard them?')) { + return; + } + } + + const sub = submissions.find((s) => s.teamId === teamId); + if (!sub) return; + + setSelectedTeamId(teamId); + setActivePass(1); + loadScoresForPass(sub, 1); + setIsMobileDetailView(true); + // Reset team profiles when switching teams + setTeamProfilesExpanded(false); + setTeamProfiles(null); + }, + [selectedTeamId, submissions, hasUnsavedChanges, loadScoresForPass] + ); + + // Handle pass switch + const handleSwitchPass = useCallback( + (pass: ActivePass) => { + if (!selectedSubmission) return; + + // Check if can switch to pass 2 (needs at least one P1 review with decision) + if (pass === 2 && selectedSubmission.pass1.aggregate.reviewCount === 0) { + return; + } + // Check if can switch to final (needs at least one P2 review OR already has final decision) + if ( + pass === 'final' && + selectedSubmission.pass2.aggregate.reviewCount === 0 && + !selectedSubmission.finalDecision + ) { + return; + } + + if (hasUnsavedChanges()) { + if (!confirm('You have unsaved changes. Discard them?')) { + return; + } + } + + setActivePass(pass); + loadScoresForPass(selectedSubmission, pass); + }, + [selectedSubmission, hasUnsavedChanges, loadScoresForPass] + ); + + // Fetch team member profiles from Tally + const fetchTeamProfiles = useCallback(async () => { + if (!selectedTeamId) return; + setTeamProfilesLoading(true); + try { + const res = await fetch( + `/api/judge/team-profiles?teamId=${selectedTeamId}` + ); + if (!res.ok) { + throw new Error('Failed to fetch team profiles'); + } + const data = await res.json(); + setTeamProfiles(data.members || []); + } catch (e) { + console.error('[fetchTeamProfiles]', e); + setTeamProfiles([]); + } finally { + setTeamProfilesLoading(false); + } + }, [selectedTeamId]); + + // Fetch profiles when section is expanded + useEffect(() => { + if (teamProfilesExpanded && teamProfiles === null && selectedTeamId) { + fetchTeamProfiles(); + } + }, [teamProfilesExpanded, teamProfiles, selectedTeamId, fetchTeamProfiles]); + + // Save score + const handleSaveScore = useCallback(async () => { + if (!selectedTeamId || activePass === 'final') return; + + setSavingScore(true); + try { + const res = await fetch(`/api/judge/scores/${selectedTeamId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pass: activePass, + problem: localScores.problem, + solution: localScores.solution, + implementation: localScores.implementation, + roadmap: localScores.roadmap, + decision: localDecision, + notes: localNotes || null, + }), + }); + + if (res.status === 409) { + const data = await res.json(); + alert(data.message || 'Cannot add more reviewers to this pass.'); + return; + } + + if (!res.ok) { + throw new Error('Failed to save score'); + } + + await fetchSubmissions(); + showSaveSuccess('Score saved'); + } catch (e) { + console.error('Save error:', e); + alert('Failed to save score'); + } finally { + setSavingScore(false); + } + }, [ + selectedTeamId, + activePass, + localScores, + localDecision, + localNotes, + fetchSubmissions, + showSaveSuccess, + ]); + + // Save final decision + const handleSaveFinalDecision = useCallback(async () => { + if (!selectedTeamId || activePass !== 'final') return; + + setSavingFinalDecision(true); + try { + const res = await fetch(`/api/judge/notes/${selectedTeamId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + final_decision: localDecision, + }), + }); + + if (res.status === 409) { + const data = await res.json(); + alert(data.message || 'Conflict detected. Please refresh.'); + await fetchSubmissions(); + return; + } + + if (!res.ok) { + throw new Error('Failed to save final decision'); + } + + await fetchSubmissions(); + showSaveSuccess('Final decision saved'); + } catch (e) { + console.error('Save error:', e); + alert('Failed to save final decision'); + } finally { + setSavingFinalDecision(false); + } + }, [ + selectedTeamId, + activePass, + localDecision, + fetchSubmissions, + showSaveSuccess, + ]); + + // Handle back button on mobile + const handleBackToList = useCallback(() => { + if (hasUnsavedChanges()) { + if (!confirm('You have unsaved changes. Discard them?')) { + return; + } + } + setIsMobileDetailView(false); + }, [hasUnsavedChanges]); + + // Filter and sort submissions + const filteredSubmissions = useMemo(() => { + let result = [...submissions]; + + // Search filter + if (debouncedSearchQuery) { + const q = debouncedSearchQuery.toLowerCase(); + result = result.filter( + (s) => + s.teamName.toLowerCase().includes(q) || + (s.projectTitle?.toLowerCase().includes(q) ?? false) + ); + } + + // Track filter + if (trackFilter !== 'all') { + result = result.filter((s) => s.track?.sdgNumber === trackFilter); + } + + // Hardware filter + if (hardwareFilter !== 'all') { + result = result.filter((s) => + hardwareFilter === 'hardware' ? s.isHardware : !s.isHardware + ); + } + + // Pass 1 filter + if (pass1Filter !== 'all') { + if (pass1Filter === 'unreviewed') { + result = result.filter((s) => s.pass1.aggregate.reviewCount === 0); + } else if (pass1Filter === 'needs_reviews') { + result = result.filter( + (s) => s.pass1.aggregate.reviewCount < PASS_1_MAX_REVIEWERS + ); + } else { + result = result.filter( + (s) => s.pass1.aggregate.consensus === pass1Filter + ); + } + } + + // Pass 2 filter + if (pass2Filter !== 'all') { + if (pass2Filter === 'unreviewed') { + result = result.filter((s) => s.pass2.aggregate.reviewCount === 0); + } else if (pass2Filter === 'needs_reviews') { + result = result.filter( + (s) => s.pass2.aggregate.reviewCount < PASS_2_MAX_REVIEWERS + ); + } else { + result = result.filter( + (s) => s.pass2.aggregate.consensus === pass2Filter + ); + } + } + + // Final decision filter + if (finalFilter !== 'all') { + if (finalFilter === 'unreviewed') { + result = result.filter((s) => !s.finalDecision); + } else { + result = result.filter((s) => s.finalDecision === finalFilter); + } + } + + // My Unreviewed quick filters + if (myUnreviewedP1) { + result = result.filter((s) => s.myPass1Score === null); + } + if (myUnreviewedP2) { + result = result.filter((s) => s.myPass2Score === null); + } + + // Sort + result.sort((a, b) => { + let cmp = 0; + switch (sortField) { + case 'teamName': + cmp = a.teamName.localeCompare(b.teamName); + break; + case 'pass1Avg': + cmp = (a.pass1Avg ?? -1) - (b.pass1Avg ?? -1); + break; + case 'pass2Avg': + cmp = (a.pass2Avg ?? -1) - (b.pass2Avg ?? -1); + break; + case 'p1ReviewCount': + cmp = a.pass1.aggregate.reviewCount - b.pass1.aggregate.reviewCount; + break; + case 'p2ReviewCount': + cmp = a.pass2.aggregate.reviewCount - b.pass2.aggregate.reviewCount; + break; + } + return sortAsc ? cmp : -cmp; + }); + + return result; + }, [ + submissions, + debouncedSearchQuery, + pass1Filter, + pass2Filter, + finalFilter, + trackFilter, + hardwareFilter, + sortField, + sortAsc, + myUnreviewedP1, + myUnreviewedP2, + ]); + + // Keyboard navigation + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + // Ignore if typing in an input or textarea + const target = e.target as HTMLElement; + if ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + ) { + return; + } + + if (!filteredSubmissions.length) return; + + const currentIndex = filteredSubmissions.findIndex( + (s) => s.teamId === selectedTeamId + ); + + if (e.key === 'ArrowDown' || e.key === 'j') { + e.preventDefault(); + const nextIndex = Math.min( + currentIndex + 1, + filteredSubmissions.length - 1 + ); + if (nextIndex !== currentIndex) { + handleSelectTeam(filteredSubmissions[nextIndex].teamId); + } + } else if (e.key === 'ArrowUp' || e.key === 'k') { + e.preventDefault(); + const prevIndex = Math.max(currentIndex - 1, 0); + if (prevIndex !== currentIndex) { + handleSelectTeam(filteredSubmissions[prevIndex].teamId); + } + } + }, + [filteredSubmissions, selectedTeamId, handleSelectTeam] + ); + + // Stats + const stats = useMemo(() => { + const total = submissions.length; + const p1Reviewed = submissions.filter( + (s) => s.pass1.aggregate.reviewCount > 0 + ).length; + const p1Complete = submissions.filter( + (s) => s.pass1.aggregate.reviewCount >= PASS_1_MAX_REVIEWERS + ).length; + const p2Reviewed = submissions.filter( + (s) => s.pass2.aggregate.reviewCount > 0 + ).length; + const finalists = submissions.filter( + (s) => s.finalDecision === 'finalist' + ).length; + return { total, p1Reviewed, p1Complete, p2Reviewed, finalists }; + }, [submissions]); + + // Track breakdown for finalists and waitlist + const trackBreakdown = useMemo(() => { + const countByTrack = ( + subs: typeof submissions, + decision: 'finalist' | 'waitlist' + ) => { + const filtered = subs.filter((s) => s.finalDecision === decision); + const teams: Record = { 4: 0, 11: 0, 13: 0 }; + const users: Record = { 4: 0, 11: 0, 13: 0 }; + let totalUsers = 0; + for (const s of filtered) { + const sdg = s.track?.sdgNumber; + totalUsers += s.memberCount || 0; + if (sdg && sdg in teams) { + teams[sdg]++; + users[sdg] += s.memberCount || 0; + } + } + return { teams, users, totalTeams: filtered.length, totalUsers }; + }; + + return { + finalists: countByTrack(submissions, 'finalist'), + waitlist: countByTrack(submissions, 'waitlist'), + }; + }, [submissions]); + + // Selected teams user count + const selectedStats = useMemo(() => { + const selectedSubs = submissions.filter((s) => + selectedTeamIds.has(s.teamId) + ); + const teamCount = selectedSubs.length; + const userCount = selectedSubs.reduce( + (sum, s) => sum + (s.memberCount || 0), + 0 + ); + return { teamCount, userCount }; + }, [submissions, selectedTeamIds]); + + // Toggle team selection + const handleToggleTeamSelection = useCallback( + (teamId: string, e: React.MouseEvent) => { + e.stopPropagation(); + setSelectedTeamIds((prev) => { + const next = new Set(prev); + if (next.has(teamId)) { + next.delete(teamId); + } else { + next.add(teamId); + } + return next; + }); + }, + [] + ); + + // Select/deselect all visible teams + const handleSelectAllVisible = useCallback(() => { + const visibleIds = filteredSubmissions.map((s) => s.teamId); + const allSelected = visibleIds.every((id) => selectedTeamIds.has(id)); + + if (allSelected) { + // Deselect all visible + setSelectedTeamIds((prev) => { + const next = new Set(prev); + visibleIds.forEach((id) => next.delete(id)); + return next; + }); + } else { + // Select all visible + setSelectedTeamIds((prev) => { + const next = new Set(prev); + visibleIds.forEach((id) => next.add(id)); + return next; + }); + } + }, [filteredSubmissions, selectedTeamIds]); + + // Check if all visible are selected (for checkbox state) + const allVisibleSelected = useMemo(() => { + if (filteredSubmissions.length === 0) return false; + return filteredSubmissions.every((s) => selectedTeamIds.has(s.teamId)); + }, [filteredSubmissions, selectedTeamIds]); + + const someVisibleSelected = useMemo(() => { + return ( + filteredSubmissions.some((s) => selectedTeamIds.has(s.teamId)) && + !allVisibleSelected + ); + }, [filteredSubmissions, selectedTeamIds, allVisibleSelected]); + + // Bulk mark decision + const handleBulkMarkDecision = useCallback( + async (decision: 'finalist' | 'waitlist' | 'not_selected') => { + if (selectedTeamIds.size === 0) return; + + const confirmMsg = `Mark ${selectedTeamIds.size} team(s) as "${decision}"?`; + if (!confirm(confirmMsg)) return; + + setBulkActionLoading(true); + try { + const res = await fetch('/api/judge/bulk-decision', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + teamIds: Array.from(selectedTeamIds), + decision, + }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.message || 'Failed to update decisions'); + } + + await fetchSubmissions(); + setSelectedTeamIds(new Set()); + showSaveSuccess(`Marked ${selectedTeamIds.size} teams as ${decision}`); + } catch (e) { + console.error('Bulk decision error:', e); + alert(e instanceof Error ? e.message : 'Failed to update decisions'); + } finally { + setBulkActionLoading(false); + } + }, + [selectedTeamIds, fetchSubmissions, showSaveSuccess] + ); + + // Export teams by decision + const handleExport = useCallback( + async (decision: 'finalist' | 'waitlist' | 'not_selected') => { + setExportLoading(true); + try { + const res = await fetch(`/api/judge/export?decision=${decision}`); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.message || 'Failed to export'); + } + + const data = await res.json(); + + // Convert to CSV (escape quotes by doubling them) + const escapeCSV = (str: string) => + `"${(str || '').replace(/"/g, '""')}"`; + + const csvRows: string[] = []; + csvRows.push('Team Name,Project Title,Member Name,Email'); + + for (const team of data.teams) { + if (team.members.length === 0) { + csvRows.push( + `${escapeCSV(team.teamName)},${escapeCSV( + team.projectTitle || '' + )},"",""` + ); + } else { + for (const member of team.members) { + const name = [member.firstName, member.lastName] + .filter(Boolean) + .join(' '); + csvRows.push( + `${escapeCSV(team.teamName)},${escapeCSV( + team.projectTitle || '' + )},${escapeCSV(name)},${escapeCSV(member.email)}` + ); + } + } + } + + // Download CSV + const csvContent = csvRows.join('\n'); + const blob = new Blob([csvContent], { + type: 'text/csv;charset=utf-8;', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${decision}-teams-${ + new Date().toISOString().split('T')[0] + }.csv`; + link.click(); + URL.revokeObjectURL(url); + + showSaveSuccess(`Exported ${data.count} ${decision} teams`); + } catch (e) { + console.error('Export error:', e); + alert(e instanceof Error ? e.message : 'Failed to export'); + } finally { + setExportLoading(false); + } + }, + [showSaveSuccess] + ); + + // Auto-select first team if none selected + useEffect(() => { + if (!selectedTeamId && filteredSubmissions.length > 0 && !loading) { + handleSelectTeam(filteredSubmissions[0].teamId); + setIsMobileDetailView(false); // Don't auto-navigate on mobile + } + }, [filteredSubmissions, selectedTeamId, loading, handleSelectTeam]); + + if (!user) { + return Loading...; + } + + if ( + user.role !== HibiscusRole.SUPERADMIN && + user.role !== HibiscusRole.JUDGE + ) { + return Access denied; + } + + if (loading) { + return Loading submissions...; + } + + if (error) { + return Error: {error}; + } + + return ( + +
+ JUDGE PORTAL + + Total: {stats.total} + + P1: {stats.p1Reviewed}/{stats.total} ({stats.p1Complete} complete) + + P2: {stats.p2Reviewed} + Finalists: {stats.finalists} + {selectedStats.teamCount > 0 && ( + + Selected: {selectedStats.userCount} users ( + {selectedStats.teamCount} teams) + + )} + + + {/* Track Breakdown Summary */} + {(trackBreakdown.finalists.totalTeams > 0 || + trackBreakdown.waitlist.totalTeams > 0) && ( + + + + Finalists: {trackBreakdown.finalists.totalTeams} teams ( + {trackBreakdown.finalists.totalUsers} people) + + + SDG4: {trackBreakdown.finalists.teams[4]} ( + {trackBreakdown.finalists.users[4]}) + + + SDG11: {trackBreakdown.finalists.teams[11]} ( + {trackBreakdown.finalists.users[11]}) + + + SDG13: {trackBreakdown.finalists.teams[13]} ( + {trackBreakdown.finalists.users[13]}) + + + + + Waitlist: {trackBreakdown.waitlist.totalTeams} teams ( + {trackBreakdown.waitlist.totalUsers} people) + + + SDG4: {trackBreakdown.waitlist.teams[4]} ( + {trackBreakdown.waitlist.users[4]}) + + + SDG11: {trackBreakdown.waitlist.teams[11]} ( + {trackBreakdown.waitlist.users[11]}) + + + SDG13: {trackBreakdown.waitlist.teams[13]} ( + {trackBreakdown.waitlist.users[13]}) + + + + )} + + {/* Bulk Actions Bar */} + + {selectedStats.teamCount > 0 && ( + + Bulk Actions: + handleBulkMarkDecision('finalist')} + disabled={bulkActionLoading} + > + Mark as Finalist + + handleBulkMarkDecision('waitlist')} + disabled={bulkActionLoading} + > + Mark as Waitlist + + handleBulkMarkDecision('not_selected')} + disabled={bulkActionLoading} + > + Mark as Not Selected + + setSelectedTeamIds(new Set())} + disabled={bulkActionLoading} + > + Clear Selection + + + )} + + Export: + handleExport('finalist')} + disabled={exportLoading} + > + Finalists CSV + + handleExport('waitlist')} + disabled={exportLoading} + > + Waitlist CSV + + handleExport('not_selected')} + disabled={exportLoading} + > + Not Selected CSV + + + +
+ + + {/* Team List Panel */} + + + + ) => + setSearchQuery(e.target.value) + } + /> + + + + setMyUnreviewedP1(!myUnreviewedP1)} + > + My Unreviewed P1 + + setMyUnreviewedP2(!myUnreviewedP2)} + > + My Unreviewed P2 + + + + setFiltersExpanded(!filtersExpanded)}> + + {filtersExpanded ? '▼' : '▶'} Filters + {activeFilterCount > 0 && ( + {activeFilterCount} + )} + + + + {filtersExpanded && ( + + + + Track: + + + + + Type: + + + + + + + P1: + + + + + P2: + + + + + Final: + + + + + + + Sort: + + setSortAsc(!sortAsc)}> + {sortAsc ? '↑' : '↓'} + + + + + )} + + + + { + if (el) el.indeterminate = someVisibleSelected; + }} + onChange={handleSelectAllVisible} + /> + + Select all visible ({filteredSubmissions.length}) + + + + + {filteredSubmissions.length === 0 ? ( + No submissions match your filters + ) : ( + filteredSubmissions.map((sub) => ( + handleSelectTeam(sub.teamId)} + > + + handleToggleTeamSelection(sub.teamId, e)} + onChange={() => {}} + /> + + + + {sub.teamName} + ({sub.memberCount || 0}) + + + {sub.isHardware && HW} + {sub.track && ( + + SDG {sub.track.sdgNumber} + + )} + + + + + P1 + = + PASS_1_MAX_REVIEWERS + } + > + {sub.pass1.aggregate.reviewCount}/ + {PASS_1_MAX_REVIEWERS} + + {sub.pass1Avg !== null && ( + {sub.pass1Avg.toFixed(1)} + )} + + {sub.pass1.aggregate.consensus || '-'} + + + + P2 + = + PASS_2_MAX_REVIEWERS + } + > + {sub.pass2.aggregate.reviewCount}/ + {PASS_2_MAX_REVIEWERS} + + {sub.pass2Avg !== null && ( + {sub.pass2Avg.toFixed(1)} + )} + + {sub.pass2.aggregate.consensus || '-'} + + + + + + + )) + )} + + + + + Showing {filteredSubmissions.length} of {submissions.length} teams + + ↑↓ or j/k to navigate + + + + {/* Detail Panel */} + + {selectedSubmission ? ( + <> + + ← Back + + {selectedSubmission.projectTitle || + selectedSubmission.teamName} + + by {selectedSubmission.teamName} + + {selectedSubmission.track && ( + + SDG {selectedSubmission.track.sdgNumber} -{' '} + {selectedSubmission.track.name} + + )} + {selectedSubmission.isHardware && ( + HARDWARE PROJECT + )} + + + + + Submission Links + + {selectedSubmission.submission?.githubUrl && ( + + GitHub + + )} + {selectedSubmission.submission?.youtubeUrl && ( + + Video + + )} + {selectedSubmission.submission?.liveUrl && ( + + Demo + + )} + {selectedSubmission.submission?.pdfUrl && ( + + PDF + + )} + {selectedSubmission.submission?.hwBomUrl && ( + + BOM + + )} + + + + {/* Member Profiles Section */} + + setTeamProfilesExpanded(!teamProfilesExpanded)} + > + + Team Members ({selectedSubmission.memberCount || '?'}) + + + + {teamProfilesExpanded && ( + + {teamProfilesLoading ? ( + + Loading member profiles... + + ) : teamProfiles && teamProfiles.length > 0 ? ( + teamProfiles.map((member) => ( + + + {member.firstName} {member.lastName} + + + {member.email || 'No email'} + + {member.tallyProfile ? ( + + {member.tallyProfile.university && ( + + University + + {member.tallyProfile.university} + + + )} + {member.tallyProfile.program && ( + + Program + + {member.tallyProfile.program} + + + )} + {member.tallyProfile.major && ( + + Major + + {member.tallyProfile.major} + + + )} + {member.tallyProfile.graduationYear && ( + + Graduation + + {member.tallyProfile.graduationYear} + + + )} + {member.tallyProfile.state && ( + + State + + {member.tallyProfile.state} + + + )} + + {member.tallyProfile.githubUrl && ( + + GitHub + + )} + {member.tallyProfile.linkedinUrl && ( + + LinkedIn + + )} + {member.tallyProfile.portfolioUrl && ( + + Portfolio + + )} + {member.tallyProfile.devpostUrl && ( + + Devpost + + )} + + + ) : member.tallyError ? ( + {member.tallyError} + ) : null} + + )) + ) : ( + + No members found + + )} + + )} + + + + handleSwitchPass(1)} + > + Pass 1 ({selectedSubmission.pass1.aggregate.reviewCount}/ + {PASS_1_MAX_REVIEWERS}) + + handleSwitchPass(2)} + > + Pass 2 ({selectedSubmission.pass2.aggregate.reviewCount}/ + {PASS_2_MAX_REVIEWERS}) + + handleSwitchPass('final')} + > + Final {selectedSubmission.finalDecision ? '✓' : '—'} + + + + + {activePass !== 'final' && ( + <> + {/* Your Review Form - Primary action, shown first */} + + Your Review + {(activePass === 1 + ? selectedSubmission.pass1.aggregate.reviewCount >= + PASS_1_MAX_REVIEWERS + : selectedSubmission.pass2.aggregate.reviewCount >= + PASS_2_MAX_REVIEWERS) && + !(activePass === 1 + ? selectedSubmission.myPass1Score + : selectedSubmission.myPass2Score) ? ( + + This pass has reached its maximum reviewer count ( + {activePass === 1 + ? PASS_1_MAX_REVIEWERS + : PASS_2_MAX_REVIEWERS} + ). + + ) : ( + <> + + {CRITERIA.map((criterion) => ( + + + {CRITERIA_LABELS[criterion]} + + {CRITERIA_HINTS[criterion]} + + + + {[1, 2, 3, 4, 5].map((score) => ( + + setLocalScores((prev) => ({ + ...prev, + [criterion]: score, + })) + } + > + {score} + + ))} + + + ))} + + + + Decision: + + {activePass === 1 ? ( + <> + setLocalDecision('yes')} + > + ✓ Yes + + setLocalDecision('maybe')} + > + ◐ Maybe + + setLocalDecision('no')} + > + ✗ No + + + ) : ( + <> + setLocalDecision('yes')} + > + ✓ Yes + + setLocalDecision('waitlist')} + > + ◐ Waitlist + + setLocalDecision('no')} + > + ✗ No + + + )} + + + + + ) => + setLocalNotes(e.target.value) + } + placeholder="Add your notes here..." + /> + + + )} + + + {/* Other Reviewers' Scores - Reference info, shown after */} + + + Other Reviews ( + {activePass === 1 + ? selectedSubmission.pass1.aggregate.reviewCount + : selectedSubmission.pass2.aggregate.reviewCount} + / + {activePass === 1 + ? PASS_1_MAX_REVIEWERS + : PASS_2_MAX_REVIEWERS} + ) + + + {(activePass === 1 + ? selectedSubmission.pass1.scores + : selectedSubmission.pass2.scores + ) + .filter((score) => score.judgeId !== user?.userId) + .map((score, idx) => ( + + + {score.judgeName || 'Unknown'} + + + P: {score.problem ?? '-'} + + S: {score.solution ?? '-'} + + + I: {score.implementation ?? '-'} + + R: {score.roadmap ?? '-'} + + Avg: {score.avgScore?.toFixed(1) ?? '-'} + + + + {score.decision || 'No decision'} + + {score.notes && ( + {score.notes} + )} + + ))} + {(activePass === 1 + ? selectedSubmission.pass1.scores + : selectedSubmission.pass2.scores + ).filter((score) => score.judgeId !== user?.userId) + .length === 0 && ( + No other reviews yet + )} + + + {/* Aggregate */} + {(activePass === 1 + ? selectedSubmission.pass1 + : selectedSubmission.pass2 + ).aggregate.reviewCount > 0 && ( + + Aggregate + + + P:{' '} + {(activePass === 1 + ? selectedSubmission.pass1 + : selectedSubmission.pass2 + ).aggregate.avgProblem?.toFixed(1) ?? '-'} + + + S:{' '} + {(activePass === 1 + ? selectedSubmission.pass1 + : selectedSubmission.pass2 + ).aggregate.avgSolution?.toFixed(1) ?? '-'} + + + I:{' '} + {(activePass === 1 + ? selectedSubmission.pass1 + : selectedSubmission.pass2 + ).aggregate.avgImplementation?.toFixed(1) ?? '-'} + + + R:{' '} + {(activePass === 1 + ? selectedSubmission.pass1 + : selectedSubmission.pass2 + ).aggregate.avgRoadmap?.toFixed(1) ?? '-'} + + + Total:{' '} + {(activePass === 1 + ? selectedSubmission.pass1 + : selectedSubmission.pass2 + ).aggregate.avgTotal?.toFixed(1) ?? '-'} + + + + Consensus: + + {(activePass === 1 + ? selectedSubmission.pass1 + : selectedSubmission.pass2 + ).aggregate.consensus || 'N/A'} + + + {Object.entries( + (activePass === 1 + ? selectedSubmission.pass1 + : selectedSubmission.pass2 + ).aggregate.decisions + ).map(([decision, count]) => ( + + {decision}: {count} + + ))} + + + + )} + + + )} + + {activePass === 'final' && ( + + Final Decision + + + Review Summary + + + Pass 1 + + Avg:{' '} + {selectedSubmission.pass1Avg?.toFixed(1) ?? '-'} + + + {selectedSubmission.pass1.aggregate.consensus || + 'N/A'} + + + + Pass 2 + + Avg:{' '} + {selectedSubmission.pass2Avg?.toFixed(1) ?? '-'} + + + {selectedSubmission.pass2.aggregate.consensus || + 'N/A'} + + + + + + + Final Decision: + + setLocalDecision('finalist')} + > + ★ Finalist + + setLocalDecision('waitlist')} + > + ◐ Waitlist + + setLocalDecision('not_selected')} + > + ✗ Not Selected + + + + + )} + + {/* Sticky Save Bar - only show if user can save */} + {(activePass === 'final' || + !( + (activePass === 1 + ? selectedSubmission.pass1.aggregate.reviewCount >= + PASS_1_MAX_REVIEWERS + : selectedSubmission.pass2.aggregate.reviewCount >= + PASS_2_MAX_REVIEWERS) && + !(activePass === 1 + ? selectedSubmission.myPass1Score + : selectedSubmission.myPass2Score) + )) && ( + + + {activePass === 'final' + ? 'Save Final Decision' + : 'Save Score'} + + {saveSuccess && ( + ✓ {saveSuccess} + )} + + )} + + + ) : ( + Select a team to view details + )} + + +
+ ); +} + +// Styled components +const PageContainer = styled.div` + max-width: 1600px; + margin: 0 auto; + padding: 1rem; + background: ${neoColors.background}; + min-height: 100vh; + outline: none; + + @media (min-width: 900px) { + padding: 2rem; + } +`; + +const Header = styled.div` + margin-bottom: 1rem; +`; + +const Title = styled.h1` + font-size: 1.5rem; + font-weight: 900; + margin: 0 0 0.5rem 0; + color: ${neoColors.text}; + + @media (min-width: 900px) { + font-size: 2rem; + margin-bottom: 1rem; + } +`; + +const StatsRow = styled.div` + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +`; + +const StatBadge = styled.span<{ $highlight?: boolean }>` + background: ${({ $highlight }) => + $highlight ? neoColors.accent.yellow : neoColors.surface}; + border: ${neoBorders.standard}; + padding: 0.25rem 0.5rem; + font-weight: ${({ $highlight }) => ($highlight ? 700 : 600)}; + font-size: 0.75rem; + + @media (min-width: 900px) { + padding: 0.5rem 1rem; + font-size: 0.85rem; + } +`; + +const TrackSummaryRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + margin-top: 0.5rem; + padding: 0.5rem 0.75rem; + background: ${neoColors.surface}; + border: ${neoBorders.standard}; +`; + +const TrackSummaryGroup = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +`; + +const TrackSummaryLabel = styled.span` + font-weight: 700; + font-size: 0.75rem; + color: ${neoColors.text}; +`; + +const TrackBadgeSmall = styled.span<{ $sdg: number }>` + padding: 0.125rem 0.375rem; + font-size: 0.7rem; + font-weight: 600; + border: 1px solid; + background: ${({ $sdg }) => getSDGColor($sdg).bg}; + border-color: ${({ $sdg }) => getSDGColor($sdg).border}; + color: ${({ $sdg }) => getSDGColor($sdg).border}; +`; + +const ActionsRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 0.75rem; + align-items: center; +`; + +const BulkActionsGroup = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +`; + +const ExportGroup = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + + @media (max-width: 899px) { + margin-left: 0; + } +`; + +const ActionLabel = styled.span` + font-weight: 600; + font-size: 0.75rem; + color: ${neoColors.textMuted}; +`; + +const ActionButton = styled.button<{ + $variant: 'success' | 'warning' | 'neutral'; +}>` + padding: 0.375rem 0.75rem; + border: ${neoBorders.standard}; + font-weight: 600; + font-size: 0.75rem; + cursor: pointer; + background: ${({ $variant }) => { + switch ($variant) { + case 'success': + return neoColors.status.success; + case 'warning': + return '#E65100'; + default: + return neoColors.surface; + } + }}; + color: ${({ $variant }) => + $variant === 'neutral' ? neoColors.text : '#fff'}; + + &:hover:not(:disabled) { + opacity: 0.9; + transform: translateY(-1px); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +const SelectAllRow = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: ${neoBorders.standard}; + background: ${neoColors.background}; +`; + +const SelectAllCheckbox = styled.input` + width: 16px; + height: 16px; + cursor: pointer; + accent-color: ${neoColors.accent.blue}; +`; + +const SelectAllLabel = styled.span` + font-size: 0.75rem; + font-weight: 600; + color: ${neoColors.textMuted}; + cursor: pointer; + + &:hover { + color: ${neoColors.text}; + } +`; + +const SplitContainer = styled.div` + display: flex; + gap: 1rem; + height: calc(100vh - 240px); + + @media (max-width: 899px) { + height: calc(100vh - 220px); + } +`; + +const ListPanel = styled.div<{ $showOnMobile: boolean }>` + width: 400px; + min-width: 350px; + display: flex; + flex-direction: column; + background: ${neoColors.surface}; + border: ${neoBorders.thick}; + overflow: hidden; + + @media (max-width: 899px) { + display: ${({ $showOnMobile }) => ($showOnMobile ? 'flex' : 'none')}; + width: 100%; + min-width: unset; + } +`; + +const FiltersSection = styled.div` + padding: 0.75rem; + border-bottom: ${neoBorders.standard}; + background: ${neoColors.background}; +`; + +const SearchWrapper = styled.div` + margin-bottom: 0.5rem; +`; + +const QuickFilterRow = styled.div` + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; +`; + +const QuickFilterButton = styled.button<{ $active: boolean }>` + padding: 0.375rem 0.75rem; + border: ${neoBorders.standard}; + background: ${({ $active }) => + $active ? neoColors.accent.blue : neoColors.surface}; + color: ${({ $active }) => ($active ? '#fff' : neoColors.text)}; + font-weight: 600; + font-size: 0.75rem; + cursor: pointer; + transition: background 0.15s ease; + + &:hover { + background: ${({ $active }) => + $active ? neoColors.accent.blue : neoColors.background}; + } +`; + +const FilterToggle = styled.button` + display: flex; + align-items: center; + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid #ddd; + background: ${neoColors.surface}; + cursor: pointer; + font-family: inherit; + border-radius: 2px; + + &:hover { + background: ${neoColors.background}; + border-color: #bbb; + } +`; + +const FilterToggleText = styled.span` + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + font-weight: 600; + color: ${neoColors.text}; +`; + +const ActiveFilterBadge = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 0.25rem; + background: ${neoColors.accent.blue}; + color: #fff; + font-size: 0.65rem; + font-weight: 700; + border-radius: 9px; +`; + +const CollapsibleFilters = styled.div` + padding-top: 0.5rem; + border-top: 1px solid #eee; + margin-top: 0.25rem; +`; + +const FilterRow = styled.div` + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } +`; + +const FilterGroup = styled.div` + display: flex; + align-items: center; + gap: 0.25rem; +`; + +const FilterLabel = styled.span` + font-weight: 600; + font-size: 0.75rem; +`; + +const Select = styled.select` + padding: 0.25rem 0.5rem; + border: ${neoBorders.standard}; + background: ${neoColors.surface}; + font-family: inherit; + font-size: 0.75rem; + cursor: pointer; + + &:focus { + outline: none; + box-shadow: ${neoShadows.small}; + } +`; + +const SortButton = styled.button` + padding: 0.25rem 0.5rem; + border: ${neoBorders.standard}; + background: ${neoColors.surface}; + cursor: pointer; + font-weight: 700; + + &:hover { + background: ${neoColors.background}; + } +`; + +const TeamList = styled.div` + flex: 1; + overflow-y: auto; +`; + +const TeamListItem = styled.div<{ $selected: boolean }>` + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #eee; + cursor: pointer; + background: ${({ $selected }) => + $selected ? neoColors.accent.blue + '20' : neoColors.surface}; + border-left: 3px solid + ${({ $selected }) => ($selected ? neoColors.accent.blue : 'transparent')}; + + &:hover { + background: ${({ $selected }) => + $selected ? neoColors.accent.blue + '20' : neoColors.background}; + } +`; + +const TeamItemRow = styled.div` + display: flex; + align-items: flex-start; + gap: 0.5rem; +`; + +const SelectionCheckbox = styled.input` + width: 16px; + height: 16px; + margin-top: 2px; + cursor: pointer; + accent-color: ${neoColors.accent.blue}; + flex-shrink: 0; +`; + +const TeamItemContent = styled.div` + flex: 1; + min-width: 0; +`; + +const MemberCount = styled.span` + font-weight: 400; + font-size: 0.75rem; + color: ${neoColors.textMuted}; + margin-left: 0.25rem; +`; + +const TeamInfo = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.25rem; +`; + +const TeamName = styled.div` + font-weight: 700; + font-size: 0.85rem; +`; + +const TeamMeta = styled.div` + display: flex; + gap: 0.25rem; +`; + +const TrackBadge = styled.span<{ $sdg: number }>` + display: inline-block; + padding: 0.0625rem 0.25rem; + font-size: 0.6rem; + font-weight: 700; + border: 1px solid; + background: ${({ $sdg }) => getSDGColor($sdg).bg}; + border-color: ${({ $sdg }) => getSDGColor($sdg).border}; + color: ${({ $sdg }) => getSDGColor($sdg).border}; +`; + +const HWBadge = styled.span` + display: inline-block; + padding: 0.125rem 0.375rem; + font-size: 0.65rem; + font-weight: 700; + background: ${neoColors.accent.yellow}; + border: 2px solid #000; +`; + +const ScoreSummary = styled.div` + display: flex; + gap: 0.5rem; + align-items: center; +`; + +const PassSummary = styled.div` + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.7rem; + padding: 0.125rem 0.25rem; + background: ${neoColors.background}; + border-radius: 2px; +`; + +const PassLabel = styled.span` + font-weight: 700; + color: ${neoColors.textMuted}; +`; + +const ReviewCount = styled.span<{ $complete: boolean }>` + font-weight: 600; + color: ${({ $complete }) => + $complete ? neoColors.status.success : neoColors.textMuted}; +`; + +const AvgScore = styled.span` + font-weight: 700; +`; + +const ConsensusBadge = styled.span<{ + $decision: string | null; + $large?: boolean; +}>` + display: inline-block; + padding: ${({ $large }) => + $large ? '0.25rem 0.75rem' : '0.125rem 0.375rem'}; + font-size: ${({ $large }) => ($large ? '0.85rem' : '0.65rem')}; + font-weight: 700; + text-transform: uppercase; + background: ${({ $decision }) => getDecisionColors($decision).bg}; + border: 2px solid ${({ $decision }) => getDecisionColors($decision).border}; + color: ${({ $decision }) => + $decision ? getDecisionColors($decision).border : '#999'}; +`; + +const ListFooter = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + font-size: 0.75rem; + color: ${neoColors.textMuted}; + border-top: ${neoBorders.standard}; + background: ${neoColors.background}; +`; + +const KeyboardHint = styled.span` + font-size: 0.7rem; + opacity: 0.7; + + @media (max-width: 899px) { + display: none; + } +`; + +const DetailPanel = styled.div<{ $showOnMobile: boolean }>` + flex: 1; + display: flex; + flex-direction: column; + background: ${neoColors.surface}; + border: ${neoBorders.thick}; + overflow-y: auto; + + @media (max-width: 899px) { + display: ${({ $showOnMobile }) => ($showOnMobile ? 'flex' : 'none')}; + } +`; + +const DetailHeader = styled.div` + padding: 1rem; + border-bottom: ${neoBorders.standard}; + background: ${neoColors.background}; +`; + +const BackButton = styled.button` + display: none; + padding: 0.25rem 0.5rem; + border: ${neoBorders.standard}; + background: ${neoColors.surface}; + cursor: pointer; + font-weight: 600; + font-size: 0.85rem; + margin-bottom: 0.5rem; + + @media (max-width: 899px) { + display: inline-block; + } +`; + +const DetailTitle = styled.h2` + font-size: 1.25rem; + font-weight: 900; + margin: 0 0 0.25rem 0; +`; + +const TeamByline = styled.div` + font-size: 0.85rem; + color: ${neoColors.textMuted}; + margin-bottom: 0.5rem; +`; + +const DetailMeta = styled.div` + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +`; + +const HardwareBadge = styled.div` + display: inline-block; + padding: 0.25rem 0.5rem; + background: ${neoColors.accent.yellow}; + border: ${neoBorders.standard}; + font-weight: 700; + font-size: 0.75rem; +`; + +const LinksSection = styled.div` + padding: 1rem; + border-bottom: ${neoBorders.standard}; +`; + +const SectionTitle = styled.h4` + margin: 0 0 0.75rem 0; + font-size: 0.85rem; + text-transform: uppercase; + color: ${neoColors.textMuted}; +`; + +const LinksGrid = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +`; + +const LinkButton = styled.a` + display: inline-block; + padding: 0.5rem 1rem; + background: ${neoColors.accent.blue}; + color: #fff; + border: ${neoBorders.standard}; + text-decoration: none; + font-weight: 600; + font-size: 0.8rem; + + &:hover { + transform: translate(1px, 1px); + box-shadow: 1px 1px 0 #000; + } +`; + +// Member Profiles styled components +const MemberProfilesSection = styled.div` + border-bottom: ${neoBorders.standard}; +`; + +const ProfilesSectionHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + cursor: pointer; + background: ${neoColors.background}; + + &:hover { + background: ${neoColors.surface}; + } +`; + +const ExpandIcon = styled.span<{ $expanded: boolean }>` + font-size: 0.75rem; + transition: transform 0.2s ease; + transform: ${({ $expanded }) => + $expanded ? 'rotate(180deg)' : 'rotate(0deg)'}; +`; + +const ProfilesContent = styled.div` + padding: 0.5rem 1rem 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +`; + +const ProfilesLoadingText = styled.div` + color: ${neoColors.textMuted}; + font-size: 0.85rem; + padding: 0.5rem 0; +`; + +const MemberCard = styled.div` + padding: 0.75rem; + background: ${neoColors.surface}; + border: ${neoBorders.standard}; +`; + +const MemberName = styled.div` + font-weight: 700; + font-size: 0.9rem; + margin-bottom: 0.25rem; +`; + +const MemberEmail = styled.div` + font-size: 0.8rem; + color: ${neoColors.textMuted}; + margin-bottom: 0.5rem; +`; + +const ProfileDetails = styled.div` + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8rem; +`; + +const ProfileField = styled.div` + display: flex; + gap: 0.5rem; +`; + +const ProfileLabel = styled.span` + color: ${neoColors.textMuted}; + min-width: 80px; +`; + +const ProfileValue = styled.span` + color: ${neoColors.text}; +`; + +const ProfileLinksRow = styled.div` + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + flex-wrap: wrap; +`; + +const ProfileLink = styled.a` + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + background: ${neoColors.accent.blue}; + color: #fff; + text-decoration: none; + border: 1px solid #000; + + &:hover { + opacity: 0.9; + } +`; + +const ProfileError = styled.div` + font-size: 0.75rem; + color: ${neoColors.status.error}; + font-style: italic; +`; + +const PassTabs = styled.div` + display: flex; + border-bottom: ${neoBorders.standard}; +`; + +const PassTab = styled.button<{ $active: boolean; $disabled?: boolean }>` + flex: 1; + padding: 0.75rem 1rem; + border: none; + border-bottom: 3px solid + ${({ $active }) => ($active ? neoColors.accent.blue : 'transparent')}; + background: ${({ $active }) => + $active ? neoColors.surface : neoColors.background}; + color: ${({ $disabled }) => + $disabled ? neoColors.textMuted : neoColors.text}; + font-weight: 700; + cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')}; + opacity: ${({ $disabled }) => ($disabled ? 0.5 : 1)}; + + &:hover:not(:disabled) { + background: ${neoColors.surface}; + } +`; + +const ScoringContent = styled.div` + flex: 1; + padding: 1rem; + overflow-y: auto; +`; + +const ReviewersSection = styled.div` + margin-bottom: 1.5rem; +`; + +const ReviewersList = styled.div` + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; +`; + +const ReviewerCard = styled.div<{ $isMe: boolean }>` + padding: 0.75rem; + background: ${({ $isMe }) => + $isMe ? neoColors.accent.blue + '10' : neoColors.background}; + border: ${neoBorders.standard}; + border-left: 3px solid + ${({ $isMe }) => ($isMe ? neoColors.accent.blue : 'transparent')}; +`; + +const ReviewerName = styled.div` + font-weight: 700; + font-size: 0.85rem; + margin-bottom: 0.5rem; +`; + +const ReviewerScores = styled.div` + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; +`; + +const ScorePill = styled.span<{ $highlight?: boolean }>` + padding: 0.25rem 0.5rem; + background: ${({ $highlight }) => + $highlight ? neoColors.accent.yellow : '#eee'}; + border: 1px solid #ccc; + font-size: 0.75rem; + font-weight: ${({ $highlight }) => ($highlight ? 700 : 400)}; +`; + +const ReviewerDecision = styled.div<{ $decision: string | null }>` + display: inline-block; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + background: ${({ $decision }) => getDecisionColors($decision).bg}; + border: 2px solid ${({ $decision }) => getDecisionColors($decision).border}; + color: ${({ $decision }) => + $decision ? getDecisionColors($decision).border : '#999'}; +`; + +const ReviewerNotes = styled.div` + margin-top: 0.5rem; + font-size: 0.8rem; + color: ${neoColors.textMuted}; + font-style: italic; +`; + +const EmptyReviews = styled.div` + padding: 1rem; + text-align: center; + color: ${neoColors.textMuted}; + font-style: italic; +`; + +const AggregateSection = styled.div` + padding: 0.75rem; + background: ${neoColors.background}; + border: ${neoBorders.standard}; +`; + +const AggregateTitle = styled.div` + font-weight: 700; + font-size: 0.85rem; + margin-bottom: 0.5rem; +`; + +const AggregateScores = styled.div` + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; +`; + +const ConsensusDisplay = styled.div` + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +`; + +const ConsensusLabel = styled.span` + font-weight: 700; + font-size: 0.85rem; +`; + +const VoteBreakdown = styled.div` + display: flex; + gap: 0.5rem; +`; + +const VoteCount = styled.span<{ $decision: string }>` + font-size: 0.75rem; + color: ${({ $decision }) => getDecisionColors($decision).border}; +`; + +const YourScoresSection = styled.div` + padding: 1rem; + background: ${neoColors.background}; + border: ${neoBorders.standard}; +`; + +const CapReached = styled.div` + padding: 1rem; + text-align: center; + color: ${neoColors.status.error}; + font-weight: 600; +`; + +const ScoresGrid = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 1rem; +`; + +const ScoreRow = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + + @media (max-width: 600px) { + flex-direction: column; + } +`; + +const ScoreLabel = styled.div` + flex: 1; +`; + +const ScoreHint = styled.div` + font-size: 0.75rem; + color: ${neoColors.textMuted}; + margin-top: 0.25rem; +`; + +const ScoreButtons = styled.div` + display: flex; + gap: 0.25rem; +`; + +const ScoreButton = styled.button<{ $selected: boolean }>` + width: 36px; + height: 36px; + border: ${neoBorders.standard}; + background: ${({ $selected }) => + $selected ? neoColors.accent.blue : neoColors.surface}; + color: ${({ $selected }) => ($selected ? '#fff' : neoColors.text)}; + font-weight: 700; + cursor: pointer; + + &:hover { + background: ${({ $selected }) => + $selected ? neoColors.accent.blue : neoColors.background}; + } +`; + +const DecisionRow = styled.div` + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; +`; + +const DecisionLabel = styled.span` + font-weight: 700; +`; + +const DecisionButtons = styled.div` + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +`; + +const DecisionBtn = styled.button<{ + $variant: 'yes' | 'maybe' | 'no'; + $selected: boolean; +}>` + padding: 0.75rem 1.5rem; + border: ${neoBorders.thick}; + font-weight: 800; + font-size: 0.95rem; + text-transform: uppercase; + cursor: pointer; + background: ${({ $variant, $selected }) => { + if (!$selected) return neoColors.surface; + switch ($variant) { + case 'yes': + return neoColors.status.success; + case 'maybe': + return '#E65100'; // Darker orange for better white text contrast + case 'no': + return neoColors.status.error; + } + }}; + color: ${({ $selected }) => ($selected ? '#fff' : neoColors.text)}; + box-shadow: ${({ $selected }) => ($selected ? neoShadows.small : 'none')}; + transition: transform 0.1s ease, box-shadow 0.1s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: ${neoShadows.small}; + } + + &:active { + transform: translateY(0); + } +`; + +const NotesWrapper = styled.div` + margin-bottom: 1rem; +`; + +const StickyActionBar = styled.div` + position: sticky; + bottom: 0; + left: 0; + right: 0; + padding: 0.75rem 1rem; + background: ${neoColors.surface}; + border-top: ${neoBorders.thick}; + display: flex; + align-items: center; + gap: 1rem; + margin: 1rem -1rem -1rem -1rem; + z-index: 10; +`; + +const SuccessToast = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: ${neoColors.status.success}; + color: #fff; + font-weight: 600; + font-size: 0.85rem; + border: ${neoBorders.standard}; + animation: fadeIn 0.2s ease-out; + + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +`; + +const FinalDecisionSection = styled.div``; + +const PreviousNotesSection = styled.div` + margin-bottom: 1.5rem; +`; + +const PreviousNotesTitle = styled.div` + font-weight: 700; + font-size: 0.85rem; + margin-bottom: 0.75rem; + text-transform: uppercase; + color: ${neoColors.textMuted}; +`; + +const ReviewSummaryGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; +`; + +const ReviewSummaryCard = styled.div` + padding: 1rem; + background: ${neoColors.background}; + border: ${neoBorders.standard}; + text-align: center; +`; + +const ReviewSummaryTitle = styled.div` + font-weight: 700; + font-size: 0.85rem; + margin-bottom: 0.5rem; +`; + +const ReviewSummaryScore = styled.div` + font-size: 1.25rem; + font-weight: 900; + margin-bottom: 0.5rem; +`; + +const FinalDecisionBtn = styled.button<{ + $variant: 'finalist' | 'waitlist' | 'not_selected'; + $selected: boolean; +}>` + padding: 0.75rem 1.5rem; + border: ${neoBorders.thick}; + font-weight: 800; + font-size: 1rem; + text-transform: uppercase; + cursor: pointer; + background: ${({ $variant, $selected }) => + $selected ? getDecisionColors($variant).border : neoColors.surface}; + color: ${({ $selected }) => ($selected ? '#fff' : neoColors.text)}; + box-shadow: ${({ $selected }) => ($selected ? neoShadows.small : 'none')}; + transition: transform 0.1s ease, box-shadow 0.1s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: ${neoShadows.small}; + } + + &:active { + transform: translateY(0); + } +`; + +const EmptyState = styled.div` + text-align: center; + padding: 2rem; + color: ${neoColors.textMuted}; +`; + +const EmptyDetail = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: ${neoColors.textMuted}; + font-size: 1rem; +`; diff --git a/apps/dashboard/pages/submit/index.tsx b/apps/dashboard/pages/submit/index.tsx index 93572f2..066fdd1 100644 --- a/apps/dashboard/pages/submit/index.tsx +++ b/apps/dashboard/pages/submit/index.tsx @@ -9,7 +9,7 @@ import { SubmissionStatusBanner } from '../../components/submit/submission-statu import { SubmissionTallyEmbed } from '../../components/submit/submission-tally-embed'; import { SubmissionGuide } from '../../components/submit/submission-guide'; import { neoColors, neoBorders } from '../../components/neo-ui/theme'; -import { ApplicationStatus } from '@hibiscus/types'; +import { ApplicationStatus, HibiscusRole } from '@hibiscus/types'; import { NeoButton } from '../../components/neo-ui/NeoButton'; const PageContainer = styled.div` @@ -80,6 +80,26 @@ const DeadlineSubtext = styled.div` color: ${neoColors.textMuted}; `; +const PreviewBanner = styled.div` + background: ${neoColors.accent.blue}20; + border: 2px solid ${neoColors.accent.blue}; + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +`; + +const PreviewTitle = styled.div` + font-size: 1rem; + font-weight: 700; + color: ${neoColors.accent.blue}; +`; + +const PreviewSubtext = styled.div` + font-size: 0.9rem; + color: ${neoColors.textMuted}; +`; + export function SubmitPage() { const { user } = useHibiscusUser(); const router = useRouter(); @@ -97,6 +117,10 @@ export function SubmitPage() { const [showSoloModal, setShowSoloModal] = useState(false); + // Check if user is admin or judge + const isAdminOrJudge = + user?.role === HibiscusRole.SUPERADMIN || user?.role === HibiscusRole.JUDGE; + // Loading state if (user === null || isLoading) { return ( @@ -106,8 +130,11 @@ export function SubmitPage() { ); } - // User must complete profile first - if (user.applicationStatus === ApplicationStatus.NOT_APPLIED) { + // User must complete profile first (skip for admin/judge) + if ( + !isAdminOrJudge && + user.applicationStatus === ApplicationStatus.NOT_APPLIED + ) { return ( @@ -123,12 +150,13 @@ export function SubmitPage() { ); } - // Users past online round cannot submit + // Users past online round cannot submit (skip for admin/judge) if ( - user.applicationStatus === ApplicationStatus.FINALIST || - user.applicationStatus === ApplicationStatus.CONFIRMED || - user.applicationStatus === ApplicationStatus.DECLINED || - user.applicationStatus === ApplicationStatus.NOT_SELECTED + !isAdminOrJudge && + (user.applicationStatus === ApplicationStatus.FINALIST || + user.applicationStatus === ApplicationStatus.CONFIRMED || + user.applicationStatus === ApplicationStatus.DECLINED || + user.applicationStatus === ApplicationStatus.NOT_SELECTED) ) { return ( @@ -145,8 +173,8 @@ export function SubmitPage() { ); } - // Error loading submission status - if (error && !status) { + // Error loading submission status (skip for admin/judge preview) + if (!isAdminOrJudge && error && !status) { return ( @@ -160,8 +188,8 @@ export function SubmitPage() { ); } - // User has no team - prompt for solo submission - if (!hasTeam) { + // User has no team - prompt for solo submission (skip for admin/judge) + if (!isAdminOrJudge && !hasTeam) { return ( Submit Project @@ -201,6 +229,36 @@ export function SubmitPage() { ); } + // Admin/Judge preview mode - show guide without submission functionality + if (isAdminOrJudge && !hasTeam) { + return ( + + Submit Project + + + Preview Mode + + You are viewing this page as{' '} + {user.role === HibiscusRole.SUPERADMIN ? 'an admin' : 'a judge'}. + This shows what participants see when submitting their projects. + + + + + + Deadline: January 17, 2026, 11:59 PM IST + + + Submit now, perfect later. You can update everything until the + deadline. + + + + + + ); + } + // User has team - show submission flow const team = status?.team; diff --git a/libs/types/src/lib/supabase.gen.ts b/libs/types/src/lib/supabase.gen.ts index 082b322..6db2521 100644 --- a/libs/types/src/lib/supabase.gen.ts +++ b/libs/types/src/lib/supabase.gen.ts @@ -508,6 +508,157 @@ export type Database = { } ]; }; + judging_notes: { + Row: { + created_at: string | null; + final_decision: string | null; + pass_1: string | null; + pass_1_at: string | null; + pass_1_by: string | null; + pass_1_implementation: number | null; + pass_1_notes: string | null; + pass_1_problem: number | null; + pass_1_roadmap: number | null; + pass_1_solution: number | null; + pass_2: string | null; + pass_2_at: string | null; + pass_2_by: string | null; + pass_2_implementation: number | null; + pass_2_notes: string | null; + pass_2_problem: number | null; + pass_2_roadmap: number | null; + pass_2_solution: number | null; + team_id: string; + updated_at: string | null; + }; + Insert: { + created_at?: string | null; + final_decision?: string | null; + pass_1?: string | null; + pass_1_at?: string | null; + pass_1_by?: string | null; + pass_1_implementation?: number | null; + pass_1_notes?: string | null; + pass_1_problem?: number | null; + pass_1_roadmap?: number | null; + pass_1_solution?: number | null; + pass_2?: string | null; + pass_2_at?: string | null; + pass_2_by?: string | null; + pass_2_implementation?: number | null; + pass_2_notes?: string | null; + pass_2_problem?: number | null; + pass_2_roadmap?: number | null; + pass_2_solution?: number | null; + team_id: string; + updated_at?: string | null; + }; + Update: { + created_at?: string | null; + final_decision?: string | null; + pass_1?: string | null; + pass_1_at?: string | null; + pass_1_by?: string | null; + pass_1_implementation?: number | null; + pass_1_notes?: string | null; + pass_1_problem?: number | null; + pass_1_roadmap?: number | null; + pass_1_solution?: number | null; + pass_2?: string | null; + pass_2_at?: string | null; + pass_2_by?: string | null; + pass_2_implementation?: number | null; + pass_2_notes?: string | null; + pass_2_problem?: number | null; + pass_2_roadmap?: number | null; + pass_2_solution?: number | null; + team_id?: string; + updated_at?: string | null; + }; + Relationships: [ + { + foreignKeyName: 'judging_notes_pass_1_by_fkey'; + columns: ['pass_1_by']; + isOneToOne: false; + referencedRelation: 'user_profiles'; + referencedColumns: ['user_id']; + }, + { + foreignKeyName: 'judging_notes_pass_2_by_fkey'; + columns: ['pass_2_by']; + isOneToOne: false; + referencedRelation: 'user_profiles'; + referencedColumns: ['user_id']; + }, + { + foreignKeyName: 'judging_notes_team_id_fkey'; + columns: ['team_id']; + isOneToOne: true; + referencedRelation: 'teams'; + referencedColumns: ['team_id']; + } + ]; + }; + judging_scores: { + Row: { + id: string; + team_id: string; + judge_id: string | null; + pass: number; + problem: number | null; + solution: number | null; + implementation: number | null; + roadmap: number | null; + decision: string | null; + notes: string | null; + created_at: string; + updated_at: string; + }; + Insert: { + id?: string; + team_id: string; + judge_id?: string | null; + pass: number; + problem?: number | null; + solution?: number | null; + implementation?: number | null; + roadmap?: number | null; + decision?: string | null; + notes?: string | null; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + team_id?: string; + judge_id?: string | null; + pass?: number; + problem?: number | null; + solution?: number | null; + implementation?: number | null; + roadmap?: number | null; + decision?: string | null; + notes?: string | null; + created_at?: string; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: 'judging_scores_team_id_fkey'; + columns: ['team_id']; + isOneToOne: false; + referencedRelation: 'teams'; + referencedColumns: ['team_id']; + }, + { + foreignKeyName: 'judging_scores_judge_id_fkey'; + columns: ['judge_id']; + isOneToOne: false; + referencedRelation: 'user_profiles'; + referencedColumns: ['user_id']; + } + ]; + }; leaderboard: { Row: { bonus_points: number; diff --git a/supabase/migrations/20260118000002_create_judging_notes.sql b/supabase/migrations/20260118000002_create_judging_notes.sql new file mode 100644 index 0000000..959d1a6 --- /dev/null +++ b/supabase/migrations/20260118000002_create_judging_notes.sql @@ -0,0 +1,97 @@ +-- Create judging_notes table for two-pass judging system (MVP: single reviewer per pass) +-- Future: migrate to judging_reviews table if multiple reviewers needed per pass + +BEGIN; + +-- Ensure moddatetime extension is available (required for updated_at trigger) +CREATE EXTENSION IF NOT EXISTS moddatetime; + +CREATE TABLE judging_notes ( + team_id UUID PRIMARY KEY REFERENCES teams(team_id) ON DELETE CASCADE, + + -- Pass 1: Initial review + pass_1 TEXT CHECK (pass_1 IN ('yes', 'no', 'maybe')), + pass_1_problem INT CHECK (pass_1_problem BETWEEN 1 AND 5), + pass_1_solution INT CHECK (pass_1_solution BETWEEN 1 AND 5), + pass_1_implementation INT CHECK (pass_1_implementation BETWEEN 1 AND 5), + pass_1_roadmap INT CHECK (pass_1_roadmap BETWEEN 1 AND 5), + pass_1_notes TEXT, + pass_1_by UUID REFERENCES user_profiles(user_id), + pass_1_at TIMESTAMPTZ, + + -- Pass 2: Second review + pass_2 TEXT CHECK (pass_2 IN ('yes', 'no', 'waitlist')), + pass_2_problem INT CHECK (pass_2_problem BETWEEN 1 AND 5), + pass_2_solution INT CHECK (pass_2_solution BETWEEN 1 AND 5), + pass_2_implementation INT CHECK (pass_2_implementation BETWEEN 1 AND 5), + pass_2_roadmap INT CHECK (pass_2_roadmap BETWEEN 1 AND 5), + pass_2_notes TEXT, + pass_2_by UUID REFERENCES user_profiles(user_id), + pass_2_at TIMESTAMPTZ, + + -- Final decision + final_decision TEXT CHECK (final_decision IN ('finalist', 'waitlist', 'not_selected')), + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexes for filtering +CREATE INDEX idx_judging_notes_pass_1 ON judging_notes(pass_1); +CREATE INDEX idx_judging_notes_pass_2 ON judging_notes(pass_2); +CREATE INDEX idx_judging_notes_final_decision ON judging_notes(final_decision); + +-- Enable RLS +ALTER TABLE judging_notes ENABLE ROW LEVEL SECURITY; + +-- RLS: Admins (role=1) and judges (role=7) can read +CREATE POLICY "Admins and judges can read judging_notes" + ON judging_notes FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM user_profiles + WHERE user_profiles.user_id = auth.uid() + AND user_profiles.role IN (1, 7) + ) + ); + +-- RLS: Admins and judges can insert +CREATE POLICY "Admins and judges can insert judging_notes" + ON judging_notes FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM user_profiles + WHERE user_profiles.user_id = auth.uid() + AND user_profiles.role IN (1, 7) + ) + ); + +-- RLS: Admins and judges can update +CREATE POLICY "Admins and judges can update judging_notes" + ON judging_notes FOR UPDATE + USING ( + EXISTS ( + SELECT 1 FROM user_profiles + WHERE user_profiles.user_id = auth.uid() + AND user_profiles.role IN (1, 7) + ) + ); + +-- RLS: Only admins can delete +CREATE POLICY "Only admins can delete judging_notes" + ON judging_notes FOR DELETE + USING ( + EXISTS ( + SELECT 1 FROM user_profiles + WHERE user_profiles.user_id = auth.uid() + AND user_profiles.role = 1 + ) + ); + +-- Auto-update updated_at +CREATE TRIGGER trigger_judging_notes_updated_at + BEFORE UPDATE ON judging_notes + FOR EACH ROW + EXECUTE FUNCTION moddatetime(updated_at); + +COMMIT; diff --git a/supabase/migrations/20260118000003_fix_judging_notes.sql b/supabase/migrations/20260118000003_fix_judging_notes.sql new file mode 100644 index 0000000..c6ce7c4 --- /dev/null +++ b/supabase/migrations/20260118000003_fix_judging_notes.sql @@ -0,0 +1,57 @@ +-- Fix migration for judging_notes table +-- Addresses: reviewer FK delete behavior, NOT NULL timestamps, reviewer indexes + +BEGIN; + +-- 1. Ensure moddatetime extension is available +CREATE EXTENSION IF NOT EXISTS moddatetime; + +-- 2. Add ON DELETE SET NULL for reviewer foreign keys +-- Constraint names verified via information_schema query +DO $$ +BEGIN + -- Drop pass_1_by FK if exists + IF EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_name = 'judging_notes' + AND constraint_name = 'judging_notes_pass_1_by_fkey' + ) THEN + ALTER TABLE judging_notes DROP CONSTRAINT judging_notes_pass_1_by_fkey; + END IF; + + -- Drop pass_2_by FK if exists + IF EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_name = 'judging_notes' + AND constraint_name = 'judging_notes_pass_2_by_fkey' + ) THEN + ALTER TABLE judging_notes DROP CONSTRAINT judging_notes_pass_2_by_fkey; + END IF; +END $$; + +ALTER TABLE judging_notes + ADD CONSTRAINT judging_notes_pass_1_by_fkey + FOREIGN KEY (pass_1_by) REFERENCES user_profiles(user_id) ON DELETE SET NULL, + ADD CONSTRAINT judging_notes_pass_2_by_fkey + FOREIGN KEY (pass_2_by) REFERENCES user_profiles(user_id) ON DELETE SET NULL; + +-- 3. Backfill NULL timestamps before adding NOT NULL constraint +UPDATE judging_notes SET created_at = NOW() WHERE created_at IS NULL; +UPDATE judging_notes SET updated_at = NOW() WHERE updated_at IS NULL; + +ALTER TABLE judging_notes + ALTER COLUMN created_at SET NOT NULL, + ALTER COLUMN created_at SET DEFAULT NOW(), + ALTER COLUMN updated_at SET NOT NULL, + ALTER COLUMN updated_at SET DEFAULT NOW(); + +-- 4. Add indexes for reviewer lookups +CREATE INDEX IF NOT EXISTS idx_judging_notes_pass_1_by ON judging_notes(pass_1_by); +CREATE INDEX IF NOT EXISTS idx_judging_notes_pass_2_by ON judging_notes(pass_2_by); + +COMMIT; + +-- Note: Magic numbers in RLS policies (role IN (1, 7)) are intentionally not changed. +-- For this hackathon tool, hardcoded IDs are acceptable. Document here: +-- Role 1 = SUPERADMIN +-- Role 7 = JUDGE diff --git a/supabase/migrations/20260120000001_create_judging_scores.sql b/supabase/migrations/20260120000001_create_judging_scores.sql new file mode 100644 index 0000000..06da241 --- /dev/null +++ b/supabase/migrations/20260120000001_create_judging_scores.sql @@ -0,0 +1,156 @@ +-- Create judging_scores table for multi-reviewer judging system +-- Supports 4 reviewers for Pass 1, 3 reviewers for Pass 2 +-- Each reviewer scores independently; aggregates computed on read + +BEGIN; + +-- Ensure moddatetime extension is available +CREATE EXTENSION IF NOT EXISTS moddatetime; + +-- 1. Create the judging_scores table +CREATE TABLE judging_scores ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE, + judge_id UUID REFERENCES user_profiles(user_id) ON DELETE SET NULL, + pass INT NOT NULL CHECK (pass IN (1, 2)), + + -- Scoring criteria (1-5 scale) + problem INT CHECK (problem BETWEEN 1 AND 5), + solution INT CHECK (solution BETWEEN 1 AND 5), + implementation INT CHECK (implementation BETWEEN 1 AND 5), + roadmap INT CHECK (roadmap BETWEEN 1 AND 5), + + -- Decision: Pass 1 = yes/no/maybe, Pass 2 = yes/no/waitlist + -- Using TEXT with CHECK constraint for flexibility + decision TEXT CHECK ( + (pass = 1 AND decision IN ('yes', 'no', 'maybe')) OR + (pass = 2 AND decision IN ('yes', 'no', 'waitlist')) OR + decision IS NULL + ), + + notes TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- One score per judge per team per pass + UNIQUE(team_id, judge_id, pass) +); + +-- 2. Add indexes for common query patterns +CREATE INDEX idx_judging_scores_team_id ON judging_scores(team_id); +CREATE INDEX idx_judging_scores_judge_id ON judging_scores(judge_id); +CREATE INDEX idx_judging_scores_pass ON judging_scores(pass); +CREATE INDEX idx_judging_scores_team_pass ON judging_scores(team_id, pass); + +-- 3. Auto-update updated_at trigger +CREATE TRIGGER trigger_judging_scores_updated_at + BEFORE UPDATE ON judging_scores + FOR EACH ROW + EXECUTE FUNCTION moddatetime(updated_at); + +-- 4. Enable RLS +ALTER TABLE judging_scores ENABLE ROW LEVEL SECURITY; + +-- 5. RLS Policies +-- Role 1 = SUPERADMIN, Role 7 = JUDGE + +-- Admins and judges can read all scores +CREATE POLICY "Admins and judges can read judging_scores" + ON judging_scores FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM user_profiles + WHERE user_profiles.user_id = auth.uid() + AND user_profiles.role IN (1, 7) + ) + ); + +-- Admins and judges can insert scores +CREATE POLICY "Admins and judges can insert judging_scores" + ON judging_scores FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM user_profiles + WHERE user_profiles.user_id = auth.uid() + AND user_profiles.role IN (1, 7) + ) + ); + +-- Judges can only update their own scores; admins can update any +CREATE POLICY "Judges update own scores, admins update any" + ON judging_scores FOR UPDATE + USING ( + EXISTS ( + SELECT 1 FROM user_profiles + WHERE user_profiles.user_id = auth.uid() + AND ( + user_profiles.role = 1 OR + (user_profiles.role = 7 AND judging_scores.judge_id = auth.uid()) + ) + ) + ); + +-- Only admins can delete scores +CREATE POLICY "Only admins can delete judging_scores" + ON judging_scores FOR DELETE + USING ( + EXISTS ( + SELECT 1 FROM user_profiles + WHERE user_profiles.user_id = auth.uid() + AND user_profiles.role = 1 + ) + ); + +-- 6. Migrate existing data from judging_notes to judging_scores +-- This preserves existing Pass 1 and Pass 2 reviews as the first reviewer's scores + +-- Migrate Pass 1 scores (only if at least one score field is populated) +INSERT INTO judging_scores (team_id, judge_id, pass, problem, solution, implementation, roadmap, decision, notes, created_at, updated_at) +SELECT + team_id, + pass_1_by, + 1 AS pass, + pass_1_problem, + pass_1_solution, + pass_1_implementation, + pass_1_roadmap, + pass_1 AS decision, + pass_1_notes, + COALESCE(pass_1_at, created_at) AS created_at, + COALESCE(pass_1_at, updated_at) AS updated_at +FROM judging_notes +WHERE pass_1_by IS NOT NULL + OR pass_1_problem IS NOT NULL + OR pass_1_solution IS NOT NULL + OR pass_1_implementation IS NOT NULL + OR pass_1_roadmap IS NOT NULL + OR pass_1 IS NOT NULL; + +-- Migrate Pass 2 scores (only if at least one score field is populated) +INSERT INTO judging_scores (team_id, judge_id, pass, problem, solution, implementation, roadmap, decision, notes, created_at, updated_at) +SELECT + team_id, + pass_2_by, + 2 AS pass, + pass_2_problem, + pass_2_solution, + pass_2_implementation, + pass_2_roadmap, + pass_2 AS decision, + pass_2_notes, + COALESCE(pass_2_at, created_at) AS created_at, + COALESCE(pass_2_at, updated_at) AS updated_at +FROM judging_notes +WHERE pass_2_by IS NOT NULL + OR pass_2_problem IS NOT NULL + OR pass_2_solution IS NOT NULL + OR pass_2_implementation IS NOT NULL + OR pass_2_roadmap IS NOT NULL + OR pass_2 IS NOT NULL; + +COMMIT; + +-- Note: We keep the existing judging_notes table for final_decision storage. +-- The pass_1_* and pass_2_* columns in judging_notes are now deprecated +-- but not removed for backward compatibility during transition.