@@ -49,15 +49,35 @@ function slackDisplay(user) {
4949 return user ?. slug ? `@${ user . slug } ` : user ?. email || "Unknown" ;
5050}
5151
52- function clampApprovalHours ( value , maxHours ) {
52+ function clampApprovalHours ( value , maxHours , allowAboveMax = false ) {
5353 if ( value === "" ) return "" ;
5454 const numeric = Number . parseFloat ( value ) ;
5555 if ( ! Number . isFinite ( numeric ) ) return value ;
5656 if ( numeric < 0 ) return "0" ;
57- if ( Number . isFinite ( maxHours ) && numeric > maxHours ) return maxHours . toFixed ( 2 ) ;
57+ if ( ! allowAboveMax && Number . isFinite ( maxHours ) && numeric > maxHours ) return maxHours . toFixed ( 2 ) ;
5858 return value ;
5959}
6060
61+ function combinedLoggedHours ( project ) {
62+ const journal = Number ( project . journalHours ?? project . totalHours ?? 0 ) ;
63+ const hackatime = Number ( project . hackatimeHours ?? 0 ) ;
64+ return Number ( project . combinedHours ?? journal + hackatime ) ;
65+ }
66+
67+ /** True when "new hours to approve" is above the logged/pending caps shown on the review page. */
68+ function approvalNeedsExceedAck ( project , requestedNewHours ) {
69+ if ( ! project || ! Number . isFinite ( requestedNewHours ) ) return false ;
70+ const pendingCap = Number ( project . pendingReviewHours ?? 0 ) ;
71+ const logged = combinedLoggedHours ( project ) ;
72+ const banked = Math . max ( Number ( project . pastApprovedHours ?? 0 ) , Number ( project . approvedHours ?? 0 ) ) ;
73+ const newTotal = banked + requestedNewHours ;
74+ return (
75+ requestedNewHours > pendingCap + 1e-9 ||
76+ requestedNewHours > logged + 1e-9 ||
77+ newTotal > logged + 1e-9
78+ ) ;
79+ }
80+
6181export function AdminReviewPage ( { projectId } ) {
6282 if ( projectId ) return < AdminReviewDetail projectId = { projectId } /> ;
6383 return < AdminReviewIndex /> ;
@@ -215,6 +235,8 @@ function AdminReviewDetail({ projectId }) {
215235 const [ message , setMessage ] = useState ( "" ) ;
216236 const [ fraudSaving , setFraudSaving ] = useState ( false ) ;
217237 const [ deleteBusy , setDeleteBusy ] = useState ( false ) ;
238+ const [ exceedModalOpen , setExceedModalOpen ] = useState ( false ) ;
239+ const [ exceedAcknowledged , setExceedAcknowledged ] = useState ( false ) ;
218240
219241 useEffect ( ( ) => {
220242 loadProject ( ) ;
@@ -264,7 +286,7 @@ function AdminReviewDetail({ projectId }) {
264286 }
265287 }
266288
267- async function submitReview ( ) {
289+ async function submitReview ( { acknowledgeExceedLoggedHours = false } = { } ) {
268290 if ( ! project ) return ;
269291 setMessage ( "" ) ;
270292
@@ -273,10 +295,14 @@ function AdminReviewDetail({ projectId }) {
273295 return ;
274296 }
275297
276- const newHoursMax = Number ( project . pendingReviewHours ?? 0 ) ;
277298 const requestedApprovedHours = Number . parseFloat ( approvedHours ) || 0 ;
278- if ( selectedAction === "approve" && requestedApprovedHours > newHoursMax ) {
279- setMessage ( `Approval cannot exceed ${ formatHours ( newHoursMax ) } new hours.` ) ;
299+ const needsExceedAck =
300+ selectedAction === "approve" && approvalNeedsExceedAck ( project , requestedApprovedHours ) ;
301+ const hasExceedAck = acknowledgeExceedLoggedHours || exceedAcknowledged ;
302+
303+ if ( needsExceedAck && ! hasExceedAck ) {
304+ setExceedModalOpen ( true ) ;
305+ setMessage ( 'Check the acknowledgment box (below the hours field or in this dialog), then submit again.' ) ;
280306 return ;
281307 }
282308
@@ -287,7 +313,11 @@ function AdminReviewDetail({ projectId }) {
287313 const body =
288314 selectedAction === "reject"
289315 ? { feedback }
290- : { approvedHours : Number . parseFloat ( approvedHours ) || 0 , feedback } ;
316+ : {
317+ approvedHours : requestedApprovedHours ,
318+ feedback,
319+ ...( hasExceedAck && needsExceedAck ? { acknowledgeExceedLoggedHours : true } : { } ) ,
320+ } ;
291321
292322 try {
293323 const response = await fetch ( endpoint , {
@@ -298,6 +328,8 @@ function AdminReviewDetail({ projectId }) {
298328 } ) ;
299329 const data = await response . json ( ) ;
300330 if ( ! response . ok ) throw new Error ( data . error || "Failed to submit review." ) ;
331+ setExceedModalOpen ( false ) ;
332+ setExceedAcknowledged ( false ) ;
301333 setProject ( ( current ) => ( {
302334 ...current ,
303335 ...data . project ,
@@ -315,6 +347,26 @@ function AdminReviewDetail({ projectId }) {
315347 }
316348 }
317349
350+ function confirmExceedAndSubmit ( ) {
351+ if ( ! exceedAcknowledged ) {
352+ setMessage ( "Check the acknowledgment box to approve above logged hours." ) ;
353+ return ;
354+ }
355+ setExceedModalOpen ( false ) ;
356+ submitReview ( { acknowledgeExceedLoggedHours : true } ) ;
357+ }
358+
359+ function handleApprovedHoursChange ( rawValue ) {
360+ if ( ! project ) return ;
361+ const pendingCap = Number ( project . pendingReviewHours ?? 0 ) ;
362+ const next = clampApprovalHours ( rawValue , pendingCap , true ) ;
363+ setApprovedHours ( next ) ;
364+ const numeric = Number . parseFloat ( next ) ;
365+ if ( Number . isFinite ( numeric ) && ! approvalNeedsExceedAck ( project , numeric ) ) {
366+ setExceedAcknowledged ( false ) ;
367+ }
368+ }
369+
318370 async function deleteProjectPermanently ( ) {
319371 if ( ! project ) return ;
320372 const ok = window . confirm (
@@ -355,6 +407,12 @@ function AdminReviewDetail({ projectId }) {
355407 const newHoursPlaceholder = formatHours ( newHoursMax ) ;
356408 const approvedHoursNumber = Number . parseFloat ( approvedHours ) ;
357409 const awardPreview = Number . isFinite ( approvedHoursNumber ) ? approvedHoursNumber * BRICKS_PER_APPROVED_HOUR : 0 ;
410+ const needsExceedAckPreview =
411+ ! isAlreadyReviewed &&
412+ selectedAction === "approve" &&
413+ Number . isFinite ( approvedHoursNumber ) &&
414+ approvalNeedsExceedAck ( project , approvedHoursNumber ) ;
415+ const loggedHoursPreview = combinedLoggedHours ( project ) ;
358416
359417 return (
360418 < main className = "admin-review-page" >
@@ -440,20 +498,36 @@ function AdminReviewDetail({ projectId }) {
440498 < span > New hours</ span >
441499 < strong > { newHoursPlaceholder } h</ strong >
442500 </ div >
443- < div >
501+ < div className = "admin-review-hours-approve-cell" >
444502 < span > New hours to approve</ span >
445503 < input
446504 className = "admin-review-hours-input"
447505 type = "number"
448506 min = "0"
449- max = { newHoursPlaceholder }
450507 step = "0.25"
451508 placeholder = { newHoursPlaceholder }
452509 value = { approvedHours }
453510 disabled = { isAlreadyReviewed }
454- onChange = { ( event ) => setApprovedHours ( clampApprovalHours ( event . target . value , newHoursMax ) ) }
511+ onChange = { ( event ) => handleApprovedHoursChange ( event . target . value ) }
455512 />
456513 < small > Only this approved amount awards bricks: { formatHours ( awardPreview ) } bricks</ small >
514+ { needsExceedAckPreview ? (
515+ < >
516+ < p className = "admin-review-hours-warning" >
517+ Above logged hours ({ formatHours ( loggedHoursPreview ) } h combined
518+ { newHoursMax < loggedHoursPreview ? `, pending cap ${ formatHours ( newHoursMax ) } h` : "" } ).
519+ </ p >
520+ < label className = "admin-review-exceed-ack admin-review-exceed-ack--inline" >
521+ < input
522+ type = "checkbox"
523+ checked = { exceedAcknowledged }
524+ disabled = { isAlreadyReviewed }
525+ onChange = { ( event ) => setExceedAcknowledged ( event . target . checked ) }
526+ />
527+ < span > I'm aware that I'm giving more hours than the logged ones</ span >
528+ </ label >
529+ </ >
530+ ) : null }
457531 </ div >
458532 < div >
459533 < span > Journal hours</ span >
@@ -510,7 +584,7 @@ function AdminReviewDetail({ projectId }) {
510584 Comment (optional for approve; required for reject)
511585 < textarea value = { feedback } onChange = { ( event ) => setFeedback ( event . target . value ) } />
512586 </ label >
513- < button className = "admin-review-submit" type = "button" onClick = { submitReview } >
587+ < button className = "admin-review-submit" type = "button" onClick = { ( ) => submitReview ( ) } >
514588 Submit Review
515589 </ button >
516590 </ >
@@ -533,6 +607,50 @@ function AdminReviewDetail({ projectId }) {
533607 ) : null }
534608 </ section >
535609 </ section >
610+
611+ { exceedModalOpen ? (
612+ < div
613+ className = "admin-review-modal-backdrop"
614+ role = "presentation"
615+ onClick = { ( ) => setExceedModalOpen ( false ) }
616+ >
617+ < div
618+ className = "admin-review-modal"
619+ role = "dialog"
620+ aria-labelledby = "admin-review-exceed-title"
621+ aria-modal = "true"
622+ onClick = { ( event ) => event . stopPropagation ( ) }
623+ >
624+ < h2 id = "admin-review-exceed-title" > Approve above logged hours?</ h2 >
625+ < p >
626+ You are approving < strong > { formatHours ( approvedHoursNumber ) } h</ strong > new, but only{ " " }
627+ < strong > { formatHours ( project . combinedHours ?? 0 ) } h</ strong > are logged (pending cap:{ " " }
628+ { formatHours ( newHoursMax ) } h).
629+ </ p >
630+ < label className = "admin-review-exceed-ack" >
631+ < input
632+ type = "checkbox"
633+ checked = { exceedAcknowledged }
634+ onChange = { ( event ) => setExceedAcknowledged ( event . target . checked ) }
635+ />
636+ < span > I'm aware that I'm giving more hours than the logged ones</ span >
637+ </ label >
638+ < div className = "admin-review-modal-actions" >
639+ < button type = "button" className = "admin-review-tool-btn" onClick = { ( ) => setExceedModalOpen ( false ) } >
640+ Cancel
641+ </ button >
642+ < button
643+ type = "button"
644+ className = "admin-review-submit"
645+ disabled = { ! exceedAcknowledged }
646+ onClick = { confirmExceedAndSubmit }
647+ >
648+ Approve anyway
649+ </ button >
650+ </ div >
651+ </ div >
652+ </ div >
653+ ) : null }
536654 </ main >
537655 ) ;
538656}
0 commit comments