1- import { FC , useLayoutEffect , useMemo , useRef , useState } from 'react' ;
1+ import { FC , useMemo } from 'react' ;
22import { defineMessages } from 'react-intl' ;
3- import {
4- Paper ,
5- Table ,
6- TableBody ,
7- TableCell ,
8- TableContainer ,
9- TableHead ,
10- TableRow ,
11- Tooltip ,
12- } from '@mui/material' ;
13- import { black , grey , white } from 'theme/colors' ;
3+ import { Tooltip } from '@mui/material' ;
144
5+ import { ColumnTemplate } from 'lib/components/table' ;
6+ import Table from 'lib/components/table/Table' ;
157import useTranslation from 'lib/hooks/useTranslation' ;
168
179import { AssessmentData , StudentRow , TabData } from '../types' ;
@@ -34,14 +26,6 @@ interface Props {
3426 showRawScore : boolean ;
3527}
3628
37- interface HeaderGroup {
38- id : number ;
39- title : string ;
40- colSpan : number ;
41- }
42-
43- const ASSESSMENT_COL_WIDTH = 'w-[160px] min-w-[160px] max-w-[160px]' ;
44-
4529const RawScore : FC < { grade : number ; maxGrade : number } > = ( {
4630 grade,
4731 maxGrade,
@@ -68,232 +52,77 @@ const formatGrade = (
6852 return `${ ( ( grade / maxGrade ) * 100 ) . toFixed ( 2 ) } %` ;
6953} ;
7054
71- const buildHeaderGroups = (
55+ const buildColumns = (
7256 assessments : AssessmentData [ ] ,
7357 tabMap : Map < number , TabData > ,
74- getId : ( tab : TabData ) => number ,
75- getTitle : ( tab : TabData ) => string ,
76- ) : HeaderGroup [ ] => {
77- const seen = assessments . reduce ( ( map , assessment ) => {
78- const tab = tabMap . get ( assessment . tabId ) ;
79- if ( ! tab ) return map ;
80- const id = getId ( tab ) ;
81- const existing = map . get ( id ) ;
82- if ( existing ) {
83- existing . colSpan += 1 ;
84- } else {
85- map . set ( id , { id, title : getTitle ( tab ) , colSpan : 1 } ) ;
86- }
87- return map ;
88- } , new Map < number , HeaderGroup > ( ) ) ;
89- return Array . from ( seen . values ( ) ) ;
90- } ;
91-
92- const stickyNameCell = {
93- position : 'sticky' ,
94- left : 0 ,
95- zIndex : 20 ,
96- backgroundColor : white ,
97- minWidth : '12rem' ,
98- boxShadow : `inset -1px 0 0 ${ grey [ 400 ] } ` ,
99- } ;
100-
101- const stickyNameHeaderCell = {
102- ...stickyNameCell ,
103- zIndex : 40 ,
104- color : black ,
105- } ;
106-
107- const headerCell = {
108- backgroundColor : white ,
109- fontWeight : 600 ,
110- textAlign : 'center' ,
111- color : black ,
112- zIndex : 30 ,
113- } ;
114-
115- const rowSpanHeaderCellSx = {
116- backgroundColor : white ,
117- // rowSpan=3 cells sit in row 1, so the last-child selector in Table sx won't
118- // match them. !important overrides MUI's default cell border for these cells.
119- borderBottom : `2px solid ${ grey [ 600 ] } !important` ,
58+ t : ReturnType < typeof useTranslation > [ 't' ] ,
59+ showRawScore : boolean ,
60+ ) : ColumnTemplate < StudentRow > [ ] => {
61+ const numberAlign = showRawScore ? 'text-left' : 'text-right' ;
62+
63+ const leaves : ColumnTemplate < StudentRow > [ ] = assessments
64+ . map ( ( a ) => {
65+ const tab = tabMap . get ( a . tabId ) ;
66+ if ( ! tab ) return null ;
67+ return {
68+ id : `assessment-${ a . id } ` ,
69+ title : (
70+ < Tooltip placement = "top" title = { a . title } >
71+ < span className = "block truncate" > { a . title } </ span >
72+ </ Tooltip >
73+ ) ,
74+ groupPath : [
75+ { id : tab . categoryId , title : tab . categoryTitle , label : tab . categoryTitle } ,
76+ { id : tab . id , title : tab . title , label : tab . title } ,
77+ ] ,
78+ widthPx : 160 ,
79+ className : `${ numberAlign } tabular-nums` ,
80+ cell : ( row ) =>
81+ formatGrade ( row . grades [ String ( a . id ) ] ?? 0 , a . maxGrade , showRawScore ) ,
82+ } satisfies ColumnTemplate < StudentRow > ;
83+ } )
84+ . filter ( ( c ) : c is ColumnTemplate < StudentRow > => c !== null ) ;
85+
86+ return [
87+ {
88+ id : 'name' ,
89+ title : t ( translations . studentName ) ,
90+ pin : 'left' ,
91+ widthPx : 192 ,
92+ cell : ( row ) => row . name ,
93+ } ,
94+ ...leaves ,
95+ {
96+ id : 'total' ,
97+ title : t ( translations . total ) ,
98+ pin : 'right' ,
99+ widthPx : 96 ,
100+ className : `${ numberAlign } tabular-nums` ,
101+ cell : ( row ) =>
102+ formatGrade ( row . totalGrade , row . totalMaxGrade , showRawScore ) ,
103+ } ,
104+ ] ;
120105} ;
121106
122- const GradebookTable : FC < Props > = ( {
123- tabs,
124- assessments,
125- students,
126- showRawScore,
127- } ) => {
107+ const GradebookTable : FC < Props > = ( { tabs, assessments, students, showRawScore } ) => {
128108 const { t } = useTranslation ( ) ;
129-
130- // MUI stickyHeader sets top:0 on every thead cell, stacking all three rows at
131- // the same y-position. Measure row heights and override `top` on rows 2 and 3.
132- const row1Ref = useRef < HTMLTableRowElement > ( null ) ;
133- const row2Ref = useRef < HTMLTableRowElement > ( null ) ;
134- const [ row2Top , setRow2Top ] = useState ( 0 ) ;
135- const [ row3Top , setRow3Top ] = useState ( 0 ) ;
136-
137109 const tabMap = useMemo (
138110 ( ) => new Map ( tabs . map ( ( tab ) => [ tab . id , tab ] ) ) ,
139111 [ tabs ] ,
140112 ) ;
141-
142- const categoryGroups = useMemo (
143- ( ) =>
144- buildHeaderGroups (
145- assessments ,
146- tabMap ,
147- ( tab ) => tab . categoryId ,
148- ( tab ) => tab . categoryTitle ,
149- ) ,
150- [ assessments , tabMap ] ,
151- ) ;
152-
153- const tabGroups = useMemo (
154- ( ) =>
155- buildHeaderGroups (
156- assessments ,
157- tabMap ,
158- ( tab ) => tab . id ,
159- ( tab ) => tab . title ,
160- ) ,
161- [ assessments , tabMap ] ,
113+ const columns = useMemo (
114+ ( ) => buildColumns ( assessments , tabMap , t , showRawScore ) ,
115+ [ assessments , tabMap , t , showRawScore ] ,
162116 ) ;
163117
164- useLayoutEffect ( ( ) => {
165- const h1 = row1Ref . current ?. offsetHeight ?? 0 ;
166- const h2 = row2Ref . current ?. offsetHeight ?? 0 ;
167- setRow2Top ( h1 ) ;
168- setRow3Top ( h1 + h2 ) ;
169- } , [ categoryGroups , tabGroups ] ) ;
170-
171- const gradeAlign = showRawScore ? 'left' : 'right' ;
172-
173118 return (
174- < TableContainer
175- className = "max-h-[70vh] w-full"
176- component = { Paper }
177- variant = "outlined"
178- >
179- < Table
180- className = "!border-separate"
181- stickyHeader
182- sx = { {
183- borderSpacing : 0 ,
184- // Sticky cells at the same z-index paint in DOM order, so a later
185- // cell's white background covers the border of the cell before it.
186- // Fix: let the COVERING cell own the border line.
187- // Horizontal: use borderLeft (not borderRight) — cell N+1 owns the vertical sep.
188- // Vertical: use borderTop on non-first header rows (not borderBottom on row above).
189- '& .MuiTableCell-root' : {
190- borderBottom : `1px solid ${ grey [ 400 ] } ` ,
191- borderLeft : `1px solid ${ grey [ 400 ] } ` ,
192- backgroundColor : white ,
193- } ,
194- '& .MuiTableCell-stickyHeader' : {
195- backgroundColor : white ,
196- } ,
197- '& .assessment-cell' : {
198- boxShadow : `inset 1px 0 0 ${ grey [ 400 ] } ` ,
199- } ,
200- // Non-last header rows: drop borderBottom (covered by the next row's bg).
201- '& .MuiTableHead-root .MuiTableRow-root:not(:last-child) .MuiTableCell-root' :
202- {
203- borderBottom : 'none' ,
204- } ,
205- // Non-first header rows: add borderTop so the line is owned by the covering row.
206- '& .MuiTableHead-root .MuiTableRow-root:not(:first-child) .MuiTableCell-root' :
207- {
208- borderTop : `1px solid ${ grey [ 400 ] } ` ,
209- } ,
210- // Last header row: thick bottom border separating header from body.
211- '& .MuiTableHead-root .MuiTableRow-root:last-child .MuiTableCell-root' :
212- {
213- borderBottom : `2px solid ${ grey [ 600 ] } ` ,
214- } ,
215- } }
216- >
217- < TableHead >
218- < TableRow ref = { row1Ref } >
219- < TableCell
220- rowSpan = { 3 }
221- sx = { { ...stickyNameHeaderCell , ...rowSpanHeaderCellSx } }
222- >
223- { t ( translations . studentName ) }
224- </ TableCell >
225- { categoryGroups . map ( ( cat ) => (
226- < TableCell key = { cat . id } colSpan = { cat . colSpan } sx = { headerCell } >
227- { cat . title }
228- </ TableCell >
229- ) ) }
230- < TableCell
231- rowSpan = { 3 }
232- sx = { { ...headerCell , ...rowSpanHeaderCellSx } }
233- >
234- { t ( translations . total ) }
235- </ TableCell >
236- </ TableRow >
237-
238- < TableRow
239- ref = { row2Ref }
240- sx = { { '& .MuiTableCell-stickyHeader' : { top : row2Top } } }
241- >
242- { tabGroups . map ( ( tab ) => (
243- < TableCell
244- key = { tab . id }
245- align = "center"
246- colSpan = { tab . colSpan }
247- sx = { headerCell }
248- >
249- { tab . title }
250- </ TableCell >
251- ) ) }
252- </ TableRow >
253-
254- < TableRow sx = { { '& .MuiTableCell-stickyHeader' : { top : row3Top } } } >
255- { assessments . map ( ( assessment ) => (
256- < TableCell
257- key = { assessment . id }
258- className = { `${ ASSESSMENT_COL_WIDTH } assessment-cell z-30 text-black` }
259- >
260- < Tooltip placement = "top" title = { assessment . title } >
261- < span className = "block truncate" > { assessment . title } </ span >
262- </ Tooltip >
263- </ TableCell >
264- ) ) }
265- </ TableRow >
266- </ TableHead >
267-
268- < TableBody >
269- { students . map ( ( student ) => (
270- < TableRow key = { student . id } >
271- < TableCell sx = { stickyNameCell } > { student . name } </ TableCell >
272- { assessments . map ( ( assessment ) => (
273- < TableCell
274- key = { assessment . id }
275- align = { gradeAlign }
276- className = { `${ ASSESSMENT_COL_WIDTH } tabular-nums` }
277- >
278- { formatGrade (
279- student . grades [ String ( assessment . id ) ] ?? 0 ,
280- assessment . maxGrade ,
281- showRawScore ,
282- ) }
283- </ TableCell >
284- ) ) }
285- < TableCell align = { gradeAlign } className = "tabular-nums" >
286- { formatGrade (
287- student . totalGrade ,
288- student . totalMaxGrade ,
289- showRawScore ,
290- ) }
291- </ TableCell >
292- </ TableRow >
293- ) ) }
294- </ TableBody >
295- </ Table >
296- </ TableContainer >
119+ < Table
120+ columns = { columns }
121+ data = { students }
122+ getRowId = { ( s ) => s . id . toString ( ) }
123+ getRowEqualityData = { ( s ) => s }
124+ maxHeight = "70vh"
125+ />
297126 ) ;
298127} ;
299128
0 commit comments