@@ -83,6 +83,17 @@ type AdminMetrics = {
8383 totalSubmittedHackatimeHours: number ;
8484};
8585
86+ type ReviewerStats = {
87+ reviewerId: string ;
88+ firstName: string | null ;
89+ lastName: string | null ;
90+ email: string | null ;
91+ approved: number ;
92+ rejected: number ;
93+ total: number ;
94+ lastReviewedAt: string | null ;
95+ };
96+
8697 type Tab = ' submissions' | ' projects' | ' users' | ' shop' | ' giftcodes' ;
8798 type StatusFilter = ' all' | ' pending' | ' approved' | ' rejected' ;
8899 type SortField = ' createdAt' | ' projectTitle' | ' userName' | ' approvalStatus' | ' nowHackatimeHours' | ' approvedHours' ;
@@ -297,6 +308,10 @@ let giftCodeError = $state('');
297308let giftCodeSuccess = $state (' ' );
298309let giftCodeResults = $state <Array <{ email: string ; code: string ; success: boolean ; error? : string }>>([]);
299310
311+ let reviewerLeaderboard = $state <ReviewerStats []>([]);
312+ let leaderboardLoading = $state (false );
313+ let leaderboardLoaded = $state (false );
314+
300315let submissionDrafts = $state <Record <number , { approvalStatus: string ; approvedHours: string ; userFeedback: string ; hoursJustification: string ; sendEmailNotification: boolean }>>(
301316 buildSubmissionDrafts (data .submissions ?? [])
302317);
@@ -798,9 +813,13 @@ async function recalculateAllProjectsHours() {
798813 }
799814
800815 submissionSuccess = { ... submissionSuccess , [submission .submissionId ]: ' Submission quick approved and synced to Airtable' };
816+
817+ const currentSubmissionId = submission .submissionId ;
801818 await loadSubmissions ();
802819 await loadProjects ();
803820 await loadMetrics ();
821+
822+ advanceToNextSubmission (currentSubmissionId );
804823 } catch (err ) {
805824 submissionErrors = {
806825 ... submissionErrors ,
@@ -817,9 +836,10 @@ async function recalculateAllProjectsHours() {
817836 approvalStatus: ' rejected' ,
818837 approvedHours: ' 0' ,
819838 };
820- // Quick Deny always sends email if the toggle is on, or uses toggle state
821839 const shouldSendEmail = submissionDrafts [submissionId ].sendEmailNotification ;
822840 await saveSubmission (submissionId , shouldSendEmail );
841+
842+ advanceToNextSubmission (submissionId );
823843 }
824844
825845 async function recalculateSubmissionHours(submissionId : number , projectId : number ) {
@@ -962,6 +982,48 @@ async function recalculateAllProjectsHours() {
962982 }
963983 }
964984
985+ async function loadReviewerLeaderboard() {
986+ leaderboardLoading = true ;
987+ try {
988+ const response = await fetch (` ${apiUrl }/api/admin/reviewer-leaderboard ` , {
989+ credentials: ' include' ,
990+ });
991+ if (response .ok ) {
992+ reviewerLeaderboard = await response .json ();
993+ leaderboardLoaded = true ;
994+ }
995+ } catch (err ) {
996+ console .error (' Failed to load reviewer leaderboard:' , err );
997+ } finally {
998+ leaderboardLoading = false ;
999+ }
1000+ }
1001+
1002+ function getNextPendingSubmission(currentSubmissionId : number ): { projectId: number ; submissionId: number } | null {
1003+ const projectIds = Object .keys (filteredGroupedSubmissions ).map (Number );
1004+
1005+ for (const projectId of projectIds ) {
1006+ const projectSubmissions = filteredGroupedSubmissions [projectId ];
1007+ for (const submission of projectSubmissions ) {
1008+ if (submission .submissionId !== currentSubmissionId && submission .approvalStatus === ' pending' ) {
1009+ return { projectId , submissionId: submission .submissionId };
1010+ }
1011+ }
1012+ }
1013+ return null ;
1014+ }
1015+
1016+ function advanceToNextSubmission(currentSubmissionId : number ) {
1017+ const next = getNextPendingSubmission (currentSubmissionId );
1018+ if (next ) {
1019+ selectedSubmissionByProject = { ... selectedSubmissionByProject , [next .projectId ]: next .submissionId };
1020+ const element = document .getElementById (` submission-card-${next .projectId } ` );
1021+ if (element ) {
1022+ element .scrollIntoView ({ behavior: ' smooth' , block: ' start' });
1023+ }
1024+ }
1025+ }
1026+
9651027 async function showGiftCodesTab() {
9661028 activeTab = ' giftcodes' ;
9671029 if (! giftCodesLoaded && ! giftCodesLoading ) {
@@ -1525,6 +1587,63 @@ function normalizeUrl(url: string | null): string | null {
15251587 </div >
15261588 </div >
15271589
1590+ <div class =" rounded-2xl border border-gray-700 bg-gray-900/70 backdrop-blur p-6 space-y-4" >
1591+ <div class =" flex items-center justify-between" >
1592+ <h3 class =" text-lg font-semibold flex items-center gap-2" >
1593+ 🏆 Reviewer Leaderboard
1594+ </h3 >
1595+ <button
1596+ class =" px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg border border-gray-700 transition-colors text-sm"
1597+ onclick ={loadReviewerLeaderboard }
1598+ disabled ={leaderboardLoading }
1599+ >
1600+ {leaderboardLoading ? ' Loading...' : (leaderboardLoaded ? ' Refresh' : ' Load Leaderboard' )}
1601+ </button >
1602+ </div >
1603+
1604+ {#if leaderboardLoaded && reviewerLeaderboard .length > 0 }
1605+ <div class =" overflow-x-auto" >
1606+ <table class =" w-full" >
1607+ <thead class =" bg-gray-800/50" >
1608+ <tr >
1609+ <th class =" px-4 py-3 text-left text-sm font-semibold text-gray-300" >#</th >
1610+ <th class =" px-4 py-3 text-left text-sm font-semibold text-gray-300" >Reviewer</th >
1611+ <th class =" px-4 py-3 text-center text-sm font-semibold text-green-400" >Approved</th >
1612+ <th class =" px-4 py-3 text-center text-sm font-semibold text-red-400" >Rejected</th >
1613+ <th class =" px-4 py-3 text-center text-sm font-semibold text-purple-400" >Total</th >
1614+ <th class =" px-4 py-3 text-left text-sm font-semibold text-gray-300" >Last Review</th >
1615+ </tr >
1616+ </thead >
1617+ <tbody class =" divide-y divide-gray-700" >
1618+ {#each reviewerLeaderboard as reviewer , index }
1619+ <tr class ="hover:bg-gray-800/30 {index === 0 ? ' bg-yellow-500/10' : index === 1 ? ' bg-gray-400/10' : index === 2 ? ' bg-amber-600/10' : ' ' }" >
1620+ <td class ="px-4 py-3 text-sm font-bold {index === 0 ? ' text-yellow-400' : index === 1 ? ' text-gray-300' : index === 2 ? ' text-amber-500' : ' text-gray-400' }" >
1621+ {#if index === 0 }🥇{:else if index === 1 }🥈{:else if index === 2 }🥉{:else }{index + 1 }{/if }
1622+ </td >
1623+ <td class =" px-4 py-3" >
1624+ <p class =" text-sm font-medium text-white" >
1625+ {reviewer .firstName || ' ' } {reviewer .lastName || ' ' }
1626+ </p >
1627+ <p class ="text-xs text-gray-400" >{reviewer .email || ` ID: ${reviewer .reviewerId } ` }</p >
1628+ </td >
1629+ <td class ="px-4 py-3 text-center text-sm font-semibold text-green-400" >{reviewer .approved }</td >
1630+ <td class ="px-4 py-3 text-center text-sm font-semibold text-red-400" >{reviewer .rejected }</td >
1631+ <td class ="px-4 py-3 text-center text-sm font-bold text-purple-400" >{reviewer .total }</td >
1632+ <td class =" px-4 py-3 text-sm text-gray-400" >
1633+ {reviewer .lastReviewedAt ? formatDate (reviewer .lastReviewedAt ) : ' —' }
1634+ </td >
1635+ </tr >
1636+ {/each }
1637+ </tbody >
1638+ </table >
1639+ </div >
1640+ {:else if leaderboardLoaded }
1641+ <p class =" text-gray-400 text-sm" >No reviews recorded yet.</p >
1642+ {:else }
1643+ <p class =" text-gray-500 text-sm" >Click "Load Leaderboard" to see reviewer stats.</p >
1644+ {/if }
1645+ </div >
1646+
15281647 {#if submissionsLoading }
15291648 <div class =" py-12 text-center text-gray-300" >Loading submissions...</div >
15301649 {:else if Object .keys (filteredGroupedSubmissions ).length === 0 }
@@ -1533,11 +1652,11 @@ function normalizeUrl(url: string | null): string | null {
15331652 </div >
15341653 {:else }
15351654 <div class =" grid gap-6" >
1536- {#each Object .entries (filteredGroupedSubmissions ) as [projectIdStr, projectSubmissions]}
1537- {@const projectId = Number (projectIdStr )}
1538- {@const selectedSubmissionId = selectedSubmissionByProject [projectId ] ?? projectSubmissions [0 ].submissionId }
1539- {@const selectedSubmission = projectSubmissions .find ((s : AdminSubmission ) => s .submissionId === selectedSubmissionId ) ?? projectSubmissions [0 ]}
1540- <div class =" rounded-2xl border border-gray-700 bg-gray-900/70 backdrop-blur p-6 space-y-4" >
1655+ {#each Object .entries (filteredGroupedSubmissions ) as [projectIdStr, projectSubmissions]}
1656+ {@const projectId = Number (projectIdStr )}
1657+ {@const selectedSubmissionId = selectedSubmissionByProject [projectId ] ?? projectSubmissions [0 ].submissionId }
1658+ {@const selectedSubmission = projectSubmissions .find ((s : AdminSubmission ) => s .submissionId === selectedSubmissionId ) ?? projectSubmissions [0 ]}
1659+ <div id = "submission-card- { projectId } " class =" rounded-2xl border border-gray-700 bg-gray-900/70 backdrop-blur p-6 space-y-4" >
15411660 {#if projectSubmissions .length > 1 }
15421661 <div class =" mb-4 pb-4 border-b border-gray-700" >
15431662 <h4 class ="text-sm font-semibold uppercase tracking-wide text-gray-400 mb-3" >Submissions ({projectSubmissions .length })</h4 >
0 commit comments