Skip to content

Commit 02b21ee

Browse files
authored
feat: implement organizer override functionality for judging scores a… (#385)
* feat: implement organizer override functionality for judging scores and enhance judging UI * fix: update response handling in score submission and enhance submissions list styling * fix: update eslint dependencies to latest versions for improved linting * fix: update ajv and json-schema-traverse dependencies in package-lock.json * fix: update * fix: update security audit level to high in pre-push checks * fix: update eslint dependencies and ensure security audit fails on error
1 parent 45ba02d commit 02b21ee

12 files changed

Lines changed: 5635 additions & 1526 deletions

File tree

.husky/pre-push

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ echo "🚀 Running pre-push checks..."
88

99
# Run security audit
1010
echo "🔒 Running security audit..."
11-
npm audit --audit-level=moderate
11+
npm audit --omit=dev --audit-level=high
1212

1313
# Run build check one more time
1414
# echo "🏗️ Final build check..."

app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,6 @@ export default function JudgingPage() {
5555
useState<AggregatedJudgingResults | null>(null);
5656
const [isFetchingResults, setIsFetchingResults] = useState(false);
5757
const [winners, setWinners] = useState<JudgingResult[]>([]);
58-
const [winnersSummary, setWinnersSummary] =
59-
useState<AggregatedJudgingResults | null>(null);
6058
const [isFetchingWinners, setIsFetchingWinners] = useState(false);
6159
const [isPublishing, setIsPublishing] = useState(false);
6260
const [isCurrentUserJudge, setIsCurrentUserJudge] = useState(false);
@@ -352,8 +350,7 @@ export default function JudgingPage() {
352350
try {
353351
const res = await getJudgingWinners(organizationId, hackathonId);
354352
if (res.success && res.data) {
355-
setWinners(res.data.results || []);
356-
setWinnersSummary(res.data);
353+
setWinners(Array.isArray(res.data) ? res.data : []);
357354
}
358355
} catch (error) {
359356
console.error('Error fetching winners:', error);
@@ -490,6 +487,7 @@ export default function JudgingPage() {
490487
judges={currentJudges}
491488
isJudgesLoading={isRefreshingJudges}
492489
currentUserId={currentUserId || undefined}
490+
canOverrideScores={canManageJudges}
493491
onSuccess={handleSuccess}
494492
/>
495493
))}

components/organization/cards/GradeSubmissionModal/ModalFooter.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface ModalFooterProps {
1010
isFetchingCriteria: boolean;
1111
hasCriteria: boolean;
1212
existingScore: { scores: unknown[]; notes?: string } | null;
13+
mode?: 'judge' | 'organizer-override';
1314
onCancel: () => void;
1415
onSubmit: () => void;
1516
}
@@ -20,9 +21,25 @@ export const ModalFooter = ({
2021
isFetchingCriteria,
2122
hasCriteria,
2223
existingScore,
24+
mode = 'judge',
2325
onCancel,
2426
onSubmit,
2527
}: ModalFooterProps) => {
28+
const isOverride = mode === 'organizer-override';
29+
const actionLabel = isOverride
30+
? existingScore
31+
? 'Update Override'
32+
: 'Apply Override'
33+
: existingScore
34+
? 'Update Grade'
35+
: 'Submit Grade';
36+
37+
const loadingLabel = isOverride
38+
? 'Applying...'
39+
: existingScore
40+
? 'Updating...'
41+
: 'Submitting...';
42+
2643
return (
2744
<div className='flex flex-shrink-0 items-center justify-between'>
2845
<div className='text-sm text-gray-400'>
@@ -59,12 +76,10 @@ export const ModalFooter = ({
5976
{isLoading ? (
6077
<>
6178
<Loader2 className='mr-2 inline h-4 w-4 animate-spin' />
62-
{existingScore ? 'Updating...' : 'Submitting...'}
79+
{loadingLabel}
6380
</>
64-
) : existingScore ? (
65-
'Update Grade'
6681
) : (
67-
'Submit Grade'
82+
actionLabel
6883
)}
6984
</Button>
7085
</div>

components/organization/cards/GradeSubmissionModal/ScoringSection.tsx

Lines changed: 48 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ interface ScoringSectionProps {
2323
getScoreColor: (percentage: number) => string;
2424
overallComment: string;
2525
onOverallCommentChange: (value: string) => void;
26+
showComments?: boolean;
2627
}
2728

2829
export const ScoringSection = ({
@@ -39,6 +40,7 @@ export const ScoringSection = ({
3940
getScoreColor,
4041
overallComment,
4142
onOverallCommentChange,
43+
showComments = true,
4244
}: ScoringSectionProps) => {
4345
const getCriterionKey = (criterion: JudgingCriterion) => {
4446
return criterion.id || criterion.name || criterion.title;
@@ -149,55 +151,58 @@ export const ScoringSection = ({
149151
/>
150152
</div>
151153

152-
{/* Comment field */}
153-
<div className='mt-4'>
154-
<label
155-
htmlFor={`comment-${key}`}
156-
className='mb-1.5 block text-[10px] font-semibold tracking-wider text-gray-500 uppercase'
157-
>
158-
Judge Feedback (Optional)
159-
</label>
160-
<textarea
161-
id={`comment-${key}`}
162-
value={comment}
163-
onChange={e => onCommentChange(key, e.target.value)}
164-
placeholder={`Share your thoughts on ${criterionTitle.toLowerCase()}...`}
165-
className={cn(
166-
'min-h-[80px] w-full rounded-lg border border-gray-800 bg-gray-950/50 p-3 text-sm text-gray-200 transition-all',
167-
'focus:border-primary focus:ring-primary/20 focus:ring-1 focus:outline-none',
168-
'resize-none placeholder:text-gray-600'
169-
)}
170-
/>
171-
</div>
154+
{showComments && (
155+
<div className='mt-4'>
156+
<label
157+
htmlFor={`comment-${key}`}
158+
className='mb-1.5 block text-[10px] font-semibold tracking-wider text-gray-500 uppercase'
159+
>
160+
Judge Feedback (Optional)
161+
</label>
162+
<textarea
163+
id={`comment-${key}`}
164+
value={comment}
165+
onChange={e => onCommentChange(key, e.target.value)}
166+
placeholder={`Share your thoughts on ${criterionTitle.toLowerCase()}...`}
167+
className={cn(
168+
'min-h-[80px] w-full rounded-lg border border-gray-800 bg-gray-950/50 p-3 text-sm text-gray-200 transition-all',
169+
'focus:border-primary focus:ring-primary/20 focus:ring-1 focus:outline-none',
170+
'resize-none placeholder:text-gray-600'
171+
)}
172+
/>
173+
</div>
174+
)}
172175
</div>
173176
);
174177
})}
175178

176179
{/* Global Comment Section */}
177-
<div className='mt-8 border-t border-gray-800 pt-8'>
178-
<h4 className='mb-4 flex items-center gap-2 text-lg font-semibold text-white'>
179-
Overall Evaluation
180-
</h4>
181-
<div className='rounded-xl border border-gray-800 bg-gray-900/50 p-5'>
182-
<label
183-
htmlFor='overall-comment'
184-
className='mb-2 block text-[10px] font-semibold tracking-wider text-gray-500 uppercase'
185-
>
186-
Summary Feedback for the Entire Project
187-
</label>
188-
<textarea
189-
id='overall-comment'
190-
value={overallComment}
191-
onChange={e => onOverallCommentChange(e.target.value)}
192-
placeholder='Summarize your evaluation or add any final notes here...'
193-
className={cn(
194-
'min-h-[120px] w-full rounded-lg border border-gray-800 bg-gray-950/50 p-4 font-sans text-sm text-gray-200 transition-all',
195-
'focus:border-primary focus:ring-primary/20 focus:ring-1 focus:outline-none',
196-
'resize-none placeholder:text-gray-600'
197-
)}
198-
/>
180+
{showComments && (
181+
<div className='mt-8 border-t border-gray-800 pt-8'>
182+
<h4 className='mb-4 flex items-center gap-2 text-lg font-semibold text-white'>
183+
Overall Evaluation
184+
</h4>
185+
<div className='rounded-xl border border-gray-800 bg-gray-900/50 p-5'>
186+
<label
187+
htmlFor='overall-comment'
188+
className='mb-2 block text-[10px] font-semibold tracking-wider text-gray-500 uppercase'
189+
>
190+
Summary Feedback for the Entire Project
191+
</label>
192+
<textarea
193+
id='overall-comment'
194+
value={overallComment}
195+
onChange={e => onOverallCommentChange(e.target.value)}
196+
placeholder='Summarize your evaluation or add any final notes here...'
197+
className={cn(
198+
'min-h-[120px] w-full rounded-lg border border-gray-800 bg-gray-950/50 p-4 font-sans text-sm text-gray-200 transition-all',
199+
'focus:border-primary focus:ring-primary/20 focus:ring-1 focus:outline-none',
200+
'resize-none placeholder:text-gray-600'
201+
)}
202+
/>
203+
</div>
199204
</div>
200-
</div>
205+
)}
201206
</div>
202207
</div>
203208
);

components/organization/cards/GradeSubmissionModal/index.tsx

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ import { useScoreCalculation } from './useScoreCalculation';
1111
import { useJudgingCriteria } from './useJudgingCriteria';
1212
import { useSubmissionScores } from './useSubmissionScores';
1313
import { useScoreForm } from './useScoreForm';
14+
import { Switch } from '@/components/ui/switch';
15+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
16+
import { Badge } from '@/components/ui/badge';
17+
import {
18+
Select,
19+
SelectContent,
20+
SelectItem,
21+
SelectTrigger,
22+
SelectValue,
23+
} from '@/components/ui/select';
24+
import { useState } from 'react';
1425

1526
interface SubmissionData {
1627
id: string;
@@ -30,6 +41,16 @@ interface GradeSubmissionModalProps {
3041
participantId: string;
3142
judgingCriteria?: JudgingCriterion[];
3243
submission: SubmissionData;
44+
mode?: 'judge' | 'organizer-override';
45+
overrideJudgeId?: string;
46+
judges?: Array<{
47+
id?: string;
48+
userId?: string;
49+
name?: string;
50+
email?: string;
51+
image?: string;
52+
role?: string;
53+
}>;
3354
onSuccess?: () => void;
3455
}
3556

@@ -41,8 +62,42 @@ export default function GradeSubmissionModal({
4162
participantId,
4263
judgingCriteria,
4364
submission,
65+
mode = 'judge',
66+
overrideJudgeId,
67+
judges = [],
4468
onSuccess,
4569
}: GradeSubmissionModalProps) {
70+
const isOverride = mode === 'organizer-override';
71+
const [creditJudge, setCreditJudge] = useState(false);
72+
const [selectedJudgeId, setSelectedJudgeId] = useState<string | undefined>(
73+
overrideJudgeId
74+
);
75+
76+
const availableJudges = judges
77+
.map(j => ({
78+
id: j.userId || j.id,
79+
name: j.name || j.email || 'Unknown Judge',
80+
email: j.email,
81+
image: j.image,
82+
role: j.role,
83+
}))
84+
.filter(j => !!j.id) as Array<{
85+
id: string;
86+
name: string;
87+
email?: string;
88+
image?: string;
89+
role?: string;
90+
}>;
91+
92+
const handleToggleCredit = (value: boolean) => {
93+
setCreditJudge(value);
94+
if (value && !selectedJudgeId && availableJudges.length > 0) {
95+
setSelectedJudgeId(availableJudges[0].id);
96+
}
97+
if (!value) {
98+
setSelectedJudgeId(undefined);
99+
}
100+
};
46101
const { criteria, isFetchingCriteria } = useJudgingCriteria({
47102
open,
48103
organizationId,
@@ -90,6 +145,8 @@ export default function GradeSubmissionModal({
90145
hackathonId,
91146
participantId: submission.id,
92147
existingScore,
148+
mode,
149+
overrideJudgeId: creditJudge ? selectedJudgeId : undefined,
93150
onSuccess,
94151
onClose: () => onOpenChange(false),
95152
});
@@ -103,7 +160,7 @@ export default function GradeSubmissionModal({
103160
<BoundlessSheet
104161
open={open}
105162
setOpen={onOpenChange}
106-
title='Grade Submission'
163+
title={isOverride ? 'Override Submission Score' : 'Grade Submission'}
107164
size='xl'
108165
>
109166
<div className='relative flex flex-col'>
@@ -117,6 +174,75 @@ export default function GradeSubmissionModal({
117174
) : (
118175
<div className='mx-auto max-w-6xl'>
119176
<ProjectHeader submission={submission} />
177+
{isOverride && (
178+
<div className='mb-6 space-y-4 rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-xs text-amber-300'>
179+
<div>
180+
Organizer override: this action directly assigns scores and
181+
bypasses judge assignment checks.
182+
</div>
183+
<div className='flex flex-wrap items-center gap-3 text-[11px] text-amber-200'>
184+
<div className='flex items-center gap-2'>
185+
<Switch
186+
checked={creditJudge}
187+
onCheckedChange={handleToggleCredit}
188+
className='data-[state=checked]:bg-amber-500'
189+
/>
190+
<span>Credit judge</span>
191+
</div>
192+
{creditJudge && (
193+
<div className='min-w-[220px]'>
194+
<Select
195+
value={selectedJudgeId}
196+
onValueChange={value => setSelectedJudgeId(value)}
197+
>
198+
<SelectTrigger className='h-8 border-amber-500/30 bg-black/20 text-amber-100'>
199+
<SelectValue placeholder='Select judge' />
200+
</SelectTrigger>
201+
<SelectContent className='border-amber-500/20 bg-black text-amber-100'>
202+
{availableJudges.length === 0 && (
203+
<SelectItem value='no-judges' disabled>
204+
No judges available
205+
</SelectItem>
206+
)}
207+
{availableJudges.map(judge => (
208+
<SelectItem key={judge.id} value={judge.id}>
209+
<div className='flex items-center gap-2'>
210+
<Avatar className='h-5 w-5 border border-amber-500/20'>
211+
<AvatarImage src={judge.image} />
212+
<AvatarFallback className='bg-amber-500/10 text-[9px] text-amber-200'>
213+
{judge.name.charAt(0).toUpperCase()}
214+
</AvatarFallback>
215+
</Avatar>
216+
<div className='min-w-0'>
217+
<div className='truncate text-xs text-amber-100'>
218+
{judge.name}
219+
</div>
220+
<div className='flex items-center gap-1 text-[10px] text-amber-300/80'>
221+
{judge.email && (
222+
<span className='truncate'>
223+
{judge.email}
224+
</span>
225+
)}
226+
{judge.role && (
227+
<Badge
228+
variant='outline'
229+
className='border-amber-500/30 bg-amber-500/10 px-1.5 py-0 text-[9px] text-amber-200'
230+
>
231+
{judge.role}
232+
</Badge>
233+
)}
234+
</div>
235+
</div>
236+
</div>
237+
</SelectItem>
238+
))}
239+
</SelectContent>
240+
</Select>
241+
</div>
242+
)}
243+
</div>
244+
</div>
245+
)}
120246

121247
<div className='mt-8 grid grid-cols-1 gap-8 lg:grid-cols-3'>
122248
<div className='lg:col-span-2'>
@@ -134,6 +260,7 @@ export default function GradeSubmissionModal({
134260
getScoreColor={getScoreColor}
135261
overallComment={overallComment}
136262
onOverallCommentChange={setOverallComment}
263+
showComments={!isOverride}
137264
/>
138265
</div>
139266

@@ -196,6 +323,7 @@ export default function GradeSubmissionModal({
196323
isFetchingCriteria={isFetchingCriteria}
197324
hasCriteria={criteria.length > 0}
198325
existingScore={existingScore}
326+
mode={mode}
199327
onCancel={() => onOpenChange(false)}
200328
onSubmit={handleSubmit}
201329
/>

0 commit comments

Comments
 (0)